From 67548690784b7344193e608d7851bc5dd684b287 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Fri, 27 Mar 2026 17:20:00 +0000 Subject: [PATCH 001/232] design for auto-compounding and composable token --- doc/autocompounding-vault-design.md | 351 ++++++++++++++++++++++++++++ 1 file changed, 351 insertions(+) create mode 100644 doc/autocompounding-vault-design.md diff --git a/doc/autocompounding-vault-design.md b/doc/autocompounding-vault-design.md new file mode 100644 index 00000000..2d6053ff --- /dev/null +++ b/doc/autocompounding-vault-design.md @@ -0,0 +1,351 @@ +# Autocompounding Vault: Design & Requirements + +## 1. Overview + +An ERC4626 autocompounding vault that wraps stability pool positions. It claims rewards, converts them to pegged tokens where possible, and redeposits — delivering compound interest. When minting pegged tokens is not viable (fee ratio too high), rewards are held as interest-bearing equivalent tokens until conditions improve. + +The vault provides: +- **Automated compounding** using the underlying SP reward system +- **A composable non-rebasing token** (ERC4626 shares) wrapping the rebasing SP token +- **Equivalent token management** — holding interest-bearing pegged-equivalent assets when minting is unfavorable, with a preference-ordered list for equivalent rotation + +A prerequisite change to the stability pool: making the SP a rebasing ERC20 token with transferable positions. + +## Architecture + +```mermaid +%%{init: {"flowchart": {"defaultRenderer": "elk"}} }%% +graph TB + U[User] -->|"1. deposit pegged"| SP["StabilityPool (Rebasing ERC20)"] + SP -->|"2. SP tokens"| U + U -->|"3. deposit SP tokens"| V + + subgraph Vault ["AutocompoundingVault (ERC4626)"] + V["Vault Core (asset = SP token)"] + EQ["Equivalent Tokens: fxSAVE / wstETH / ..."] + end + + V -->|"vault shares"| U + V -->|"claim rewards"| SP + V -->|"deposit pegged"| SP + V -->|"mintPeggedTokenCapped"| M[Minter] + V -->|"swap collateral"| SW["Swapper / 1inch"] + + SPM[StabilityPoolManager] -->|"depositReward"| SP + SPM -->|"notifyLiquidation"| SP + SPM -->|"vault.compound"| V +``` + +## Sequence Diagrams + +### User Deposit & Withdrawal + +```mermaid +sequenceDiagram + participant User + participant SP as StabilityPool + participant Vault + + rect rgb(230, 245, 230) + Note over User,Vault: Deposit + User->>SP: deposit(pegged) + SP-->>User: SP tokens (rebasing) + User->>SP: approve(vault, amount) + User->>Vault: deposit(spTokens, receiver) + Vault->>SP: transferFrom(user, vault, amount) + Vault-->>User: vault shares (non-rebasing) + end + + rect rgb(245, 235, 225) + Note over User,Vault: Withdrawal + User->>Vault: redeem(shares, receiver, owner) + Vault->>SP: transfer(user, spTokens) + Vault-->>User: SP tokens + User->>SP: requestWithdrawal() + Note over User: wait for window... + User->>SP: withdraw(pegged) + end +``` + +### Harvest Compound + +```mermaid +sequenceDiagram + participant Bot + participant SPM as StabilityPoolManager + participant SP as StabilityPool + participant Vault + participant Minter + participant Swap as Swapper + + Bot->>SPM: harvest(bountyReceiver, minBounty) + SPM->>SP: depositReward(WRAPPED_COLLATERAL, amount) + Note over SP: linear distribution over 1 week + + SPM->>Vault: compound() + Note over Vault: Claims PREVIOUS period's
distributed rewards + Vault->>SP: claim(vault) + SP-->>Vault: wrapped collateral + + Vault->>Minter: mintPeggedTokenCapped(collateral, vault, 0, maxFeeRatio) + Minter-->>Vault: pegged + wrappedCollateralUsed + + alt Full mint (fee acceptable for all collateral) + Vault->>SP: deposit(allPegged, vault, 0) + else Partial mint (fee limit hit) + Vault->>SP: deposit(mintedPegged, vault, 0) + Vault->>Swap: swap(remainingCollateral -> top-preference equivalent) + end + + opt Equivalent rotation (existing holdings, fees acceptable) + Vault->>Swap: swap(bottom-of-list equivalent -> collateral) + Vault->>Minter: mintPeggedTokenCapped(collateral, vault, 0, maxFeeRatio) + Vault->>SP: deposit(pegged, vault, 0) + end +``` + +### Rebalance Compound + +```mermaid +sequenceDiagram + participant Bot + participant SPM as StabilityPoolManager + participant SP as StabilityPool + participant Vault + participant Minter + participant Swap as Swapper + + Bot->>SPM: rebalance(bountyReceiver, minPegged) + SPM->>SP: notifyLiquidation(liquidated, returned) + Note over SP: Loss applied via product factor
Reward distributed immediately + + SPM->>Vault: compound() + Vault->>SP: claim(vault) + SP-->>Vault: wrapped collateral (harvest + liquidation) + + Vault->>Minter: mintPeggedTokenCapped(collateral, vault, 0, maxFeeRatio) + Note over Minter: Fee likely high (low CR)
Little or nothing minted + + alt Some pegged minted + Vault->>SP: deposit(pegged, vault, 0) + end + + Vault->>Swap: swap(remainingCollateral -> top-preference equivalent) + Note over Vault: Collateral held as equivalent
until conditions improve +``` + +### Equivalent Rotation (conditions improve) + +```mermaid +sequenceDiagram + participant Anyone + participant Vault + participant Swap as Swapper + participant Minter + participant SP as StabilityPool + + Anyone->>Vault: convertEquivalent(token, amount) + Vault->>Swap: swap(equivalent -> collateral) + Swap-->>Vault: wrapped collateral + Vault->>Minter: mintPeggedTokenCapped(collateral, vault, 0, maxFeeRatio) + + alt Fee acceptable + Minter-->>Vault: pegged tokens + Vault->>SP: deposit(pegged, vault, 0) + Note over Vault: Equivalent decreases
SP position increases + else Fee too high + Note over Vault: Keep as collateral or
swap back to equivalent + end +``` + +## 2. Motivation + +### Problem +SP depositors earn wrapped collateral from harvests but must manually claim and reinvest. This delivers simple interest — rewards don't earn further rewards. + +### Solution +Automate claim-convert-redeposit. Long-term holders benefit proportionally more because compounded rewards generate additional rewards. + +### Compound vs Simple Interest + +| | Simple (SP direct) | Compound (Vault) | +|---|---|---| +| Balance after reward | `b` (unchanged) | `b + b/T * r` (grows) | +| Reward | `r * b/T` claimed as collateral | Reinvested as pegged | +| Future reward share | Proportional to `b` | Proportional to `b + compounded` | + +At 10% APY: 5yr simple=1,500 vs compound=1,611 (+7.4%). 10yr: 2,000 vs 2,594 (+29.7%). + +### Fairness Guarantee + +`totalAssets()` includes pending claimable rewards via SP's `claimable()` view function (accurately simulates 1-week linear distribution). New depositors buy at correct price — no dilution. Compound can be lazy without affecting fairness. + +## 3. Design Decisions + +### 3.1 SP as Rebasing ERC20 + +**Decision:** `balanceOf()` returns compounded real value (= `assetBalanceOf()`). `totalSupply()` returns `totalAssetSupply()`. Both already exist. New: `transfer`, `transferFrom`, `approve`, `allowance`. + +**Why rebasing:** +- Non-rebasing shares + conversion function is exactly what the vault provides. Making the SP non-rebasing would duplicate the vault's role. +- Clean two-layer architecture: SP token = raw position (rebases down on loss), vault = compounding wrapper (non-rebasing). Like stETH/wstETH. +- SP only rebases **downward** (losses), discrete events (rebalances), not continuous. +- The vault IS the non-rebasing wrapped version for DeFi protocols. + +**Why not full ERC4626 on SP:** SP v2 is 20,711 bytes (~3,300 headroom). Minimal ERC20 fits; full ERC4626 is risky on size. The vault provides the ERC4626 interface. + +**Transfer implementation:** Checkpoint sender and receiver (updates rewards at pre-transfer balances), then move balance. + +**Approval and rebasing:** Since `balanceOf` rebases downward on liquidation, an approval may exceed the user's balance after a loss event. This is the same behavior as stETH — `transferFrom` transfers up to `min(allowance, balance)`. Accepted behavior for downward-rebasing tokens; documented in the interface. + +### 3.2 Vault Valuation on SP Loss + +On SP liquidation, `assetBalanceOf(vault)` drops -> `totalAssets()` drops -> share price drops. Automatic — no vault action needed. Equivalent token holdings are unaffected; only the SP position component decreases. + +### 3.3 Vault Architecture — stETH/wstETH Pattern + +Same pattern as stETH (rebasing) / wstETH (non-rebasing ERC4626). SP token rebases down on loss; vault share is non-rebasing, DeFi-composable. Value per vault share increases via compounding. + +### 3.4 Deposit and Withdrawal Flow + +**Decision:** Users deposit pegged into SP first, then transfer SP tokens to vault. Withdrawals reverse. + +``` +Deposit: User -> SP.deposit(pegged) -> SP tokens -> vault.deposit(spTokens) -> vault shares +Withdraw: User -> vault.redeem(shares) -> SP tokens -> SP.withdraw(pegged) with time lock +``` + +**Why:** The SP handles time lock and withdrawal fees — no duplication needed. SP-as-ERC20 makes the transfer seamless. The UI can chain both steps. + +**ERC4626 asset = SP token.** `totalAssets()` = SP token value + equivalent token value. + +**Additional token support:** `depositEquivalent` / `withdrawEquivalent` for equivalent tokens. EIP-7575 was considered but doesn't fit — equivalent tokens are a compound side effect requiring unified logic, not independent deposit paths. + +### 3.5 Minting: Fees and maxFeeRatio + +**Decision:** Use `mintPeggedToken()` with fees (not free mint). Add `mintPeggedTokenCapped` to the Minter with a `maxFeeRatio` parameter. + +**Why fees:** The vault automates what users would do manually. Users pay the fee. No special Minter role needed. + +**Minter change — new function alongside existing:** +```solidity +// Existing (unchanged, backward compatible) +function mintPeggedToken(uint256 wrappedIn, address receiver, uint256 minPeggedOut) + returns (uint256 peggedOut) + +// New: stops at fee threshold, returns unused collateral +function mintPeggedTokenCapped( + uint256 wrappedIn, address receiver, uint256 minPeggedOut, int256 maxFeeRatio +) returns (uint256 peggedOut, uint256 wrappedCollateralUsed) +``` + +The capped version processes fee bands until `incentiveRatio > maxFeeRatio`, then stops. Returns pegged minted so far and how much collateral was used. Unused collateral stays with the caller. Backward compatible — existing function untouched. + +### 3.6 Compound Flow + +**Decision:** The minter's fee mechanism naturally handles harvest vs liquidation. So no need for StabilityPoolManager to get involved. + +**Why:** If the fee ratio is acceptable -> mint pegged -> compound. If not -> equivalent token. This applies regardless of reward source. The CR state at compound time determines the outcome: +- After harvest (high CR, low fees) -> most/all mints to pegged +- After rebalance (low CR, high fees) -> equivalent token +- Mixed -> partial mint, remainder to equivalent + +**Compound flow:** +``` +vault.compound() + 1. Claim all rewards from SP + 2. Call mintPeggedTokenCapped(collateral, vault, 0, maxFeeRatio) + 3. Deposit minted pegged into SP (increases vault's SP balance) + 4. Remaining collateral (not used by mint) -> swap to top-preference equivalent token + 5. Check existing equivalent holdings -> if fee acceptable, convert bottom-of-list -> pegged -> SP + 6. Non-collateral tokens (e.g. LEVERAGED_TOKEN) -> ignore/sweep +``` + +Vault checks collateral balance before/after mint to confirm actual usage. + +### 3.7 Compound Trigger + +**Decision:** StabilityPoolManager calls `vault.compound()` during harvest and rebalance + compound is permissionless (anyone can call). + +**StabilityPoolManager trigger:** One-line addition per pool in `harvest() and rebalance()`. Automates compounding with zero external infrastructure. Each compound captures previously distributed rewards (natural 1-week lag from linear distribution) and rebalance rewards. + +**Permissionless:** Allows compounding between harvests. No bounty — StabilityPoolManager harvest bounty incentivizes the trigger. + +### 3.8 Equivalent Token Management + +**Decision:** Preference-ordered list of equivalent tokens, updatable by a keeper/bot role. Single vault holds all equivalents internally. + +**Current design:** +- Ordered list of equivalent tokens (e.g. [fxSAVE, wstETH]) — top = preferred +- Keeper/bot role updates ordering based on external rate data (no on-chain rate calculation) +- On compound: unmintable collateral -> swap to top-preference equivalent +- On equivalent rotation: convert bottom-of-list equivalents -> collateral -> try mint -> SP (when fees permit) +- Old equivalents (after governance changes default) remain, gradually converted on subsequent compounds when fees are favorable + +**User access:** +- `depositEquivalent(token, amount, receiver)` -> mint vault shares at equivalent's value +- `withdrawEquivalent(token, shares, receiver)` -> return equivalent tokens directly if vault holds enough + +**Pricing in `totalAssets()`:** Equivalent tokens pegged to the same RWA are assumed equal value. For precision, oracle pricing could be added later. + +**Open questions (deferred):** +- On-chain APY calculation for automated ordering (currently relies on off-chain bot) +- Whether equivalent rotation should account for swap costs +- Maximum number of equivalents before gas becomes prohibitive + +### 3.9 Withdrawal Time Lock + +**Decision:** No time lock in the vault. SP's existing time lock governs all pegged withdrawals. Shared base contracts would create contract code duplication making partial upgrades harder. + +## 4. Token & Reward Flow + +### Harvest (both pool types) +``` +StabilityPoolManager.harvest() + -> depositReward(WRAPPED_COLLATERAL, amount) on SP + -> linear distribution over 1 week + -> StabilityPoolManager calls vault.compound() + -> vault claims rewards + -> mintPeggedTokenCapped(collateral, vault, 0, maxFeeRatio) + -> deposit pegged into SP + -> remaining collateral -> top-preference equivalent token + -> check: convert bottom-of-list equivalents if fees acceptable +``` + +### SP Rebalance / Liquidation (both pool types) +``` +StabilityPoolManager.rebalance() + -> SP.notifyLiquidation(liquidated, returned) + -> vault's SP balance reduced (loss via product factor — automatic) + -> liquidation rewards claimable on next compound() + -> on compound: fee likely high -> collateral -> equivalent token +``` + +### Equivalent Rotation (conditions improve) +``` +vault.compound() or vault.convertEquivalent(token, amount) + -> swap equivalent -> collateral via swapper + -> mintPeggedTokenCapped -> deposit pegged into SP +``` + +## 5. Access Control + +| Role | On Contract | Purpose | +|------|------------|---------| +| `KEEPER_ROLE` | Vault | Swap execution + equivalent list ordering | +| Owner | Vault | Configure swapper, maxFeeRatio, upgrade | +| Anyone | Vault | `deposit`, `redeem`, `compound`, `convertEquivalent`, `depositEquivalent`, `withdrawEquivalent` | + +## 6. Contracts + +| Contract | Action | Purpose | +|----------|--------|---------| +| `AutocompoundingVault` | Create | ERC4626 vault + equivalent token management | +| `IAutocompoundingVault` | Create | Interface | +| `StabilityPool_v2` | Modify | Add ERC20 (transfer/approve/allowance) | +| `Minter_v2` | Modify | Add `mintPeggedTokenCapped` with maxFeeRatio | +| `StabilityPoolManager_v1` | Modify | Add vault.compound() calls in harvest and rebalance | + +## 7. Future Directions + +- **On-chain APY calculation:** For automated equivalent token ordering without off-chain bot dependency. Not to be confused with the conversion rate between a token and its wrapped form (e.g. stETH/wstETH rate) — that is available on-chain already. From e405121920708e6910d06aa057bcf55a2a6bb743 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Mon, 30 Mar 2026 13:11:37 +0100 Subject: [PATCH 002/232] force migration of user integrals 1st step --- .claude/settings.json | 4 +- doc/stability-pool-v3-upgrade.md | 83 ++ regression/coverage.txt | 128 +-- regression/gas.txt | 45 +- regression/sizes.txt | 24 +- ... => Deploy_StabilityPool_v3_mainnet.s.sol} | 26 +- script/config/ConfigBase.sol | 28 +- script/patch/ForceMigrateAccumulator_v1.sol | 121 +++ script/src/DeployMintersShared.sol | 10 +- script/src/contracts/LeveragedToken.sol | 10 +- script/src/contracts/StabilityPool.sol | 58 +- script/test/SPv3MigrationTest.t.sol | 259 ++++++ src/minter/StabilityPool_v3.sol | 793 ++++++++++++++++++ ...ultipleRewardCompoundingAccumulator_v3.sol | 528 ++++++++++++ test/Minter_feeRange.t.sol | 2 +- test/Rebalance.t.sol | 10 +- test/StabilityPool.t.sol | 72 +- test/StabilityPoolExtras.t.sol | 4 +- test/StabilityPoolExtras2.t.sol | 8 +- test/StabilityPoolFeatures.t.sol | 4 +- test/StabilityPoolRebalance.t.sol | 4 +- test/StabilityPoolSpec.t.sol | 2 +- 22 files changed, 2041 insertions(+), 182 deletions(-) create mode 100644 doc/stability-pool-v3-upgrade.md rename script/{Deploy_StabilityPool_v2_mainnet.s.sol => Deploy_StabilityPool_v3_mainnet.s.sol} (82%) create mode 100644 script/patch/ForceMigrateAccumulator_v1.sol create mode 100644 script/test/SPv3MigrationTest.t.sol create mode 100644 src/minter/StabilityPool_v3.sol create mode 100644 src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol diff --git a/.claude/settings.json b/.claude/settings.json index f422b4ed..ffd4ecb2 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -2,7 +2,9 @@ "permissions": { "allow": [ "Bash(grep:*)", - "Bash(git log:*)" + "Bash(git log:*)", + "Bash(2)", + "Read(//home/tfras/github/baofinance/harbor/**)" ] } } diff --git a/doc/stability-pool-v3-upgrade.md b/doc/stability-pool-v3-upgrade.md new file mode 100644 index 00000000..a131f82e --- /dev/null +++ b/doc/stability-pool-v3-upgrade.md @@ -0,0 +1,83 @@ +# Stability Pool V3 Upgrade + +## What Changed + +The stability pool has been upgraded from v2 to v3. This upgrade makes two changes: + +### 1. Internal Storage Cleanup + +When the stability pool was upgraded from v1 to v2, the internal format for tracking reward data was widened from 192 bits to 256 bits. To avoid disrupting users, v2 read from the old format on first access and lazily migrated data to the new format when users interacted with their position. + +This upgrade force-migrates all remaining users to the new format and permanently removes the legacy read path. The result is simpler, cheaper, and more efficient code — all users benefit from reduced gas costs on every interaction. + +**No user-visible values changed.** Balances, claimable rewards, claimed rewards, and withdrawal requests are all preserved exactly. + +### 2. ERC20 Token Interface + +The stability pool now implements the ERC20 token standard. This means your stability pool position is a transferable token with `balanceOf`, `transfer`, `transferFrom`, `approve`, and `allowance` functions. + +**Key properties:** +- `balanceOf(address)` returns your compounded balance (same as `assetBalanceOf`) — the real value of your position after any liquidation losses +- The token **rebases downward** on liquidation events — your balance decreases proportionally when the pool absorbs a rebalance, reflecting the loss. This is similar to how stETH works +- You can transfer your stability pool position to another address without withdrawing and redepositing +- `name()`, `symbol()`, and `decimals()` are available for wallet and DeFi integration + +**What this enables:** +- Transferring positions between wallets +- Integration with DeFi protocols that accept ERC20 tokens +- Foundation for the upcoming autocompounding vault (which wraps the rebasing SP token into a non-rebasing ERC4626 share, like wstETH wraps stETH) + +## What Didn't Change + +- **Deposit and withdrawal** work exactly as before +- **Reward claiming** works exactly as before +- **Withdrawal time lock and fees** are unchanged +- **All balances and rewards** are preserved exactly — verified per-holder before and after migration +- **The stability pool address** (proxy) is the same — no contract address changes + +## How the Migration Was Executed + +For each of the 22 stability pools across all markets: + +1. The proxy was upgraded to a temporary migration contract (`ForceMigrateAccumulator_v1`) +2. All historical depositors were force-checkpointed, writing their reward data to the new format +3. The proxy was upgraded to `StabilityPool_v3`, which uses the cleaned-up accumulator with no legacy fallback + +The migration was executed as an atomic Safe batch transaction. A comprehensive test suite verified that all user-visible values (balances, claimable, claimed) were preserved identically before and after the migration. + +## Technical Details + +### Contracts + +| Contract | Purpose | +|----------|---------| +| `MultipleRewardCompoundingAccumulator_v3` | Cleaned accumulator — reads only from V2 storage, no V1 fallback | +| `StabilityPool_v3` | Stability pool with clean accumulator + ERC20 interface | +| `ForceMigrateAccumulator_v1` | One-shot migration contract (temporary, no longer deployed) | + +### Storage Migration + +The accumulator tracks reward integrals per user per reward token: + +| | V1 (legacy) | V2 (current) | +|---|---|---| +| Integral type | `uint192` | `uint256` | +| Storage slots | 2 per entry | 3 per entry | +| Read path | Direct | V2-first, V1 fallback | + +After migration, all data is in V2 format. The V1 mapping is dead storage. The V1 fallback code has been permanently removed from the codebase. + +### ERC20 Rebasing Behavior + +The SP token rebases downward on liquidation events: +- `balanceOf(user)` = `assetBalanceOf(user)` = compounded balance after losses +- `totalSupply()` = `totalAssetSupply()` = total pool balance +- Approvals may exceed balance after a loss event (same behavior as stETH) +- `transferFrom` transfers up to `min(allowance, balance)` + +### Migration Scope + +- 22 stability pools across 11 markets (BTC, ETH, EUR, GOLD, MCAP, SILVER) +- ~117 holder-pool pairs force-migrated +- ~40 unique addresses across all pools +- See `doc/migration-sp-v3-holders.md` for the full holder inventory diff --git a/regression/coverage.txt b/regression/coverage.txt index 7c1d3e3e..47dfb68a 100644 --- a/regression/coverage.txt +++ b/regression/coverage.txt @@ -1,62 +1,66 @@ -| File | % Lines | % Statements | % Branches | % Funcs | -|--------------------------------------------------------------------|--------------------|--------------------|------------------|------------------| -| script/config/ConfigBase.sol | X 75% (6/8) | X 75% (6/8) | ✓ 100% (0/0) | X 75% (3/4) | -| script/config/chains/ConfigChain_mainnet.sol | X 0% (0/21) | X 0% (0/13) | ✓ 100% (0/0) | X 0% (0/8) | -| script/config/collaterals/ConfigCollateral_fxUSD_mainnet.sol | X 33% (2/6) | X 20% (1/5) | ✓ 100% (0/0) | X 33% (1/3) | -| script/config/collaterals/ConfigCollateral_stETH_mainnet.sol | X 33% (2/6) | X 20% (1/5) | ✓ 100% (0/0) | X 33% (1/3) | -| script/config/pegs/ConfigPeg.sol | X 0% (0/8) | X 0% (0/7) | ✓ 100% (0/0) | X 0% (0/4) | -| script/config/pegs/ConfigPeg_BTC.sol | X 33% (2/6) | X 33% (1/3) | ✓ 100% (0/0) | X 33% (1/3) | -| script/config/pegs/ConfigPeg_ETH.sol | X 33% (2/6) | X 33% (1/3) | ✓ 100% (0/0) | X 33% (1/3) | -| script/config/pegs/ConfigPeg_EUR.sol | X 33% (2/6) | X 33% (1/3) | ✓ 100% (0/0) | X 33% (1/3) | -| script/config/pegs/ConfigPeg_GOLD.sol | X 33% (2/6) | X 33% (1/3) | ✓ 100% (0/0) | X 33% (1/3) | -| script/config/pegs/ConfigPeg_MCAP.sol | X 0% (0/6) | X 0% (0/3) | ✓ 100% (0/0) | X 0% (0/3) | -| script/config/pegs/ConfigPeg_SILVER.sol | X 0% (0/6) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/3) | -| script/config/stabilitypool/ConfigStabilityPool.sol | X 0% (0/6) | X 0% (0/3) | ✓ 100% (0/0) | X 0% (0/3) | -| script/config/stabilitypool/ConfigStabilityPoolManager.sol | X 0% (0/6) | X 0% (0/3) | ✓ 100% (0/0) | X 0% (0/3) | -| script/config/stabilitypool/ConfigStabilityPoolManagerCommon.sol | X 0% (0/21) | X 0% (0/17) | ✓ 100% (0/0) | X 0% (0/7) | -| script/config/volatility/ConfigPriceVolatility_105.sol | X 0% (0/65) | X 0% (0/71) | ✓ 100% (0/0) | X 0% (0/2) | -| script/config/volatility/ConfigPriceVolatility_105_stable.sol | X 0% (0/65) | X 0% (0/71) | ✓ 100% (0/0) | X 0% (0/2) | -| script/config/volatility/ConfigPriceVolatility_115.sol | X 0% (0/65) | X 0% (0/71) | ✓ 100% (0/0) | X 0% (0/2) | -| script/config/volatility/ConfigPriceVolatility_115_stable.sol | X 0% (0/65) | X 0% (0/71) | ✓ 100% (0/0) | X 0% (0/2) | -| script/config/volatility/ConfigPriceVolatility_125.sol | X 0% (0/65) | X 0% (0/71) | ✓ 100% (0/0) | X 0% (0/2) | -| script/config/volatility/ConfigPriceVolatility_125_stable.sol | X 0% (0/65) | X 0% (0/71) | ✓ 100% (0/0) | X 0% (0/2) | -| script/config/volatility/ConfigPriceVolatility_130.sol | X 0% (0/65) | X 0% (0/71) | ✓ 100% (0/0) | X 0% (0/2) | -| script/config/volatility/ConfigPriceVolatility_130_stable.sol | X 0% (0/65) | X 0% (0/71) | ✓ 100% (0/0) | X 0% (0/2) | -| script/src/DeployMintersShared.sol | X 0% (0/84) | X 0% (0/102) | X 0% (0/4) | X 0% (0/9) | -| script/src/Deploy_BTC_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | -| script/src/Deploy_ETH_Minter.sol | X 0% (0/4) | X 0% (0/3) | ✓ 100% (0/0) | X 0% (0/1) | -| script/src/Deploy_EUR_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | -| script/src/Deploy_GOLD_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | -| script/src/Deploy_MCAP_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | -| script/src/Deploy_SILVER_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | -| script/src/HarborFactoryDeployer.sol | X 0% (0/16) | X 0% (0/11) | ✓ 100% (0/0) | X 0% (0/5) | -| script/src/contracts/Genesis.sol | X 0% (0/10) | X 0% (0/12) | ✓ 100% (0/0) | X 0% (0/2) | -| script/src/contracts/LeveragedToken.sol | X 0% (0/17) | X 0% (0/27) | ✓ 100% (0/0) | X 0% (0/1) | -| script/src/contracts/Minter.sol | X 0% (0/42) | X 0% (0/46) | ✓ 100% (0/0) | X 0% (0/8) | -| script/src/contracts/PeggedToken.sol | X 0% (0/24) | X 0% (0/33) | X 0% (0/6) | X 0% (0/1) | -| script/src/contracts/StabilityPool.sol | X 0% (0/18) | X 0% (0/25) | ✓ 100% (0/0) | X 0% (0/3) | -| script/src/contracts/StabilityPoolManager.sol | X 0% (0/26) | X 0% (0/30) | ✓ 100% (0/0) | X 0% (0/4) | -| script/test/SPLRemediationTest.t.sol | X 0% (0/3) | X 0% (0/2) | ✓ 100% (0/0) | X 0% (0/1) | -| src/../script/src/HarborFactoryDeployer.sol | X 0% (0/16) | X 0% (0/11) | ✓ 100% (0/0) | X 0% (0/5) | -| src/math/DecrementalFloatingPoint.sol | ✓ 100% (31/31) | ✓ 100% (33/33) | ✓ 100% (9/9) | ✓ 100% (6/6) | -| src/minter/Genesis_v1.sol | X 99% (88/89) | ✓ 100% (88/88) | ✓ 100% (14/14) | X 92% (11/12) | -| src/minter/Minter_v1.sol | X 31% (188/601) | X 29% (191/649) | X 16% (16/102) | X 56% (38/68) | -| src/minter/Minter_v2.sol | X 99% (593/601) | X 99% (642/649) | X 93% (95/102) | X 99% (67/68) | -| src/minter/PostRebalanceRemediationForStabilityPool_v2.sol | X 0% (0/38) | X 0% (0/45) | X 0% (0/9) | X 0% (0/6) | -| src/minter/ReservePool_v1.sol | X 94% (15/16) | ✓ 100% (15/15) | ✓ 100% (3/3) | X 80% (4/5) | -| src/minter/StabilityPoolManager_v1.sol | X 99% (165/166) | X 99% (176/177) | X 95% (20/21) | ✓ 100% (24/24) | -| src/minter/StabilityPool_v1.sol | X 61% (124/203) | X 58% (129/223) | X 18% (6/33) | X 73% (16/22) | -| src/minter/StabilityPool_v2.sol | X 97% (193/199) | X 96% (211/219) | X 84% (26/31) | ✓ 100% (22/22) | -| src/minter/TokenDistributor_v1.sol | X 96% (94/98) | X 97% (112/116) | X 77% (10/13) | X 93% (14/15) | -| src/minter/library/ConfigIncentiveLib.sol | ✓ 100% (24/24) | ✓ 100% (17/17) | ✓ 100% (2/2) | ✓ 100% (9/9) | -| src/minter/library/Config_v1.sol | X 96% (76/79) | X 97% (92/95) | X 90% (18/20) | ✓ 100% (6/6) | -| src/price/PriceOracle_v1.sol | X 97% (31/32) | X 97% (36/37) | X 85% (11/13) | ✓ 100% (3/3) | -| src/price/StakedETHWrappedPriceOracle_v1.sol | X 0% (0/29) | X 0% (0/27) | X 0% (0/5) | X 0% (0/4) | -| src/reward/accumulator/MultipleRewardCompoundingAccumulator.sol | X 93% (127/136) | X 94% (161/171) | X 81% (13/16) | X 95% (20/21) | -| src/reward/accumulator/MultipleRewardCompoundingAccumulator_v2.sol | X 99% (145/147) | X 99% (182/184) | X 89% (16/18) | ✓ 100% (22/22) | -| src/reward/distributor/LinearMultipleRewardDistributor.sol | ✓ 100% (77/77) | ✓ 100% (86/86) | ✓ 100% (12/12) | ✓ 100% (14/14) | -| src/reward/distributor/LinearMultipleRewardDistributor_v2.sol | ✓ 100% (77/77) | ✓ 100% (86/86) | ✓ 100% (12/12) | ✓ 100% (14/14) | -| src/reward/distributor/LinearReward.sol | ✓ 100% (25/25) | ✓ 100% (27/27) | ✓ 100% (6/6) | ✓ 100% (2/2) | -| src/util/FmtLib.sol | X 0% (0/19) | X 0% (0/26) | X 0% (0/4) | X 0% (0/1) | -| src/util/WordCodec.sol | ✓ 100% (15/15) | ✓ 100% (14/14) | ✓ 100% (0/0) | ✓ 100% (4/4) | -| Total | X 64% (3976/6236) | X 63% (4172/6659) | X 59% (401/685) | X 66% (610/920) | +| File | % Lines | % Statements | % Branches | % Funcs | +|--------------------------------------------------------------------|--------------------|--------------------|------------------|-------------------| +| script/config/ConfigBase.sol | X 75% (6/8) | X 75% (6/8) | ✓ 100% (0/0) | X 75% (3/4) | +| script/config/ConfigTokenNames.sol | X 0% (0/24) | X 0% (0/20) | ✓ 100% (0/0) | X 0% (0/11) | +| script/config/chains/ConfigChain_mainnet.sol | X 0% (0/21) | X 0% (0/13) | ✓ 100% (0/0) | X 0% (0/8) | +| script/config/collaterals/ConfigCollateral_fxUSD_mainnet.sol | X 33% (2/6) | X 20% (1/5) | ✓ 100% (0/0) | X 33% (1/3) | +| script/config/collaterals/ConfigCollateral_stETH_mainnet.sol | X 33% (2/6) | X 20% (1/5) | ✓ 100% (0/0) | X 33% (1/3) | +| script/config/pegs/ConfigPeg.sol | X 0% (0/8) | X 0% (0/7) | ✓ 100% (0/0) | X 0% (0/4) | +| script/config/pegs/ConfigPeg_BTC.sol | X 33% (2/6) | X 33% (1/3) | ✓ 100% (0/0) | X 33% (1/3) | +| script/config/pegs/ConfigPeg_ETH.sol | X 33% (2/6) | X 33% (1/3) | ✓ 100% (0/0) | X 33% (1/3) | +| script/config/pegs/ConfigPeg_EUR.sol | X 33% (2/6) | X 33% (1/3) | ✓ 100% (0/0) | X 33% (1/3) | +| script/config/pegs/ConfigPeg_GOLD.sol | X 33% (2/6) | X 33% (1/3) | ✓ 100% (0/0) | X 33% (1/3) | +| script/config/pegs/ConfigPeg_MCAP.sol | X 0% (0/6) | X 0% (0/3) | ✓ 100% (0/0) | X 0% (0/3) | +| script/config/pegs/ConfigPeg_SILVER.sol | X 0% (0/6) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/3) | +| script/config/stabilitypool/ConfigStabilityPool.sol | X 0% (0/6) | X 0% (0/3) | ✓ 100% (0/0) | X 0% (0/3) | +| script/config/stabilitypool/ConfigStabilityPoolManager.sol | X 0% (0/6) | X 0% (0/3) | ✓ 100% (0/0) | X 0% (0/3) | +| script/config/stabilitypool/ConfigStabilityPoolManagerCommon.sol | X 0% (0/21) | X 0% (0/17) | ✓ 100% (0/0) | X 0% (0/7) | +| script/config/volatility/ConfigPriceVolatility_105.sol | X 0% (0/65) | X 0% (0/71) | ✓ 100% (0/0) | X 0% (0/2) | +| script/config/volatility/ConfigPriceVolatility_105_stable.sol | X 0% (0/65) | X 0% (0/71) | ✓ 100% (0/0) | X 0% (0/2) | +| script/config/volatility/ConfigPriceVolatility_115.sol | X 0% (0/65) | X 0% (0/71) | ✓ 100% (0/0) | X 0% (0/2) | +| script/config/volatility/ConfigPriceVolatility_115_stable.sol | X 0% (0/65) | X 0% (0/71) | ✓ 100% (0/0) | X 0% (0/2) | +| script/config/volatility/ConfigPriceVolatility_125.sol | X 0% (0/65) | X 0% (0/71) | ✓ 100% (0/0) | X 0% (0/2) | +| script/config/volatility/ConfigPriceVolatility_125_stable.sol | X 0% (0/65) | X 0% (0/71) | ✓ 100% (0/0) | X 0% (0/2) | +| script/config/volatility/ConfigPriceVolatility_130.sol | X 0% (0/65) | X 0% (0/71) | ✓ 100% (0/0) | X 0% (0/2) | +| script/config/volatility/ConfigPriceVolatility_130_stable.sol | X 0% (0/65) | X 0% (0/71) | ✓ 100% (0/0) | X 0% (0/2) | +| script/patch/ForceMigrateAccumulator_v1.sol | X 0% (0/24) | X 0% (0/29) | X 0% (0/2) | X 0% (0/3) | +| script/src/DeployMintersShared.sol | X 0% (0/84) | X 0% (0/102) | X 0% (0/4) | X 0% (0/9) | +| script/src/Deploy_BTC_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | +| script/src/Deploy_ETH_Minter.sol | X 0% (0/4) | X 0% (0/3) | ✓ 100% (0/0) | X 0% (0/1) | +| script/src/Deploy_EUR_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | +| script/src/Deploy_GOLD_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | +| script/src/Deploy_MCAP_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | +| script/src/Deploy_SILVER_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | +| script/src/HarborFactoryDeployer.sol | X 0% (0/16) | X 0% (0/11) | ✓ 100% (0/0) | X 0% (0/5) | +| script/src/contracts/Genesis.sol | X 0% (0/10) | X 0% (0/12) | ✓ 100% (0/0) | X 0% (0/2) | +| script/src/contracts/LeveragedToken.sol | X 0% (0/15) | X 0% (0/23) | ✓ 100% (0/0) | X 0% (0/1) | +| script/src/contracts/Minter.sol | X 0% (0/42) | X 0% (0/46) | ✓ 100% (0/0) | X 0% (0/8) | +| script/src/contracts/PeggedToken.sol | X 0% (0/24) | X 0% (0/33) | X 0% (0/6) | X 0% (0/1) | +| script/src/contracts/StabilityPool.sol | X 0% (0/26) | X 0% (0/41) | ✓ 100% (0/0) | X 0% (0/3) | +| script/src/contracts/StabilityPoolManager.sol | X 0% (0/26) | X 0% (0/30) | ✓ 100% (0/0) | X 0% (0/4) | +| script/test/SPLRemediationTest.t.sol | X 0% (0/3) | X 0% (0/2) | ✓ 100% (0/0) | X 0% (0/1) | +| src/../script/src/HarborFactoryDeployer.sol | X 0% (0/16) | X 0% (0/11) | ✓ 100% (0/0) | X 0% (0/5) | +| src/math/DecrementalFloatingPoint.sol | ✓ 100% (31/31) | ✓ 100% (33/33) | ✓ 100% (9/9) | ✓ 100% (6/6) | +| src/minter/Genesis_v1.sol | X 99% (88/89) | ✓ 100% (88/88) | ✓ 100% (14/14) | X 92% (11/12) | +| src/minter/Minter_v1.sol | X 31% (188/601) | X 29% (191/649) | X 16% (16/102) | X 56% (38/68) | +| src/minter/Minter_v2.sol | X 99% (593/601) | X 99% (642/649) | X 93% (95/102) | X 99% (67/68) | +| src/minter/PostRebalanceRemediationForStabilityPool_v2.sol | X 0% (0/38) | X 0% (0/45) | X 0% (0/9) | X 0% (0/6) | +| src/minter/ReservePool_v1.sol | X 94% (15/16) | ✓ 100% (15/15) | ✓ 100% (3/3) | X 80% (4/5) | +| src/minter/StabilityPoolManager_v1.sol | X 99% (165/166) | X 99% (176/177) | X 95% (20/21) | ✓ 100% (24/24) | +| src/minter/StabilityPool_v1.sol | X 61% (124/203) | X 58% (129/223) | X 18% (6/33) | X 73% (16/22) | +| src/minter/StabilityPool_v2.sol | X 68% (136/199) | X 68% (150/219) | X 32% (10/31) | X 64% (14/22) | +| src/minter/StabilityPool_v3.sol | X 76% (210/278) | X 74% (224/303) | X 68% (27/40) | X 71% (25/35) | +| src/minter/TokenDistributor_v1.sol | X 96% (94/98) | X 97% (112/116) | X 77% (10/13) | X 93% (14/15) | +| src/minter/library/ConfigIncentiveLib.sol | ✓ 100% (24/24) | ✓ 100% (17/17) | ✓ 100% (2/2) | ✓ 100% (9/9) | +| src/minter/library/Config_v1.sol | X 96% (76/79) | X 97% (92/95) | X 90% (18/20) | ✓ 100% (6/6) | +| src/price/PriceOracle_v1.sol | X 97% (31/32) | X 97% (36/37) | X 85% (11/13) | ✓ 100% (3/3) | +| src/price/StakedETHWrappedPriceOracle_v1.sol | X 0% (0/29) | X 0% (0/27) | X 0% (0/5) | X 0% (0/4) | +| src/reward/accumulator/MultipleRewardCompoundingAccumulator.sol | X 93% (127/136) | X 94% (161/171) | X 81% (13/16) | X 95% (20/21) | +| src/reward/accumulator/MultipleRewardCompoundingAccumulator_v2.sol | X 96% (141/147) | X 96% (177/184) | X 83% (15/18) | ✓ 100% (22/22) | +| src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol | X 77% (106/137) | X 80% (139/173) | X 69% (11/16) | X 68% (15/22) | +| src/reward/distributor/LinearMultipleRewardDistributor.sol | ✓ 100% (77/77) | ✓ 100% (86/86) | ✓ 100% (12/12) | ✓ 100% (14/14) | +| src/reward/distributor/LinearMultipleRewardDistributor_v2.sol | ✓ 100% (77/77) | ✓ 100% (86/86) | ✓ 100% (12/12) | ✓ 100% (14/14) | +| src/reward/distributor/LinearReward.sol | ✓ 100% (25/25) | ✓ 100% (27/27) | ✓ 100% (6/6) | ✓ 100% (2/2) | +| src/util/FmtLib.sol | X 0% (0/19) | X 0% (0/26) | X 0% (0/4) | X 0% (0/1) | +| src/util/WordCodec.sol | ✓ 100% (15/15) | ✓ 100% (14/14) | ✓ 100% (0/0) | ✓ 100% (4/4) | +| Total | X 62% (4231/6821) | X 61% (4469/7369) | X 55% (422/764) | X 63% (642/1013) | diff --git a/regression/gas.txt b/regression/gas.txt index 6c724f9a..8f2a0a13 100644 --- a/regression/gas.txt +++ b/regression/gas.txt @@ -1,44 +1,44 @@ script/config/markets/ConfigMarket_BTC_fxUSD_mainnet.sol:ConfigMarket_BTC_fxUSD_mainnet | function name | max | |-----------------|-----------| -| collateral | 5.210e+02 | -| peg | 5.230e+02 | +| collateral | 4.990e+02 | +| peg | 5.010e+02 | script/config/markets/ConfigMarket_BTC_stETH_mainnet.sol:ConfigMarket_BTC_stETH_mainnet | function name | max | |-----------------|-----------| -| collateral | 5.210e+02 | -| peg | 5.230e+02 | +| collateral | 4.990e+02 | +| peg | 5.010e+02 | script/config/markets/ConfigMarket_ETH_fxUSD_mainnet.sol:ConfigMarket_ETH_fxUSD_mainnet | function name | max | |-----------------|-----------| -| collateral | 5.210e+02 | -| peg | 5.230e+02 | +| collateral | 4.990e+02 | +| peg | 5.010e+02 | script/config/markets/ConfigMarket_EUR_fxUSD_mainnet.sol:ConfigMarket_EUR_fxUSD_mainnet | function name | max | |-----------------|-----------| -| collateral | 5.210e+02 | -| peg | 5.230e+02 | +| collateral | 4.990e+02 | +| peg | 5.010e+02 | script/config/markets/ConfigMarket_EUR_stETH_mainnet.sol:ConfigMarket_EUR_stETH_mainnet | function name | max | |-----------------|-----------| -| collateral | 5.210e+02 | -| peg | 5.230e+02 | +| collateral | 4.990e+02 | +| peg | 5.010e+02 | script/config/markets/ConfigMarket_GOLD_fxUSD_mainnet.sol:ConfigMarket_GOLD_fxUSD_mainnet | function name | max | |-----------------|-----------| -| collateral | 5.210e+02 | -| peg | 5.230e+02 | +| collateral | 4.990e+02 | +| peg | 5.010e+02 | script/config/markets/ConfigMarket_GOLD_stETH_mainnet.sol:ConfigMarket_GOLD_stETH_mainnet | function name | max | |-----------------|-----------| -| collateral | 5.210e+02 | -| peg | 5.230e+02 | +| collateral | 4.990e+02 | +| peg | 5.010e+02 | src/minter/Genesis_v1.sol:Genesis_v1 | function name | max | @@ -152,7 +152,7 @@ src/minter/StabilityPoolManager_v1.sol:StabilityPoolManager_v1 | function name | max | |----------------------------|-----------| | feeReceiver | 2.441e+03 | -| harvest | 4.441e+05 | +| harvest | 4.442e+05 | | harvestBountyRatio | 2.369e+03 | | harvestCutRatio | 2.371e+03 | | harvestable | 3.052e+04 | @@ -202,7 +202,6 @@ src/minter/StabilityPool_v2.sol:StabilityPool_v2 | function name | max | |-----------------------|-----------| | ASSET_TOKEN | 3.270e+02 | -| LIQUIDATION_TOKEN | 3.280e+02 | | REWARD_DEPOSITOR_ROLE | 2.840e+02 | | activeRewardTokens | 7.416e+03 | | assetBalanceOf | 8.049e+03 | @@ -214,15 +213,23 @@ src/minter/StabilityPool_v2.sol:StabilityPool_v2 | depositReward | 6.693e+04 | | getWithdrawalRequest | 2.745e+03 | | grantRoles | 2.636e+04 | -| initialize | 2.041e+05 | | notifyLiquidation | 1.107e+05 | -| owner | 2.424e+03 | | proxiableUUID | 3.410e+02 | | sweep | 4.020e+04 | | totalAssetSupply | 2.489e+03 | -| transferOwnership | 1.207e+04 | | withdraw | 3.013e+05 | +src/minter/StabilityPool_v3.sol:StabilityPool_v3 +| function name | max | +|-------------------|-----------| +| ASSET_TOKEN | 3.490e+02 | +| LIQUIDATION_TOKEN | 3.500e+02 | +| grantRoles | 2.638e+04 | +| initialize | 2.041e+05 | +| owner | 2.424e+03 | +| totalAssetSupply | 2.489e+03 | +| transferOwnership | 1.207e+04 | + src/minter/TokenDistributor_v1.sol:TokenDistributor_v1 | function name | max | |----------------------|-----------| diff --git a/regression/sizes.txt b/regression/sizes.txt index 1942aa3a..8eec4522 100644 --- a/regression/sizes.txt +++ b/regression/sizes.txt @@ -1,17 +1,17 @@ | Contract | Runtime Size (B) | Runtime Margin (B) | Initcode Size (B) | Deploy Gas | Deploy Cost ($) | |---------------------------------------------|--------------------|----------------------|---------------------|--------------|-------------------| | ConfigIncentiveLib | 85 | 24,491 | 135 | 18,350 | 1.84 | -| ConfigMarket_BTC_fxUSD_mainnet | 5,034 | 19,542 | 5,062 | 1,057,420 | 105.74 | -| ConfigMarket_BTC_stETH_mainnet | 5,060 | 19,516 | 5,088 | 1,062,880 | 106.29 | -| ConfigMarket_ETH_fxUSD_mainnet | 5,034 | 19,542 | 5,062 | 1,057,420 | 105.74 | -| ConfigMarket_EUR_fxUSD_mainnet | 5,022 | 19,554 | 5,050 | 1,054,900 | 105.49 | -| ConfigMarket_EUR_stETH_mainnet | 5,048 | 19,528 | 5,076 | 1,060,360 | 106.04 | -| ConfigMarket_GOLD_fxUSD_mainnet | 5,038 | 19,538 | 5,066 | 1,058,260 | 105.83 | -| ConfigMarket_GOLD_stETH_mainnet | 5,064 | 19,512 | 5,092 | 1,063,720 | 106.37 | -| ConfigMarket_MCAP_fxUSD_mainnet | 5,040 | 19,536 | 5,068 | 1,058,680 | 105.87 | -| ConfigMarket_MCAP_stETH_mainnet | 5,066 | 19,510 | 5,094 | 1,064,140 | 106.41 | -| ConfigMarket_SILVER_fxUSD_mainnet | 5,034 | 19,542 | 5,062 | 1,057,420 | 105.74 | -| ConfigMarket_SILVER_stETH_mainnet | 5,060 | 19,516 | 5,088 | 1,062,880 | 106.29 | +| ConfigMarket_BTC_fxUSD_mainnet | 6,175 | 18,401 | 6,203 | 1,297,030 | 129.70 | +| ConfigMarket_BTC_stETH_mainnet | 6,201 | 18,375 | 6,229 | 1,302,490 | 130.25 | +| ConfigMarket_ETH_fxUSD_mainnet | 6,175 | 18,401 | 6,203 | 1,297,030 | 129.70 | +| ConfigMarket_EUR_fxUSD_mainnet | 6,163 | 18,413 | 6,191 | 1,294,510 | 129.45 | +| ConfigMarket_EUR_stETH_mainnet | 6,189 | 18,387 | 6,217 | 1,299,970 | 130.00 | +| ConfigMarket_GOLD_fxUSD_mainnet | 6,179 | 18,397 | 6,207 | 1,297,870 | 129.79 | +| ConfigMarket_GOLD_stETH_mainnet | 6,205 | 18,371 | 6,233 | 1,303,330 | 130.33 | +| ConfigMarket_MCAP_fxUSD_mainnet | 6,181 | 18,395 | 6,209 | 1,298,290 | 129.83 | +| ConfigMarket_MCAP_stETH_mainnet | 6,207 | 18,369 | 6,235 | 1,303,750 | 130.38 | +| ConfigMarket_SILVER_fxUSD_mainnet | 6,175 | 18,401 | 6,203 | 1,297,030 | 129.70 | +| ConfigMarket_SILVER_stETH_mainnet | 6,201 | 18,375 | 6,229 | 1,302,490 | 130.25 | | ConfigPeg_BTC | 766 | 23,810 | 794 | 161,140 | 16.11 | | ConfigPeg_ETH | 766 | 23,810 | 794 | 161,140 | 16.11 | | ConfigPeg_EUR | 768 | 23,808 | 796 | 161,560 | 16.16 | @@ -34,6 +34,7 @@ | FakeOwnable2Step | 1,346 | 23,230 | 1,374 | 282,940 | 28.29 | | FakeUUPSUpgradeable | 3,236 | 21,340 | 3,293 | 680,130 | 68.01 | | FmtLib | 85 | 24,491 | 135 | 18,350 | 1.84 | +| ForceMigrateAccumulator_v1 | 3,364 | 21,212 | 3,847 | 711,270 | 71.13 | | Genesis_v1 | 7,486 | 17,090 | 8,423 | 1,581,430 | 158.14 | | LinearReward | 85 | 24,491 | 135 | 18,350 | 1.84 | | MinterMarketConfigLib | 85 | 24,491 | 135 | 18,350 | 1.84 | @@ -46,6 +47,7 @@ | StabilityPoolManager_v1 | 11,209 | 13,367 | 13,057 | 2,372,370 | 237.24 | | StabilityPool_v1 | 21,092 | 3,484 | 23,085 | 4,449,250 | 444.93 | | StabilityPool_v2 | 20,711 | 3,865 | 22,579 | 4,367,990 | 436.80 | +| StabilityPool_v3 | 22,532 | 2,044 | 25,056 | 4,756,960 | 475.70 | | StakedETHWrappedPriceOracle_v1 | 3,695 | 20,881 | 4,653 | 785,530 | 78.55 | | TokenDistributor_v1 | 10,116 | 14,460 | 10,365 | 2,126,850 | 212.69 | | WordCodec | 85 | 24,491 | 135 | 18,350 | 1.84 | diff --git a/script/Deploy_StabilityPool_v2_mainnet.s.sol b/script/Deploy_StabilityPool_v3_mainnet.s.sol similarity index 82% rename from script/Deploy_StabilityPool_v2_mainnet.s.sol rename to script/Deploy_StabilityPool_v3_mainnet.s.sol index 1cf90f79..162b990b 100644 --- a/script/Deploy_StabilityPool_v2_mainnet.s.sol +++ b/script/Deploy_StabilityPool_v3_mainnet.s.sol @@ -19,15 +19,16 @@ import {Deploy_SILVER_Minter} from "script/src/Deploy_SILVER_Minter.sol"; import {SafeBatch} from "script/safe/SafeBatch.s.sol"; -// TODO: put this in a file and have everything share it (or break it up or something) interface IFullMinterConfig { function wrappedCollateralToken() external view returns (address); } -/// @notice Deploy StabilityPool v2 implementations and queue upgrade transactions for all minters. -/// @dev Broadcasts implementation deployments, then queues UUPS upgrade calls as a Safe batch. -/// Run via: script/run-script Deploy_StabilityPool_v2_mainnet --salt harbor_v1 --network mainnet --broadcast -contract Deploy_StabilityPool_v2_mainnet is +/// @notice Deploy StabilityPool_v3 implementations and queue upgrade transactions for all pools. +/// @dev Prerequisite: Remediate_Accumulators must have been executed first to force-migrate +/// all users from V1 to V2 accumulator storage. +/// +/// Run via: script/run-script Deploy_StabilityPool_v3_mainnet --salt harbor_v1 --network mainnet --broadcast +contract Deploy_StabilityPool_v3_mainnet is SafeBatch, Deploy_BTC_Minter, Deploy_ETH_Minter, @@ -48,30 +49,29 @@ contract Deploy_StabilityPool_v2_mainnet is address implLeveraged = deployStabilityPoolImplementation( StabilityPoolLeveraged, state, - marketKey, + markets[i], minter, - leveragedToken, - address(markets[i]) + leveragedToken ); + address implCollateral = deployStabilityPoolImplementation( StabilityPoolCollateral, state, - marketKey, + markets[i], minter, - collateralToken, - address(markets[i]) + collateralToken ); // Queue Safe upgrade transactions queue( _saltString(marketKey, StabilityPoolLeveraged), abi.encodeCall(UUPSUpgradeable.upgradeToAndCall, (implLeveraged, "")), - string.concat("upgrade to StabilityPool_v2 ", implLeveraged.toHexString()) + string.concat("upgrade to StabilityPool_v3 ", implLeveraged.toHexString()) ); queue( _saltString(marketKey, StabilityPoolCollateral), abi.encodeCall(UUPSUpgradeable.upgradeToAndCall, (implCollateral, "")), - string.concat("upgrade to StabilityPool_v2 ", implCollateral.toHexString()) + string.concat("upgrade to StabilityPool_v3 ", implCollateral.toHexString()) ); } } diff --git a/script/config/ConfigBase.sol b/script/config/ConfigBase.sol index bb3a57c2..75565e88 100644 --- a/script/config/ConfigBase.sol +++ b/script/config/ConfigBase.sol @@ -1,6 +1,9 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.28 <0.9.0; +import {LibString} from "@solady/utils/LibString.sol"; +import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; + /// @notice Base contract for all Harbor configuration contracts. /// @dev Config contracts provide keys via methods, not string parsing. abstract contract ConfigBase { @@ -28,9 +31,10 @@ abstract contract Config_MinterMarket { /// @dev Provides type safety for price market config parameters. abstract contract Config_PriceMarket {} -/// @notice Library for computing minter market salt from configuration. -/// @dev Used by deployment scripts to generate salt for minter market configs. +/// @notice Library for computing minter market identifiers from configuration. +/// @dev Used by deployment scripts for salt, token names/symbols, and oracle keys. library MinterMarketConfigLib { + using LibString for string; /// @notice Get the peg identifier from a market config. /// @param config The minter market config contract. /// @return The peg identifier (e.g., "BTC", "ETH"). @@ -52,6 +56,26 @@ library MinterMarketConfigLib { return string.concat(peg(config), "::", collateral(config)); } + /// @notice Pegged token name (e.g., "Harbor anchored ETH"). + function peggedName(Config_MinterMarket config) internal view returns (string memory) { + return ConfigPeg(address(config)).name(); + } + + /// @notice Pegged token symbol (e.g., "haETH"). + function peggedSymbol(Config_MinterMarket config) internal view returns (string memory) { + return ConfigPeg(address(config)).symbol(); + } + + /// @notice Leveraged token name (e.g., "Harbor sail: variable leveraged long stETH against ETH"). + function leveragedName(Config_MinterMarket config) internal view returns (string memory) { + return string.concat("Harbor sail: variable leveraged long ", collateral(config), " against ", peg(config)); + } + + /// @notice Leveraged token symbol (e.g., "hsSTETH-ETH"). + function leveragedSymbol(Config_MinterMarket config) internal view returns (string memory) { + return string.concat("hs", collateral(config).upper(), "-", peg(config).upper()); + } + /// @notice Computes the price oracle key for a minter market config. /// @dev The price oracle uses a reversed key format: collateral::peg (not peg::collateral). /// @param config The minter market config contract. diff --git a/script/patch/ForceMigrateAccumulator_v1.sol b/script/patch/ForceMigrateAccumulator_v1.sol new file mode 100644 index 00000000..47258fbd --- /dev/null +++ b/script/patch/ForceMigrateAccumulator_v1.sol @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.30; + +import {HarborPauser_v1} from "@bao/HarborPauser_v1.sol"; + +/// @title ForceMigrateAccumulator_v1 +/// @notice One-shot upgrade that copies user reward snapshot data from +/// the legacy V1 format (uint192 integral, 2 slots) to the V2 format +/// (uint256 integral, 3 slots). +/// +/// Inherits HarborPauser_v1: all calls except `remediate` and `balances` +/// revert with Paused. Owner read from proxy storage. UUPS upgrade +/// authorization via HarborFixedOwnable. +/// +/// Lifecycle: +/// 1. Upgrade proxy to this contract +/// 2. Call `remediate(tokens, holders)` to copy V1 → V2 for each holder/token pair +/// 3. Upgrade proxy to StabilityPool_v3 +/// +/// @custom:oz-upgrades +/// @custom:oz-upgrades-from src/minter/StabilityPool_v2.sol:StabilityPool_v2 +// solhint-disable-next-line contract-name-capwords +contract ForceMigrateAccumulator_v1 is HarborPauser_v1 { + // ── Storage layout (mirrors Accumulator_v2) ───────────────────────────── + + struct ClaimData { + uint128 pending; + uint128 claimed; + } + + struct RewardSnapshot { + uint64 timestamp; + uint192 integral; + } + + /// @dev V1: 2 slots per entry. + struct UserRewardSnapshot { + ClaimData rewards; + RewardSnapshot checkpoint; + } + + /// @dev V2: 3 slots per entry. + struct UserRewardSnapshotV2 { + ClaimData rewards; + uint64 timestamp; + uint256 integral; + } + + struct AccumulatorStorage { + mapping(address => address) rewardReceiver; + mapping(address => mapping(uint8 => uint256)) tokenToExponentToIntegral; + mapping(address => mapping(address => UserRewardSnapshot)) userRewardSnapshot; + mapping(address => mapping(address => UserRewardSnapshotV2)) userRewardSnapshotV2; + } + + // chisel eval 'keccak256(abi.encode(uint256(keccak256("bao.storage.MultipleRewardCompoundingAccumulator")) - 1)) & ~bytes32(uint256(0xff))' + bytes32 private constant _ACCUMULATOR_STORAGE = 0x47ddc56aaabfe9761e2e64ce86720771c5fd1fd7ef0605da74e07d71de0e7900; + + function _getAccumulatorStorage() private pure returns (AccumulatorStorage storage $) { + // solhint-disable-next-line no-inline-assembly + assembly { + $.slot := _ACCUMULATOR_STORAGE + } + } + + // ── Events ────────────────────────────────────────────────────────────── + + event AccountMigrated(address indexed account, address indexed token); + event MigrationComplete(uint256 holderCount, uint256 tokenCount); + + // ── View ───────────────────────────────────────────────────────────────── + + /// @notice Returns the raw V1 and V2 integral values for an account and reward token. + /// @dev Always reads both slots. After remediation, both will be populated with the same value. + function balances(address account, address token) external view returns (uint256 oldIntegral, uint256 newIntegral) { + AccumulatorStorage storage $ = _getAccumulatorStorage(); + oldIntegral = uint256($.userRewardSnapshot[account][token].checkpoint.integral); + newIntegral = $.userRewardSnapshotV2[account][token].integral; + } + + // ── Remediation ───────────────────────────────────────────────────────── + + /// @notice Copy V1 snapshot data to V2 format for each holder/token pair. + /// @dev Pure data copy — no recalculation. Idempotent: already-migrated users are skipped. + /// @param tokens The reward token addresses to migrate. + /// @param holders The holder addresses to migrate. + function remediate(address[] calldata tokens, address[] calldata holders) external onlyOwner { + AccumulatorStorage storage $ = _getAccumulatorStorage(); + + for (uint256 i = 0; i < holders.length; i++) { + address account = holders[i]; + + for (uint256 j = 0; j < tokens.length; j++) { + address token = tokens[j]; + + // Skip if already migrated + UserRewardSnapshotV2 storage v2 = $.userRewardSnapshotV2[account][token]; + if (v2.integral != 0 || v2.timestamp != 0) { + continue; + } + + // Skip if no V1 data + UserRewardSnapshot storage v1 = $.userRewardSnapshot[account][token]; + if (v1.checkpoint.timestamp == 0) { + continue; + } + + // Copy V1 → V2 + v2.rewards.pending = v1.rewards.pending; + v2.rewards.claimed = v1.rewards.claimed; + v2.timestamp = v1.checkpoint.timestamp; + v2.integral = uint256(v1.checkpoint.integral); + + emit AccountMigrated(account, token); + } + } + + emit MigrationComplete(holders.length, tokens.length); + } +} diff --git a/script/src/DeployMintersShared.sol b/script/src/DeployMintersShared.sol index 6240353b..4b976a91 100644 --- a/script/src/DeployMintersShared.sol +++ b/script/src/DeployMintersShared.sol @@ -200,19 +200,17 @@ abstract contract DeployMintersShared is deployStabilityPool( StabilityPoolCollateral, stateData, - marketKey, + Config_MinterMarket(address(cfg)), minter, - cfg.wrappedCollateralToken(), - address(cfg) + cfg.wrappedCollateralToken() ); deployStabilityPool( StabilityPoolLeveraged, stateData, - marketKey, + Config_MinterMarket(address(cfg)), minter, - _predictAddress(marketKey, "leveraged"), - address(cfg) + _predictAddress(marketKey, "leveraged") ); } diff --git a/script/src/contracts/LeveragedToken.sol b/script/src/contracts/LeveragedToken.sol index c80ff24b..3e226709 100644 --- a/script/src/contracts/LeveragedToken.sol +++ b/script/src/contracts/LeveragedToken.sol @@ -8,13 +8,9 @@ import {MintableBurnableERC20_v1} from "@bao/MintableBurnableERC20_v1.sol"; import {IMintableRole} from "@bao/interfaces/IMintableRole.sol"; import {IBurnableRole} from "@bao/interfaces/IBurnableRole.sol"; import {Config_MinterMarket, IMarketConfig, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; -import {LibString} from "@solady/utils/LibString.sol"; - /// @notice Harbor leveraged token deployment logic. /// @dev Leveraged tokens are unique per market (e.g., hsFXUSD-BTC for BTC::fxUSD market). abstract contract LeveragedToken is HarborFactoryDeployer { - using LibString for string; - // ========== LEVERAGED TOKEN DEPLOYMENT ========== /// @notice Deploy a leveraged token and grant minter roles. @@ -23,12 +19,10 @@ abstract contract LeveragedToken is HarborFactoryDeployer { Config_MinterMarket marketConfig ) internal returns (address leveragedToken) { string memory marketKey = MinterMarketConfigLib.salt(marketConfig); - string memory peg = MinterMarketConfigLib.peg(marketConfig); - string memory collateral = MinterMarketConfigLib.collateral(marketConfig); string memory leveragedKey = string.concat(marketKey, "::leveraged"); - string memory tokenName = string.concat("Harbor sail: variable leveraged long ", collateral, " against ", peg); - string memory tokenSymbol = string.concat("hs", collateral.upper(), "-", peg.upper()); + string memory tokenName = MinterMarketConfigLib.leveragedName(marketConfig); + string memory tokenSymbol = MinterMarketConfigLib.leveragedSymbol(marketConfig); console.log(" > %s", leveragedKey); console.log(" Name: %s", tokenName); diff --git a/script/src/contracts/StabilityPool.sol b/script/src/contracts/StabilityPool.sol index f52bbfaf..ad67d257 100644 --- a/script/src/contracts/StabilityPool.sol +++ b/script/src/contracts/StabilityPool.sol @@ -8,6 +8,8 @@ import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; import {DeploymentState} from "@bao-script/deployment/DeploymentState.sol"; import {StabilityPool_v2} from "@harbor/minter/StabilityPool_v2.sol"; +import {StabilityPool_v3} from "@harbor/minter/StabilityPool_v3.sol"; +import {Config_MinterMarket, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; /// @notice Config interface for stability pool deployment parameters. interface IStabilityPoolMarketConfig { @@ -17,7 +19,7 @@ interface IStabilityPoolMarketConfig { function minTotalSupply() external view returns (uint256); } -/// @notice Harbor StabilityPool_v2 deployment logic. +/// @notice Harbor StabilityPool deployment logic. /// @dev Each market has TWO stability pools: Collateral (wrapped collateral) and Leveraged (leveraged token). /// @dev Both pools grant: REBALANCER_ROLE, REWARD_DEPOSITOR_ROLE to StabilityPoolManager. abstract contract StabilityPool is HarborFactoryDeployer { @@ -30,32 +32,49 @@ abstract contract StabilityPool is HarborFactoryDeployer { function deployStabilityPoolImplementation( string memory spType, DeploymentTypes.State memory stateData, - string memory marketKey, + Config_MinterMarket marketConfig, address minter, - address liquidationToken, - address configContract + address liquidationToken ) internal virtual returns (address impl) { + string memory marketKey = MinterMarketConfigLib.salt(marketConfig); string memory spKey = string.concat(marketKey, "::", spType); console.log(" > %s", spKey); - IStabilityPoolMarketConfig cfg = IStabilityPoolMarketConfig(configContract); + string memory liqSymbol = keccak256(bytes(spType)) == keccak256("stabilityPoolCollateral") + ? MinterMarketConfigLib.collateral(marketConfig) + : MinterMarketConfigLib.leveragedSymbol(marketConfig); + + IStabilityPoolMarketConfig cfg = IStabilityPoolMarketConfig(address(marketConfig)); + string memory tokenName = string.concat( + "Harbor stability pool: ", + MinterMarketConfigLib.peggedSymbol(marketConfig), + " (", + liqSymbol, + ")" + ); + string memory tokenSymbol = string.concat("hsp", MinterMarketConfigLib.peg(marketConfig), "(", liqSymbol, ")"); + impl = address( - new StabilityPool_v2( + new StabilityPool_v3( minter, liquidationToken, cfg.stabilityPoolWithdrawalDelay(), cfg.stabilityPoolWithdrawalPeriod(), - cfg.minTotalSupply() + cfg.minTotalSupply(), + tokenName, + tokenSymbol ) ); - console.log(" Impl: %s", impl); + console.log(" Impl: %s", impl); + console.log(" Name: %s", tokenName); + console.log(" Symbol: %s", tokenSymbol); DeploymentState.recordImplementation( stateData, DeploymentTypes.ImplementationRecord({ proxy: spKey, - contractSource: "@harbor/minter/StabilityPool_v2.sol", - contractType: "StabilityPool_v2", + contractSource: "@harbor/minter/StabilityPool_v3.sol", + contractType: "StabilityPool_v3", implementation: impl, deploymentTime: uint64(block.timestamp) }) @@ -66,26 +85,19 @@ abstract contract StabilityPool is HarborFactoryDeployer { function deployStabilityPool( string memory spType, DeploymentTypes.State memory stateData, - string memory marketKey, + Config_MinterMarket marketConfig, address minter, - address liquidationToken, - address configContract + address liquidationToken ) internal returns (address proxy) { + string memory marketKey = MinterMarketConfigLib.salt(marketConfig); string memory spKey = string.concat(marketKey, "::", spType); console.log(" > %s", spKey); - address impl = deployStabilityPoolImplementation( - spType, - stateData, - marketKey, - minter, - liquidationToken, - configContract - ); + address impl = deployStabilityPoolImplementation(spType, stateData, marketConfig, minter, liquidationToken); - IStabilityPoolMarketConfig cfg = IStabilityPoolMarketConfig(configContract); + IStabilityPoolMarketConfig cfg = IStabilityPoolMarketConfig(address(marketConfig)); bytes memory initData = abi.encodeCall( - StabilityPool_v2.initialize, + StabilityPool_v3.initialize, (owner(), cfg.stabilityPoolEarlyWithdrawalFeeRatio(), treasury()) ); diff --git a/script/test/SPv3MigrationTest.t.sol b/script/test/SPv3MigrationTest.t.sol new file mode 100644 index 00000000..62ff829e --- /dev/null +++ b/script/test/SPv3MigrationTest.t.sol @@ -0,0 +1,259 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.28 <0.9.0; + +import {BaoTest} from "@bao-test/BaoTest.sol"; +import {HarborFactoryDeployer} from "@harbor/../script/src/HarborFactoryDeployer.sol"; +import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; +import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; +import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; +import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IMinter} from "src/interfaces/IMinter.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {ForceMigrateAccumulator_v1} from "script/patch/ForceMigrateAccumulator_v1.sol"; +import {StabilityPool_v3} from "src/minter/StabilityPool_v3.sol"; +import {console2 as console} from "forge-std/console2.sol"; + +/// @title SPv3MigrationTest +/// @notice Mainnet fork test that validates the full migration lifecycle: +/// snapshot → upgrade to ForceMigrateAccumulator_v1 → remediate → upgrade to StabilityPool_v3 +/// Verifies all user-visible values are preserved and post-migration operations work. +/// +/// Run: forge test --mc SPv3MigrationTest --fork-url mainnet -vv +contract SPv3MigrationTest is BaoTest, HarborFactoryDeployer { + // ── Addresses ─────────────────────────────────────────────────────────── + + address spc; // collateral stability pool (ETH::fxUSD) + address minter; + address wrappedCollateral; + address peg; + address proxyOwner; + + // ── Per-holder snapshot ───────────────────────────────────────────────── + + struct HolderState { + address holder; + uint256 balance; + uint256 claimableCollateral; + uint256 claimedCollateral; + } + + HolderState[] pre; + + // ── Holders for ETH::fxUSD::stabilityPoolCollateral ───────────────────── + // Source: Etherscan tokentx API query (doc/migration-sp-v3-holders.md) + + function _holders() internal pure returns (address[] memory h) { + h = new address[](24); + h[0] = 0x13F210c8bAf5f5DBAFf3E917E2e5A49E73BBAF12; + h[1] = 0x1a9152528AEFbcD9E5df4E0770f4F510e7056913; + h[2] = 0x31632636D664895f1BD9D03f5F7c162A2A6980EB; + h[3] = 0x3dFc49e5112005179Da613BdE5973229082dAc35; + h[4] = 0x4382916A88b6EEd40530ef47deD0D402563dDACa; + h[5] = 0x4dAf8ce9D729ca4F121381ec4B22123627C1C004; + h[6] = 0x50b3Bf1B3119afc37A25c841c06C1BDD05Da1Fab; + h[7] = 0x742fC5146d7Ff18291E3B7499811AD87015Fc7E4; + h[8] = 0x7F14A89F5333A334C0EA3B5AAA7Dc2c8E1C72de6; + h[9] = 0x81253f3Fc43D5e399610beE4D7a235826A7663b8; + h[10] = 0x8b7698945dBCedF33F5e8d9E62B1Af8101318575; + h[11] = 0x9bABfC1A1952a6ed2caC1922BFfE80c0506364a2; + h[12] = 0x9db1D99D1C79A3A2C0123fcd0abB13d9B7c75657; + h[13] = 0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e; + h[14] = 0xb9ab9578a34a05c86124c399735fdE44dEc80E7F; + h[15] = 0xbA26035B9CD76cdA5b767A966b8A3392E476Fc0F; + h[16] = 0xc16A44D0759ec03c677E97eF020a2345d4dC27Fb; + h[17] = 0xDC1330EF8dc913C39bd29F9523418eEEacEf03D6; + h[18] = 0xdd9c0BB1102D45357bEC81BbdffBb615D64C0ff9; + h[19] = 0xE39165aDE355988EFb24dA4f2403971101134CAB; + h[20] = 0xeEbF37253066532aFeB3FAcb4F2a411703353A83; + h[21] = 0xef5E7606769400DC667DDC520C911D84405e61b7; + h[22] = 0xF7e64540f42094497E2De0F06232992b03942898; + h[23] = 0xf7e9CAaaEEb6cC9657E1Dd490F044114AecC31B2; + } + + // ── Setup ─────────────────────────────────────────────────────────────── + + function setUp() public { + vm.createSelectFork(vm.rpcUrl("mainnet")); + _setSaltPrefix("harbor_v1"); + spc = _predictAddress("ETH", "fxUSD", "stabilityPoolCollateral"); + minter = _predictAddress("ETH", "fxUSD", "minter"); + peg = _predictAddress("ETH", "pegged"); + wrappedCollateral = IMinter(minter).WRAPPED_COLLATERAL_TOKEN(); + proxyOwner = IBaoOwnable(spc).owner(); + + _snapshotAll(); + _runMigration(); + } + + function _snapshotAll() internal { + address[] memory holders = _holders(); + for (uint256 i = 0; i < holders.length; i++) { + pre.push( + HolderState({ + holder: holders[i], + balance: IStabilityPool(spc).assetBalanceOf(holders[i]), + claimableCollateral: IMultipleRewardAccumulator(spc).claimable(holders[i], wrappedCollateral), + claimedCollateral: IMultipleRewardAccumulator(spc).claimed(holders[i], wrappedCollateral) + }) + ); + } + } + + function _runMigration() internal { + // Capture reward tokens BEFORE upgrading (pauser fallback reverts all other calls) + address[] memory tokens = IMultipleRewardDistributor(spc).activeRewardTokens(); + address[] memory holders = _holders(); + + // 1. Upgrade to migration contract + vm.prank(proxyOwner); + UUPSUpgradeable(spc).upgradeToAndCall(address(new ForceMigrateAccumulator_v1()), ""); + + ForceMigrateAccumulator_v1 mig = ForceMigrateAccumulator_v1(spc); + + // 2. Snapshot pre-remediation balances + uint256[][] memory preOld = new uint256[][](holders.length); + uint256[][] memory preNew = new uint256[][](holders.length); + for (uint256 i = 0; i < holders.length; i++) { + preOld[i] = new uint256[](tokens.length); + preNew[i] = new uint256[](tokens.length); + for (uint256 j = 0; j < tokens.length; j++) { + (preOld[i][j], preNew[i][j]) = mig.balances(holders[i], tokens[j]); + } + } + + // 3. Remediate: copy V1 → V2 for all holders + vm.prank(proxyOwner); + mig.remediate(tokens, holders); + + // 4. Verify post-remediation for each case + for (uint256 i = 0; i < holders.length; i++) { + for (uint256 j = 0; j < tokens.length; j++) { + (uint256 postOld, uint256 postNew) = mig.balances(holders[i], tokens[j]); + string memory label = string.concat(vm.toString(holders[i]), " token ", vm.toString(j)); + + // old slot is never modified by remediate + assertEq(postOld, preOld[i][j], string.concat("old unchanged: ", label)); + + if (preNew[i][j] != 0) { + // already migrated: new unchanged (remediate skipped this user) + assertEq(postNew, preNew[i][j], string.concat("already migrated, new unchanged: ", label)); + } else if (preOld[i][j] != 0) { + // was unmigrated: new == old (remediate copied) + assertEq(postNew, preOld[i][j], string.concat("migrated, new == old: ", label)); + } else { + // no data: both still zero + assertEq(postNew, 0, string.concat("no data, new still 0: ", label)); + } + } + } + + // 5. Upgrade to v3 + vm.prank(proxyOwner); + UUPSUpgradeable(spc).upgradeToAndCall( + address( + new StabilityPool_v3( + minter, + wrappedCollateral, + 3600, + 90000, + 1 ether, + "Harbor SP: haETH-fxUSD collateral", + "spETH-FXUSD-C" + ) + ), + "" + ); + } + + // ── Tests: State Preservation ─────────────────────────────────────────── + + function test_balancesPreserved() public view { + for (uint256 i = 0; i < pre.length; i++) { + assertEq( + IStabilityPool(spc).assetBalanceOf(pre[i].holder), + pre[i].balance, + string.concat("balance: ", vm.toString(pre[i].holder)) + ); + } + } + + function test_claimablePreserved() public view { + for (uint256 i = 0; i < pre.length; i++) { + assertEq( + IMultipleRewardAccumulator(spc).claimable(pre[i].holder, wrappedCollateral), + pre[i].claimableCollateral, + string.concat("claimable: ", vm.toString(pre[i].holder)) + ); + } + } + + function test_claimedPreserved() public view { + for (uint256 i = 0; i < pre.length; i++) { + assertEq( + IMultipleRewardAccumulator(spc).claimed(pre[i].holder, wrappedCollateral), + pre[i].claimedCollateral, + string.concat("claimed: ", vm.toString(pre[i].holder)) + ); + } + } + + function test_totalSupplyPreserved() public view { + uint256 total = 0; + for (uint256 i = 0; i < pre.length; i++) { + total += pre[i].balance; + } + assertApproxEqAbs(IStabilityPool(spc).totalAssetSupply(), total, pre.length, "totalSupply ~ sum of balances"); + } + + // ── Tests: Post-Migration Operations ──────────────────────────────────── + + function test_deposit() public { + address newUser = makeAddr("newUser"); + uint256 amount = 1 ether; + deal(peg, newUser, amount); + vm.startPrank(newUser); + IERC20(peg).approve(spc, amount); + IStabilityPool(spc).deposit(amount, newUser, 0); + vm.stopPrank(); + assertEq(IStabilityPool(spc).assetBalanceOf(newUser), amount, "deposit works on v3"); + } + + function test_claim() public { + for (uint256 i = 0; i < pre.length; i++) { + if (pre[i].claimableCollateral == 0) continue; + uint256 balBefore = IERC20(wrappedCollateral).balanceOf(pre[i].holder); + vm.prank(pre[i].holder); + IMultipleRewardAccumulator(spc).claim(); + uint256 received = IERC20(wrappedCollateral).balanceOf(pre[i].holder) - balBefore; + assertEq(received, pre[i].claimableCollateral, string.concat("claim: ", vm.toString(pre[i].holder))); + break; // test one holder + } + } + + function test_transfer() public { + // Find a holder with balance + for (uint256 i = 0; i < pre.length; i++) { + if (pre[i].balance == 0) continue; + address from = pre[i].holder; + address to = makeAddr("recipient"); + uint256 amount = pre[i].balance / 10; + + vm.prank(from); + StabilityPool_v3(spc).transfer(to, amount); + + assertEq(StabilityPool_v3(spc).balanceOf(to), amount, "recipient balance"); + assertEq(StabilityPool_v3(spc).balanceOf(from), pre[i].balance - amount, "sender balance"); + break; + } + } + + function test_erc20Metadata() public view { + StabilityPool_v3 sp3 = StabilityPool_v3(spc); + assertEq(bytes(sp3.name()).length > 0, true, "name not empty"); + assertEq(bytes(sp3.symbol()).length > 0, true, "symbol not empty"); + assertEq(sp3.decimals(), 18, "decimals"); + console.log(" name: %s", sp3.name()); + console.log(" symbol: %s", sp3.symbol()); + } +} diff --git a/src/minter/StabilityPool_v3.sol b/src/minter/StabilityPool_v3.sol new file mode 100644 index 00000000..9a130ccd --- /dev/null +++ b/src/minter/StabilityPool_v3.sol @@ -0,0 +1,793 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.30; + +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; + +import {Token} from "@bao/Token.sol"; +import {TokenHolder} from "@bao/TokenHolder.sol"; + +import {DecrementalFloatingPoint} from "src/math/DecrementalFloatingPoint.sol"; +import {MultipleRewardCompoundingAccumulator} from "src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol"; + +import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; +import {IMinter} from "src/interfaces/IMinter.sol"; +// solhint-disable not-rely-on-time +// slither-disable-start timestamp + +/// @title StabilityPool_v3 +/// @notice Stability pool with rebasing ERC20 interface and cleaned-up accumulator. +/// `balanceOf` returns the compounded real value (same as `assetBalanceOf`). +/// `totalSupply` returns `totalAssetSupply`. Transfers checkpoint both parties. +/// Rebases downward on liquidation events (like stETH). The autocompounding vault +/// serves as the non-rebasing wrapped version (like wstETH). +/// +/// Uses the v3 accumulator which requires all users to have been force-migrated +/// from the legacy uint192 integral format. No on-demand migration fallback. +/// +/// @author rootminus0x1 +/// @dev Uses UUPS proxy, erc7201 storage +/// @custom:oz-upgrades +/// @custom:oz-upgrades-from src/minter/StabilityPool_v2.sol:StabilityPool_v2 +// solhint-disable-next-line contract-name-capwords +contract StabilityPool_v3 is + Initializable, + UUPSUpgradeable, + MultipleRewardCompoundingAccumulator, + TokenHolder, + IStabilityPool, + IERC20Metadata +{ + using SafeERC20 for IERC20; + using DecrementalFloatingPoint for uint128; + + /************* + * Constants * + *************/ + + /// The role used for reward manager in super contracts + /// @dev we define it here in the most derived contract to avoid clashes + uint256 private constant _REWARD_MANAGER_ROLE = _ROLE_0; + + uint256 public constant REBALANCER_ROLE = _ROLE_1; + + uint256 private constant _REWARD_DEPOSITOR_ROLE = _ROLE_2; + + /// @notice Role that exempts an account from early-withdrawal fees + uint256 public constant EXEMPT_WITHDRAWAL_FEE_ROLE = _ROLE_3; + + uint256 private constant _MAX_EARLY_WITHDRAWAL_FEE = 1 ether; + + // these variables are set in the constructor, not the initializer, to improve contract size and gas usage + // to change them the contract must be upgraded + + /// @inheritdoc IStabilityPool + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + address public immutable ASSET_TOKEN; + + /// @inheritdoc IStabilityPool + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + address public immutable LIQUIDATION_TOKEN; + + /// @dev ERC20 name stored as two bytes32 (up to 64 characters) + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + bytes32 private immutable _ERC20_NAME_0; + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + bytes32 private immutable _ERC20_NAME_1; + + /// @dev ERC20 symbol stored as bytes32 (up to 32 characters) + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + bytes32 private immutable _ERC20_SYMBOL; + + /// @dev ERC20 decimals, matching the ASSET_TOKEN + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + uint8 private immutable _ERC20_DECIMALS; + + /// @dev the pool cannot have less than this supply once it has reached that supply + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + uint256 public immutable MIN_TOTAL_ASSET_SUPPLY; + + /// @dev the minimum deposit size, used to guarantee the MIN_TOTAL_ASSET_SUPPLY if non-zero + /// Although strictly it is only needed for the first deposit, it's a small amount and so not a big penalty for all + /// with the added protection of making multiple small deposit attack vectors harder + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + uint256 public immutable MIN_DEPOSIT; // = MIN_TOTAL_ASSET_SUPPLY; + + /// @dev immutable withdrawal window configuration + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + uint64 public immutable WITHDRAWAL_START_DELAY; + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + uint64 public immutable WITHDRAWAL_END_WINDOW; + + /*********** + * Structs * + ***********/ + + /// @dev The token balance struct. The compiler will pack this into single `uint256`. + /// + /// @param product The encoding product data, see the comments of `DecrementalFloatingPoint`. + /// @param amount The amount of token currently. + /// @param updatedAt The timestamp in day when the struct is updated. + struct TokenBalance { + uint128 product; // TODO: this could be 124 bits + uint104 amount; // This has to store 1e36 + uint40 updatedAt; // TODO: this could be days rather than seconds requiring fewer bits + } + + /// @dev The withdrawal request window for an account + struct WithdrawalRequest { + uint64 start; + uint64 end; + } + + /// @dev Packed fee payment configuration: fits in one 256-bit slot + /// @param feeAddress The address that receives early withdrawal fees (160 bits) + /// @param earlyWithdrawalFee The fee ratio scaled by 1e18 (uint96) + struct FeePayment { + address feeAddress; + uint96 earlyWithdrawalFee; + } + + /************* + * Variables * + *************/ + + // Share-with-proxy Storage + // ------------------------ + /// @custom:storage-location erc7201:bao.storage.StabilityPool + struct StabilityPoolStorage { + /// @dev The TokenBalance struct for current total supply. + TokenBalance totalAssetSupply; + /// @dev Mapping account address to TokenBalance struct. Accessed via assetBalanceOf + mapping(address => TokenBalance) assetBalances; + /// @notice Mapping from index to history totalSupply. + /// If there are multiple updates at the same timestamp, only the last one will be recorded. + mapping(uint256 => TokenBalance) totalAssetSupplyHistory; + uint256 totalAssetSupplyHistoryLength; // number of total supply history records + /// @notice The address of token wrapper for liquidated base token; + // address wrapper; + /// @notice Error trackers for the error correction in the loss calculation. + uint256 lastAssetLossError; + /// @notice Mapping from account to withdrawal request + mapping(address => WithdrawalRequest) withdrawalRequests; + /// @dev Packed fee configuration (address + uint96) + FeePayment feePayment; + } + + /// @custom:storage-location erc7201:bao.storage.StabilityPool_v3 + struct StabilityPoolERC20AllowancesStorage { + /// @dev ERC20 allowances: owner => spender => amount + mapping(address => mapping(address => uint256)) allowances; + } + + // chisel eval 'keccak256(abi.encode(uint256(keccak256("bao.storage.StabilityPool_v3")) - 1)) & ~bytes32(uint256(0xff))' + bytes32 private constant _V3_STORAGE = 0xb4346888fe08dd20fe3aa583577b90a0e39bc6ca623364fcc9a4cf38a1ec7f00; + + // internal as it is used in testing + function _getERC20Storage() internal pure returns (StabilityPoolERC20AllowancesStorage storage $) { + // solhint-disable-next-line no-inline-assembly + assembly { + $.slot := _V3_STORAGE + } + } + + // chisel eval 'keccak256(abi.encode(uint256(keccak256("bao.storage.StabilityPool")) - 1)) & ~bytes32(uint256(0xff))' + bytes32 private constant _STABILITYPOOL_STORAGE = + 0xcb62d703974340239a82baeadff6ad7af3673eb85d9779bde2587fc9e0e3e400; + + // internal as it is used in testing + function _getStabilityPoolStorage() internal pure returns (StabilityPoolStorage storage $) { + // solhint-disable-next-line no-inline-assembly + assembly { + $.slot := _STABILITYPOOL_STORAGE + } + } + + /*************** + * Constructor * + ***************/ + + function initialize(address owner_, uint256 earlyWithdrawalFee_, address feeAddress_) external initializer { + _initializeOwner(owner_); + __UUPSUpgradeable_init(); + __ReentrancyGuardTransient_init(); + + StabilityPoolStorage storage $ = _getStabilityPoolStorage(); + + // initialize fee configuration on the proxy + if (earlyWithdrawalFee_ > _MAX_EARLY_WITHDRAWAL_FEE) { + revert InvalidFee(earlyWithdrawalFee_); + } + if (feeAddress_ == address(0)) { + revert InvalidFeeAddress(feeAddress_); + } + $.feePayment = FeePayment({feeAddress: feeAddress_, earlyWithdrawalFee: uint96(earlyWithdrawalFee_)}); + + TokenBalance memory initialSupply = TokenBalance({ + product: DecrementalFloatingPoint.init(), + amount: 0, + updatedAt: uint40(block.timestamp - 1) // set to 1 second ago so this is sure to be the start of history + }); + $.totalAssetSupply = initialSupply; + $.totalAssetSupplyHistory[0] = initialSupply; + $.totalAssetSupplyHistoryLength = 1; + } + + /// @notice In UUPS proxies the constructor is used only to stop the implementation being initialized to any version + /// https://forum.openzeppelin.com/t/what-does-disableinitializers-function-mean/28730 + /// @custom:oz-upgrades-unsafe-allow constructor + error TransferExceedsBalance(address from, uint256 amount, uint256 balance); + error InsufficientAllowance(address spender, uint256 currentAllowance, uint256 needed); + error StringTooLong(); + + constructor( + address minter_, + address liquidationToken_, + uint256 withdrawalStartDelay_, + uint256 withdrawalEndWindow_, + uint256 minTotalAssetSupply, + string memory name_, + string memory symbol_ + ) MultipleRewardCompoundingAccumulator(_REWARD_MANAGER_ROLE, _REWARD_DEPOSITOR_ROLE, 1 weeks) { + _disableInitializers(); + address asset = IMinter(minter_).PEGGED_TOKEN(); + Token.sanityCheckERC20Token(asset); + // slither-disable-next-line missing-zero-check + ASSET_TOKEN = asset; + Token.sanityCheckERC20Token(liquidationToken_); + if ( + liquidationToken_ != IMinter(minter_).WRAPPED_COLLATERAL_TOKEN() && + liquidationToken_ != IMinter(minter_).LEVERAGED_TOKEN() + ) { + revert InvalidLiquidationToken(liquidationToken_); + } + LIQUIDATION_TOKEN = liquidationToken_; + + (_ERC20_NAME_0, _ERC20_NAME_1) = _packString64(name_); + (_ERC20_SYMBOL, ) = _packString64(symbol_); + _ERC20_DECIMALS = IERC20Metadata(asset).decimals(); + + if (withdrawalEndWindow_ == 0) { + revert InvalidWithdrawalWindow(withdrawalStartDelay_, withdrawalEndWindow_); + } + + // set these two to the same thing, for public visibility + // their purpose is the same thing - preventing a complete emptying of a non-empty pool + MIN_TOTAL_ASSET_SUPPLY = minTotalAssetSupply; + MIN_DEPOSIT = minTotalAssetSupply; + + // set immutable withdrawal window params + WITHDRAWAL_START_DELAY = uint64(withdrawalStartDelay_); + WITHDRAWAL_END_WINDOW = uint64(withdrawalEndWindow_); + } + + /// @notice The check that allow this contract to be upgraded: + /// In UUPS proxies the implementation is responsible for upgrading itself and only owners can upgrade this contract. + function _authorizeUpgrade(address) internal override onlyOwner {} // solhint-disable-line no-empty-blocks + + /************************* + * Public View Functions * + *************************/ + + /// @inheritdoc IStabilityPool + function totalAssetSupply() external view returns (uint256 totalSupply_) { + StabilityPoolStorage storage $ = _getStabilityPoolStorage(); + totalSupply_ = $.totalAssetSupply.amount; + } + + /// @inheritdoc IStabilityPool + // solhint-disable-next-line explicit-types + function totalAssetSupplyHistory(uint index) external view returns (uint40 atDay, uint256 amount) { + StabilityPoolStorage storage $ = _getStabilityPoolStorage(); + TokenBalance memory record = $.totalAssetSupplyHistory[index]; + atDay = record.updatedAt; + amount = record.amount; + } + + /// @inheritdoc IStabilityPool + function assetBalanceOf(address account) external view returns (uint256 amount) { + StabilityPoolStorage storage $ = _getStabilityPoolStorage(); + TokenBalance memory balance = $.assetBalances[account]; + amount = _getCompoundedBalance(balance.amount, balance.product, $.totalAssetSupply.product); + } + + /// @inheritdoc IStabilityPool + function lastAssetLossError() external view returns (uint256) { + StabilityPoolStorage storage $ = _getStabilityPoolStorage(); + return $.lastAssetLossError; + } + + // expose claimable from parent via interface + + /// @inheritdoc IStabilityPool + /// @notice Returns the configured withdrawal request window for an account. + function getWithdrawalRequest(address account) external view returns (uint64 start, uint64 end) { + StabilityPoolStorage storage $ = _getStabilityPoolStorage(); + WithdrawalRequest memory request = $.withdrawalRequests[account]; + start = request.start; + end = request.end; + } + + /// @inheritdoc IStabilityPool + /// @notice Returns the current early withdrawal fee ratio (scaled by 1e18). + function getEarlyWithdrawalFee() external view returns (uint256) { + StabilityPoolStorage storage $ = _getStabilityPoolStorage(); + return uint256($.feePayment.earlyWithdrawalFee); + } + + /// @inheritdoc IStabilityPool + /// @notice Returns the current fee recipient address for early withdrawal fees. + function getFeeAddress() external view returns (address) { + StabilityPoolStorage storage $ = _getStabilityPoolStorage(); + return $.feePayment.feeAddress; + } + + /// @inheritdoc IStabilityPool + /// @notice Returns the global withdrawal window configuration. + function getWithdrawalWindow() external view returns (uint64 startDelay, uint64 endWindow) { + startDelay = WITHDRAWAL_START_DELAY; + endWindow = WITHDRAWAL_END_WINDOW; + } + /*********************** + * ERC20 View Functions * + ***********************/ + + /// @notice Returns the ERC20 name of this stability pool token. + function name() external view returns (string memory) { + return _unpackString64(_ERC20_NAME_0, _ERC20_NAME_1); + } + + /// @notice Returns the ERC20 symbol of this stability pool token. + function symbol() external view returns (string memory) { + return _unpackString64(_ERC20_SYMBOL, bytes32(0)); + } + + /// @notice Returns the ERC20 decimals, matching the underlying asset token. + function decimals() external view returns (uint8) { + return _ERC20_DECIMALS; + } + + /// @notice Returns the compounded balance of `account` (rebasing ERC20). + /// @dev Same as assetBalanceOf. Rebases downward on liquidation events. + function balanceOf(address account) external view returns (uint256) { + StabilityPoolStorage storage $ = _getStabilityPoolStorage(); + TokenBalance memory balance = $.assetBalances[account]; + return _getCompoundedBalance(balance.amount, balance.product, $.totalAssetSupply.product); + } + + /// @notice Returns the total supply of stability pool tokens. + function totalSupply() external view returns (uint256) { + StabilityPoolStorage storage $ = _getStabilityPoolStorage(); + return $.totalAssetSupply.amount; + } + + /// @notice Returns the ERC20 allowance of `spender` for `owner_`. + function allowance(address owner_, address spender) external view returns (uint256) { + StabilityPoolERC20AllowancesStorage storage $ = _getERC20Storage(); + return $.allowances[owner_][spender]; + } + + /*************************** + * ERC20 Mutator Functions * + ***************************/ + + /// @notice Transfer stability pool tokens to `to`. Checkpoints both parties. + function transfer(address to, uint256 amount) external nonReentrant returns (bool) { + _transferBalance(_msgSender(), to, amount); + return true; + } + + /// @notice Transfer stability pool tokens from `from` to `to` using allowance. + function transferFrom(address from, address to, uint256 amount) external nonReentrant returns (bool) { + StabilityPoolERC20AllowancesStorage storage $ = _getERC20Storage(); + address spender = _msgSender(); + uint256 currentAllowance = $.allowances[from][spender]; + if (currentAllowance != type(uint256).max) { + if (currentAllowance < amount) { + revert InsufficientAllowance(spender, currentAllowance, amount); + } + unchecked { + $.allowances[from][spender] = currentAllowance - amount; + } + } + _transferBalance(from, to, amount); + return true; + } + + /// @notice Approve `spender` to transfer up to `amount` of the caller's tokens. + /// @dev Since balanceOf rebases downward on liquidation, an approval may exceed + /// the owner's balance after a loss event. transferFrom transfers up to + /// min(allowance, balance). Same behavior as stETH. + function approve(address spender, uint256 amount) external returns (bool) { + StabilityPoolERC20AllowancesStorage storage $ = _getERC20Storage(); + $.allowances[_msgSender()][spender] = amount; + emit Approval(_msgSender(), spender, amount); + return true; + } + + /**************************** + * Public Mutator Functions * + ****************************/ + + /// @inheritdoc IStabilityPool + // slither-disable-next-line reentrancy-benign,reentrancy-no-eth + function deposit( + uint256 assetAmount, + address receiver, + uint256 minAmount + ) external nonReentrant returns (uint256 assetsDeposited) { + if (receiver == address(0)) { + revert InvalidReceiver(address(0)); + } + address sender = _msgSender(); + + assetsDeposited = Token.allOf(sender, ASSET_TOKEN, assetAmount); + if (assetsDeposited < minAmount) { + revert DepositAmountLessThanMinimum(assetsDeposited, minAmount); + } + + // Required for ERC20 compatibility - we're actually minting ourselves + StabilityPoolStorage storage $ = _getStabilityPoolStorage(); + + // If depositing before the end of a valid withdrawal window, cancel the request + WithdrawalRequest memory request = $.withdrawalRequests[sender]; + if (request.start != 0 && request.end > request.start && block.timestamp <= request.end) { + $.withdrawalRequests[sender] = WithdrawalRequest({start: 0, end: 0}); + emit WithdrawalRequestCancelled(sender); + } + + // Emit deposit event for off-chain indexers and auditing + emit Deposit(sender, receiver, assetsDeposited); + + // get the assets from the sender + IERC20(ASSET_TOKEN).safeTransferFrom(sender, address(this), assetsDeposited); + // send their representative to the gauge, if one + _checkpoint(receiver); + + // do the deposit + // update the global record + // It should never exceed `type(uint104).max`. + TokenBalance memory supply = $.totalAssetSupply; + supply.amount += uint104(assetsDeposited); + if (supply.amount < MIN_TOTAL_ASSET_SUPPLY) { + revert DepositAmountLessThanMinimum(assetsDeposited, MIN_TOTAL_ASSET_SUPPLY); + } + supply.updatedAt = uint40(block.timestamp); + + _recordTotalSupply(supply); + + // update the user record + TokenBalance memory balance = $.assetBalances[receiver]; + balance.amount += uint104(assetsDeposited); + $.assetBalances[receiver] = balance; + emit UserDepositChange(receiver, balance.amount, 0); + } + + /// @inheritdoc IStabilityPool + // slither-disable-next-line reentrancy-no-eth,reentrancy-eth,reentrancy-unlimited-gas,reentrancy-benign + // slither-disable-next-line cyclomatic-complexity + function withdraw( + uint256 assetAmount, + address receiver, + uint256 minAmount + ) external virtual nonReentrant returns (uint256 assetsWithdrawn) { + if (receiver == address(0)) { + revert InvalidReceiver(address(0)); + } + + StabilityPoolStorage storage $ = _getStabilityPoolStorage(); + + address sender = _msgSender(); + // slither-disable-next-line reentrancy-no-eth + _checkpoint(sender); + + // Read any existing withdrawal request (optional) + WithdrawalRequest memory request = $.withdrawalRequests[sender]; + + TokenBalance memory balance = $.assetBalances[sender]; + if (assetAmount == type(uint256).max) { + assetsWithdrawn = balance.amount; + } else if (assetAmount > balance.amount) { + revert WithdrawAmountExceedsBalance(assetAmount, balance.amount); + } else { + assetsWithdrawn = assetAmount; + } + if (assetsWithdrawn == 0) { + revert WithdrawZeroAmount(); + } + if (assetsWithdrawn < minAmount) { + revert WithdrawAmountLessThanMinimum(assetsWithdrawn, minAmount); + } + + // Floor the total supply at the minimum. + // assetsWithdrawn is the user's requested amount (or balance if max). + // Both assetsWithdrawn and any fee come out of total supply. + TokenBalance memory supply = $.totalAssetSupply; + if (supply.amount - assetsWithdrawn < MIN_TOTAL_ASSET_SUPPLY) { + assetsWithdrawn = supply.amount - MIN_TOTAL_ASSET_SUPPLY; + } + + // Determine fee policy + // - If no request: fee applies + // - If request exists: fee applies outside [start, end]; no fee during window + uint256 feeAmount = 0; + bool hasRequest = (request.start != 0 && request.end > request.start); + bool inWindow = hasRequest && block.timestamp >= request.start && block.timestamp <= request.end; + // Role-based fee exemption: addresses with EXEMPT_WITHDRAWAL_FEE_ROLE never pay early-withdrawal fees + bool isExempt = hasAnyRole(sender, EXEMPT_WITHDRAWAL_FEE_ROLE); + if (!inWindow && !isExempt) { + feeAmount = Math.mulDiv( + assetsWithdrawn, + uint256($.feePayment.earlyWithdrawalFee), + 1 ether, + Math.Rounding.Ceil + ); + assetsWithdrawn -= feeAmount; + } + + // Close any existing withdrawal request after successful withdrawal + if (hasRequest) { + $.withdrawalRequests[sender] = WithdrawalRequest({start: 0, end: 0}); + emit WithdrawalRequestUpdated(sender, request.start, 0); + } + emit Withdraw(sender, receiver, assetsWithdrawn); + + // update the global record + unchecked { + supply.amount -= uint104(assetsWithdrawn + feeAmount); + supply.updatedAt = uint40(block.timestamp); + } + _recordTotalSupply(supply); + + // update the user record + unchecked { + balance.amount -= uint104(assetsWithdrawn + feeAmount); + } + $.assetBalances[sender] = balance; + + emit UserDepositChange(sender, balance.amount, 0); + + IERC20(ASSET_TOKEN).safeTransfer(receiver, assetsWithdrawn); + + // Transfer fee if applicable + if (feeAmount > 0) { + IERC20(ASSET_TOKEN).safeTransfer($.feePayment.feeAddress, feeAmount); + emit EarlyWithdrawalFee(sender, feeAmount); + } + } + + /// @inheritdoc IStabilityPool + /// @notice Creates or updates the withdrawal request window for msg.sender. + /// @dev Window is [start, end] where start = now + WITHDRAWAL_START_DELAY and end = start + WITHDRAWAL_END_WINDOW. + function requestWithdrawal() external nonReentrant { + address sender = _msgSender(); + StabilityPoolStorage storage $ = _getStabilityPoolStorage(); + // Guard against unconfigured window in implementation (constructor ensures > 0) + if (WITHDRAWAL_END_WINDOW == 0) { + revert InvalidWithdrawalWindow(WITHDRAWAL_START_DELAY, WITHDRAWAL_END_WINDOW); + } + uint64 start = uint64(block.timestamp + WITHDRAWAL_START_DELAY); + uint64 end = uint64(start + WITHDRAWAL_END_WINDOW); + $.withdrawalRequests[sender] = WithdrawalRequest({start: start, end: end}); + emit WithdrawalRequested(sender, start, end); + } + + /********************** + * Internal Functions * + **********************/ + + /// @inheritdoc MultipleRewardCompoundingAccumulator + // slither-disable-next-line reentrancy-events,reentrancy-benign,reentrancy-no-eth // function is only called from nonReentrant external functions + function _checkpoint(address account) internal virtual override { + StabilityPoolStorage storage $ = _getStabilityPoolStorage(); + + super._checkpoint(account); + + if (account != address(0)) { + TokenBalance memory supply = $.totalAssetSupply; + TokenBalance memory balance = $.assetBalances[account]; + uint104 newBalance = uint104(_getCompoundedBalance(balance.amount, balance.product, supply.product)); + if (newBalance != balance.amount) { + // no unchecked here, just in case + emit UserDepositChange(account, newBalance, balance.amount - newBalance); + } + balance = TokenBalance({amount: newBalance, product: supply.product, updatedAt: uint40(block.timestamp)}); + $.assetBalances[account] = balance; + } + } + + /// @inheritdoc MultipleRewardCompoundingAccumulator + function _getTotalPoolShare() internal view virtual override returns (uint128 currentProd, uint256 totalShare) { + StabilityPoolStorage storage $ = _getStabilityPoolStorage(); + TokenBalance memory supply = $.totalAssetSupply; + currentProd = supply.product; + totalShare = supply.amount; + } + + /// @inheritdoc MultipleRewardCompoundingAccumulator + function _getUserPoolShare( + address account + ) internal view virtual override returns (uint128 previousProd, uint256 share) { + StabilityPoolStorage storage $ = _getStabilityPoolStorage(); + TokenBalance memory balance = $.assetBalances[account]; + previousProd = balance.product; + share = balance.amount; + } + + /// @dev Internal function to reduce asset accounting. + /// @param loss The amount of asset lost. + + function _notifyLoss(uint256 loss) internal { + StabilityPoolStorage storage $ = _getStabilityPoolStorage(); + TokenBalance memory supply = $.totalAssetSupply; + if (supply.amount == 0) { + return; + } + // Enforce minimum balance to prevent complete depletion + if (loss >= supply.amount - MIN_TOTAL_ASSET_SUPPLY) { + // Loss would breach minimum - limit it + loss = supply.amount - MIN_TOTAL_ASSET_SUPPLY; + } + if (loss == 0) { + return; // No loss to apply + } + + // calculate the loss per unit. which, due to integer division, has errors + uint256 assetLossPerUnitStaked; + // those errors are contained in an over-applied error which is essentially + // lossError ≈ supply.amount - (loss % supply.amount) + // this lossError (over application) is subtracted from any future call to this function + // the loss error does not affect the supply, only the user share of that and ensures that + // when it comes to making a claim, users get a fair allocation. + + uint256 lossInEther = loss * 1 ether; + + // calculate the new loss error (over applied) + // Handle case where loss is less than the over-application error + if (lossInEther <= $.lastAssetLossError) { + // Consume the error by the loss amount + $.lastAssetLossError -= lossInEther; + assetLossPerUnitStaked = 0; // No loss per unit staked, as the error absorbs the loss + } else { + // Calculate adjusted loss after accounting for error + uint256 lossNumerator = lossInEther - $.lastAssetLossError; + // Use ceiling division (n-1)/d + 1 to round up if there's any remainder + // this is an optimised version of ceilDiv in that we know the denominator is > zero (see code above) + // This ensures the pool is not disadvantaged and only favours the pool when necessary. + assetLossPerUnitStaked = (lossNumerator - 1) / uint256(supply.amount) + 1; + // Store the over-application as the new error + $.lastAssetLossError = (assetLossPerUnitStaked * uint256(supply.amount)) - lossNumerator; + } + // Reduce supply by loss amount + supply.amount -= uint104(loss); + + // Update product factor and total supply + // The newProductFactor is the factor by which to change all deposits, due to the depletion of StabilityPool assets in the liquidation. + // As we don't allow pool emptying it is (1 - assetLossPerUnitStaked) which is always > 0 and < 1. + uint128 newProductFactor = 1 ether - uint128(assetLossPerUnitStaked); + supply.product = supply.product.mul(newProductFactor); + supply.updatedAt = uint40(block.timestamp); + _recordTotalSupply(supply); + } + + /// @dev Internal function to record the historical total supply. + /// @param supply The new total supply to record. + function _recordTotalSupply(TokenBalance memory supply) private { + StabilityPoolStorage storage $ = _getStabilityPoolStorage(); + uint256 totalSupplyHistoryLength_ = $.totalAssetSupplyHistoryLength; + + // slither-disable-next-line incorrect-equality + if ($.totalAssetSupplyHistory[totalSupplyHistoryLength_ - 1].updatedAt == supply.updatedAt) { + $.totalAssetSupplyHistory[totalSupplyHistoryLength_ - 1] = supply; + } else { + $.totalAssetSupplyHistory[totalSupplyHistoryLength_] = supply; + $.totalAssetSupplyHistoryLength = totalSupplyHistoryLength_ + 1; + } + $.totalAssetSupply = supply; + } + + // ERC20 internal helpers + // ------------------------------------------------------- + + /// @dev Transfer compounded balance between two accounts. + /// Checkpoints both parties to update rewards at pre-transfer balances, then moves the amount. + function _transferBalance(address from, address to, uint256 amount) internal { + if (from == address(0) || to == address(0)) { + revert InvalidReceiver(address(0)); + } + if (from == to) { + revert InvalidReceiver(to); + } + + _checkpoint(from); + _checkpoint(to); + + StabilityPoolStorage storage $ = _getStabilityPoolStorage(); + + TokenBalance memory fromBalance = $.assetBalances[from]; + if (amount > fromBalance.amount) { + revert TransferExceedsBalance(from, amount, fromBalance.amount); + } + unchecked { + fromBalance.amount -= uint104(amount); + } + $.assetBalances[from] = fromBalance; + + TokenBalance memory toBalance = $.assetBalances[to]; + toBalance.amount += uint104(amount); + toBalance.product = $.totalAssetSupply.product; + toBalance.updatedAt = uint40(block.timestamp); + $.assetBalances[to] = toBalance; + + emit Transfer(from, to, amount); + } + + /// @dev Pack a string (up to 64 chars) into two bytes32 values. + function _packString64(string memory s) internal pure returns (bytes32 b0, bytes32 b1) { + bytes memory b = bytes(s); + if (b.length > 64) { + revert StringTooLong(); + } + // solhint-disable-next-line no-inline-assembly + assembly { + b0 := mload(add(b, 32)) + b1 := mload(add(b, 64)) + } + if (b.length < 32) { + b0 = bytes32(uint256(b0) & ~(type(uint256).max >> (b.length * 8))); + b1 = bytes32(0); + } else if (b.length < 64) { + b1 = bytes32(uint256(b1) & ~(type(uint256).max >> ((b.length - 32) * 8))); + } + } + + /// @dev Unpack two bytes32 values back to a string, trimming trailing zeros. + function _unpackString64(bytes32 b0, bytes32 b1) internal pure returns (string memory) { + uint256 len0; + for (len0 = 32; len0 > 0; len0--) { + if (b0[len0 - 1] != 0) break; + } + uint256 len1; + for (len1 = 32; len1 > 0; len1--) { + if (b1[len1 - 1] != 0) break; + } + bytes memory result = new bytes(len0 + len1); + for (uint256 i = 0; i < len0; i++) { + result[i] = b0[i]; + } + for (uint256 i = 0; i < len1; i++) { + result[len0 + i] = b1[i]; + } + return string(result); + } + + // Rebalancing support + // ------------------------------------------------------- + /// @notice function used to control access to the sweep function for extracting harvestable amounts + function _checkSweeper() internal view override(TokenHolder) { + _checkOwnerOrRoles(REBALANCER_ROLE); + } + + /// @inheritdoc IStabilityPool + // slither-disable-next-line reentrancy-no-eth,reentrancy-benign should only ever called from nonReentrant functions + function notifyLiquidation(uint256 liquidated, uint256 returned) external onlyRoles(REBALANCER_ROLE) { + // Emit liquidation event to record loss and conversion details + emit Liquidated(ASSET_TOKEN, liquidated, LIQUIDATION_TOKEN, returned); + // recalculate balances and + // make sure rewards in-flight rewards are distributed on the pre-loss balances + _checkpoint(address(0)); + + // capture the reward, distributed immediately, at the prior-to-loss balances + _accumulateReward(LIQUIDATION_TOKEN, returned); + + // update balances due to loss + _notifyLoss(liquidated); + } +} + +// slither-disable-end timestamp diff --git a/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol b/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol new file mode 100644 index 00000000..922a5717 --- /dev/null +++ b/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol @@ -0,0 +1,528 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.30; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {ReentrancyGuardTransientUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardTransientUpgradeable.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; + +import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; + +import {DecrementalFloatingPoint} from "src/math/DecrementalFloatingPoint.sol"; +import {LinearMultipleRewardDistributor} from "src/reward/distributor/LinearMultipleRewardDistributor_v2.sol"; + +// solhint-disable not-rely-on-time + +/// @title MultipleRewardCompoundingAccumulator +/// @notice `MultipleRewardCompoundingAccumulator` is a reward accumulator for reward distribution in a staking pool. +/// In the staking pool, the total stakes will decrease unexpectedly and the user stakes will also decrease proportionally. +/// The contract will distribute rewards in proportion to a staker’s share of total stakes with only O(1) complexity. +/// +/// This accumulator handles complex staking scenarios where both user stakes and total +/// stakes can decrease unexpectedly. It efficiently tracks user rewards based on their +/// proportional share of the total pool, even as these values change over time. +/// +/// The mathematical model uses a system of checkpoints and floating-point calculations +/// to handle stake reductions, reward distributions, and precision concerns. It introduces +/// epochs to handle cases where total supply reduces to zero and uses exponents to +/// manage precision loss in calculations. +/// +/// Key features: +/// - O(1) complexity for reward calculations regardless of time elapsed +/// - Support for multiple reward tokens +/// - Handles stake decreases correctly without requiring per-user operations +/// - Precision-preserving calculations using floating-point representation +/// - Customizable reward receivers +/// - Support for claiming historical rewards +/// +/// Assume that there are n events e[1], e[2], ..., and e[n]. The types of events are user stake, +/// user unstake, total stakes decrease and reward distribution. +/// Right after event e[i], let the total pool stakes be s[i], the user pool stakes be u[i], +/// the total stake decrease is d[i], and the rewards distributed be r[i]. +/// +/// The basic assumptions are, if +/// + e[i] is user stake, r[i] = 0, u[i] > u[i-1] and s[i] - s[i-1] = u[i] - u[i-1]. +/// + e[i] is user unstake, r[i] = 0, u[i] < u[i-1] and s[i] - s[i-1] = u[i] - u[i-1]. +/// + e[i] is total stakes decrease, r[i] = 0, d[i] > 0, s[i] = s[i-1] - d[i] and u[i] = u[i-1] * (1 - d[i] / s[i-1]) +/// + e[i] is reward distribution, r[i] > 0, u[i] = u[i-1] and s[i] = s[i-1]. +/// +/// So under the assumptions, if +/// + e[i] is user stake/unstake, we can maintain the value of u[i] and s[i] easily. +/// + e[i] is total stakes decrease, we can only maintain the value of s[i] easily. +/// +/// To compute the value of u[i], assuming the only events are total stakes decrease. Then after n events, +/// u[n] = u[0] * (1 - d[1]/s[0]) * (1 - d[2]/s[1]) * ... * (1 - d[n]/s[n-1]) +/// +/// To compute the user stakes correctly, we can maintain the value of +/// p[n] = (1 - d[1]/s[0]) * (1 - d[2]/s[1]) * ... * (1 - d[n]/s[n-1]) +/// +/// Then the user stakes from event x to event y is u[y] = u[x] * p[y] / p[x] +/// +/// As for the accumutated rewards, the total amount of rewards for the user is: +/// u[0] u[1] u[n-1] +/// g[n] = r[1] * ---- + r[2] * ---- + ... + r[n] * ------ +/// s[0] s[1] s[n-1] +/// +/// Also, u[n] = u[0] * p[n], we have +/// p[0] p[1] p[n-1] +/// g[n] = u[0] * (r[1] * ---- + r[2] * ---- + ... + r[n] * ------) +/// s[0] s[1] s[n-1] +/// +/// And, the rewards from event x to event y (both inclusive) for the user is: +/// p[x-1] p[x] p[y-1] +/// g[x->y] = u[x] * (r[x] * ------ + r[x+1] * ---- + ... + r[y] * ------) +/// s[x-1] s[x] s[y-1] +/// +/// To check the accumulated total user rewards, we can maintain the value of +/// p[0] p[1] p[n-1] +/// acc = r[1] * ---- + r[2] * ---- + ... + r[n] * ------ +/// s[0] s[1] s[n-1] +/// +/// For each event, if +/// + e[i] is user stake or unstake, new accumulated rewards is +/// gain += u[i-1] * (acc - last_user_acc) / last_user_prod, +/// and update `last_user_acc` to `acc` +/// and update `last_user_prod` to p[i]. +/// + e[i] is total stakes decrease, p[i] *= (1 - d[i] / s[i-1]) +/// + e[i] is reward distribution, acc += r[i] * p[i-1] / s[i-1]. +/// +/// Notice that total stakes decrease event will possible make s[i] be zero. We introduce epoch to handle this problem. +/// When the total supply reduces to zero, we start a new epoch. +/// +/// Another problem is precision loss in solidity, the p[i] will eventually become a very small non-zero value. To solve +/// the problem, we treat p[i] as m[i] * 10^{-18 - 9 * e[i]}, where m[i] is the magnitude and e[i] is the exponent. +/// When the value of m[i] is smaller than 10^9, we will multiply m[i] by 1e9 and then increase e[i] by one. + +// e[i]: The i-th event in the system (can be stake, unstake, total stakes decrease, or reward distribution) +// s[i]: Total pool stakes after event i (corresponds to the contract's internal tracking of total stakes) +// u[i]: User's personal stakes after event i (tracked per user) +// d[i]: Amount of total stake decrease in event i +// r[i]: Amount of rewards distributed in event i +// These variables directly map to contract data structures: + +// Math Notation Code Implementation +// s[i] Tracked via the product value in _getTotalPoolShare() +// u[i] Tracked via user checkpoint data in userRewardSnapshot +// d[i] Used in calculations when total stakes decrease +// r[i] Amount added to reward accumulators +// The mathematical model describes how these values interact during different events to maintain accurate reward distribution despite fluctuating stake amounts. + +/// +/// @dev The method comes from liquity's StabilityPool, the paper is in +/// https://github.com/liquity/dev/blob/main/papers/Scalable_Reward_Distribution_with_Compounding_Stakes.pdf + +abstract contract MultipleRewardCompoundingAccumulator is + ReentrancyGuardTransientUpgradeable, + LinearMultipleRewardDistributor, + IMultipleRewardAccumulator +{ + using SafeERC20 for IERC20; + using DecrementalFloatingPoint for uint128; + + /************* + * Constants * + *************/ + + /// @dev The precision used to calculate accumulated rewards. + uint256 internal constant _REWARD_PRECISION = 1e18; + + /// @dev Compiler will pack this into single `uint256`. + struct ClaimData { + // The number of pending rewards. + uint128 pending; + // The number of claimed rewards. + uint128 claimed; + } + + /// @dev User reward snapshot. Occupies 3 slots. + struct UserRewardSnapshotV2 { + // The claim data for the user. + ClaimData rewards; + // The timestamp when the snapshot is updated. + uint64 timestamp; + // The reward integral until now. + uint256 integral; + } + + /************* + * Variables * + *************/ + + struct MultipleRewardCompoundingAccumulatorStorage { + /// @inheritdoc IMultipleRewardAccumulator + mapping(address => address) rewardReceiver; + /// @notice Mapping from reward token address to global reward snapshot. + /// + /// - The inner mapping records the `acc` at different `exponent` + /// - The outer mapping records the (exponent => acc) mappings, for different tokens. + /// + /// @dev The integral is defined as 1e18 * ∫(rate(t) * prod(t) / totalPoolShare(t) dt). + mapping(address => mapping(uint8 => uint256)) tokenToExponentToIntegral; + /// @dev V1 mapping slot. No longer read after force migration. Kept for storage layout. + mapping(address => mapping(address => bytes)) legacyUserRewardSnapshot; + /// @notice Mapping from user address to reward token address to user reward snapshot. + mapping(address => mapping(address => UserRewardSnapshotV2)) userRewardSnapshot; + } + + // slither-disable-next-line dead-code + function _tokenToExponentToIntegral(address token, uint8 exponent) internal view returns (uint256 globalIntegral) { + MultipleRewardCompoundingAccumulatorStorage storage $ = _getMultipleRewardCompoundingAccumulatorStorage(); + globalIntegral = $.tokenToExponentToIntegral[token][exponent]; + } + + function _getUserRewardSnapshot( + address account, + address token + ) internal view returns (uint64 timestamp, uint256 integral, uint128 pending, uint128 claimed_) { + MultipleRewardCompoundingAccumulatorStorage storage $ = _getMultipleRewardCompoundingAccumulatorStorage(); + UserRewardSnapshotV2 storage v2 = $.userRewardSnapshot[account][token]; + return (v2.timestamp, v2.integral, v2.rewards.pending, v2.rewards.claimed); + } + + function _setUserRewardSnapshot( + address account, + address token, + uint64 timestamp, + uint256 integral, + uint128 pending, + uint128 claimed_ + ) internal { + MultipleRewardCompoundingAccumulatorStorage storage $ = _getMultipleRewardCompoundingAccumulatorStorage(); + UserRewardSnapshotV2 storage v2 = $.userRewardSnapshot[account][token]; + v2.rewards.pending = pending; + v2.rewards.claimed = claimed_; + v2.timestamp = timestamp; + v2.integral = integral; + } + + // chisel eval 'keccak256(abi.encode(uint256(keccak256("bao.storage.MultipleRewardCompoundingAccumulator")) - 1)) & ~bytes32(uint256(0xff))' + bytes32 private constant _MULTIPLEREWARDCOMPOUNDINGACCUMULATOR_STORAGE = + 0x47ddc56aaabfe9761e2e64ce86720771c5fd1fd7ef0605da74e07d71de0e7900; + + function _getMultipleRewardCompoundingAccumulatorStorage() + private + pure + returns (MultipleRewardCompoundingAccumulatorStorage storage $) + { + // solhint-disable-next-line no-inline-assembly + assembly { + $.slot := _MULTIPLEREWARDCOMPOUNDINGACCUMULATOR_STORAGE + } + } + + /*************** + * Constructor * + ***************/ + + // solhint-disable-next-line func-name-mixedcase + // function __MultipleRewardCompoundingAccumulator_init() internal onlyInitializing { + // // __LinearMultipleRewardDistributor_init(); + // __ReentrancyGuardTransient_init(); + // // __MultipleRewardCompoundingAccumulator_init_unchained(); + // } + + // // solhint-disable-next-line func-name-mixedcase, no-empty-blocks + // function __MultipleRewardCompoundingAccumulator_init_unchained() internal onlyInitializing {} + + /// @custom:oz-upgrades-unsafe-allow constructor + /// @dev we don't disable initializers here, because this contract is abstract - the deriving contract should do that. + constructor( + uint256 rewardManagerRole, + uint256 rewardDepositorRole, + uint40 periodLength + ) LinearMultipleRewardDistributor(rewardManagerRole, rewardDepositorRole, periodLength) {} + + /************************* + * Public View Functions * + *************************/ + + function rewardReceiver(address account) external view returns (address) { + MultipleRewardCompoundingAccumulatorStorage storage $ = _getMultipleRewardCompoundingAccumulatorStorage(); + return $.rewardReceiver[account]; + } + + /// @inheritdoc IMultipleRewardAccumulator + function claimable(address account, address token) external view virtual override returns (uint256) { + return _claimable(account, token, true); + } + + /// @inheritdoc IMultipleRewardAccumulator + function claimed(address account, address token) external view returns (uint256) { + (, , , uint128 claimedAmount) = _getUserRewardSnapshot(account, token); + return claimedAmount; + } + + /**************************** + * Public Mutator Functions * + ****************************/ + + /// @inheritdoc IMultipleRewardAccumulator + function setRewardReceiver(address newReceiver) external { + address caller = _msgSender(); + MultipleRewardCompoundingAccumulatorStorage storage $ = _getMultipleRewardCompoundingAccumulatorStorage(); + address oldReceiver = $.rewardReceiver[caller]; + $.rewardReceiver[caller] = newReceiver; + + emit UpdateRewardReceiver(caller, oldReceiver, newReceiver); + } + + /// @inheritdoc IMultipleRewardAccumulator + function checkpoint(address account) external virtual override nonReentrant { + _checkpoint(account); + } + + /// @inheritdoc IMultipleRewardAccumulator + function claim() external override { + address sender = _msgSender(); + claim(sender, address(0)); + } + + /// @inheritdoc IMultipleRewardAccumulator + function claim(address account) external override { + claim(account, address(0)); + } + + /// @inheritdoc IMultipleRewardAccumulator + function claim(address account, address receiver) public override nonReentrant { + if (account != _msgSender() && receiver != address(0)) { + revert ClaimOthersRewardToAnother(); + } + _checkpoint(account); + _claim(account, receiver); + } + + /// @inheritdoc IMultipleRewardAccumulator + function claimHistorical(address[] memory tokens) external nonReentrant { + address sender = _msgSender(); + MultipleRewardCompoundingAccumulatorStorage storage $ = _getMultipleRewardCompoundingAccumulatorStorage(); + + _checkpoint(sender); + + address receiver = $.rewardReceiver[sender]; + if (receiver == address(0)) { + receiver = sender; + } + for (uint256 i = 0; i < tokens.length; i++) { + _claimSingle(sender, tokens[i], receiver); // wake-disable-line unchecked-return-value + } + } + + /// @inheritdoc IMultipleRewardAccumulator + function claimHistorical(address account, address[] memory tokens) external nonReentrant { + _checkpoint(account); + MultipleRewardCompoundingAccumulatorStorage storage $ = _getMultipleRewardCompoundingAccumulatorStorage(); + + address receiver = $.rewardReceiver[account]; + if (receiver == address(0)) { + receiver = account; + } + for (uint256 i = 0; i < tokens.length; i++) { + _claimSingle(account, tokens[i], receiver); // wake-disable-line unchecked-return-value + } + } + + /********************** + * Internal Functions * + **********************/ + + // @dev like a mulDiv, but for product factors + function _scaleAdjustedValue( + uint256 baseValue, + uint128 toProd, + uint128 fromProd + ) internal pure returns (uint256 adjusted) { + uint8 fromExp = fromProd.exponent(); + uint8 toExp = toProd.exponent(); + uint256 fromMag = fromProd.magnitude(); + uint256 toMag = toProd.magnitude(); + + if (baseValue == 0 || toExp < fromExp || toExp - fromExp > DecrementalFloatingPoint._MAX_EXPONENT_DIFFERENCE) { + adjusted = 0; // Too many scale changes + } else { + adjusted = DecrementalFloatingPoint._divByScaleFactor( + Math.mulDiv(baseValue, toMag, fromMag), + toExp - fromExp + ); + } + } + + /// @dev Internal function to compute the amount of asset deposited after several liquidation. + /// + /// @param initialBalance The amount of asset deposited initially. + /// @param initialProduct The epoch state snapshot at initial depositing. + /// @return compoundedBalance The amount asset deposited after several liquidation. + function _getCompoundedBalance( + uint256 initialBalance, + uint128 initialProduct, + uint128 currentProduct + ) internal pure returns (uint256 compoundedBalance) { + return _scaleAdjustedValue(initialBalance, currentProduct, initialProduct); + } + + function _claimable( + address account, + address token, + bool includeTemporalPending + ) internal view virtual returns (uint256) { + (, uint256 userCheckpointIntegral, uint128 userPending, ) = _getUserRewardSnapshot(account, token); + return _claimableFrom(account, token, includeTemporalPending, userCheckpointIntegral, userPending); + } + + /// @dev Core claimable calculation that accepts pre-read snapshot data. + /// Avoids re-reading the user snapshot when the caller already has it. + function _claimableFrom( + address account, + address token, + bool includeTemporalPending, + uint256 userCheckpointIntegral, + uint128 userPending + ) internal view virtual returns (uint256 claimable_) { + MultipleRewardCompoundingAccumulatorStorage storage $ = _getMultipleRewardCompoundingAccumulatorStorage(); + + claimable_ = uint256(userPending); + (uint128 userProd, uint256 shares) = _getUserPoolShare(account); + + if (shares > 0) { + uint8 userExponent = userProd.exponent(); + (uint128 currentProd, uint256 totalShares) = _getTotalPoolShare(); + uint8 maxExponentsToCheck = uint8( + Math.min(DecrementalFloatingPoint._MAX_EXPONENT_DIFFERENCE, currentProd.exponent() - userExponent) + ); + // Get the sum 'S' from the epoch at which the stake was made. The gain may span many exponent changes. + mapping(uint8 => uint256) storage tokenIntegrals = $.tokenToExponentToIntegral[token]; + uint256 integral = tokenIntegrals[userExponent]; + + for (uint8 i = 1; i <= maxExponentsToCheck; ++i) { + uint256 integralAtScale = tokenIntegrals[userExponent + i]; + if (integralAtScale > 0) { + // Skip zero integrals for gas efficiency + integral += DecrementalFloatingPoint._divByScaleFactor(integralAtScale, i); + } + } + if (integral > userCheckpointIntegral) { + claimable_ += Math.mulDiv( + shares, + integral - userCheckpointIntegral, + userProd.magnitude() * _REWARD_PRECISION + ); + } + + if (includeTemporalPending && totalShares > 0) { + (uint256 amount, ) = _pendingRewards(token); + // if exponents are the same this degenerates to (amount * shares) / totalShares + claimable_ += _scaleAdjustedValue(amount * shares, currentProd, userProd) / totalShares; + } + } + } + + /// @dev Internal function to update the global and user snapshot. + /// @param account The address of user to update. + /// Use zero address if you only want to update global snapshot. + function _checkpoint(address account) internal virtual { + _distributePendingReward(); + + if (account != address(0)) { + // get all the reward tokens ever + address[] memory activeTokens = activeRewardTokens(); + address[] memory historicalTokens = historicalRewardTokens(); + + uint256 activeLength = activeTokens.length; + uint256 totalLength = activeLength + historicalTokens.length; + + // Early exit if no tokens to process + if (totalLength == 0) { + return; + } + + (uint128 currentProd, ) = _getTotalPoolShare(); + uint8 exponent = currentProd.exponent(); + + for (uint256 i = 0; i < totalLength; i++) { + address token = (i < activeLength) ? activeTokens[i] : historicalTokens[i - activeLength]; + (, uint256 snapIntegral, uint128 snapPending, uint128 snapClaimed) = _getUserRewardSnapshot( + account, + token + ); + uint128 newPending = uint128(_claimableFrom(account, token, false, snapIntegral, snapPending)); + _setUserRewardSnapshot( + account, + token, + uint64(block.timestamp), + _tokenToExponentToIntegral(token, exponent), + newPending, + snapClaimed + ); + } + } + } + + /// @dev Internal function to claim active reward tokens. + /// + /// @param account The address of user to claim. + /// @param receiver The address of recipient of the reward token. + function _claim(address account, address receiver) internal virtual { + MultipleRewardCompoundingAccumulatorStorage storage $ = _getMultipleRewardCompoundingAccumulatorStorage(); + address receiverStored = $.rewardReceiver[account]; + if (receiverStored != address(0) && receiver == address(0)) { + receiver = receiverStored; + } + if (receiver == address(0)) { + receiver = account; + } + address[] memory activeRewardTokens = activeRewardTokens(); + for (uint256 i = 0; i < activeRewardTokens.length; i++) { + _claimSingle(account, activeRewardTokens[i], receiver); // wake-disable-line unchecked-return-value + } + } + + /// @dev Internal function to claim single reward token. + /// Caller should make sure `_checkpoint` is called before this function. + /// + /// @param account The address of user to claim. + /// @param token The address of reward token. + /// @param receiver The address of recipient of the reward token. + function _claimSingle(address account, address token, address receiver) internal virtual returns (uint256) { + (uint64 ts, uint256 integral, uint128 pending, uint128 claimed_) = _getUserRewardSnapshot(account, token); + uint256 amount = pending; + if (amount > 0) { + _setUserRewardSnapshot(account, token, ts, integral, 0, claimed_ + pending); + + IERC20(token).safeTransfer(receiver, amount); + + emit Claim(account, token, receiver, amount); + } + return amount; + } + + /// @inheritdoc LinearMultipleRewardDistributor + function _accumulateReward(address token, uint256 amount) internal virtual override { + // slither-disable-next-line incorrect-equality + if (amount == 0) { + return; + } + + (uint128 currentProd, uint256 totalShare) = _getTotalPoolShare(); + if (totalShare == 0) { + // no deposits, queue rewards + _getRewardData(token).queued += uint96(amount); + return; + } + + uint8 exponent = currentProd.exponent(); + + MultipleRewardCompoundingAccumulatorStorage storage $ = _getMultipleRewardCompoundingAccumulatorStorage(); + uint256 integral = $.tokenToExponentToIntegral[token][exponent]; + integral += Math.mulDiv(amount * _REWARD_PRECISION, uint256(currentProd.magnitude()), totalShare); + + $.tokenToExponentToIntegral[token][exponent] = integral; + } + + /// @dev Internal function to get the total pool shares. + function _getTotalPoolShare() internal view virtual returns (uint128 currentProd, uint256 totalShare); + + /// @dev Internal function to get the amount of user shares. + /// + /// @param account The address of user to query. + function _getUserPoolShare(address account) internal view virtual returns (uint128 previousProd, uint256 share); +} diff --git a/test/Minter_feeRange.t.sol b/test/Minter_feeRange.t.sol index 458dd8a1..2a9da749 100644 --- a/test/Minter_feeRange.t.sol +++ b/test/Minter_feeRange.t.sol @@ -838,7 +838,7 @@ contract TestMinterFixedFeeRange_ is TestMinterFeeRange { assertNear( post.leveragedPrice, post.minterLeveraged == 0 ? 1e18 : pre.leveragedPrice, - 400, + 400 * ((1 ether + measurePrice - 1) / measurePrice), // scale abs tolerance with inverse price 2000, "rl leveraged price" ); diff --git a/test/Rebalance.t.sol b/test/Rebalance.t.sol index 485e470c..bf401f90 100644 --- a/test/Rebalance.t.sol +++ b/test/Rebalance.t.sol @@ -8,7 +8,7 @@ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IMinter} from "src/interfaces/IMinter.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; -import {StabilityPool_v2} from "src/minter/StabilityPool_v2.sol"; +import {StabilityPool_v3} from "src/minter/StabilityPool_v3.sol"; import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; @@ -41,18 +41,18 @@ contract TestLiquidate is TestStabilityPool2SetUp { IERC20(wrappedCollateralToken).approve(stabilityPoolCollateral, 100 ether); stabilityPoolCollateralEmpty = UnsafeUpgrades.deployUUPSProxy( - address(new StabilityPool_v2(minter, wrappedCollateralToken, 3600, 90000, 1 ether)), + address(new StabilityPool_v3(minter, wrappedCollateralToken, 3600, 90000, 1 ether, "SP Col", "spC")), abi.encodeCall( - StabilityPool_v2.initialize, + StabilityPool_v3.initialize, (owner, 0.025 ether, 0x3dFc49e5112005179Da613BdE5973229082dAc35) ) ); IBaoOwnable(stabilityPoolCollateralEmpty).transferOwnership(owner); stabilityPoolLeveragedEmpty = UnsafeUpgrades.deployUUPSProxy( - address(new StabilityPool_v2(minter, leveragedToken, 3600, 90000, 1 ether)), + address(new StabilityPool_v3(minter, leveragedToken, 3600, 90000, 1 ether, "SP Lev", "spL")), abi.encodeCall( - StabilityPool_v2.initialize, + StabilityPool_v3.initialize, (owner, 0.025 ether, 0x3dFc49e5112005179Da613BdE5973229082dAc35) ) ); diff --git a/test/StabilityPool.t.sol b/test/StabilityPool.t.sol index 136e8805..06294222 100644 --- a/test/StabilityPool.t.sol +++ b/test/StabilityPool.t.sol @@ -17,7 +17,7 @@ import {IMintableRole} from "@bao/interfaces/IMintableRole.sol"; import {IMintable} from "@bao/interfaces/IMintable.sol"; import {IMinter} from "src/interfaces/IMinter.sol"; -import {StabilityPool_v2} from "src/minter/StabilityPool_v2.sol"; +import {StabilityPool_v3} from "src/minter/StabilityPool_v3.sol"; import {MintableBurnableERC20_v1} from "@bao/MintableBurnableERC20_v1.sol"; import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; @@ -29,25 +29,25 @@ import {DecrementalFloatingPoint} from "src/math/DecrementalFloatingPoint.sol"; import {TestMinterFeeSetUp} from "test/Minter_fees.t.sol"; // New version for testing upgrades -contract StabilityPool_vN is StabilityPool_v2 { +contract StabilityPool_vN is StabilityPool_v3 { // Keep the same constructor signature constructor( address minter_, address liquidationToken_ - ) StabilityPool_v2(minter_, liquidationToken_, 3600, 90000, 1 ether) {} + ) StabilityPool_v3(minter_, liquidationToken_, 3600, 90000, 1 ether, "SP vN", "spVN") {} // Add a new function to verify the upgrade worked function version() external pure returns (string memory) { - return "v2"; + return "v3"; } } // used to expose internal functions -contract MockStabilityPool is StabilityPool_v2 { +contract MockStabilityPool is StabilityPool_v3 { constructor( address minter_, address liquidationToken_ - ) StabilityPool_v2(minter_, liquidationToken_, 3600, 90000, 1 ether) {} + ) StabilityPool_v3(minter_, liquidationToken_, 3600, 90000, 1 ether, "Mock SP", "mSP") {} /// @notice Exposes the product value for testing purposes function __totalSupply() external view returns (TokenBalance memory) { @@ -110,10 +110,10 @@ contract TestStabilityPoolSetUp is TestMinterFeeSetUp { ); vm.label(stabilityPoolToken, string.concat("lp", SPName)); - // use mock stability pool to expose internals for testing, otherwise it's identical to StabilityPool_v2 + // use mock stability pool to expose internals for testing, otherwise it's identical to StabilityPool_v3 stabilityPool = UnsafeUpgrades.deployUUPSProxy( - address(new MockStabilityPool(minter, liquidationToken)), // "StabilityPool_v2.sol", - abi.encodeCall(StabilityPool_v2.initialize, (owner, EARLY_WITHDRAWAL_FEE, FEE_ADDRESS)) + address(new MockStabilityPool(minter, liquidationToken)), // "StabilityPool_v3.sol", + abi.encodeCall(StabilityPool_v3.initialize, (owner, EARLY_WITHDRAWAL_FEE, FEE_ADDRESS)) ); vm.label(stabilityPool, SPName); @@ -169,7 +169,7 @@ contract TestStabilityPoolSetUp is TestMinterFeeSetUp { } function test_initOnly(address sp, address liquidateTo) internal view { - assertEq(StabilityPool_v2(sp).owner(), owner); + assertEq(StabilityPool_v3(sp).owner(), owner); assertEq(IStabilityPool(sp).ASSET_TOKEN(), peggedToken); assertEq(IStabilityPool(sp).LIQUIDATION_TOKEN(), liquidateTo); assertEq(IStabilityPool(sp).totalAssetSupply(), 0); @@ -200,7 +200,7 @@ contract TestStabilityPoolInit is TestStabilityPoolSetUp { // Verify the upgrade was successful by calling the new version function assertEq( StabilityPool_vN(stabilityPoolCollateral).version(), - "v2", + "v3", "Upgrade should succeed and new function should be available" ); } @@ -223,13 +223,29 @@ contract TestStabilityPoolInitEvents is TestStabilityPoolSetUp { vm.expectEmit(); emit Initializable.Initialized(type(uint64).max); // from the logic contract constructor address( - new StabilityPool_v2(minter, wrappedCollateralToken, WITHDRAWAL_START_DELAY, WITHDRAWAL_END_WINDOW, 1 ether) + new StabilityPool_v3( + minter, + wrappedCollateralToken, + WITHDRAWAL_START_DELAY, + WITHDRAWAL_END_WINDOW, + 1 ether, + "Test SP", + "tSP" + ) ); } function test_initEvents(address liquidateTo) internal { address sp = address( - new StabilityPool_v2(minter, liquidateTo, WITHDRAWAL_START_DELAY, WITHDRAWAL_END_WINDOW, 1 ether) + new StabilityPool_v3( + minter, + liquidateTo, + WITHDRAWAL_START_DELAY, + WITHDRAWAL_END_WINDOW, + 1 ether, + "Test SP", + "tSP" + ) ); vm.expectEmit(); emit IERC1967.Upgraded(address(sp)); @@ -239,8 +255,8 @@ contract TestStabilityPoolInitEvents is TestStabilityPoolSetUp { emit Initializable.Initialized(1); // from the proxy delegate call address spProxy = UnsafeUpgrades.deployUUPSProxy( - sp, // "StabilityPool_v2.sol", - abi.encodeCall(StabilityPool_v2.initialize, (owner, EARLY_WITHDRAWAL_FEE, FEE_ADDRESS)) + sp, // "StabilityPool_v3.sol", + abi.encodeCall(StabilityPool_v3.initialize, (owner, EARLY_WITHDRAWAL_FEE, FEE_ADDRESS)) ); IBaoOwnable(spProxy).transferOwnership(owner); @@ -257,23 +273,39 @@ contract TestStabilityPoolInitEvents is TestStabilityPoolSetUp { function test_initialize_invalidFee_reverts() public { address spImpl = address( - new StabilityPool_v2(minter, wrappedCollateralToken, WITHDRAWAL_START_DELAY, WITHDRAWAL_END_WINDOW, 1 ether) + new StabilityPool_v3( + minter, + wrappedCollateralToken, + WITHDRAWAL_START_DELAY, + WITHDRAWAL_END_WINDOW, + 1 ether, + "Test SP", + "tSP" + ) ); vm.expectRevert(abi.encodeWithSelector(IStabilityPool.InvalidFee.selector, 1 ether + 1)); UnsafeUpgrades.deployUUPSProxy( spImpl, - abi.encodeCall(StabilityPool_v2.initialize, (owner, 1 ether + 1, FEE_ADDRESS)) + abi.encodeCall(StabilityPool_v3.initialize, (owner, 1 ether + 1, FEE_ADDRESS)) ); } function test_initialize_invalidFeeAddress_reverts() public { address spImpl = address( - new StabilityPool_v2(minter, wrappedCollateralToken, WITHDRAWAL_START_DELAY, WITHDRAWAL_END_WINDOW, 1 ether) + new StabilityPool_v3( + minter, + wrappedCollateralToken, + WITHDRAWAL_START_DELAY, + WITHDRAWAL_END_WINDOW, + 1 ether, + "Test SP", + "tSP" + ) ); vm.expectRevert(abi.encodeWithSelector(IStabilityPool.InvalidFeeAddress.selector, address(0))); UnsafeUpgrades.deployUUPSProxy( spImpl, - abi.encodeCall(StabilityPool_v2.initialize, (owner, EARLY_WITHDRAWAL_FEE, address(0))) + abi.encodeCall(StabilityPool_v3.initialize, (owner, EARLY_WITHDRAWAL_FEE, address(0))) ); } } @@ -395,7 +427,7 @@ contract TestStabilityPoolDepositWithdraw is TestStabilityPoolSetUp { // Deploy a fresh pool proxy but skip configuring window/fee address unconfigured = UnsafeUpgrades.deployUUPSProxy( address(new MockStabilityPool(minter, wrappedCollateralToken)), - abi.encodeCall(StabilityPool_v2.initialize, (owner, EARLY_WITHDRAWAL_FEE, FEE_ADDRESS)) + abi.encodeCall(StabilityPool_v3.initialize, (owner, EARLY_WITHDRAWAL_FEE, FEE_ADDRESS)) ); IBaoOwnable(unconfigured).transferOwnership(owner); diff --git a/test/StabilityPoolExtras.t.sol b/test/StabilityPoolExtras.t.sol index 145826f9..1c21c0ff 100644 --- a/test/StabilityPoolExtras.t.sol +++ b/test/StabilityPoolExtras.t.sol @@ -12,9 +12,9 @@ import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; import {TestStabilityPoolRebalanceSetUp} from "test/StabilityPoolRebalance.t.sol"; /// @title TestStabilityPoolExtra -/// @dev This contract is designed to test additional functionalities and edge cases of the StabilityPool_v2 contract. +/// @dev This contract is designed to test additional functionalities and edge cases of the StabilityPool contract. /// It extends the TestStabilityPoolSetUp to include more complex scenarios and edge cases. -/// @notice Test contract specifically designed to achieve 100% coverage for StabilityPool_v2 +/// @notice Test contract specifically designed to achieve 100% coverage for StabilityPool contract TestStabilityPoolExtra1 is TestStabilityPoolRebalanceSetUp { // Constants for testing uint256 constant DEPOSIT_AMOUNT = 100 ether; diff --git a/test/StabilityPoolExtras2.t.sol b/test/StabilityPoolExtras2.t.sol index 84695a68..6a8d6540 100644 --- a/test/StabilityPoolExtras2.t.sol +++ b/test/StabilityPoolExtras2.t.sol @@ -10,12 +10,12 @@ import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; import {MockERC20} from "@bao-test/mocks/MockERC20.sol"; import {TestStabilityPoolSetUp} from "test/StabilityPool.t.sol"; -import {StabilityPool_v2} from "src/minter/StabilityPool_v2.sol"; +import {StabilityPool_v3} from "src/minter/StabilityPool_v3.sol"; /// @title TestStabilityPoolExtra -/// @dev This contract is designed to test additional functionalities and edge cases of the StabilityPool_v2 contract. +/// @dev This contract is designed to test additional functionalities and edge cases of the StabilityPool_v3 contract. /// It extends the TestStabilityPoolSetUp to include more complex scenarios and edge cases. -/// @notice Test contract specifically designed to achieve 100% coverage for StabilityPool_v2 +/// @notice Test contract specifically designed to achieve 100% coverage for StabilityPool_v3 contract TestStabilityPoolExtra2 is TestStabilityPoolSetUp { address user3; address user4; @@ -217,6 +217,6 @@ contract TestStabilityPoolExtra2 is TestStabilityPoolSetUp { function testReinitializeContract() public { // Try to initialize again (contract is already initialized) vm.expectRevert(Initializable.InvalidInitialization.selector); - StabilityPool_v2(stabilityPoolCollateral).initialize(owner, EARLY_WITHDRAWAL_FEE, FEE_ADDRESS); + StabilityPool_v3(stabilityPoolCollateral).initialize(owner, EARLY_WITHDRAWAL_FEE, FEE_ADDRESS); } } diff --git a/test/StabilityPoolFeatures.t.sol b/test/StabilityPoolFeatures.t.sol index 806adcb9..13c865e4 100644 --- a/test/StabilityPoolFeatures.t.sol +++ b/test/StabilityPoolFeatures.t.sol @@ -6,7 +6,7 @@ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; import {IWrappedPriceOracle} from "src/interfaces/IWrappedPriceOracle.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; -import {StabilityPool_v2} from "src/minter/StabilityPool_v2.sol"; +import {StabilityPool_v3} from "src/minter/StabilityPool_v3.sol"; import {TestStabilityPoolSetUp} from "test/StabilityPool.t.sol"; contract StabilityPoolFeatures is TestStabilityPoolSetUp { @@ -107,7 +107,7 @@ contract StabilityPoolFeatures is TestStabilityPoolSetUp { IStabilityPool(stabilityPoolCollateral).deposit(2 * price, user1, 0); // Grant exemption role to user1 (owner-only) - uint256 exemptRole = StabilityPool_v2(stabilityPoolCollateral).EXEMPT_WITHDRAWAL_FEE_ROLE(); + uint256 exemptRole = StabilityPool_v3(stabilityPoolCollateral).EXEMPT_WITHDRAWAL_FEE_ROLE(); vm.prank(owner); IBaoRoles(stabilityPoolCollateral).grantRoles(user1, exemptRole); diff --git a/test/StabilityPoolRebalance.t.sol b/test/StabilityPoolRebalance.t.sol index 137d3996..a4a302dd 100644 --- a/test/StabilityPoolRebalance.t.sol +++ b/test/StabilityPoolRebalance.t.sol @@ -83,9 +83,9 @@ abstract contract TestStabilityPoolRebalanceSetUp is TestStabilityPoolSetUp { } /// @title TestStabilityPoolRebalance -/// @dev This contract is designed to test additional functionalities and edge cases of the StabilityPool_v2 contract. +/// @dev This contract is designed to test additional functionalities and edge cases of the StabilityPool contract. /// It extends the TestStabilityPoolSetUp to include more complex scenarios and edge cases. -/// @notice Test contract specifically designed to achieve 100% coverage for StabilityPool_v2 +/// @notice Test contract specifically designed to achieve 100% coverage for StabilityPool contract TestStabilityPoolRebalance is TestStabilityPoolRebalanceSetUp { // Constants for testing uint256 constant DEPOSIT_AMOUNT = 100 ether; diff --git a/test/StabilityPoolSpec.t.sol b/test/StabilityPoolSpec.t.sol index 3d6f0fdb..8c1da554 100644 --- a/test/StabilityPoolSpec.t.sol +++ b/test/StabilityPoolSpec.t.sol @@ -14,7 +14,7 @@ import {MockERC20} from "@bao-test/mocks/MockERC20.sol"; import {TestStabilityPoolRebalanceSetUp} from "test/StabilityPoolRebalance.t.sol"; /// @title StabilityPoolSpec -/// @notice Specification tests for the StabilityPool_v2 contract +/// @notice Specification tests for the StabilityPool contract /// @dev Based on the testing approach from rebalance-pool contract TestStabilityPoolSpec is TestStabilityPoolRebalanceSetUp { MockERC20 liquidationToken; From a1bf29fd2a4e8a5567ab92301e7f94e1d74b2d00 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Mon, 30 Mar 2026 13:13:42 +0100 Subject: [PATCH 003/232] token naming consolidated in one file --- script/config/ConfigBase.sol | 24 ------ script/config/ConfigTokenNames.sol | 78 +++++++++++++++++++ .../ConfigMarket_BTC_fxUSD_mainnet.sol | 4 +- .../ConfigMarket_BTC_stETH_mainnet.sol | 4 +- .../ConfigMarket_ETH_fxUSD_mainnet.sol | 4 +- .../ConfigMarket_EUR_fxUSD_mainnet.sol | 4 +- .../ConfigMarket_EUR_stETH_mainnet.sol | 4 +- .../ConfigMarket_GOLD_fxUSD_mainnet.sol | 4 +- .../ConfigMarket_GOLD_stETH_mainnet.sol | 4 +- .../ConfigMarket_MCAP_fxUSD_mainnet.sol | 4 +- .../ConfigMarket_MCAP_stETH_mainnet.sol | 4 +- .../ConfigMarket_SILVER_fxUSD_mainnet.sol | 4 +- .../ConfigMarket_SILVER_stETH_mainnet.sol | 4 +- script/src/contracts/LeveragedToken.sol | 5 +- script/src/contracts/StabilityPool.sol | 16 ++-- 15 files changed, 119 insertions(+), 48 deletions(-) create mode 100644 script/config/ConfigTokenNames.sol diff --git a/script/config/ConfigBase.sol b/script/config/ConfigBase.sol index 75565e88..f078e7d3 100644 --- a/script/config/ConfigBase.sol +++ b/script/config/ConfigBase.sol @@ -1,9 +1,6 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.28 <0.9.0; -import {LibString} from "@solady/utils/LibString.sol"; -import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; - /// @notice Base contract for all Harbor configuration contracts. /// @dev Config contracts provide keys via methods, not string parsing. abstract contract ConfigBase { @@ -34,7 +31,6 @@ abstract contract Config_PriceMarket {} /// @notice Library for computing minter market identifiers from configuration. /// @dev Used by deployment scripts for salt, token names/symbols, and oracle keys. library MinterMarketConfigLib { - using LibString for string; /// @notice Get the peg identifier from a market config. /// @param config The minter market config contract. /// @return The peg identifier (e.g., "BTC", "ETH"). @@ -56,26 +52,6 @@ library MinterMarketConfigLib { return string.concat(peg(config), "::", collateral(config)); } - /// @notice Pegged token name (e.g., "Harbor anchored ETH"). - function peggedName(Config_MinterMarket config) internal view returns (string memory) { - return ConfigPeg(address(config)).name(); - } - - /// @notice Pegged token symbol (e.g., "haETH"). - function peggedSymbol(Config_MinterMarket config) internal view returns (string memory) { - return ConfigPeg(address(config)).symbol(); - } - - /// @notice Leveraged token name (e.g., "Harbor sail: variable leveraged long stETH against ETH"). - function leveragedName(Config_MinterMarket config) internal view returns (string memory) { - return string.concat("Harbor sail: variable leveraged long ", collateral(config), " against ", peg(config)); - } - - /// @notice Leveraged token symbol (e.g., "hsSTETH-ETH"). - function leveragedSymbol(Config_MinterMarket config) internal view returns (string memory) { - return string.concat("hs", collateral(config).upper(), "-", peg(config).upper()); - } - /// @notice Computes the price oracle key for a minter market config. /// @dev The price oracle uses a reversed key format: collateral::peg (not peg::collateral). /// @param config The minter market config contract. diff --git a/script/config/ConfigTokenNames.sol b/script/config/ConfigTokenNames.sol new file mode 100644 index 00000000..81ab06a4 --- /dev/null +++ b/script/config/ConfigTokenNames.sol @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.28 <0.9.0; + +import {LibString} from "@solady/utils/LibString.sol"; +import {IMarketConfig} from "./ConfigBase.sol"; + +/// @notice Mixin that derives all Harbor token names and symbols from peg() and collateral(). +/// @dev Inherited by market configs alongside ConfigPeg and ConfigCollateral. +/// Accesses peg/collateral via IMarketConfig(this) to avoid diamond inheritance conflicts. +abstract contract ConfigTokenNames { + using LibString for string; + + function _peg() private view returns (string memory) { + return IMarketConfig(address(this)).peg(); + } + + function _collateral() private view returns (string memory) { + return IMarketConfig(address(this)).collateral(); + } + + // ── Pegged token ──────────────────────────────────────────────────── + + /// @notice Pegged token name (e.g., "Harbor anchored ETH"). + function peggedName() public view returns (string memory) { + return string.concat("Harbor anchored ", _peg()); + } + + /// @notice Pegged token symbol (e.g., "haETH"). + function peggedSymbol() public view returns (string memory) { + return string.concat("ha", _peg().upper()); + } + + // ── Leveraged token ───────────────────────────────────────────────── + + /// @notice Leveraged token name (e.g., "Harbor sail: variable leveraged long fxUSD against ETH"). + function leveragedName() public view returns (string memory) { + return string.concat("Harbor sail: variable leveraged long ", _collateral(), " against ", _peg()); + } + + /// @notice Leveraged token symbol (e.g., "hsFXUSD-ETH"). + function leveragedSymbol() public view returns (string memory) { + return string.concat("hs", _collateral().upper(), "-", _peg().upper()); + } + + // ── Stability pool tokens ─────────────────────────────────────────── + + enum Liquidation { + Collateral, + Leveraged + } + + function _spStrings(Liquidation liquidation) private view returns (string memory name, string memory symbol) { + string memory liqSymbol = liquidation == Liquidation.Collateral ? _collateral() : leveragedSymbol(); + + name = string.concat("Harbor stability pool: ", peggedSymbol(), " (", liqSymbol, ")"); + symbol = string.concat("hsp", _peg(), "(", liqSymbol, ")"); + } + + /// @notice Collateral stability pool name (e.g., "Harbor SP: haETH"). + function spCollateralName() public view returns (string memory name) { + (name, ) = _spStrings(Liquidation.Collateral); + } + + /// @notice Collateral stability pool symbol (e.g., "sp(haETH)"). + function spCollateralSymbol() public view returns (string memory symbol) { + (, symbol) = _spStrings(Liquidation.Collateral); + } + + /// @notice Leveraged stability pool name (e.g., "Harbor SP: hsFXUSD-ETH"). + function spLeveragedName() public view returns (string memory name) { + (name, ) = _spStrings(Liquidation.Leveraged); + } + + /// @notice Leveraged stability pool symbol (e.g., "sp(hsFXUSD-ETH)"). + function spLeveragedSymbol() public view returns (string memory symbol) { + (, symbol) = _spStrings(Liquidation.Leveraged); + } +} diff --git a/script/config/markets/ConfigMarket_BTC_fxUSD_mainnet.sol b/script/config/markets/ConfigMarket_BTC_fxUSD_mainnet.sol index 49231711..b102c55e 100644 --- a/script/config/markets/ConfigMarket_BTC_fxUSD_mainnet.sol +++ b/script/config/markets/ConfigMarket_BTC_fxUSD_mainnet.sol @@ -8,6 +8,7 @@ import {ConfigPriceVolatility_130_stable} from "../volatility/ConfigPriceVolatil import {ConfigStabilityPool} from "../stabilitypool/ConfigStabilityPool.sol"; import {ConfigStabilityPoolManager} from "../stabilitypool/ConfigStabilityPoolManager.sol"; import {Config_MinterMarket} from "../ConfigBase.sol"; +import {ConfigTokenNames} from "../ConfigTokenNames.sol"; /// @notice Market configuration for BTC::fxUSD. contract ConfigMarket_BTC_fxUSD_mainnet is @@ -17,5 +18,6 @@ contract ConfigMarket_BTC_fxUSD_mainnet is ConfigCollateral_fxUSD_mainnet, ConfigPriceVolatility_130_stable, ConfigStabilityPool, - ConfigStabilityPoolManager + ConfigStabilityPoolManager, + ConfigTokenNames {} diff --git a/script/config/markets/ConfigMarket_BTC_stETH_mainnet.sol b/script/config/markets/ConfigMarket_BTC_stETH_mainnet.sol index f245c522..b75285d7 100644 --- a/script/config/markets/ConfigMarket_BTC_stETH_mainnet.sol +++ b/script/config/markets/ConfigMarket_BTC_stETH_mainnet.sol @@ -8,6 +8,7 @@ import {ConfigPriceVolatility_125_stable} from "../volatility/ConfigPriceVolatil import {ConfigStabilityPool} from "../stabilitypool/ConfigStabilityPool.sol"; import {ConfigStabilityPoolManager} from "../stabilitypool/ConfigStabilityPoolManager.sol"; import {Config_MinterMarket} from "../ConfigBase.sol"; +import {ConfigTokenNames} from "../ConfigTokenNames.sol"; /// @notice Market configuration for BTC::stETH. contract ConfigMarket_BTC_stETH_mainnet is @@ -17,5 +18,6 @@ contract ConfigMarket_BTC_stETH_mainnet is ConfigCollateral_stETH_mainnet, ConfigPriceVolatility_125_stable, ConfigStabilityPool, - ConfigStabilityPoolManager + ConfigStabilityPoolManager, + ConfigTokenNames {} diff --git a/script/config/markets/ConfigMarket_ETH_fxUSD_mainnet.sol b/script/config/markets/ConfigMarket_ETH_fxUSD_mainnet.sol index d649e960..dd64f4e3 100644 --- a/script/config/markets/ConfigMarket_ETH_fxUSD_mainnet.sol +++ b/script/config/markets/ConfigMarket_ETH_fxUSD_mainnet.sol @@ -7,6 +7,7 @@ import {ConfigCollateral_fxUSD_mainnet} from "../collaterals/ConfigCollateral_fx import {ConfigPriceVolatility_130_stable} from "../volatility/ConfigPriceVolatility_130_stable.sol"; import {ConfigStabilityPool} from "../stabilitypool/ConfigStabilityPool.sol"; import {ConfigStabilityPoolManager} from "../stabilitypool/ConfigStabilityPoolManager.sol"; +import {ConfigTokenNames} from "../ConfigTokenNames.sol"; import {Config_MinterMarket} from "../ConfigBase.sol"; /// @notice Market configuration for ETH::fxUSD. @@ -17,5 +18,6 @@ contract ConfigMarket_ETH_fxUSD_mainnet is ConfigCollateral_fxUSD_mainnet, ConfigPriceVolatility_130_stable, ConfigStabilityPool, - ConfigStabilityPoolManager + ConfigStabilityPoolManager, + ConfigTokenNames {} diff --git a/script/config/markets/ConfigMarket_EUR_fxUSD_mainnet.sol b/script/config/markets/ConfigMarket_EUR_fxUSD_mainnet.sol index 9267422c..6dfb657e 100644 --- a/script/config/markets/ConfigMarket_EUR_fxUSD_mainnet.sol +++ b/script/config/markets/ConfigMarket_EUR_fxUSD_mainnet.sol @@ -8,6 +8,7 @@ import {ConfigPriceVolatility_105} from "../volatility/ConfigPriceVolatility_105 import {ConfigStabilityPool} from "../stabilitypool/ConfigStabilityPool.sol"; import {ConfigStabilityPoolManager} from "../stabilitypool/ConfigStabilityPoolManager.sol"; import {Config_MinterMarket} from "../ConfigBase.sol"; +import {ConfigTokenNames} from "../ConfigTokenNames.sol"; /// @notice Market configuration for EUR::fxUSD. contract ConfigMarket_EUR_fxUSD_mainnet is @@ -17,5 +18,6 @@ contract ConfigMarket_EUR_fxUSD_mainnet is ConfigCollateral_fxUSD_mainnet, ConfigPriceVolatility_105, ConfigStabilityPool, - ConfigStabilityPoolManager + ConfigStabilityPoolManager, + ConfigTokenNames {} diff --git a/script/config/markets/ConfigMarket_EUR_stETH_mainnet.sol b/script/config/markets/ConfigMarket_EUR_stETH_mainnet.sol index c843f427..deafde1c 100644 --- a/script/config/markets/ConfigMarket_EUR_stETH_mainnet.sol +++ b/script/config/markets/ConfigMarket_EUR_stETH_mainnet.sol @@ -8,6 +8,7 @@ import {ConfigPriceVolatility_130} from "../volatility/ConfigPriceVolatility_130 import {ConfigStabilityPool} from "../stabilitypool/ConfigStabilityPool.sol"; import {ConfigStabilityPoolManager} from "../stabilitypool/ConfigStabilityPoolManager.sol"; import {Config_MinterMarket} from "../ConfigBase.sol"; +import {ConfigTokenNames} from "../ConfigTokenNames.sol"; /// @notice Market configuration for EUR::stETH. contract ConfigMarket_EUR_stETH_mainnet is @@ -17,5 +18,6 @@ contract ConfigMarket_EUR_stETH_mainnet is ConfigCollateral_stETH_mainnet, ConfigPriceVolatility_130, ConfigStabilityPool, - ConfigStabilityPoolManager + ConfigStabilityPoolManager, + ConfigTokenNames {} diff --git a/script/config/markets/ConfigMarket_GOLD_fxUSD_mainnet.sol b/script/config/markets/ConfigMarket_GOLD_fxUSD_mainnet.sol index e25207c1..3fb856af 100644 --- a/script/config/markets/ConfigMarket_GOLD_fxUSD_mainnet.sol +++ b/script/config/markets/ConfigMarket_GOLD_fxUSD_mainnet.sol @@ -8,6 +8,7 @@ import {ConfigPriceVolatility_115} from "../volatility/ConfigPriceVolatility_115 import {ConfigStabilityPool} from "../stabilitypool/ConfigStabilityPool.sol"; import {ConfigStabilityPoolManager} from "../stabilitypool/ConfigStabilityPoolManager.sol"; import {Config_MinterMarket} from "../ConfigBase.sol"; +import {ConfigTokenNames} from "../ConfigTokenNames.sol"; /// @notice Market configuration for GOLD::fxUSD. contract ConfigMarket_GOLD_fxUSD_mainnet is @@ -17,5 +18,6 @@ contract ConfigMarket_GOLD_fxUSD_mainnet is ConfigCollateral_fxUSD_mainnet, ConfigPriceVolatility_115, ConfigStabilityPool, - ConfigStabilityPoolManager + ConfigStabilityPoolManager, + ConfigTokenNames {} diff --git a/script/config/markets/ConfigMarket_GOLD_stETH_mainnet.sol b/script/config/markets/ConfigMarket_GOLD_stETH_mainnet.sol index 91ebaca6..c22187c1 100644 --- a/script/config/markets/ConfigMarket_GOLD_stETH_mainnet.sol +++ b/script/config/markets/ConfigMarket_GOLD_stETH_mainnet.sol @@ -8,6 +8,7 @@ import {ConfigPriceVolatility_130} from "../volatility/ConfigPriceVolatility_130 import {ConfigStabilityPool} from "../stabilitypool/ConfigStabilityPool.sol"; import {ConfigStabilityPoolManager} from "../stabilitypool/ConfigStabilityPoolManager.sol"; import {Config_MinterMarket} from "../ConfigBase.sol"; +import {ConfigTokenNames} from "../ConfigTokenNames.sol"; /// @notice Market configuration for GOLD::stETH. contract ConfigMarket_GOLD_stETH_mainnet is @@ -17,5 +18,6 @@ contract ConfigMarket_GOLD_stETH_mainnet is ConfigCollateral_stETH_mainnet, ConfigPriceVolatility_130, ConfigStabilityPool, - ConfigStabilityPoolManager + ConfigStabilityPoolManager, + ConfigTokenNames {} diff --git a/script/config/markets/ConfigMarket_MCAP_fxUSD_mainnet.sol b/script/config/markets/ConfigMarket_MCAP_fxUSD_mainnet.sol index fc2abc35..4f6877b0 100644 --- a/script/config/markets/ConfigMarket_MCAP_fxUSD_mainnet.sol +++ b/script/config/markets/ConfigMarket_MCAP_fxUSD_mainnet.sol @@ -8,6 +8,7 @@ import {ConfigPriceVolatility_130} from "../volatility/ConfigPriceVolatility_130 import {ConfigStabilityPool} from "../stabilitypool/ConfigStabilityPool.sol"; import {ConfigStabilityPoolManager} from "../stabilitypool/ConfigStabilityPoolManager.sol"; import {Config_MinterMarket} from "../ConfigBase.sol"; +import {ConfigTokenNames} from "../ConfigTokenNames.sol"; /// @notice Market configuration for MCAP::fxUSD. contract ConfigMarket_MCAP_fxUSD_mainnet is @@ -17,5 +18,6 @@ contract ConfigMarket_MCAP_fxUSD_mainnet is ConfigCollateral_fxUSD_mainnet, ConfigPriceVolatility_130, ConfigStabilityPool, - ConfigStabilityPoolManager + ConfigStabilityPoolManager, + ConfigTokenNames {} diff --git a/script/config/markets/ConfigMarket_MCAP_stETH_mainnet.sol b/script/config/markets/ConfigMarket_MCAP_stETH_mainnet.sol index aa75f269..67ce8d2d 100644 --- a/script/config/markets/ConfigMarket_MCAP_stETH_mainnet.sol +++ b/script/config/markets/ConfigMarket_MCAP_stETH_mainnet.sol @@ -8,6 +8,7 @@ import {ConfigPriceVolatility_130} from "../volatility/ConfigPriceVolatility_130 import {ConfigStabilityPool} from "../stabilitypool/ConfigStabilityPool.sol"; import {ConfigStabilityPoolManager} from "../stabilitypool/ConfigStabilityPoolManager.sol"; import {Config_MinterMarket} from "../ConfigBase.sol"; +import {ConfigTokenNames} from "../ConfigTokenNames.sol"; /// @notice Market configuration for MCAP::stETH. contract ConfigMarket_MCAP_stETH_mainnet is @@ -17,5 +18,6 @@ contract ConfigMarket_MCAP_stETH_mainnet is ConfigCollateral_stETH_mainnet, ConfigPriceVolatility_130, ConfigStabilityPool, - ConfigStabilityPoolManager + ConfigStabilityPoolManager, + ConfigTokenNames {} diff --git a/script/config/markets/ConfigMarket_SILVER_fxUSD_mainnet.sol b/script/config/markets/ConfigMarket_SILVER_fxUSD_mainnet.sol index e9a2aba9..0bd45d78 100644 --- a/script/config/markets/ConfigMarket_SILVER_fxUSD_mainnet.sol +++ b/script/config/markets/ConfigMarket_SILVER_fxUSD_mainnet.sol @@ -8,6 +8,7 @@ import {ConfigPriceVolatility_125} from "../volatility/ConfigPriceVolatility_125 import {ConfigStabilityPool} from "../stabilitypool/ConfigStabilityPool.sol"; import {ConfigStabilityPoolManager} from "../stabilitypool/ConfigStabilityPoolManager.sol"; import {Config_MinterMarket} from "../ConfigBase.sol"; +import {ConfigTokenNames} from "../ConfigTokenNames.sol"; /// @notice Market configuration for SILVER::fxUSD. contract ConfigMarket_SILVER_fxUSD_mainnet is @@ -17,5 +18,6 @@ contract ConfigMarket_SILVER_fxUSD_mainnet is ConfigCollateral_fxUSD_mainnet, ConfigPriceVolatility_125, ConfigStabilityPool, - ConfigStabilityPoolManager + ConfigStabilityPoolManager, + ConfigTokenNames {} diff --git a/script/config/markets/ConfigMarket_SILVER_stETH_mainnet.sol b/script/config/markets/ConfigMarket_SILVER_stETH_mainnet.sol index 1de28636..b7657f73 100644 --- a/script/config/markets/ConfigMarket_SILVER_stETH_mainnet.sol +++ b/script/config/markets/ConfigMarket_SILVER_stETH_mainnet.sol @@ -8,6 +8,7 @@ import {ConfigPriceVolatility_130} from "../volatility/ConfigPriceVolatility_130 import {ConfigStabilityPool} from "../stabilitypool/ConfigStabilityPool.sol"; import {ConfigStabilityPoolManager} from "../stabilitypool/ConfigStabilityPoolManager.sol"; import {Config_MinterMarket} from "../ConfigBase.sol"; +import {ConfigTokenNames} from "../ConfigTokenNames.sol"; /// @notice Market configuration for SILVER::stETH. contract ConfigMarket_SILVER_stETH_mainnet is @@ -17,5 +18,6 @@ contract ConfigMarket_SILVER_stETH_mainnet is ConfigCollateral_stETH_mainnet, ConfigPriceVolatility_130, ConfigStabilityPool, - ConfigStabilityPoolManager + ConfigStabilityPoolManager, + ConfigTokenNames {} diff --git a/script/src/contracts/LeveragedToken.sol b/script/src/contracts/LeveragedToken.sol index 3e226709..d230981d 100644 --- a/script/src/contracts/LeveragedToken.sol +++ b/script/src/contracts/LeveragedToken.sol @@ -8,6 +8,7 @@ import {MintableBurnableERC20_v1} from "@bao/MintableBurnableERC20_v1.sol"; import {IMintableRole} from "@bao/interfaces/IMintableRole.sol"; import {IBurnableRole} from "@bao/interfaces/IBurnableRole.sol"; import {Config_MinterMarket, IMarketConfig, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; +import {ConfigTokenNames} from "script/config/ConfigTokenNames.sol"; /// @notice Harbor leveraged token deployment logic. /// @dev Leveraged tokens are unique per market (e.g., hsFXUSD-BTC for BTC::fxUSD market). abstract contract LeveragedToken is HarborFactoryDeployer { @@ -21,8 +22,8 @@ abstract contract LeveragedToken is HarborFactoryDeployer { string memory marketKey = MinterMarketConfigLib.salt(marketConfig); string memory leveragedKey = string.concat(marketKey, "::leveraged"); - string memory tokenName = MinterMarketConfigLib.leveragedName(marketConfig); - string memory tokenSymbol = MinterMarketConfigLib.leveragedSymbol(marketConfig); + string memory tokenName = ConfigTokenNames(address(marketConfig)).leveragedName(); + string memory tokenSymbol = ConfigTokenNames(address(marketConfig)).leveragedSymbol(); console.log(" > %s", leveragedKey); console.log(" Name: %s", tokenName); diff --git a/script/src/contracts/StabilityPool.sol b/script/src/contracts/StabilityPool.sol index ad67d257..6c64da14 100644 --- a/script/src/contracts/StabilityPool.sol +++ b/script/src/contracts/StabilityPool.sol @@ -10,6 +10,7 @@ import {DeploymentState} from "@bao-script/deployment/DeploymentState.sol"; import {StabilityPool_v2} from "@harbor/minter/StabilityPool_v2.sol"; import {StabilityPool_v3} from "@harbor/minter/StabilityPool_v3.sol"; import {Config_MinterMarket, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; +import {ConfigTokenNames} from "script/config/ConfigTokenNames.sol"; /// @notice Config interface for stability pool deployment parameters. interface IStabilityPoolMarketConfig { @@ -40,19 +41,12 @@ abstract contract StabilityPool is HarborFactoryDeployer { string memory spKey = string.concat(marketKey, "::", spType); console.log(" > %s", spKey); - string memory liqSymbol = keccak256(bytes(spType)) == keccak256("stabilityPoolCollateral") - ? MinterMarketConfigLib.collateral(marketConfig) - : MinterMarketConfigLib.leveragedSymbol(marketConfig); + ConfigTokenNames names = ConfigTokenNames(address(marketConfig)); + bool isCollateral = keccak256(bytes(spType)) == keccak256("stabilityPoolCollateral"); + string memory tokenName = isCollateral ? names.spCollateralName() : names.spLeveragedName(); + string memory tokenSymbol = isCollateral ? names.spCollateralSymbol() : names.spLeveragedSymbol(); IStabilityPoolMarketConfig cfg = IStabilityPoolMarketConfig(address(marketConfig)); - string memory tokenName = string.concat( - "Harbor stability pool: ", - MinterMarketConfigLib.peggedSymbol(marketConfig), - " (", - liqSymbol, - ")" - ); - string memory tokenSymbol = string.concat("hsp", MinterMarketConfigLib.peg(marketConfig), "(", liqSymbol, ")"); impl = address( new StabilityPool_v3( From 8041d4f14efa8cfefa8634963f6c13cde0b393a8 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Mon, 30 Mar 2026 13:15:04 +0100 Subject: [PATCH 004/232] remediation - not tested yet --- script/Remediate_Accumulators.s.sol | 250 ++++++++++++++++++ .../run-upgrade-test-remediate-accumulators | 77 ++++++ 2 files changed, 327 insertions(+) create mode 100644 script/Remediate_Accumulators.s.sol create mode 100755 script/test/run-upgrade-test-remediate-accumulators diff --git a/script/Remediate_Accumulators.s.sol b/script/Remediate_Accumulators.s.sol new file mode 100644 index 00000000..766e023b --- /dev/null +++ b/script/Remediate_Accumulators.s.sol @@ -0,0 +1,250 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.28 <0.9.0; + +import {console2 as console} from "forge-std/console2.sol"; +import {LibString} from "@solady/utils/LibString.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {Config_MinterMarket, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; + +import {ForceMigrateAccumulator_v1} from "script/patch/ForceMigrateAccumulator_v1.sol"; +import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; + +import {Deploy_BTC_Minter} from "script/src/Deploy_BTC_Minter.sol"; +import {Deploy_ETH_Minter} from "script/src/Deploy_ETH_Minter.sol"; +import {Deploy_EUR_Minter} from "script/src/Deploy_EUR_Minter.sol"; +import {Deploy_GOLD_Minter} from "script/src/Deploy_GOLD_Minter.sol"; +import {Deploy_MCAP_Minter} from "script/src/Deploy_MCAP_Minter.sol"; +import {Deploy_SILVER_Minter} from "script/src/Deploy_SILVER_Minter.sol"; + +import {SafeBatch} from "script/safe/SafeBatch.s.sol"; + +/// @notice Force-migrate accumulator storage from V1 (uint192) to V2 (uint256) format +/// for all stability pools across all markets. +/// +/// Per pool, the Safe batch contains 3 atomic transactions: +/// 1. Upgrade proxy → ForceMigrateAccumulator_v1 +/// 2. Call remediate(tokens, holders) to copy V1 → V2 +/// 3. Restore proxy → existing StabilityPool_v2 implementation +/// +/// After this, all users have V2 data. The V1 fallback in the accumulator +/// still exists but never triggers. A subsequent upgrade to StabilityPool_v3 +/// removes the fallback permanently. +/// +/// Run via: +/// script/run-script Remediate_Accumulators --salt harbor_v1 --network mainnet --broadcast --local +contract Remediate_Accumulators is + SafeBatch, + Deploy_BTC_Minter, + Deploy_ETH_Minter, + Deploy_EUR_Minter, + Deploy_GOLD_Minter, + Deploy_MCAP_Minter, + Deploy_SILVER_Minter +{ + using LibString for address; + + // StabilityPoolCollateral and StabilityPoolLeveraged inherited from StabilityPool deployment helper + + // ERC1967 implementation slot + bytes32 constant IMPL_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; + + // Deployed once, shared across all pools (no constructor params) + address migImpl; + + function _doOneMinter(Config_MinterMarket[] memory markets) internal { + for (uint256 i = 0; i < markets.length; i++) { + string memory marketKey = MinterMarketConfigLib.salt(markets[i]); + + _remediatePool(marketKey, StabilityPoolCollateral); + _remediatePool(marketKey, StabilityPoolLeveraged); + } + } + + function _remediatePool(string memory marketKey, string memory spType) internal { + string memory fullSalt = _saltString(marketKey, spType); + address pool = _predictAddressFromFullSalt(fullSalt); + + // Read current implementation (to restore after remediation) + address currentImpl = address(uint160(uint256(vm.load(pool, IMPL_SLOT)))); + require(currentImpl.code.length != 0, string.concat("no impl for ", fullSalt)); + + // Read active reward tokens before upgrade (pauser fallback would revert) + address[] memory tokens = IMultipleRewardDistributor(pool).activeRewardTokens(); + + // Get holders for this pool (defined per-pool below) + address[] memory holders = _getHolders(marketKey, spType); + + if (holders.length == 0) { + console.log(" > %s: no holders, skipping", fullSalt); + return; + } + + console.log(" > %s: %d holders, %d tokens", fullSalt, holders.length, tokens.length); + + // 1. Upgrade to migration contract + queue( + fullSalt, + abi.encodeCall(UUPSUpgradeable.upgradeToAndCall, (migImpl, "")), + "upgrade to ForceMigrateAccumulator_v1" + ); + + // 2. Remediate + queue( + pool, + abi.encodeCall(ForceMigrateAccumulator_v1.remediate, (tokens, holders)), + string.concat("remediate ", fullSalt) + ); + + // 3. Restore to original implementation + queue( + fullSalt, + abi.encodeCall(UUPSUpgradeable.upgradeToAndCall, (currentImpl, "")), + string.concat("restore to ", currentImpl.toHexString()) + ); + } + + function build() internal override { + Config_MinterMarket[] memory markets; + + vm.startBroadcast(); + migImpl = address(new ForceMigrateAccumulator_v1()); + console.log(" Migration impl: %s", migImpl); + vm.stopBroadcast(); + + (, markets) = createBTCMintersConfig(); + _doOneMinter(markets); + + (, markets) = createETHMintersConfig(); + _doOneMinter(markets); + + (, markets) = createEURMintersConfig(); + _doOneMinter(markets); + + (, markets) = createGOLDMintersConfig(); + _doOneMinter(markets); + + (, markets) = createMCAPMintersConfig(); + _doOneMinter(markets); + + (, markets) = createSILVERMintersConfig(); + _doOneMinter(markets); + } + + // ═══════════════════════════════════════════════════════════════════════ + // Holder lists per pool + // Source: Etherscan tokentx API query, 2026-03-28 + // Pools with 0 holders are omitted (MCAP markets, some GOLD/SILVER) + // ═══════════════════════════════════════════════════════════════════════ + + // solhint-disable func-name-mixedcase + + function _getHolders(string memory marketKey, string memory spType) internal pure returns (address[] memory) { + bytes32 key = keccak256(abi.encodePacked(marketKey, "::", spType)); + + if (key == keccak256("BTC::fxUSD::stabilityPoolCollateral")) return _holders_BTC_fxUSD_Col(); + if (key == keccak256("BTC::fxUSD::stabilityPoolLeveraged")) return _holders_BTC_fxUSD_Lev(); + if (key == keccak256("BTC::stETH::stabilityPoolCollateral")) return _holders_BTC_stETH_Col(); + if (key == keccak256("BTC::stETH::stabilityPoolLeveraged")) return _holders_BTC_stETH_Lev(); + if (key == keccak256("ETH::fxUSD::stabilityPoolCollateral")) return _holders_ETH_fxUSD_Col(); + if (key == keccak256("ETH::fxUSD::stabilityPoolLeveraged")) return _holders_ETH_fxUSD_Lev(); + if (key == keccak256("EUR::fxUSD::stabilityPoolCollateral")) return _holders_EUR_fxUSD_Col(); + if (key == keccak256("EUR::fxUSD::stabilityPoolLeveraged")) return _holders_EUR_fxUSD_Lev(); + if (key == keccak256("EUR::stETH::stabilityPoolCollateral")) return _holders_EUR_stETH_Col(); + if (key == keccak256("EUR::stETH::stabilityPoolLeveraged")) return _holders_EUR_stETH_Lev(); + if (key == keccak256("GOLD::fxUSD::stabilityPoolCollateral")) return _holders_GOLD_fxUSD_Col(); + if (key == keccak256("GOLD::fxUSD::stabilityPoolLeveraged")) return _holders_GOLD_fxUSD_Lev(); + if (key == keccak256("GOLD::stETH::stabilityPoolCollateral")) return _holders_GOLD_stETH_Col(); + if (key == keccak256("GOLD::stETH::stabilityPoolLeveraged")) return _holders_GOLD_stETH_Lev(); + if (key == keccak256("SILVER::fxUSD::stabilityPoolCollateral")) return _holders_SILVER_fxUSD_Col(); + if (key == keccak256("SILVER::fxUSD::stabilityPoolLeveraged")) return _holders_SILVER_fxUSD_Lev(); + if (key == keccak256("SILVER::stETH::stabilityPoolCollateral")) return _holders_SILVER_stETH_Col(); + if (key == keccak256("SILVER::stETH::stabilityPoolLeveraged")) return _holders_SILVER_stETH_Lev(); + + // MCAP pools: 0 holders + return new address[](0); + } + + function _holders_BTC_fxUSD_Col() internal pure returns (address[] memory h) { + h = new address[](9); + h[0] = 0x061B84FDe0aa74ecbF8eCDB0481576feE9Ae35aa; + h[1] = 0x1a9152528AEFbcD9E5df4E0770f4F510e7056913; + h[2] = 0x9bABfC1A1952a6ed2caC1922BFfE80c0506364a2; + h[3] = 0x9Dd897df19FfC27d6685E98Accc394f88a73e475; + h[4] = 0xaa17879e7cac3AEE12D6aa568691e638EF0C57f0; + h[5] = 0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e; + h[6] = 0xb9ab9578a34a05c86124c399735fdE44dEc80E7F; + h[7] = 0xbA26035B9CD76cdA5b767A966b8A3392E476Fc0F; + h[8] = 0xDD4dAd7E9FD518e271bEA1d820B95E3215D735D5; + } + + function _holders_BTC_fxUSD_Lev() internal pure returns (address[] memory h) { + h = new address[](13); + h[0] = 0x2880a6bb2cD1DF6E03dC8BbFBEd009DE586c2603; + h[1] = 0x5dE79E0C5632056B9FB19a740cE0f3EF03adEEB3; + h[2] = 0x742fC5146d7Ff18291E3B7499811AD87015Fc7E4; + h[3] = 0x754Ba099408892F500e3675b9816ea1B0dc33CBb; + h[4] = 0x7e4f98217A085F1a06332EDff805513b6Ea79357; + h[5] = 0x9Af8FBF66Bf3645f505D58614D7a13D411b99907; + h[6] = 0x9bABfC1A1952a6ed2caC1922BFfE80c0506364a2; + h[7] = 0x9Dd897df19FfC27d6685E98Accc394f88a73e475; + h[8] = 0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e; + h[9] = 0xb9ab9578a34a05c86124c399735fdE44dEc80E7F; + h[10] = 0xbA26035B9CD76cdA5b767A966b8A3392E476Fc0F; + h[11] = 0xDD0CDF8D98d9Ad3ADfaa49AaECD444Bfa01d9C9a; + h[12] = 0xDD4dAd7E9FD518e271bEA1d820B95E3215D735D5; + } + + // TODO: Add remaining holder lists for all pools + // For now, returning empty arrays for pools not yet populated + + function _holders_BTC_stETH_Col() internal pure returns (address[] memory) { + return new address[](0); + } + function _holders_BTC_stETH_Lev() internal pure returns (address[] memory) { + return new address[](0); + } + function _holders_ETH_fxUSD_Col() internal pure returns (address[] memory) { + return new address[](0); + } + function _holders_ETH_fxUSD_Lev() internal pure returns (address[] memory) { + return new address[](0); + } + function _holders_EUR_fxUSD_Col() internal pure returns (address[] memory) { + return new address[](0); + } + function _holders_EUR_fxUSD_Lev() internal pure returns (address[] memory) { + return new address[](0); + } + function _holders_EUR_stETH_Col() internal pure returns (address[] memory) { + return new address[](0); + } + function _holders_EUR_stETH_Lev() internal pure returns (address[] memory) { + return new address[](0); + } + function _holders_GOLD_fxUSD_Col() internal pure returns (address[] memory) { + return new address[](0); + } + function _holders_GOLD_fxUSD_Lev() internal pure returns (address[] memory) { + return new address[](0); + } + function _holders_GOLD_stETH_Col() internal pure returns (address[] memory) { + return new address[](0); + } + function _holders_GOLD_stETH_Lev() internal pure returns (address[] memory) { + return new address[](0); + } + function _holders_SILVER_fxUSD_Col() internal pure returns (address[] memory) { + return new address[](0); + } + function _holders_SILVER_fxUSD_Lev() internal pure returns (address[] memory) { + return new address[](0); + } + function _holders_SILVER_stETH_Col() internal pure returns (address[] memory) { + return new address[](0); + } + function _holders_SILVER_stETH_Lev() internal pure returns (address[] memory) { + return new address[](0); + } + + // solhint-enable func-name-mixedcase +} diff --git a/script/test/run-upgrade-test-remediate-accumulators b/script/test/run-upgrade-test-remediate-accumulators new file mode 100755 index 00000000..140d7e90 --- /dev/null +++ b/script/test/run-upgrade-test-remediate-accumulators @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$SCRIPT_DIR/../.." + +cd "$PROJECT_ROOT" + +# ── Configuration ────────────────────────────────────────────────────────────── + +FORGE_VERBOSITY="${FORGE_VERBOSITY:--vv}" + +while [[ $# -gt 0 ]]; do + case "$1" in + -h | --help) + echo "Usage: $(basename "$0")" + echo "" + echo "Runs the accumulator V1→V2 force migration verification on local anvil." + echo "" + echo "Steps:" + echo " 1. Start anvil fork" + echo " 2. Deploy and execute remediation Safe batch locally" + echo " 3. Run post-remediation assertions" + echo "" + echo "Environment variables:" + echo " FORGE_VERBOSITY Forge verbosity flag (default: -vv)" + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + exit 1 + ;; + esac +done + +# ── Helpers ──────────────────────────────────────────────────────────────────── + +prompt() { + local msg="$1" + echo "" + echo "──────────────────────────────────────────────────────────────" + echo "$msg" + echo "──────────────────────────────────────────────────────────────" + read -rn1 -p "Ready? [Y/n] " response + echo "" + if [[ "$response" =~ ^[Nn]$ ]]; then + echo "Aborted." + exit 1 + fi +} + +# ── Main ─────────────────────────────────────────────────────────────────────── + +echo "======================================================================" +echo " Accumulator V1→V2 Force Migration Verification" +echo "======================================================================" + +# ── Step 1: Start anvil ────────────────────────────────────────────────────── + +prompt "Start anvil fork: script/anvil --block latest" + +# ── Step 2: Deploy and execute remediation ──────────────────────────────────── + +echo "" +echo "Deploying ForceMigrateAccumulator_v1, executing remediation locally..." +./script/run-script Remediate_Accumulators --network mainnet --salt harbor_v1 --broadcast --local + +# ── Step 3: Run assertions ──────────────────────────────────────────────────── + +echo "" +echo "Running post-remediation assertions..." +forge test \ + --match-path script/test/SPv3MigrationTest.t.sol \ + --fork-url local $FORGE_VERBOSITY + +echo "" +echo "Done." From c7fa686e012cc79c6584b002b3295fda357cb4a3 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Tue, 31 Mar 2026 14:03:54 +0100 Subject: [PATCH 005/232] =?UTF-8?q?consolodated=20docs=20fixes/=20(8=20fil?= =?UTF-8?q?es)=20=E2=80=94=20resolved=20bugs:=20sp-overflow,=20linear-rewa?= =?UTF-8?q?rd-underflow,=20finishat-zero,=20epoch-removal,=20genesis-end,?= =?UTF-8?q?=20rebalance-remediation,=20remediation-ETH-fxUSD-SPL,=20sp-v3-?= =?UTF-8?q?upgrade=20frontend/=20(7=20files)=20=E2=80=94=20frontend=20inte?= =?UTF-8?q?gration:=20config,=20stability-pool,=20claim,=20troubleshooting?= =?UTF-8?q?,=20display,=20tokens,=20redeem=20guides/=20(7=20files)=20?= =?UTF-8?q?=E2=80=94=20operational=20reference:=20fee-structure,=20rewards?= =?UTF-8?q?,=20oracle-price-feeds,=20risk-parameters,=20sail-token-setup,?= =?UTF-8?q?=20deployment,=20marks-system=20ideas/=20(2=20files)=20?= =?UTF-8?q?=E2=80=94=20future=20work:=20autocompounding-vault-design,=20sp?= =?UTF-8?q?-auto-compounding-harvests=20tooling/=20(1=20file)=20=E2=80=94?= =?UTF-8?q?=20deploy-script-testing=20subgraph/=20(1=20file)=20=E2=80=94?= =?UTF-8?q?=20setup=20Core=20protocol=20docs=20stay=20at=20doc/=20root:=20?= =?UTF-8?q?rebalance.md,=20harvest.md,=20gauge-rewards.md,=20gauge-rewards?= =?UTF-8?q?-integral.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.json | 3 +- EXECUTIVE_SUMMARY.md | 120 - EXECUTIVE_SUMMARY_EXPANDED.md | 474 ---- FINISHAT_ZERO_ROOT_CAUSE_FOUND.md | 227 -- FRONTEND-WALLET-ERROR-FIX.md | 235 -- MAINNET_UNDERFLOW_COMPLETE_REPORT.md | 223 -- OVERFLOW_SCOPE_ANALYSIS.md | 453 ---- SOLUTION_ANALYSIS.md | 410 ---- UNDERFLOW_BUG_TESTS_SUMMARY.md | 182 -- UPGRADE_TEST_SUMMARY.md | 119 - WHY_FINISHAT_IS_ZERO.md | 162 -- deployments/etherscan-links.md | 149 -- doc/autocompounding-vault-design.md | 351 --- doc/{ => fixes}/epoch-removal-summary.md | 0 doc/fixes/finishat-zero.md | 38 + doc/fixes/genesis-end.md | 90 + doc/fixes/linear-reward-underflow.md | 46 + doc/{ => fixes}/rebalance-remediation.md | 0 doc/{ => fixes}/remediation-ETH-fxUSD-SPL.md | 0 doc/fixes/sp-overflow.md | 103 + .../sp-v3-upgrade.md} | 0 doc/frontend/claim.md | 221 ++ doc/frontend/config.md | 151 ++ doc/frontend/display.md | 244 ++ doc/frontend/redeem.md | 136 ++ doc/frontend/stability-pool.md | 324 +++ doc/frontend/tokens.md | 180 ++ doc/frontend/troubleshooting.md | 300 +++ doc/guides/ANCHOR-LEDGER-MARKS-EXPLANATION.md | 132 - doc/guides/CHAINLINK-MIN-MAX-REALITY.md | 208 -- doc/guides/CHECK-HARVESTABLE.md | 189 -- doc/guides/CURRENT-STATUS.md | 142 -- doc/guides/DAILY-POLL-SIMULATION.md | 132 - doc/guides/DAILY-SNAPSHOT-APPROACH.md | 196 -- doc/guides/DEBUG-END-GENESIS.md | 128 - doc/guides/DEPLOYMENT-SUMMARY-CLEAN-CHAIN.txt | 39 - doc/guides/DEPLOYMENT-SUMMARY.txt | 38 - doc/guides/DEPOSIT-FEES-TO-POOLS-GUIDE.md | 206 -- doc/guides/DEV-ACCOUNT-INFO.txt | 76 - doc/guides/DEV-ADDRESS-ANCHOR-MARKS-REPORT.md | 100 - doc/guides/DEV-WALLET-MARKS-REPORT.md | 119 - doc/guides/DIAGNOSE-DEPLOYMENT.md | 232 -- doc/guides/END-GENESIS-FIX.txt | 84 - doc/guides/EVENT-STATUS.md | 138 -- doc/guides/FEE-EXPLANATION.md | 146 -- doc/guides/FEE-STRUCTURE-DESIGN.md | 274 --- doc/guides/FEE-STRUCTURE-QUICK-REFERENCE.md | 244 -- doc/guides/FEE-TO-STABILITY-POOL-REWARDS.md | 354 --- doc/guides/FIX-END-GENESIS.md | 104 - doc/guides/FRONTEND-ADDRESSES-CLEAN-CHAIN.txt | 61 - doc/guides/FRONTEND-APR-CALCULATION.md | 519 ---- doc/guides/FRONTEND-BASIC-CLAIM-DETAILED.md | 549 ----- doc/guides/FRONTEND-CLAIM-AND-COMPOUND.md | 986 -------- doc/guides/FRONTEND-COLLATERAL-RATIO-FIX.md | 194 -- doc/guides/FRONTEND-COMPOUND-DETAILED.md | 1038 -------- doc/guides/FRONTEND-CONFIG-CLEAN-CHAIN.txt | 41 - doc/guides/FRONTEND-CONFIG-FINAL.txt | 76 - .../FRONTEND-CONFIG-FRESH-DEPLOYMENT.md | 264 -- doc/guides/FRONTEND-CONFIG-NEW-DEPLOYMENT.md | 454 ---- doc/guides/FRONTEND-CONFIG-NEW.txt | 40 - doc/guides/FRONTEND-CONFIG-READY.md | 322 --- doc/guides/FRONTEND-CONFIG.md | 320 --- .../FRONTEND-CONTRACT-ADDRESSES-CURRENT.txt | 81 - doc/guides/FRONTEND-CONTRACT-ADDRESSES.txt | 100 - doc/guides/FRONTEND-DRY-RUN-EMPTY-DATA-FIX.md | 459 ---- .../FRONTEND-DRY-RUN-ERROR-TROUBLESHOOTING.md | 510 ---- doc/guides/FRONTEND-FIX-REQUIRED.txt | 72 - doc/guides/FRONTEND-HA-TOKEN-MARKS.md | 2157 ----------------- doc/guides/FRONTEND-INFO-ACTIVE-GENESIS.txt | 55 - doc/guides/FRONTEND-INFO-ACTIVE.txt | 67 - doc/guides/FRONTEND-INFO-FINAL.txt | 75 - doc/guides/FRONTEND-INFO-FIXED.txt | 42 - doc/guides/FRONTEND-INFO-FRESH-DEPLOY.txt | 62 - doc/guides/FRONTEND-INFO.txt | 324 --- doc/guides/FRONTEND-LEVERAGE-RATIO.md | 423 ---- doc/guides/FRONTEND-MARKS-DISPLAY-GUIDE.md | 3 - doc/guides/FRONTEND-PEGGED-TOKEN-PRICE.md | 3 - doc/guides/FRONTEND-PEGGED-TOKEN-VALUE.md | 353 --- .../FRONTEND-READ-STABILITY-POOL-DEPOSITS.md | 675 ------ .../FRONTEND-REDEEM-ERROR-TROUBLESHOOTING.md | 269 -- doc/guides/FRONTEND-REDEEM-FEE-CALCULATION.md | 512 ---- .../FRONTEND-REWARD-TOKENS-AND-RATES.md | 409 ---- .../FRONTEND-SAIL-TOKEN-IMPLEMENTATION.md | 613 ----- .../FRONTEND-SAIL-TOKEN-QUICK-REFERENCE.md | 108 - doc/guides/FRONTEND-SAIL-TOKEN-TVL.md | 342 --- .../FRONTEND-STABILITY-POOL-CONTRACT-QUERY.md | 622 ----- doc/guides/FRONTEND-STABILITY-POOL-DEPOSIT.md | 495 ---- ...FRONTEND-STABILITY-POOL-REWARDS-DISPLAY.md | 462 ---- ...NTEND-STABILITY-POOL-WITHDRAWAL-REQUEST.md | 580 ----- doc/guides/FRONTEND-TROUBLESHOOTING.md | 340 --- doc/guides/FRONTEND-UPDATE-NEW-GENESIS.txt | 80 - doc/guides/FRONTEND-WALLET-ERROR-FIX.md | 233 -- doc/guides/GENESIS-END-REQUIRED.md | 114 - doc/guides/GENESIS-INVESTIGATION.md | 92 - doc/guides/HA-TOKEN-DEBUG-FIX.md | 114 - doc/guides/HA-TOKEN-TRACKING-ENABLED.md | 160 -- doc/guides/HA-TOKEN-TRACKING-STATUS.md | 214 -- doc/guides/HARVEST-FLOW-CLARIFIED.md | 218 -- doc/guides/HARVEST-REWARDS-DISTRIBUTION.md | 282 --- doc/guides/HOW-HARVEST-CUTS-ARE-DECIDED.md | 274 --- doc/guides/HOW-TO-FETCH-COLLATERAL-RATIO.txt | 3 - doc/guides/LATEST-EVENTS-SUMMARY.md | 156 -- doc/guides/LIQUIDATION-REWARDS-EXPLAINED.md | 222 -- .../LP-RISK-PARAMETER-RESPONSE-DRAFT.md | 153 -- .../LP-RISK-PARAMETER-RESPONSE-FINAL.md | 76 - doc/guides/LP-RISK-PARAMETER-RESPONSE.md | 360 --- doc/guides/MIN-COLLATERAL-RATIO-INFO.txt | 3 - doc/guides/MINTER-FEE-STRUCTURE-SUMMARY.md | 151 -- doc/guides/MINTER-FUNCTIONS-FRONTEND.txt | 3 - doc/guides/ORACLE-PRICE-EXPLAINED.md | 250 -- doc/guides/ORACLE-PRICES-SUMMARY.md | 220 -- doc/guides/PRICE-FEED-FIX-COMPLETE.md | 52 - doc/guides/PRICE-FEED-FIX.md | 70 - doc/guides/PRICE-FEED-UPDATE-SUMMARY.md | 74 - doc/guides/REBALANCE-THRESHOLD-INFO.txt | 168 -- doc/guides/REBALANCE-TRIGGER-AND-TARGET.md | 310 --- doc/guides/REWARDS-TESTING-STATUS.md | 164 -- doc/guides/RISK-MITIGATION-CONFIGURATION.md | 515 ---- doc/guides/SAIL-TOKEN-FINAL-SETUP.md | 117 - doc/guides/SAIL-TOKEN-IMPLEMENTATION.md | 175 -- doc/guides/SAIL-TOKEN-SETUP-SUMMARY.md | 152 -- doc/guides/SAIL-TOKEN-SUBGRAPH-SETUP.md | 198 -- .../SET-WITHDRAWAL-WINDOW-FOR-TESTING.md | 158 -- doc/guides/SETUP-COMPLETE-SUMMARY.md | 322 --- ...BILITY-POOL-MANAGER-FUNCTIONS-FRONTEND.txt | 3 - .../STABILITY-POOL-REWARDS-EXPLAINED.md | 266 -- doc/guides/STABILITY-POOL-TRACKING-STATUS.md | 147 -- doc/guides/STOP-SUBGRAPH-PLAN.md | 104 - doc/guides/SUBGRAPH-DEPLOYED-SUCCESS.txt | 16 - .../SUBGRAPH-MULTIPLIER-REQUIREMENTS.md | 87 - doc/guides/SUBGRAPH-STATUS-FOR-FRONTEND.md | 89 - doc/guides/SUBGRAPH-STOPPED-SUMMARY.md | 107 - doc/guides/SUBGRAPH-SYNC-ISSUE.md | 180 -- doc/guides/SUBGRAPH-UPDATE-REQUIRED.txt | 30 - doc/guides/USER-MARKS-SUMMARY.txt | 32 - doc/guides/USER-TESTING-PLAN.md | 1099 --------- doc/guides/WITHDRAWAL-MARKS-FIX.md | 290 --- doc/guides/contract-addresses-and-tokens.txt | 3 - .../contract-addresses-fresh-deployment.txt | 3 - .../contract-addresses-new-deployment.txt | 49 - doc/guides/contracts-diagram.md | 70 - doc/guides/contracts.md | 21 - doc/guides/deployment-addresses-fresh.txt | 3 - doc/guides/deployment-addresses.md | 3 - doc/guides/deployment.md | 134 + doc/guides/fee-structure.md | 212 ++ doc/guides/graph-node-local-setup.md | 3 - doc/guides/marks-system.md | 100 + doc/guides/oracle-price-feeds.md | 82 + doc/guides/rewards.md | 182 ++ doc/guides/risk-parameters.md | 146 ++ doc/guides/sail-token-setup.md | 136 ++ doc/guides/sepolia-deployment-analysis.md | 3 - doc/ideas/autocompounding-vault-design.md | 210 ++ doc/ideas/sp-auto-compounding-harvests.md | 203 ++ doc/subgraph/setup.md | 207 ++ doc/{ => tooling}/deploy-script-testing.md | 0 157 files changed, 3447 insertions(+), 29925 deletions(-) delete mode 100644 EXECUTIVE_SUMMARY.md delete mode 100644 EXECUTIVE_SUMMARY_EXPANDED.md delete mode 100644 FINISHAT_ZERO_ROOT_CAUSE_FOUND.md delete mode 100644 FRONTEND-WALLET-ERROR-FIX.md delete mode 100644 MAINNET_UNDERFLOW_COMPLETE_REPORT.md delete mode 100644 OVERFLOW_SCOPE_ANALYSIS.md delete mode 100644 SOLUTION_ANALYSIS.md delete mode 100644 UNDERFLOW_BUG_TESTS_SUMMARY.md delete mode 100644 UPGRADE_TEST_SUMMARY.md delete mode 100644 WHY_FINISHAT_IS_ZERO.md delete mode 100644 deployments/etherscan-links.md delete mode 100644 doc/autocompounding-vault-design.md rename doc/{ => fixes}/epoch-removal-summary.md (100%) create mode 100644 doc/fixes/finishat-zero.md create mode 100644 doc/fixes/genesis-end.md create mode 100644 doc/fixes/linear-reward-underflow.md rename doc/{ => fixes}/rebalance-remediation.md (100%) rename doc/{ => fixes}/remediation-ETH-fxUSD-SPL.md (100%) create mode 100644 doc/fixes/sp-overflow.md rename doc/{stability-pool-v3-upgrade.md => fixes/sp-v3-upgrade.md} (100%) create mode 100644 doc/frontend/claim.md create mode 100644 doc/frontend/config.md create mode 100644 doc/frontend/display.md create mode 100644 doc/frontend/redeem.md create mode 100644 doc/frontend/stability-pool.md create mode 100644 doc/frontend/tokens.md create mode 100644 doc/frontend/troubleshooting.md delete mode 100644 doc/guides/ANCHOR-LEDGER-MARKS-EXPLANATION.md delete mode 100644 doc/guides/CHAINLINK-MIN-MAX-REALITY.md delete mode 100644 doc/guides/CHECK-HARVESTABLE.md delete mode 100644 doc/guides/CURRENT-STATUS.md delete mode 100644 doc/guides/DAILY-POLL-SIMULATION.md delete mode 100644 doc/guides/DAILY-SNAPSHOT-APPROACH.md delete mode 100644 doc/guides/DEBUG-END-GENESIS.md delete mode 100644 doc/guides/DEPLOYMENT-SUMMARY-CLEAN-CHAIN.txt delete mode 100644 doc/guides/DEPLOYMENT-SUMMARY.txt delete mode 100644 doc/guides/DEPOSIT-FEES-TO-POOLS-GUIDE.md delete mode 100644 doc/guides/DEV-ACCOUNT-INFO.txt delete mode 100644 doc/guides/DEV-ADDRESS-ANCHOR-MARKS-REPORT.md delete mode 100644 doc/guides/DEV-WALLET-MARKS-REPORT.md delete mode 100644 doc/guides/DIAGNOSE-DEPLOYMENT.md delete mode 100644 doc/guides/END-GENESIS-FIX.txt delete mode 100644 doc/guides/EVENT-STATUS.md delete mode 100644 doc/guides/FEE-EXPLANATION.md delete mode 100644 doc/guides/FEE-STRUCTURE-DESIGN.md delete mode 100644 doc/guides/FEE-STRUCTURE-QUICK-REFERENCE.md delete mode 100644 doc/guides/FEE-TO-STABILITY-POOL-REWARDS.md delete mode 100644 doc/guides/FIX-END-GENESIS.md delete mode 100644 doc/guides/FRONTEND-ADDRESSES-CLEAN-CHAIN.txt delete mode 100644 doc/guides/FRONTEND-APR-CALCULATION.md delete mode 100644 doc/guides/FRONTEND-BASIC-CLAIM-DETAILED.md delete mode 100644 doc/guides/FRONTEND-CLAIM-AND-COMPOUND.md delete mode 100644 doc/guides/FRONTEND-COLLATERAL-RATIO-FIX.md delete mode 100644 doc/guides/FRONTEND-COMPOUND-DETAILED.md delete mode 100644 doc/guides/FRONTEND-CONFIG-CLEAN-CHAIN.txt delete mode 100644 doc/guides/FRONTEND-CONFIG-FINAL.txt delete mode 100644 doc/guides/FRONTEND-CONFIG-FRESH-DEPLOYMENT.md delete mode 100644 doc/guides/FRONTEND-CONFIG-NEW-DEPLOYMENT.md delete mode 100644 doc/guides/FRONTEND-CONFIG-NEW.txt delete mode 100644 doc/guides/FRONTEND-CONFIG-READY.md delete mode 100644 doc/guides/FRONTEND-CONFIG.md delete mode 100644 doc/guides/FRONTEND-CONTRACT-ADDRESSES-CURRENT.txt delete mode 100644 doc/guides/FRONTEND-CONTRACT-ADDRESSES.txt delete mode 100644 doc/guides/FRONTEND-DRY-RUN-EMPTY-DATA-FIX.md delete mode 100644 doc/guides/FRONTEND-DRY-RUN-ERROR-TROUBLESHOOTING.md delete mode 100644 doc/guides/FRONTEND-FIX-REQUIRED.txt delete mode 100644 doc/guides/FRONTEND-HA-TOKEN-MARKS.md delete mode 100644 doc/guides/FRONTEND-INFO-ACTIVE-GENESIS.txt delete mode 100644 doc/guides/FRONTEND-INFO-ACTIVE.txt delete mode 100644 doc/guides/FRONTEND-INFO-FINAL.txt delete mode 100644 doc/guides/FRONTEND-INFO-FIXED.txt delete mode 100644 doc/guides/FRONTEND-INFO-FRESH-DEPLOY.txt delete mode 100644 doc/guides/FRONTEND-INFO.txt delete mode 100644 doc/guides/FRONTEND-LEVERAGE-RATIO.md delete mode 100644 doc/guides/FRONTEND-MARKS-DISPLAY-GUIDE.md delete mode 100644 doc/guides/FRONTEND-PEGGED-TOKEN-PRICE.md delete mode 100644 doc/guides/FRONTEND-PEGGED-TOKEN-VALUE.md delete mode 100644 doc/guides/FRONTEND-READ-STABILITY-POOL-DEPOSITS.md delete mode 100644 doc/guides/FRONTEND-REDEEM-ERROR-TROUBLESHOOTING.md delete mode 100644 doc/guides/FRONTEND-REDEEM-FEE-CALCULATION.md delete mode 100644 doc/guides/FRONTEND-REWARD-TOKENS-AND-RATES.md delete mode 100644 doc/guides/FRONTEND-SAIL-TOKEN-IMPLEMENTATION.md delete mode 100644 doc/guides/FRONTEND-SAIL-TOKEN-QUICK-REFERENCE.md delete mode 100644 doc/guides/FRONTEND-SAIL-TOKEN-TVL.md delete mode 100644 doc/guides/FRONTEND-STABILITY-POOL-CONTRACT-QUERY.md delete mode 100644 doc/guides/FRONTEND-STABILITY-POOL-DEPOSIT.md delete mode 100644 doc/guides/FRONTEND-STABILITY-POOL-REWARDS-DISPLAY.md delete mode 100644 doc/guides/FRONTEND-STABILITY-POOL-WITHDRAWAL-REQUEST.md delete mode 100644 doc/guides/FRONTEND-TROUBLESHOOTING.md delete mode 100644 doc/guides/FRONTEND-UPDATE-NEW-GENESIS.txt delete mode 100644 doc/guides/FRONTEND-WALLET-ERROR-FIX.md delete mode 100644 doc/guides/GENESIS-END-REQUIRED.md delete mode 100644 doc/guides/GENESIS-INVESTIGATION.md delete mode 100644 doc/guides/HA-TOKEN-DEBUG-FIX.md delete mode 100644 doc/guides/HA-TOKEN-TRACKING-ENABLED.md delete mode 100644 doc/guides/HA-TOKEN-TRACKING-STATUS.md delete mode 100644 doc/guides/HARVEST-FLOW-CLARIFIED.md delete mode 100644 doc/guides/HARVEST-REWARDS-DISTRIBUTION.md delete mode 100644 doc/guides/HOW-HARVEST-CUTS-ARE-DECIDED.md delete mode 100644 doc/guides/HOW-TO-FETCH-COLLATERAL-RATIO.txt delete mode 100644 doc/guides/LATEST-EVENTS-SUMMARY.md delete mode 100644 doc/guides/LIQUIDATION-REWARDS-EXPLAINED.md delete mode 100644 doc/guides/LP-RISK-PARAMETER-RESPONSE-DRAFT.md delete mode 100644 doc/guides/LP-RISK-PARAMETER-RESPONSE-FINAL.md delete mode 100644 doc/guides/LP-RISK-PARAMETER-RESPONSE.md delete mode 100644 doc/guides/MIN-COLLATERAL-RATIO-INFO.txt delete mode 100644 doc/guides/MINTER-FEE-STRUCTURE-SUMMARY.md delete mode 100644 doc/guides/MINTER-FUNCTIONS-FRONTEND.txt delete mode 100644 doc/guides/ORACLE-PRICE-EXPLAINED.md delete mode 100644 doc/guides/ORACLE-PRICES-SUMMARY.md delete mode 100644 doc/guides/PRICE-FEED-FIX-COMPLETE.md delete mode 100644 doc/guides/PRICE-FEED-FIX.md delete mode 100644 doc/guides/PRICE-FEED-UPDATE-SUMMARY.md delete mode 100644 doc/guides/REBALANCE-THRESHOLD-INFO.txt delete mode 100644 doc/guides/REBALANCE-TRIGGER-AND-TARGET.md delete mode 100644 doc/guides/REWARDS-TESTING-STATUS.md delete mode 100644 doc/guides/RISK-MITIGATION-CONFIGURATION.md delete mode 100644 doc/guides/SAIL-TOKEN-FINAL-SETUP.md delete mode 100644 doc/guides/SAIL-TOKEN-IMPLEMENTATION.md delete mode 100644 doc/guides/SAIL-TOKEN-SETUP-SUMMARY.md delete mode 100644 doc/guides/SAIL-TOKEN-SUBGRAPH-SETUP.md delete mode 100644 doc/guides/SET-WITHDRAWAL-WINDOW-FOR-TESTING.md delete mode 100644 doc/guides/SETUP-COMPLETE-SUMMARY.md delete mode 100644 doc/guides/STABILITY-POOL-MANAGER-FUNCTIONS-FRONTEND.txt delete mode 100644 doc/guides/STABILITY-POOL-REWARDS-EXPLAINED.md delete mode 100644 doc/guides/STABILITY-POOL-TRACKING-STATUS.md delete mode 100644 doc/guides/STOP-SUBGRAPH-PLAN.md delete mode 100644 doc/guides/SUBGRAPH-DEPLOYED-SUCCESS.txt delete mode 100644 doc/guides/SUBGRAPH-MULTIPLIER-REQUIREMENTS.md delete mode 100644 doc/guides/SUBGRAPH-STATUS-FOR-FRONTEND.md delete mode 100644 doc/guides/SUBGRAPH-STOPPED-SUMMARY.md delete mode 100644 doc/guides/SUBGRAPH-SYNC-ISSUE.md delete mode 100644 doc/guides/SUBGRAPH-UPDATE-REQUIRED.txt delete mode 100644 doc/guides/USER-MARKS-SUMMARY.txt delete mode 100644 doc/guides/USER-TESTING-PLAN.md delete mode 100644 doc/guides/WITHDRAWAL-MARKS-FIX.md delete mode 100644 doc/guides/contract-addresses-and-tokens.txt delete mode 100644 doc/guides/contract-addresses-fresh-deployment.txt delete mode 100644 doc/guides/contract-addresses-new-deployment.txt delete mode 100644 doc/guides/contracts-diagram.md delete mode 100644 doc/guides/contracts.md delete mode 100644 doc/guides/deployment-addresses-fresh.txt delete mode 100644 doc/guides/deployment-addresses.md create mode 100644 doc/guides/deployment.md create mode 100644 doc/guides/fee-structure.md delete mode 100644 doc/guides/graph-node-local-setup.md create mode 100644 doc/guides/marks-system.md create mode 100644 doc/guides/oracle-price-feeds.md create mode 100644 doc/guides/rewards.md create mode 100644 doc/guides/risk-parameters.md create mode 100644 doc/guides/sail-token-setup.md delete mode 100644 doc/guides/sepolia-deployment-analysis.md create mode 100644 doc/ideas/autocompounding-vault-design.md create mode 100644 doc/ideas/sp-auto-compounding-harvests.md create mode 100644 doc/subgraph/setup.md rename doc/{ => tooling}/deploy-script-testing.md (100%) diff --git a/.claude/settings.json b/.claude/settings.json index ffd4ecb2..c18d4c0b 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -4,7 +4,8 @@ "Bash(grep:*)", "Bash(git log:*)", "Bash(2)", - "Read(//home/tfras/github/baofinance/harbor/**)" + "Read(//home/tfras/github/baofinance/harbor/**)", + "Bash(find /home/tfras/github/baofinance/harbor/doc -type f \\\\\\(-name *.md -o -name *.txt \\\\\\) ! -path */.venv/* ! -path */node_modules/* ! -path */lib/*)" ] } } diff --git a/EXECUTIVE_SUMMARY.md b/EXECUTIVE_SUMMARY.md deleted file mode 100644 index af4ba2bc..00000000 --- a/EXECUTIVE_SUMMARY.md +++ /dev/null @@ -1,120 +0,0 @@ -# Stability Pool Overflow - Executive Summary - -## The Problem (30 seconds) - -**The Stability Pool will completely break within 1-2 weeks due to an integer overflow.** - -- **Current deposits**: 0.097 BTC ($9,700) -- **Reward rate**: 75 tokens/week (fxSAVE) -- **Math**: Small deposits + large rewards = accounting number too big for storage -- **When it breaks**: ALL operations fail (deposits, withdrawals, claims) - -## Recommended Solution (30 seconds) - -**Queue rewards when overflow would occur, resume when deposits increase.** - -- Operations continue working (deposit/withdraw) -- fxSAVE rewards temporarily pause (queued, not lost) -- Auto-resumes when deposits reach ~$240k OR if loss event occurs -- 10 lines of code, zero storage changes, very safe - -## Key Metrics - -| Metric | Value | -|--------|-------| -| **Time to failure** | 1-2 weeks | -| **Impact if we do nothing** | Pool completely frozen | -| **Impact with solution** | Rewards pause, operations work | -| **Deposits needed to fully fix** | +2.4 BTC ($240k @ $100k/BTC) | -| **Code changes** | ~10 lines, very low risk | -| **Reversible** | Yes | - -## User Impact Comparison - -### Without Fix (Do Nothing) -- ❌ Cannot deposit -- ❌ Cannot withdraw -- ❌ Cannot claim rewards -- 🔴 **Critical user experience failure** - -### With Fix (Queue Solution) -- ✅ Can deposit -- ✅ Can withdraw -- ✅ Can claim other rewards -- ⚠️ **fxSAVE rewards paused until more deposits** -- 🟡 **Degraded but functional** - -## What Happens After Deployment - -### Scenario 1: No New Deposits -- Pool works normally for deposits/withdrawals -- fxSAVE rewards stop accruing (APY = 0%) -- Rewards queue up (not lost, just delayed) -- Need to communicate: "Rewards paused, deposit to resume" - -### Scenario 2: Deposits Arrive -- Need +2.4 BTC ($240k) for full fix -- Queued rewards distribute gradually -- Everything returns to normal - -### Scenario 3: Loss Event Occurs -- Natural liquidation loss triggers reset -- Overflow problem solved immediately -- Queued rewards distribute -- Cannot control or predict this - -## Communication Strategy - -**Week 1 (Post-Deployment):** -> "We've upgraded the Stability Pool to handle edge cases. fxSAVE rewards may pause temporarily if the reward-to-deposit ratio exceeds limits. All funds remain safe and accessible. Deposits help restore normal reward distribution." - -**If Rewards Pause:** -> "fxSAVE rewards are temporarily queued due to the current reward-to-deposit ratio. Your rewards are not lost—they will be distributed once the pool reaches optimal deposit levels (~2.5 BTC total). All other operations (deposit, withdraw, claim other tokens) work normally." - -**Dashboard Update:** -- Show queue status -- Show deposits needed -- Show progress bar to target - -## Risk Assessment - -| Risk | Severity | Mitigation | -|------|----------|------------| -| Users confused about paused rewards | Medium | Clear communication, FAQ, dashboard | -| Deposits never arrive | Medium | Incentive programs, reduce reward rate | -| Reputational damage | Low | Transparent communication, funds always safe | -| Technical risk | Very Low | Simple code, no storage changes, well-tested | - -## Decision Required - -**Question**: Are we comfortable with: -1. fxSAVE rewards potentially pausing? -2. Needing to communicate the pause to users? -3. Depending on either $240k deposits OR natural loss event to fully resolve? - -**If YES**: Proceed with queue solution (recommended) -**If NO**: Alternative is to stop/reduce fxSAVE distributions via governance before overflow occurs - -## Timeline - -- **Today**: Make decision -- **This week**: Deploy fix -- **Week 1-2**: Monitor, communicate if rewards pause -- **Ongoing**: Track deposits, consider incentives if needed - -## Bottom Line - -**The math is simple**: -``` -Small deposits (0.097 BTC) + Large rewards (75 tokens/week) = Overflow -``` - -**The fix is simple**: Queue rewards until deposits increase - -**The trade-off is acceptable**: Paused rewards >> Completely frozen pool - -**Recommend**: Deploy queue solution, communicate transparently, monitor deposits. - ---- - -**Questions? Contact the engineering team for technical details or review SOLUTION_ANALYSIS.md for full breakdown.** diff --git a/EXECUTIVE_SUMMARY_EXPANDED.md b/EXECUTIVE_SUMMARY_EXPANDED.md deleted file mode 100644 index 722a8e5d..00000000 --- a/EXECUTIVE_SUMMARY_EXPANDED.md +++ /dev/null @@ -1,474 +0,0 @@ -# Stability Pool Overflow - Expanded Executive Summary - -## Current State (Right Now) - -The Stability Pool is operating at **94.5% of its maximum accounting capacity**: - -- **Total Deposits**: 0.097 BTC (≈ $9,700 at $100k/BTC) -- **Reward Token**: fxSAVE (0x7743e50F534a7f9F1791DdE7dCD89F7783Eefc39) -- **Distribution Rate**: ~75 tokens per week -- **Accounting Capacity Used**: 5.933×10⁵⁷ out of 6.277×10⁵⁷ maximum (94.5%) -- **Exponent**: 0 (no significant loss events have occurred) - -**The Problem**: The next reward distribution will add 7.73×10⁵⁶ to the accounting number, which **exceeds the maximum by 4.28×10⁵⁶**. This causes a Panic 0x11 integer overflow, freezing ALL pool operations. - -**Time to Failure**: 1-2 weeks (next reward distribution) - ---- - -## The Queueing Mechanism: What It Does - -Instead of letting the pool break, we queue rewards when the accounting number would overflow: - -```solidity -// When integral would overflow: -if (newIntegral > uint192.max) { - // Don't break - just queue the rewards - rewardData[token].queued += amount; - return; // Skip accumulation for now -} -``` - -**Key Point**: This is a **contract-level bank**, not user-level balances. When rewards are queued: -- They don't accrue to individual user accounts -- Users see **0% APY for fxSAVE** (not earning) -- The contract holds the tokens until conditions improve - ---- - -## User Experience: What Users Will See - -### Phase 1: Before Queue Activates (Now - Week 1) -**Everything works normally:** -- ✅ Deposits work -- ✅ Withdrawals work -- ✅ fxSAVE rewards accumulate at current APY -- ✅ Users can claim all rewards - -**Behind the scenes**: Accounting capacity at 94.5% and rising - -### Phase 2: Queue Activates (Week 1-2) -**Operations continue, but fxSAVE rewards pause:** -- ✅ Deposits work (and help!) -- ✅ Withdrawals work -- ✅ Can claim other reward tokens (if any) -- ⚠️ **fxSAVE rewards show 0% APY** -- ⚠️ **New fxSAVE tokens don't accrue to user balances** -- 📊 Dashboard shows: "fxSAVE rewards temporarily queued - deposit to resume" - -**What users experience:** -``` -Current fxSAVE balance: 100 tokens (example) -After 1 week of queueing: Still 100 tokens -After 2 weeks of queueing: Still 100 tokens -``` - -The rewards are being collected by the contract (queued), but **not distributed** to users. - -### Phase 3: Queue Clears (When Unblocked) -**Normal operations resume:** -- ✅ All queued tokens distribute to users -- ✅ fxSAVE APY returns to normal -- ✅ Users receive backlog of queued rewards proportional to their deposits - -**What users experience:** -``` -Week 0: 100 tokens, 0% APY (queue active) -Week 3: Deposits arrive, queue clears -Week 4: 175 tokens (100 original + 75 from queue), APY restored -``` - ---- - -## Unblocking Conditions: The Numbers - -The queue clears when **EITHER** of these happens: - -### Option 1: More Deposits Arrive - -**Target**: Increase total deposits from **0.097 BTC to 2.5 BTC** - -| Current | Required | Additional Needed | USD Value (@$100k/BTC) | -|---------|----------|-------------------|------------------------| -| 0.097 BTC | 2.5 BTC | **+2.4 BTC** | **$240,000** | - -**Why this number?** -``` -Current growth rate: 7.73×10⁵⁶ per week (causes overflow) -Safe growth rate: 0.30×10⁵⁷ per week (50% headroom) - -To achieve safe rate: -totalShare = (75 tokens × 1×10¹⁸ × 1×10¹⁸ × 1×10³⁶) / 0.30×10⁵⁷ - = 2.5 BTC -``` - -**At different BTC prices:** -- @ $95k/BTC: $228,000 additional -- @ $90k/BTC: $216,000 additional -- @ $80k/BTC: $192,000 additional - -**Gradual deposit scenarios:** - -| Week | Total Deposits | Weekly Growth | Status | -|------|----------------|---------------|--------| -| 0 (now) | 0.097 BTC | 7.73×10⁵⁶ | ❌ Overflow! Queue starts | -| 1 | 0.35 BTC | 2.14×10⁵⁷ | ❌ Still too high | -| 2 | 0.60 BTC | 1.25×10⁵⁷ | ❌ Still too high | -| 3 | 1.10 BTC | 0.68×10⁵⁷ | ⚠️ Better, but risky | -| 4 | 2.50 BTC | 0.30×10⁵⁷ | ✅ Safe! Can resume | - -**Important**: Even at 2.5 BTC, we can't dump all queued rewards at once. If 3 weeks of rewards are queued (225 tokens), distributing them all would cause another overflow. Must distribute gradually: -- Week 4: Distribute 75 tokens (1 week worth) -- Week 5: Distribute 75 + 75 queued -- Week 6: Distribute remaining queued rewards - -### Option 2: A Loss Event Occurs - -**What is a loss event?** -When the pool experiences a liquidation loss, the internal accounting resets: -- Exponent increments: 0 → 1 -- Integral resets to 0 at new exponent -- Overflow problem solved immediately - -**How likely is this?** -- ❌ Cannot control or predict -- ❌ Not desirable (losses hurt users) -- ⚠️ May never happen -- ✅ If it happens, unblocks immediately - -**Impact if it happens:** -``` -Before loss: integral[exponent=0] = 5.933×10⁵⁷ (overflow!) -After loss: integral[exponent=1] = 0 (fresh start) -Queued rewards: Can now distribute safely -``` - ---- - -## Detailed Scenarios with Numbers - -### Scenario A: No Action Taken (Current Code) - -**Timeline:** -- **Week 0** (now): Everything works, integral at 94.5% capacity -- **Week 1**: depositReward called → Panic 0x11 overflow → **POOL FREEZES** -- **Ongoing**: ALL operations fail - -**User trying to deposit 0.5 BTC:** -``` -Transaction → calls deposit() → calls _accumulateReward() -→ tries to add 7.73×10⁵⁶ to integral -→ PANIC 0x11 (arithmetic overflow) -→ Transaction reverts -``` - -**User trying to withdraw their 0.05 BTC:** -``` -Transaction → calls withdraw() → calls _accumulateReward() -→ PANIC 0x11 → Transaction reverts -``` - -**Business impact:** -- Users cannot access their funds (frozen, not lost) -- Emergency support burden -- Requires urgent upgrade under pressure -- Reputational damage - ---- - -### Scenario B: Queue Implemented, No New Deposits - -**Timeline:** -- **Week 1**: Queue starts, 75 tokens queued -- **Week 2**: 150 tokens queued (cumulative) -- **Week 3**: 225 tokens queued -- **Week 4**: 300 tokens queued -- **Ongoing**: Queue grows at 75 tokens/week indefinitely - -**User with 0.05 BTC deposited (50% of pool):** - -| Week | Their fxSAVE Balance | Expected (if no queue) | Difference | -|------|----------------------|------------------------|------------| -| 0 | 100 tokens | 100 tokens | 0 | -| 1 | 100 tokens | 137.5 tokens | -37.5 | -| 2 | 100 tokens | 175 tokens | -75 | -| 3 | 100 tokens | 212.5 tokens | -112.5 | -| 4 | 100 tokens | 250 tokens | -150 | - -**What they see on dashboard:** -``` -Your fxSAVE Balance: 100 tokens -Current APY: 0% (rewards queued) -Queue Status: 225 tokens queued -Deposits Needed: +2.4 BTC to resume -Progress: 0.097 / 2.5 BTC (3.9%) -``` - -**Communication needed:** -> "fxSAVE rewards are temporarily queued due to the current reward-to-deposit ratio. Your existing rewards are safe, but new rewards won't accrue until the pool reaches 2.5 BTC total deposits. Depositing helps restore reward distribution for everyone." - ---- - -### Scenario C: Sufficient Deposits Arrive (2.5 BTC Total) - -**Timeline:** -- **Weeks 1-3**: Queue active, 225 tokens accumulated -- **Week 4**: Large deposit brings totalShare to 2.5 BTC -- **Week 4-7**: Gradual distribution of queued rewards - -**Week-by-week breakdown:** - -| Week | Event | Integral Change | Queue | Status | -|------|-------|----------------|-------|--------| -| 1 | 75 tokens queued | +0 (queued) | 75 | Paused | -| 2 | 75 tokens queued | +0 (queued) | 150 | Paused | -| 3 | 75 tokens queued | +0 (queued) | 225 | Paused | -| 4 | 2.5 BTC deposit arrives | +0 | 225 | Ready! | -| 4 | Distribute 75 tokens | +0.30×10⁵⁷ | 150 | OK | -| 5 | Distribute 75 + 75 queued | +0.60×10⁵⁷ | 75 | OK | -| 6 | Distribute 75 + 75 queued | +0.60×10⁵⁷ | 0 | ✅ Cleared! | -| 7+ | Normal operations | +0.30×10⁵⁷/week | 0 | Normal | - -**User with 0.05 BTC deposited (now 2% of 2.5 BTC pool):** - -| Week | Their fxSAVE Balance | Notes | -|------|----------------------|-------| -| 0-3 | 100 tokens | Queue active, 0% APY | -| 4 | 101.5 tokens | Received 2% of 75 distributed | -| 5 | 104.5 tokens | Received 2% of 150 distributed | -| 6 | 107.5 tokens | Received 2% of 150 distributed, queue cleared | -| 7+ | Growing | Normal APY restored | - ---- - -### Scenario D: Optimal Deposits (5+ BTC Total) - -With 5 BTC total deposits: -- Growth rate: 0.15×10⁵⁷ per week (very safe) -- Can distribute large batches of queued rewards -- 300 tokens (4 weeks queued): Only adds 0.60×10⁵⁷ - -**User experience:** -``` -Week 4: 5 BTC deposit arrives -Week 4: All 225 queued tokens distributed immediately -Week 5: Normal operations, sustainable APY -``` - ---- - -## What Users Can Do While Queued - -### ✅ Operations That Work -1. **Deposit more**: Helps everyone by increasing totalShare -2. **Withdraw funds**: Full access to principal -3. **Claim other rewards**: If other tokens are active -4. **View balances**: All existing balances visible - -### ⚠️ Affected Operations -1. **fxSAVE accrual**: Shows 0% APY -2. **fxSAVE claims**: Only get existing balance, no new rewards -3. **Dashboard**: Shows queue status and progress - -### ❌ Operations That Don't Work (Only if we do nothing) -If we don't implement queueing: -1. **Cannot deposit** (transaction reverts) -2. **Cannot withdraw** (transaction reverts) -3. **Cannot claim** (transaction reverts) - ---- - -## Communication Strategy: Detailed Messaging - -### Pre-Deployment (This Week) -**To team:** -> "Deploying upgrade to handle edge case in reward accounting. fxSAVE rewards may temporarily pause if reward-to-deposit ratio exceeds safe limits. Preparing user communications." - -### Week 1 (Post-Deployment, Before Queue Activates) -**Dashboard banner:** -> "ℹ️ System upgrade deployed. All operations normal." - -**No user action needed** - everything works normally. - -### Week 1-2 (Queue Activates) -**Dashboard banner (prominent):** -> "⚠️ fxSAVE rewards temporarily queued due to current pool size. Your funds are safe and accessible. Deposits help restore reward distribution." - -**Detailed page:** -``` -fxSAVE Reward Status: Queued -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -Current Situation: -• Your funds are completely safe -• All deposits and withdrawals working normally -• fxSAVE rewards are being collected but not yet distributed - -Why is this happening? -• Current pool size: 0.097 BTC ($9,700) -• The reward-to-deposit ratio exceeds safe accounting limits -• This is a protective measure to ensure pool stability - -What happens to my rewards? -• Existing fxSAVE balance: Unchanged and claimable -• New fxSAVE rewards: Queued (not lost, just delayed) -• Queued amount: 75 tokens (updated weekly) - -When will rewards resume? -• Target pool size: 2.5 BTC ($250,000) -• Current progress: ▓░░░░░░░░░ 3.9% -• Or: If a natural liquidation event occurs - -How can I help? -• Depositing BTC helps everyone -• Each 0.1 BTC deposited: +4% progress -• At 2.5 BTC: All queued rewards distribute - -Questions? [Contact Support] -``` - -### Week 2+ (Queue Growing) -**Weekly update email:** -``` -Subject: Stability Pool Update - Week [X] - -fxSAVE Reward Queue Update: - -Pool Size: 0.15 BTC (↑ from 0.097 BTC) -Queued: 150 tokens (2 weeks) -Progress: 6% toward 2.5 BTC target -Status: Deposits/withdrawals working normally - -Your deposits help restore reward distribution for the entire community. - -[Deposit Now] [Learn More] -``` - ---- - -## Risk Assessment: Detailed Impact Analysis - -### Technical Risks - -| Risk | Probability | Impact | Mitigation | -|------|-------------|--------|------------| -| Code bug in queue logic | Very Low | High | Thorough testing, simple code (10 lines) | -| UUPS storage corruption | Very Low | Critical | No storage changes required | -| Overflow in queue counter | Very Low | Medium | uint96 max = 7.9×10²⁸ tokens | -| Gas cost increase | None | N/A | No new transactions required | - -### Business Risks - -| Risk | Probability | Impact | Mitigation | -|------|-------------|--------|------------| -| User confusion | High | Medium | Clear messaging, FAQ, support | -| Users withdraw funds | Medium | High | Transparent communication, emphasize safety | -| Deposits never arrive | Medium | High | Incentive programs, reduce reward rate | -| Negative perception | Medium | Medium | Proactive communication, funds always safe | -| Competitor advantage | Low | Low | Industry-standard protective measure | - -### User Experience Risks - -| Scenario | User Type | Impact | Communication | -|----------|-----------|--------|----------------| -| Can't claim new fxSAVE | Active user | High | "Rewards queued, not lost" | -| Sees 0% APY | Potential depositor | High | "Temporary, helps resume" | -| Wants to withdraw | Concerned user | Low | "Works normally" | -| Wants to deposit | New user | None | "Helps everyone" | - ---- - -## Decision Framework - -**Question 1: What happens if we do nothing?** -- Pool breaks in 1-2 weeks -- ALL operations fail (deposits, withdrawals, claims) -- Emergency upgrade required under pressure -- User funds safe but inaccessible - -**Question 2: What happens with queueing?** -- Pool operations continue (deposits, withdrawals work) -- fxSAVE rewards pause (0% APY) -- Rewards resume when deposits reach 2.5 BTC OR loss event -- Users may not understand, requires communication - -**Question 3: Can we get $240k in deposits?** -- **If YES**: Queue clears in weeks/months, normal operations resume -- **If NO**: Queue persists indefinitely, but pool still functional -- **Alternative**: Reduce fxSAVE distribution rate via governance - -**Question 4: Are we comfortable with the trade-off?** -- **Paused rewards** (degraded UX) vs **Frozen pool** (critical failure) -- **Temporary 0% APY** vs **Cannot access funds** -- **Need communication** vs **Emergency upgrade** - ---- - -## Timeline: Detailed Action Plan - -### Immediate (Today) -- [ ] Review this analysis -- [ ] Make go/no-go decision -- [ ] Approve communication strategy -- [ ] Alert support team - -### This Week -- [ ] Deploy queue mechanism upgrade -- [ ] Test on testnet (if possible) -- [ ] Prepare dashboard changes -- [ ] Draft user communications -- [ ] Monitor integral capacity (currently 94.5%) - -### Week 1-2 -- [ ] Queue likely activates -- [ ] Deploy dashboard updates (queue status, progress bar) -- [ ] Send user notifications -- [ ] Monitor user reactions -- [ ] Track queue growth -- [ ] Consider deposit incentives - -### Week 3+ -- [ ] Weekly updates to users -- [ ] Track deposit progress -- [ ] Adjust communication as needed -- [ ] If deposits arrive: Communicate queue clearing timeline -- [ ] If no deposits: Evaluate alternative options - -### Long-term -- [ ] Reduce reward rate if queue persists (requires governance) -- [ ] Design incentive program for deposits -- [ ] Monitor for loss events (automatic unblock) -- [ ] Plan for next distribution period - ---- - -## Bottom Line - -**The Math:** -``` -Current: 0.097 BTC deposits + 75 tokens/week = 7.73×10⁵⁶ growth -Overflow at: 6.28×10⁵⁷ maximum -Time to failure: 1-2 weeks - -Safe: 2.5 BTC deposits + 75 tokens/week = 0.30×10⁵⁷ growth -Unblocks at: $240k additional deposits OR loss event -``` - -**The Trade-off:** -- **Without queueing**: Pool completely breaks, all operations fail -- **With queueing**: Pool works, but fxSAVE rewards pause until unblocked - -**The Recommendation:** -Deploy queueing mechanism. It prevents catastrophic failure while maintaining core functionality. Users experience degraded service (0% fxSAVE APY) instead of complete failure (cannot access funds). - -**The Communication:** -Be transparent: "Your funds are safe, rewards are temporarily queued, deposits help resume distribution." - -**The Risk:** -Low technical risk (simple code, no storage changes), medium business risk (user perception, need good communication). - ---- - -**Next Step: Approve deployment and communication strategy.** - -For technical implementation details, see SOLUTION_ANALYSIS.md. diff --git a/FINISHAT_ZERO_ROOT_CAUSE_FOUND.md b/FINISHAT_ZERO_ROOT_CAUSE_FOUND.md deleted file mode 100644 index 34dc89e6..00000000 --- a/FINISHAT_ZERO_ROOT_CAUSE_FOUND.md +++ /dev/null @@ -1,227 +0,0 @@ -# Root Cause Found: Why finishAt is Zero - -## Executive Summary - -**Mystery Solved**: Token 1 has `finishAt = 0` and `lastUpdate > 0` because it was registered as a reward token but **never received any reward deposits**. This is normal contract behavior, not a bug or corruption. - -## The Smoking Gun - -### Test Evidence - -Created test: [test/ExplainFinishAtZero.t.sol](test/ExplainFinishAtZero.t.sol) - -This test **successfully replicates the exact mainnet state** without any storage manipulation: -- Token1 registered as active reward token -- Token0 receives deposits, Token1 never does -- After 4 deposits to Token0, Token1 has: - - `lastUpdate: 1769846711` ✅ (matches mainnet exactly) - - `finishAt: 0` ✅ (matches mainnet exactly) - - `rate: 0` ✅ - - `queued: 0` ✅ - -## How This Happens (Step by Step) - -### The Code Path - -When `depositReward(token0, amount)` is called: - -1. **First**: `_distributePendingReward()` is called - ```solidity - // src/reward/distributor/LinearMultipleRewardDistributor.sol:235-254 - function _distributePendingReward() internal { - address[] memory activeRewardTokens_ = $.activeRewardTokens.values(); - for (uint256 i = 0; i < activeRewardTokens_.length; i++) { - address token = activeRewardTokens_[i]; - (uint256 pending, ) = $.rewardData[token].pending(); - $.rewardData[token].lastUpdate = uint40(block.timestamp); // ⚠️ ALWAYS updates! - - if (pending > 0) { - _accumulateReward(token, pending); - } - } - } - ``` - - **Key Point**: This function loops through **ALL** active reward tokens and **ALWAYS** sets `lastUpdate = block.timestamp` for every token, regardless of whether they have pending rewards. - -2. **Second**: `_notifyReward(token0, amount)` is called - ```solidity - // src/reward/distributor/LinearMultipleRewardDistributor.sol:222-232 - function _notifyReward(address token, uint256 amount) internal { - if (REWARD_PERIOD_LENGTH == 0) { - _accumulateReward(token, amount); - } else { - LinearReward.RewardData memory data = $.rewardData[token]; - data.increase(REWARD_PERIOD_LENGTH, amount); // ⚠️ Only for this token! - $.rewardData[token] = data; - } - } - ``` - - **Key Point**: This only calls `increase()` for the token being deposited (token0), which sets both `lastUpdate` and `finishAt`. - -### The Result for Token1 - -When Token0 receives deposits but Token1 never does: - -| Deposit # | Token0 State | Token1 State | -|-----------|-------------|--------------| -| Initial | lastUpdate: 0
finishAt: 0 | lastUpdate: 0
finishAt: 0 | -| After deposit 1 | lastUpdate: ✅ SET
finishAt: ✅ SET | lastUpdate: ✅ SET
finishAt: ❌ STILL 0 | -| After deposit 2 | lastUpdate: ✅ UPDATED
finishAt: ✅ UPDATED | lastUpdate: ✅ UPDATED
finishAt: ❌ STILL 0 | -| After deposit 3 | lastUpdate: ✅ UPDATED
finishAt: ✅ UPDATED | lastUpdate: ✅ UPDATED
finishAt: ❌ STILL 0 | -| After deposit 4 | lastUpdate: ✅ UPDATED
finishAt: ✅ UPDATED | lastUpdate: ✅ UPDATED
finishAt: ❌ STILL 0 | - -## Why This Creates the Underflow Bug - -With Token1 having `finishAt: 0` and `lastUpdate: 1769846711`: - -### Condition 1: `finishAt < periodLength` -```solidity -// src/reward/distributor/LinearReward.sol:48 -uint256 _elapsed = block.timestamp - (_data.finishAt - _periodLength); -``` -- Calculation: `block.timestamp - (0 - 604800)` -- **Result**: UNDERFLOW ❌ - -### Condition 2: `finishAt < lastUpdate` -```solidity -// src/reward/distributor/LinearReward.sol:52 -_amount = _amount + uint256(_data.rate) * (_data.finishAt - _data.lastUpdate); -``` -- Calculation: `0 - 1769846711` -- **Result**: UNDERFLOW ❌ - -## Historical Timeline on Mainnet - -Going back through blocks, Token 1 **always** had `finishAt = 0`: - -| Block | Timestamp | lastUpdate | finishAt | Event | -|-------|-----------|------------|----------|-------| -| 24200000 | 1767577079 | 1767577079 | **0** | Token0 deposit | -| 24250000 | 1768574651 | 1768574651 | **0** | Token0 deposit | -| 24280000 | 1768952291 | 1768952291 | **0** | Token0 deposit | -| 24290000 | 1769019179 | 1769019179 | **0** | Token0 deposit | -| 24295000 | 1769114399 | 1769114399 | **0** | Token0 deposit | -| 24300000 | 1769153363 | 1769153363 | **0** | Token0 deposit | -| 24320000 | 1769315855 | 1769315855 | **0** | Token0 deposit | -| 24340000 | 1769608823 | 1769608823 | **0** | Token0 deposit | -| 24355000 | 1769846711 | 1769846711 | **0** | Token0 deposit | -| 24404265 | 1770459107 | 1769846711 | **0** | Current block | - -**Pattern**: Every time Token0 received a deposit, Token1's `lastUpdate` was updated but `finishAt` remained 0. - -## Is This a Bug? - -### The Contract Behavior is Intentional - -The `_distributePendingReward()` function updating `lastUpdate` for all active tokens is **by design**. It ensures that pending rewards are properly tracked and accumulated before new rewards are deposited. - -### The Problem is the Edge Case - -The contract logic **assumes** that all registered reward tokens will eventually receive deposits. The code doesn't handle the edge case where: -1. A token is registered as an active reward token -2. But never receives any deposits - -This edge case creates a state that: -- Is valid from the contract's perspective (token is active, no deposits yet) -- But triggers arithmetic underflow in the LinearReward library - -## Why Was Token1 Registered Without Deposits? - -Possible scenarios: -1. **Planned but not executed**: Token1 was registered in anticipation of future rewards, but deposits never happened -2. **Changed plans**: Initial plan was to distribute Token1 as rewards, but plans changed -3. **Test/placeholder**: Token1 was registered for testing purposes -4. **Misconfiguration**: Token1 was registered by mistake - -## The Fix - -Two approaches: - -### Option 1: Apply the Safe Subtraction Fix (Recommended) -```solidity -// src/reward/distributor/LinearReward.sol - -// Line 48-50 (Fixed): -uint256 periodStart = _data.finishAt >= _periodLength ? _data.finishAt - _periodLength : 0; -uint256 _elapsed = block.timestamp >= periodStart ? block.timestamp - periodStart : 0; - -// Line 52-54 (Fixed): -uint256 timeSinceLastUpdate = _data.finishAt >= _data.lastUpdate ? _data.finishAt - _data.lastUpdate : 0; -_amount = _amount + uint256(_data.rate) * timeSinceLastUpdate; -``` - -This handles the edge case gracefully and prevents underflow. - -### Option 2: Prevent the Edge Case -Modify `_distributePendingReward()` to only update `lastUpdate` for tokens that have been initialized (finishAt > 0): - -```solidity -function _distributePendingReward() internal { - address[] memory activeRewardTokens_ = $.activeRewardTokens.values(); - for (uint256 i = 0; i < activeRewardTokens_.length; i++) { - address token = activeRewardTokens_[i]; - LinearReward.RewardData storage data = $.rewardData[token]; - - // Only update if token has been initialized (received at least one deposit) - if (data.finishAt > 0) { - (uint256 pending, ) = data.pending(); - data.lastUpdate = uint40(block.timestamp); - - if (pending > 0) { - _accumulateReward(token, pending); - } - } - } -} -``` - -**Recommendation**: Use **Option 1** because: -- It's safer and more defensive -- Handles all edge cases, not just this one -- Doesn't change core contract logic -- Minimal code changes - -## Immediate Action Required - -1. ✅ **Root cause identified and confirmed with test** -2. ⏭️ **Apply the fix from Option 1** -3. ⏭️ **Test the fix** (tests already exist) -4. ⏭️ **Deploy upgrade** to mainnet -5. ⏭️ **Optionally unregister Token1** if it won't be used -6. ⏭️ **Add documentation** about not registering tokens without deposits - -## Files Created - -1. **[test/ExplainFinishAtZero.t.sol](test/ExplainFinishAtZero.t.sol)** ⭐ NEW - - Demonstrates exactly how the state occurs - - Replicates mainnet state without storage manipulation - - Confirms the root cause - -2. **[test/InvestigateTransactionHistory.t.sol](test/InvestigateTransactionHistory.t.sol)** ⭐ NEW - - Historical analysis across blocks - - Contract upgrade checks - - Storage slot examination - -3. **[WHY_FINISHAT_IS_ZERO.md](WHY_FINISHAT_IS_ZERO.md)** - - Investigation timeline - - Theories and analysis - -4. **[MAINNET_UNDERFLOW_COMPLETE_REPORT.md](MAINNET_UNDERFLOW_COMPLETE_REPORT.md)** - - Complete bug documentation - - Test coverage summary - -## Conclusion - -**Root Cause**: Token 1 was registered as an active reward token but never received deposits. The `_distributePendingReward()` function updated its `lastUpdate` every time other tokens received deposits, but `finishAt` remained 0 because `increase()` was never called for Token1. - -**Status**: ✅ **MYSTERY SOLVED** -**Impact**: Still CRITICAL - users cannot deposit -**Solution**: Apply safe subtraction fix to LinearReward.sol -**Prevention**: Document that registered reward tokens should receive deposits, or handle the edge case in code - ---- - -**Investigation Complete** -**Next Step**: Apply the fix and deploy upgrade diff --git a/FRONTEND-WALLET-ERROR-FIX.md b/FRONTEND-WALLET-ERROR-FIX.md deleted file mode 100644 index da9040b5..00000000 --- a/FRONTEND-WALLET-ERROR-FIX.md +++ /dev/null @@ -1,235 +0,0 @@ -# Fix: "THE METHOD ETH_SENDRAWTRANSACTION DOES NOT EXIST" Error - -## Problem - -When trying to approve wstETH for deposit, the wallet shows: - -``` -THE METHOD ETH_SENDRAWTRANSACTION DOES NOT EXIST/IS NOT AVAILABLE -``` - -## Root Cause - -The wallet (MetaMask/other) is trying to use `eth_sendRawTransaction` which Anvil may not support in the same way as mainnet, OR the frontend is configured incorrectly. - -## Solutions - -### Solution 1: Ensure Correct RPC Configuration - -Make sure your frontend is using the Anvil RPC URL, not mainnet: - -```typescript -// ✅ CORRECT - Use localhost:8545 -const provider = new ethers.providers.JsonRpcProvider("http://localhost:8545"); - -// ❌ WRONG - Don't use mainnet RPC -// const provider = new ethers.providers.JsonRpcProvider("https://eth-mainnet.g.alchemy.com/..."); -``` - -### Solution 2: Use Wallet Provider, Not Raw Transactions - -When using a wallet like MetaMask, use the wallet's provider, not raw transaction methods: - -```typescript -// ✅ CORRECT - Use wallet provider -import { ethers } from "ethers"; - -// Get provider from wallet -const provider = new ethers.providers.Web3Provider(window.ethereum); -const signer = provider.getSigner(); - -// Use the signer to send transactions -const wstETH = new ethers.Contract( - "0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0", // wstETH address - ERC20_ABI, - signer, -); - -// This will use the wallet's signing mechanism, not raw transactions -const tx = await wstETH.approve( - "0x8806fc80A0274Eda6a45E2944f6bB6E6Bb635831", // Genesis contract - ethers.constants.MaxUint256, -); -await tx.wait(); - -// ❌ WRONG - Don't use raw transactions -// const rawTx = await signer.signTransaction(...); -// await provider.sendTransaction(rawTx); -``` - -### Solution 3: Ensure Wallet is Connected to Anvil Network - -Add the Anvil network to MetaMask: - -```typescript -// Add Anvil network to wallet -const anvilNetwork = { - chainId: "0x7A69", // 31337 in hex - chainName: "Anvil Local", - nativeCurrency: { - name: "Ether", - symbol: "ETH", - decimals: 18, - }, - rpcUrls: ["http://localhost:8545"], - blockExplorerUrls: [], -}; - -try { - await window.ethereum.request({ - method: "wallet_addEthereumChain", - params: [anvilNetwork], - }); -} catch (error) { - console.error("Error adding network:", error); -} - -// Switch to Anvil network -await window.ethereum.request({ - method: "wallet_switchEthereumChain", - params: [{ chainId: "0x7A69" }], -}); -``` - -### Solution 4: Check Wallet Provider Configuration - -If using wagmi or similar, ensure the RPC URL is correct: - -```typescript -// wagmi configuration -import { configureChains, createConfig } from "wagmi"; -import { jsonRpcProvider } from "wagmi/providers/jsonRpc"; - -const { chains, publicClient } = configureChains( - [ - { - id: 31337, - name: "Anvil Local", - network: "anvil", - nativeCurrency: { - decimals: 18, - name: "Ether", - symbol: "ETH", - }, - rpcUrls: { - default: { - http: ["http://localhost:8545"], - }, - }, - }, - ], - [ - jsonRpcProvider({ - rpc: (chain) => ({ - http: "http://localhost:8545", - }), - }), - ], -); -``` - -### Solution 5: Use ethers.js Correctly with Wallets - -```typescript -// ✅ CORRECT - Full example -import { ethers } from "ethers"; - -async function approveWstETH() { - // 1. Get provider from wallet - if (!window.ethereum) { - throw new Error("No wallet found"); - } - - const provider = new ethers.providers.Web3Provider(window.ethereum); - - // 2. Request account access - await provider.send("eth_requestAccounts", []); - - // 3. Get signer - const signer = provider.getSigner(); - - // 4. Create contract instance with signer - const wstETH = new ethers.Contract( - "0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0", - [ - "function approve(address spender, uint256 amount) external returns (bool)", - "function allowance(address owner, address spender) external view returns (uint256)", - ], - signer, - ); - - // 5. Check current allowance - const currentAllowance = await wstETH.allowance( - await signer.getAddress(), - "0x8806fc80A0274Eda6a45E2944f6bB6E6Bb635831", - ); - - // 6. Approve if needed - if (currentAllowance.lt(ethers.utils.parseEther("1000"))) { - const tx = await wstETH.approve("0x8806fc80A0274Eda6a45E2944f6bB6E6Bb635831", ethers.constants.MaxUint256); - console.log("Transaction sent:", tx.hash); - await tx.wait(); - console.log("Approval confirmed!"); - } -} -``` - -## Quick Debug Checklist - -1. ✅ Is Anvil running on `http://localhost:8545`? - - ```bash - curl http://localhost:8545 -X POST -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","method":"eth_chainId","params":[],"id":1}' - # Should return: {"result":"0x7a69"} (31337 in hex) - ``` - -2. ✅ Is the wallet connected to chain ID 31337? - - Check MetaMask network dropdown - - Should show "Anvil Local" or chain ID 31337 - -3. ✅ Is the frontend using `http://localhost:8545` as RPC URL? - - Check browser console for network requests - - Should see requests to `localhost:8545`, not mainnet RPCs - -4. ✅ Is the code using wallet provider, not raw transactions? - - Look for `eth_sendRawTransaction` in your code - - Should use `signer.sendTransaction()` or `contract.method()` instead - -## Common Mistakes - -❌ **Using mainnet RPC URL:** - -```typescript -const provider = new ethers.providers.JsonRpcProvider("https://eth-mainnet.g.alchemy.com/..."); -``` - -❌ **Trying to send raw transactions manually:** - -```typescript -const rawTx = await signer.signTransaction(tx); -await provider.send("eth_sendRawTransaction", [rawTx]); -``` - -❌ **Not connecting wallet to Anvil network:** - -- Wallet is on mainnet but trying to interact with Anvil contracts - -## Verification - -After applying fixes, test the approval: - -```typescript -// Test approval -const wstETH = new ethers.Contract(wstETHAddress, ERC20_ABI, signer); -const tx = await wstETH.approve(genesisAddress, ethers.constants.MaxUint256); -console.log("Tx hash:", tx.hash); -const receipt = await tx.wait(); -console.log("Confirmed in block:", receipt.blockNumber); -``` - -## Current Contract Addresses (from latest deployment) - -- **wstETH**: `0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0` -- **Genesis**: `0x8806fc80A0274Eda6a45E2944f6bB6E6Bb635831` -- **Chain ID**: `31337` -- **RPC URL**: `http://localhost:8545` diff --git a/MAINNET_UNDERFLOW_COMPLETE_REPORT.md b/MAINNET_UNDERFLOW_COMPLETE_REPORT.md deleted file mode 100644 index 9870d32c..00000000 --- a/MAINNET_UNDERFLOW_COMPLETE_REPORT.md +++ /dev/null @@ -1,223 +0,0 @@ -# Mainnet Underflow Investigation - Complete Report - -## Executive Summary - -**Status**: ✅ **BUG CONFIRMED ON MAINNET** -**Contract**: `0x9e56F1E1E80EBf165A1dAa99F9787B41cD5bFE40` (StabilityPool) -**Block**: 24404265 -**Impact**: **Deposits completely blocked** - users cannot deposit into the pool - -## Root Cause - -### Problematic State on Mainnet - -**Reward Token 1**: `0x9567c243F647f9Ac37efb7Fc26BD9551Dce0BE1B` - -``` -lastUpdate: 1769846711 (valid timestamp) -finishAt: 0 ❌ PROBLEM! -rate: 0 -queued: 0 -block.timestamp: 1770459107 -``` - -### How This State Occurred (Without Storage Corruption) - -This state can legitimately occur through normal operations: - -1. **Reward token registered** → `finishAt: 0`, `lastUpdate: 0` -2. **First rewards deposited** → `lastUpdate` and `finishAt` get set -3. **Reward period finishes** → `finishAt` remains set, distribution complete -4. **Token unregistered or period expires** → State becomes inconsistent - -### The Underflow Conditions - -With `finishAt = 0` and `lastUpdate = 1769846711`: - -**Condition 1**: `finishAt < periodLength` -- `0 < 1209600` ✅ TRUE -- Would trigger line 48 underflow: `block.timestamp - (finishAt - periodLength)` -- Calculation: `block.timestamp - (0 - 1209600)` = **UNDERFLOW** - -**Condition 2**: `finishAt < lastUpdate` -- `0 < 1769846711` ✅ TRUE -- Would trigger line 52 underflow: `(finishAt - lastUpdate)` -- Calculation: `0 - 1769846711` = **UNDERFLOW** - -## User Impact - -### What Users Experience - -When users try to deposit into the StabilityPool: - -``` -Error: "Arithmetic operation resulted in underflow or overflow" -Panic Code: 0x11 -``` - -The deposit transaction **simulation fails**, preventing users from depositing. - -### Why Deposits Fail - -1. User calls `StabilityPool.deposit()` -2. Internally calls `_distributePendingReward()` -3. Loops through all active reward tokens -4. Calls `LinearReward.increase()` on each token -5. **Token 1 with `finishAt = 0` causes underflow** -6. Entire transaction reverts - -## The Bug in Code - -**File**: `src/reward/distributor/LinearReward.sol` - -### Line 48 Bug (Buggy Code): -```solidity -// CURRENT BUGGY CODE: -uint256 _elapsed = block.timestamp - (_data.finishAt - _periodLength); - -// UNDERFLOWS WHEN: -// - finishAt < periodLength -// - OR (finishAt - periodLength) > block.timestamp -``` - -### Line 52 Bug (Buggy Code): -```solidity -// CURRENT BUGGY CODE: -_amount = _amount + uint256(_data.rate) * (_data.finishAt - _data.lastUpdate); - -// UNDERFLOWS WHEN: -// - finishAt < lastUpdate -``` - -## The Fix (from Bug Report) - -### Line 48-50 (Fixed): -```solidity -// FIXED CODE: -uint256 periodStart = _data.finishAt >= _periodLength ? _data.finishAt - _periodLength : 0; -uint256 _elapsed = block.timestamp >= periodStart ? block.timestamp - periodStart : 0; -``` - -### Line 52-54 (Fixed): -```solidity -// FIXED CODE: -uint256 timeSinceLastUpdate = _data.finishAt >= _data.lastUpdate ? _data.finishAt - _data.lastUpdate : 0; -_amount = _amount + uint256(_data.rate) * timeSinceLastUpdate; -``` - -## Test Coverage - -### Storage Manipulation Tests (Already Created) - -These tests use `vm.store()` to create the underflow conditions: - -1. ✅ `test_depositReward_UnderflowBug_Line48_BlockTimestampTooLow()` - - **Location**: [test/reward/distributor/LinearMultipleRewardDistributor.t.sol:696](test/reward/distributor/LinearMultipleRewardDistributor.t.sol#L696) - - **Tests**: Line 48 underflow - - **Status**: ❌ FAILS with panic 0x11 (demonstrates bug) - -2. ✅ `test_depositReward_UnderflowBug_Line52_FinishAtLessThanLastUpdate()` - - **Location**: [test/reward/distributor/LinearMultipleRewardDistributor.t.sol:756](test/reward/distributor/LinearMultipleRewardDistributor.t.sol#L756) - - **Tests**: Line 52 underflow - - **Status**: ❌ FAILS with panic 0x11 (demonstrates bug) - -### Mainnet Investigation Tests (Created) - -These tests fork mainnet to investigate the actual issue: - -3. ✅ `test_InvestigateContractState()` - - **Location**: [test/MainnetUnderflowInvestigation.t.sol:31](test/MainnetUnderflowInvestigation.t.sol#L31) - - **Purpose**: Inspect actual mainnet state - - **Findings**: Confirmed `finishAt: 0`, `lastUpdate: 1769846711` - -4. ✅ `test_InvestigatePreviousBlocks()` - - **Location**: [test/MainnetUnderflowInvestigation.t.sol:93](test/MainnetUnderflowInvestigation.t.sol#L93) - - **Purpose**: Track when the problematic state occurred - - **Findings**: State changed between blocks 24404165 and 24404265 - -## Key Findings - -### Why Storage Manipulation Was Needed for Tests - -The underflow bugs in lines 48 and 52 are in the **`else` branch** of `increase()`: - -```solidity -if (block.timestamp >= _data.finishAt) { - // Safe branch - handles period completion -} else { - // BUGGY BRANCH - underflow can occur here - uint256 _elapsed = block.timestamp - (_data.finishAt - _periodLength); // Line 48 - // ... - _amount = _amount + uint256(_data.rate) * (_data.finishAt - _data.lastUpdate); // Line 52 -} -``` - -To enter the `else` branch, we need: `block.timestamp < finishAt` - -On mainnet with `finishAt = 0`: -- `block.timestamp < finishAt` → `1770459107 < 0` → **FALSE** -- So we enter the `if` branch, not the `else` branch - -**This explains why storage manipulation was necessary** - to create `finishAt > block.timestamp` to enter the buggy `else` branch. - -### The Real Question - -**How did `finishAt` become 0 on mainnet while `lastUpdate` remained set?** - -This requires further investigation of: -1. Transaction history between blocks 24404165-24404265 -2. Possible `unregisterRewardToken` calls -3. Contract upgrade events -4. Admin actions - -## Recommendations - -### Immediate Actions - -1. **Apply the fix** from the bug report to `src/reward/distributor/LinearReward.sol` -2. **Test the fix** - verify the two failing tests now pass -3. **Deploy upgrade** to the StabilityPool contract -4. **Unregister problematic token** (0x9567c243F647f9Ac37efb7Fc26BD9551Dce0BE1B) if possible -5. **Monitor** for any other tokens that might enter this state - -### Testing Checklist - -- [ ] Run: `forge test --match-test "test_depositReward_UnderflowBug_Line" -vv` - - Expected: Both tests **FAIL** with panic 0x11 (before fix) - - Expected: Both tests **PASS** (after fix) - -- [ ] Run mainnet fork tests: - - `forge test --match-contract "MainnetUnderflowInvestigation" -vv` - -- [ ] Verify deposit works after fix applied - -## Files Created/Modified - -1. **[test/reward/distributor/LinearMultipleRewardDistributor.t.sol](test/reward/distributor/LinearMultipleRewardDistributor.t.sol)** - - Added 6 comprehensive underflow tests (lines 689-992) - -2. **[test/MainnetUnderflowInvestigation.t.sol](test/MainnetUnderflowInvestigation.t.sol)** ⭐ NEW - - Mainnet fork investigation tests - -3. **[test/UnderflowBugRealScenario.t.sol](test/UnderflowBugRealScenario.t.sol)** ⭐ NEW - - Attempts to replicate without storage manipulation - -4. **[test/MainnetDepositFailure_RealScenario.t.sol](test/MainnetDepositFailure_RealScenario.t.sol)** ⭐ NEW - - Tests the actual deposit failure scenario - -5. **[UNDERFLOW_BUG_TESTS_SUMMARY.md](UNDERFLOW_BUG_TESTS_SUMMARY.md)** ⭐ NEW - - Comprehensive test documentation - -## Next Steps - -1. **Investigate transaction history** to understand how `finishAt` became 0 -2. **Apply the fix** to LinearReward.sol -3. **Deploy upgrade** to mainnet -4. **Verify fix** with mainnet fork tests -5. **Resume user deposits** - ---- - -**Status**: Bug confirmed, fix identified, tests created -**Priority**: 🔴 **CRITICAL** - User deposits completely blocked -**Solution**: Apply safe subtraction checks from bug report diff --git a/OVERFLOW_SCOPE_ANALYSIS.md b/OVERFLOW_SCOPE_ANALYSIS.md deleted file mode 100644 index 01bb5fbb..00000000 --- a/OVERFLOW_SCOPE_ANALYSIS.md +++ /dev/null @@ -1,453 +0,0 @@ -# Overflow Issue: Scope Analysis - -## Executive Summary - -**The uint192 integral overflow issue affects ANY StabilityPool that uses the MultipleRewardCompoundingAccumulator base contract, regardless of:** -- Number of reward tokens (1 or 2+ tokens) -- Type of asset (BTC, ETH, or any other) -- Which stability pool (collateral vs leveraged) - -**The determining factor is the RATIO**: `(reward amount × magnitude) / total deposits` - ---- - -## Architectural Overview - -### Two Stability Pools - -The system architecture includes **TWO** independent stability pools: - -1. **Collateral Stability Pool** (`stabilityPoolCollateral`) - - Accepts collateral deposits (e.g., haBTC) - - Used for collateral liquidations - - Can have multiple reward tokens - -2. **Leveraged Stability Pool** (`stabilityPoolLeveraged`) - - Accepts leveraged token deposits (e.g., hsBTC-fxUSD) - - Used for leveraged position liquidations - - Can have multiple reward tokens - -**Both pools use the same base contract**: `StabilityPool_v2` → `MultipleRewardCompoundingAccumulator` - -### Mainnet Deployment (as of block 24404265) - -**Primary Pool Being Tested**: -- Address: `0x9e56F1E1E80EBf165A1dAa99F9787B41cD5bFE40` -- Deposit Token: `haBTC` (0x25bA4A826E1A1346dcA2Ab530831dbFF9C08bEA7) -- **Two Active Reward Tokens**: - 1. `fxSAVE` (0x7743e50F534a7f9F1791DdE7dCD89F7783Eefc39) - **OVERFLOWING** - 2. `hsBTC-fxUSD` (0x9567c243F647f9Ac37efb7Fc26BD9551Dce0BE1B) - Status unknown - ---- - -## How the Overflow Works - -### Per-Token Integral Storage - -```solidity -// From MultipleRewardCompoundingAccumulatorStorage (line 168) -mapping(address => mapping(uint8 => uint192)) tokenToExponentToIntegral; -``` - -**Key Point**: Each reward token has its **own independent integral**: -- `tokenToExponentToIntegral[fxSAVE][0] = 5.933×10⁵⁷` (94.5% full - CRITICAL) -- `tokenToExponentToIntegral[hsBTC-fxUSD][0] = ???` (unknown status) - -### The Overflow Calculation - -```solidity -function _accumulateReward(address token, uint256 amount) { - // ... - uint256 toAdd = Math.mulDiv(amountScaled, magnitude, totalShare); - integral += uint192(toAdd); // ← Overflow happens here -} -``` - -**Formula**: `toAdd = (amount × 1e18 × magnitude) / totalShare` - -**For fxSAVE specifically**: -``` -toAdd = (75×10¹⁸ × 1×10¹⁸ × 1×10³⁶) / 0.097×10¹⁸ - ≈ 7.73×10⁵⁶ per distribution -``` - -**Current state**: -``` -integral[fxSAVE] = 5.933×10⁵⁷ (94.5% of uint192 max) -Next addition = 7.73×10⁵⁶ -New value = 6.705×10⁵⁷ ← EXCEEDS 6.277×10⁵⁷ max -Result = Panic 0x11 overflow -``` - ---- - -## Question 1: Does this affect pools with 1 vs 2 reward tokens? - -**Answer**: BOTH can be affected, but **independently per token**. - -### Pool with 1 Reward Token -```solidity -activeRewardTokens = [tokenA] -tokenToExponentToIntegral[tokenA][0] = can overflow -``` - -If `tokenA` has the problematic ratio (low deposits, high rewards), it will overflow. - -### Pool with 2 Reward Tokens -```solidity -activeRewardTokens = [tokenA, tokenB] -tokenToExponentToIntegral[tokenA][0] = can overflow -tokenToExponentToIntegral[tokenB][0] = can overflow (independently) -``` - -**Each token can overflow separately**: -- If `tokenA` (fxSAVE) has 75 tokens/week and pool has 0.097 BTC → **OVERFLOWS** -- If `tokenB` (hsBTC-fxUSD) has 1 token/week and pool has 0.097 BTC → May be safe - -**Current Mainnet Situation**: -- `fxSAVE` integral: **CRITICAL** (94.5% full, 1-2 weeks to overflow) -- `hsBTC-fxUSD` integral: **UNKNOWN** (could be safe, could also be high) - -**Important**: When `depositReward()` is called for ANY token, it triggers `_accumulateReward()` for that specific token. If fxSAVE overflows, depositing fxSAVE rewards fails, but hsBTC-fxUSD rewards might still work (if not also overflowing). - ---- - -## Question 2: Does this only affect BTC pools? - -**Answer**: NO. This affects ANY pool where the deposit-to-reward ratio is unfavorable. - -### The Math is Asset-Agnostic - -The overflow depends on: -``` -toAdd = (rewardAmount × magnitude × PRECISION²) / totalShare -``` - -**Variables**: -- `rewardAmount`: Amount of reward token being distributed -- `magnitude`: From DecrementalFloatingPoint (typically 1×10³⁶ at exponent=0) -- `totalShare`: Total deposits in the pool -- `PRECISION`: 1×10¹⁸ - -**The ratio that matters**: -``` -If (rewardAmount × magnitude) / totalShare is large → overflow risk -``` - -### Examples of Problematic Scenarios - -#### Scenario A: BTC Pool (Current Mainnet) -``` -Total deposits: 0.097 BTC (≈ $9,700) -Reward rate: 75 fxSAVE tokens/week -toAdd: 7.73×10⁵⁶ per week -Status: ❌ OVERFLOW in 1-2 weeks -``` - -#### Scenario B: ETH Pool (Hypothetical) -``` -Total deposits: 0.1 ETH (≈ $400) -Reward rate: 100 tokens/week -toAdd: (100×10¹⁸ × 1×10³⁶) / 0.1×10¹⁸ = 1.0×10⁵⁷ -Status: ❌ OVERFLOW even faster -``` - -#### Scenario C: Stablecoin Pool (Hypothetical) -``` -Total deposits: $10,000 USDC -Reward rate: 1 token/week -Decimals: 6 (USDC has 6 decimals!) -totalShare: 10,000×10⁶ = 1×10¹⁰ -toAdd: (1×10¹⁸ × 1×10³⁶) / 1×10¹⁰ = 1×10⁴⁴ -Status: ❌ OVERFLOW very quickly (even worse!) -``` - -**Key Insight**: Assets with fewer decimals (USDC = 6, USDT = 6) are MORE susceptible because `totalShare` is smaller! - -#### Scenario D: Large ETH Pool (Safe) -``` -Total deposits: 100 ETH (≈ $400,000) -Reward rate: 100 tokens/week -toAdd: (100×10¹⁸ × 1×10³⁶) / 100×10¹⁸ = 1×10³⁶ -Status: ✅ Safe (would take ~6×10²¹ weeks to overflow) -``` - ---- - -## Question 3: Does this affect both Collateral and Leveraged pools? - -**Answer**: YES, if deployed. Both use the same base contract. - -### Current Deployment Status - -**Known Active Pool** (from MainnetUpgradeTest.t.sol): -- Address: `0x9e56F1E1E80EBf165A1dAa99F9787B41cD5bFE40` -- Type: Unknown (need to verify if collateral or leveraged) -- Deposit Token: haBTC -- Reward Tokens: fxSAVE + hsBTC-fxUSD - -**Architecture Supports**: -- `stabilityPoolCollateral` - May or may not be deployed/active -- `stabilityPoolLeveraged` - May or may not be deployed/active - -### If Both Pools Are Active - -Each pool would have: -- Independent `totalShare` (deposit amounts) -- Independent reward distributions -- Independent integral values per token - -**Example**: -``` -Collateral Pool: - - Deposits: 5 BTC ($500k) - - Rewards: 50 tokens/week - - toAdd: 1×10⁵⁵ per week - - Status: ✅ Safe - -Leveraged Pool: - - Deposits: 0.097 BTC ($9.7k) - - Rewards: 75 tokens/week - - toAdd: 7.73×10⁵⁶ per week - - Status: ❌ OVERFLOW -``` - -**They would overflow independently** based on their own deposit/reward ratios. - ---- - -## Question 4: Are there other limitations? - -**Answer**: YES. Several important limitations and edge cases. - -### 1. Exponent-Based Fragmentation - -```solidity -mapping(address => mapping(uint8 => uint192)) tokenToExponentToIntegral; -// ↑ -// exponent changes on loss events -``` - -**What this means**: -- Each loss event increments the exponent: 0 → 1 → 2 → ... -- Each exponent has its own integral value (starts at 0) -- **Integrals at different exponents are separate** - -**Implications**: -- Overflow at exponent=0 doesn't affect exponent=1 -- A loss event "resets" the overflow problem (integral goes back to 0) -- But you can't control when loss events occur -- Maximum 8 exponents (limited by uint8 and SCALE_FACTOR logic) - -### 2. Decimal Precision Issues - -**Assets with Different Decimals**: -```solidity -// BTC (18 decimals) -totalShare = 0.097×10¹⁸ = 9.7×10¹⁶ - -// USDC (6 decimals) -totalShare = 10,000×10⁶ = 1×10¹⁰ ← Much smaller denominator! - -// Custom token (8 decimals) -totalShare = 100×10⁸ = 1×10¹⁰ -``` - -**Lower decimals = Faster overflow** because `totalShare` denominator is smaller. - -### 3. Magnitude-Based Scaling - -```solidity -uint256 magnitude = uint256(currentProd.magnitude()); // From DecrementalFloatingPoint -uint256 toAdd = Math.mulDiv(amountScaled, magnitude, totalShare); -``` - -**Magnitude changes over time**: -- Starts at 1×10³⁶ (exponent=0) -- Decreases with loss events -- When magnitude < 1×10²⁷, exponent increments and magnitude rescales - -**Implications**: -- If magnitude decreases (losses), `toAdd` becomes smaller → slower overflow -- If magnitude stays high (no losses), `toAdd` stays large → faster overflow -- Current mainnet: magnitude = 1×10³⁶ (no significant losses) - -### 4. Reward Period Duration - -```solidity -// From LinearReward.sol -function increase(RewardData memory _data, uint256 _periodLength, uint256 _amount) { - // ... - _data.rate = _amount / _periodLength; - _data.finishAt = block.timestamp + _periodLength; -} -``` - -**Period length affects distribution frequency, NOT total amount**: -- Short period (1 week): 75 tokens over 7 days -- Long period (4 weeks): 75 tokens over 28 days -- **Same integral growth per distribution**, just smoothed differently - -### 5. Queue Counter Limitations - -If implementing the queue solution: -```solidity -struct RewardData { - uint96 queued; // ← Limited to uint96 - // ... -} -``` - -**uint96 maximum**: 7.9×10²⁸ tokens - -**Implications**: -- If queue grows beyond uint96 max, queue counter itself overflows -- At 75 tokens/week: Would take 1.06×10²⁷ weeks to overflow -- Practically unlimited, but theoretically bounded - -### 6. Multiple Markets - -```solidity -// From deployment scripts -function deployMinterMarket(string memory marketKey, ...) { - // Creates separate pools per market -} -``` - -**Each market has separate pools**: -- Market A: BTC/fxUSD with 2 stability pools -- Market B: ETH/fxETH with 2 stability pools -- Each pool has independent integrals - -**Scope**: This overflow issue must be considered **for every pool in every market**. - -### 7. Reward Token Diversity - -**Different reward tokens accumulate differently**: -```solidity -// Token A: High distribution rate -depositReward(tokenA, 1000 ether) // Large amounts -→ integral[tokenA] grows quickly - -// Token B: Low distribution rate -depositReward(tokenB, 1 ether) // Small amounts -→ integral[tokenB] grows slowly -``` - -**Implication**: In a 2-token system, one token might overflow while the other is fine. - -### 8. User Distribution Limits - -**User reward snapshots also use uint192**: -```solidity -struct RewardSnapshot { - uint64 timestamp; - uint192 integral; // ← User's checkpoint -} -``` - -**If global integral overflows**: -- New users can't create snapshots (deposit fails) -- Existing users can't update snapshots (withdraw/claim fails) -- **Everyone is blocked**, not just new operations - ---- - -## Summary Table: What's Affected? - -| Factor | Affected? | Why | -|--------|-----------|-----| -| **Pools with 1 reward token** | ✅ Yes | Each token has own integral | -| **Pools with 2+ reward tokens** | ✅ Yes | Each token independently | -| **BTC pools** | ✅ Yes | Current mainnet case | -| **ETH pools** | ✅ Yes | Same math applies | -| **Stablecoin pools** | ✅ Yes (worse!) | Fewer decimals = faster overflow | -| **Collateral stability pool** | ✅ Yes | Uses same base contract | -| **Leveraged stability pool** | ✅ Yes | Uses same base contract | -| **All markets** | ✅ Yes | Each pool independent | -| **Low deposit pools** | ❌❌❌ CRITICAL | Small denominator → large toAdd | -| **High reward pools** | ❌❌❌ CRITICAL | Large numerator → large toAdd | -| **Pools at exponent > 0** | ⚠️ Depends | Resets integral, but can overflow again | - ---- - -## Critical Determining Factors - -**A pool will overflow if**: -``` -(rewardAmount × 1e18 × magnitude) / totalShare > remaining integral headroom -``` - -**Vulnerable pools**: -1. ❌ Low deposits (< $50k equivalent) -2. ❌ High reward rates (> 10 tokens/week) -3. ❌ No recent loss events (exponent=0, integral accumulated) -4. ❌ Low decimal assets (USDC, USDT = 6 decimals) -5. ❌ Long time since last overflow/reset - -**Safe pools**: -1. ✅ High deposits (> $1M equivalent) -2. ✅ Low reward rates (< 1 token/week) -3. ✅ Recent loss events (integral recently reset) -4. ✅ High decimal assets (18 decimals) - ---- - -## Recommended Actions - -### Immediate -1. **Check ALL deployed pools** for current integral values: - - Collateral pool (if active) - - Leveraged pool (if active) - - All reward tokens in each pool - - All markets - -2. **Identify at-risk pools**: Any with integral > 80% of uint192 max - -3. **Prioritize by time-to-overflow**: - - Critical: < 2 weeks - - High: 2-8 weeks - - Medium: 2-6 months - - Low: > 6 months - -### Per Pool Analysis Needed - -For each pool, calculate: -```javascript -const currentIntegral = await pool.tokenToExponentToIntegral(rewardToken, exponent); -const totalShare = await pool.totalAssetSupply(); -const rewardRate = await pool.rewardData(rewardToken).rate; -const magnitude = /* from pool state */; - -const toAdd = (rewardRate * 1e18 * magnitude) / totalShare; -const headroom = (6.277e57 - currentIntegral); -const weeksToOverflow = headroom / toAdd; - -console.log(`Pool will overflow in ${weeksToOverflow} weeks`); -``` - -### Long-term Solution - -**The queueing mechanism works for ALL cases**: -- Works with 1 or multiple reward tokens -- Works with any asset type -- Works for both collateral and leveraged pools -- Automatically clears when deposits increase OR loss events occur - -**Deploy once, protects all pools**. - ---- - -## Conclusion - -**This is NOT limited to**: -- ❌ 2-token pools (affects 1-token too) -- ❌ BTC pools (affects ANY asset) -- ❌ One specific pool (affects ALL pools) - -**This IS a systemic issue affecting**: -- ✅ ALL StabilityPool contracts using MultipleRewardCompoundingAccumulator -- ✅ Each reward token independently -- ✅ Any pool with unfavorable deposit/reward ratio - -**Immediate priority**: Check ALL deployed pools across ALL markets for current integral values. diff --git a/SOLUTION_ANALYSIS.md b/SOLUTION_ANALYSIS.md deleted file mode 100644 index 49131593..00000000 --- a/SOLUTION_ANALYSIS.md +++ /dev/null @@ -1,410 +0,0 @@ -# Stability Pool Overflow Issue - Solution Analysis - -## Executive Summary - -The Stability Pool's reward accounting system will overflow within 1-2 reward distributions due to: -- **Root Cause**: Very small deposit base (0.097 BTC ≈ $9,700) relative to reward amounts (75 tokens/week) -- **Immediate Impact**: All operations (deposit, withdraw, claim) will fail with Panic 0x11 -- **Recommended Solution**: Queue rewards when overflow would occur, resume when deposits increase - ---- - -## Current State (Mainnet) - -### Pool Metrics -- **Total Deposits**: 0.097 BTC (≈ $9,700 at $100k/BTC) -- **Reward Token**: fxSAVE (0x7743e50F534a7f9F1791DdE7dCD89F7783Eefc39) -- **Distribution Rate**: ~75 tokens per week -- **Integral Value**: 5.933×10⁵⁷ (94.5% of uint192 maximum) -- **Exponent**: 0 (no significant loss events) - -### Critical Threshold -- **uint192 Maximum**: 6.277×10⁵⁷ -- **Next Distribution Impact**: +7.73×10⁵⁶ -- **Result**: **Exceeds maximum by 4.28×10⁵⁶** → Panic 0x11 overflow - -### Time to Failure -- **Estimated**: 1-2 reward distributions from now -- **Once Failed**: ALL pool operations break (deposits, withdrawals, claims) - ---- - -## Solution 2: Queue Rewards (Recommended) - -### How It Works - -```solidity -// When integral would overflow: -if (newIntegral > uint192.max) { - // Instead of reverting, queue the rewards - rewardData[token].queued += amount; - emit RewardQueuedDueToIntegralOverflow(token, exponent, amount); - return; // Don't accumulate yet -} -``` - -**Key Points:** -- Rewards are **not lost**, just queued -- Operations continue working (deposit, withdraw, claim other tokens) -- Rewards distribute automatically once conditions improve - -### What Needs to Happen to Unblock - -The queue clears when **EITHER**: -1. **More deposits arrive** (increases totalShare, reduces integral growth rate) -2. **A loss event occurs** (increments exponent, resets integral to 0) - ---- - -## Deposit Requirements Analysis - -### Current Calculation -``` -Integral Growth = (reward × 1×10¹⁸ × magnitude) / totalShare - = (75×10¹⁸ × 1×10¹⁸ × 1×10³⁶) / 0.097×10¹⁸ - ≈ 7.73×10⁵⁶ per distribution -``` - -### Safe Threshold -To leave 50% headroom (integral can grow to ~3×10⁵⁷ more): -``` -Need: toAdd < 0.3×10⁵⁷ per distribution - -Required totalShare: -totalShare = (75×10¹⁸ × 1×10¹⁸ × 1×10³⁶) / 0.3×10⁵⁷ - = 2.5×10¹⁸ - = 2.5 BTC -``` - -### Additional Deposits Needed - -| Current | Required | Additional Needed | USD Value (@$100k/BTC) | -|---------|----------|-------------------|------------------------| -| 0.097 BTC | 2.5 BTC | **2.4 BTC** | **$240,000** | - -At different BTC prices: -- **@ $95k/BTC**: $228,000 -- **@ $90k/BTC**: $216,000 -- **@ $80k/BTC**: $192,000 - ---- - -## Impact Scenarios - -### Scenario 1: No Action Taken (Current Code) - -**Timeline:** -- **Week 0** (now): Operations work -- **Week 1**: Next distribution → **OVERFLOW** → ALL OPERATIONS FAIL -- **Ongoing**: Pool completely frozen - -**User Impact:** -- ❌ Cannot deposit new funds -- ❌ Cannot withdraw existing funds -- ❌ Cannot claim any rewards (all tokens) -- ⚠️ Funds are safe but inaccessible - -**Business Impact:** -- Complete loss of pool functionality -- User support burden -- Reputational damage -- Emergency upgrade required under pressure - ---- - -### Scenario 2: Implement Queue Solution (Recommended) - -#### 2A: No New Deposits - -**Timeline:** -- **Week 1**: Queue starts, 75 tokens queued -- **Week 2**: 150 tokens queued (cumulative) -- **Week 3**: 225 tokens queued -- **Ongoing**: Queue grows indefinitely - -**User Impact:** -- ✅ Can deposit new funds -- ✅ Can withdraw existing funds -- ✅ Can claim other reward tokens (if any) -- ⚠️ **fxSAVE rewards stop accumulating** (APY = 0% for fxSAVE) -- 📊 Can monitor queue via events - -**Business Impact:** -- Pool remains functional for deposits/withdrawals -- Reduced APY visible to users (transparency issue) -- Need communication about temporarily paused rewards -- Queue can be monitored off-chain - -#### 2B: Moderate Deposits (1 BTC over 4 weeks) - -**Assumptions:** -- Deposits increase gradually: 0.097 → 1.1 BTC -- Distribution continues at 75 tokens/week - -**Analysis:** -``` -Week 1: totalShare = 0.35 BTC - → toAdd = 2.14×10⁵⁷ (still too high, queue 75 tokens) - -Week 2: totalShare = 0.60 BTC - → toAdd = 1.25×10⁵⁷ (still too high, queue 150 tokens total) - -Week 3: totalShare = 0.85 BTC - → toAdd = 0.88×10⁵⁷ (still too high, queue 225 tokens total) - -Week 4: totalShare = 1.10 BTC - → toAdd = 0.68×10⁵⁷ (SAFE! but integral near max) - → Can distribute queued + new: 300 tokens - → Integral += 2.04×10⁵⁷ → OVERFLOW AGAIN! -``` - -**Conclusion**: Partial deposits delay but don't solve the problem. - -#### 2C: Sufficient Deposits (2.5 BTC total) - -**Assumptions:** -- Large deposit brings totalShare to 2.5 BTC -- 3 weeks of queued rewards: 225 tokens - -**Analysis:** -``` -Week 4: totalShare = 2.5 BTC - → toAdd for 75 tokens = 0.30×10⁵⁷ (SAFE!) - - Distribute queued + new: 300 tokens - → integral += 1.2×10⁵⁷ - → New integral = 7.13×10⁵⁷ - - PROBLEM: 7.13×10⁵⁷ > 6.28×10⁵⁷ max → STILL OVERFLOWS! -``` - -**Better approach**: Gradually distribute queued rewards -``` -Week 4: Distribute 75 tokens (1 week worth) - → integral += 0.30×10⁵⁷ → 6.23×10⁵⁷ (OK) -Week 5: Distribute 75 + 75 queued - → integral += 0.60×10⁵⁷ → 6.83×10⁵⁷ (OVERFLOW) -``` - -**Conclusion**: Even with 2.5 BTC, must distribute queued rewards slowly OR need even more deposits. - -#### 2D: Optimal Deposits (5+ BTC total) - -**Analysis:** -``` -totalShare = 5 BTC -toAdd for 75 tokens = 0.15×10⁵⁷ - -Can safely distribute large batches: -- 300 tokens (4 weeks queued): 0.60×10⁵⁷ → Total: 6.53×10⁵⁷ (OK!) -- Or distribute all queued gradually over time -``` - ---- - -## Loss Event Alternative - -### What is a Loss Event? -When the pool experiences a liquidation loss, the `magnitude` drops. If magnitude falls below 1×10²⁷, the system: -1. Increments `exponent`: 0 → 1 -2. Resets `integral[exponent=1]` to 0 -3. Continues accumulating at new exponent - -### Impact if Loss Occurs -- ✅ Integral resets to 0 at new exponent -- ✅ Overflow problem solved immediately -- ✅ Queued rewards can be distributed -- ⚠️ Users at old exponent get rewards via aggregation (complex but works) - -### Likelihood -- **Cannot control**: Loss events depend on external liquidations -- **Not desirable**: Losses hurt users -- **Cannot rely on**: May not happen in time - ---- - -## Comparison Table - -| Criteria | Do Nothing | Queue (No Deposits) | Queue (2.5 BTC) | Queue (5 BTC) | -|----------|------------|---------------------|-----------------|---------------| -| **Deposits Work** | ❌ Broken | ✅ Yes | ✅ Yes | ✅ Yes | -| **Withdrawals Work** | ❌ Broken | ✅ Yes | ✅ Yes | ✅ Yes | -| **fxSAVE Rewards** | ❌ None | ❌ Paused | ⚠️ Slow Resume | ✅ Full Resume | -| **User Experience** | 🔴 Critical | 🟡 Degraded | 🟡 Degraded | 🟢 Good | -| **Additional Deposits Needed** | N/A | None | $240k | $500k | -| **Risk Level** | 🔴 High | 🟡 Medium | 🟢 Low | 🟢 Low | -| **Implementation** | Current | Simple | Simple | Simple | -| **Reversible** | No | Yes | Yes | Yes | - ---- - -## Recommended Action Plan - -### Phase 1: Immediate (This Upgrade) -1. ✅ Fix finishAt=0 underflow bug (already done) -2. ✅ Implement queue-on-overflow logic -3. ✅ Add monitoring events -4. ✅ Deploy upgrade - -**Result**: Pool continues functioning, rewards pause for fxSAVE - -### Phase 2: Communication (Week 1) -1. Notify users about temporarily paused fxSAVE rewards -2. Explain that funds are safe and accessible -3. Encourage deposits with messaging: - - "Depositing helps restore reward distribution" - - "Queued rewards will be distributed once deposits reach threshold" -4. Provide transparent queue status dashboard - -### Phase 3: Monitoring (Ongoing) -1. Track queue growth via events -2. Monitor deposit levels -3. Watch for loss events (exponent changes) -4. Communicate progress to users - -### Phase 4: If Deposits Don't Arrive -**Options:** -1. **Incentivize deposits** with bonus rewards -2. **Reduce fxSAVE distribution rate** (requires governance) -3. **Add secondary rewards** in different token -4. **Accept paused state** until natural loss event - ---- - -## Risk Assessment - -### Queue Solution Risks - -**Low Risk:** -- ✅ Existing code already has queue mechanism (line 503) -- ✅ No storage changes required -- ✅ UUPS-safe upgrade -- ✅ Reversible logic - -**Medium Risk:** -- ⚠️ User perception: "Why did my rewards stop?" -- ⚠️ Requires good communication -- ⚠️ Depends on future deposits or loss events - -**Mitigations:** -- Clear communication strategy -- Transparent queue status -- User education about integral mechanism -- Incentive programs for deposits - -### Alternative Solutions Comparison - -| Solution | Storage Risk | User Impact | Complexity | Success Probability | -|----------|--------------|-------------|------------|---------------------| -| **Queue** | 🟢 None | 🟡 Rewards pause | 🟢 Low | 🟢 High (if deposits arrive) | -| **Epoch Reset** | 🟡 New storage | 🟢 Rewards continue | 🔴 High | 🟢 High (but you removed epochs) | -| **Cap at Max** | 🟢 None | 🔴 Unfair distribution | 🟢 Low | 🔴 Low (unfair to users) | -| **Graceful Revert** | 🟢 None | 🔴 Blocks operations | 🟢 Low | 🔴 Low (same as do nothing) | - ---- - -## Code Changes Required - -### Minimal Change (Solution 2) - -```solidity -function _accumulateReward(address token, uint256 amount) internal virtual override { - if (amount == 0) return; - - (uint128 currentProd, uint256 totalShare) = _getTotalPoolShare(); - - if (totalShare == 0) { - _getRewardData(token).queued += uint96(amount); - return; - } - - uint8 exponent = currentProd.exponent(); - uint256 magnitude = uint256(currentProd.magnitude()); - - MultipleRewardCompoundingAccumulatorStorage storage $ = - _getMultipleRewardCompoundingAccumulatorStorage(); - uint192 integral = $.tokenToExponentToIntegral[token][exponent]; - - uint256 amountScaled = amount * _REWARD_PRECISION; - uint256 toAdd = Math.mulDiv(amountScaled, magnitude, totalShare); - - // NEW: Check if adding would overflow - uint256 newIntegral = uint256(integral) + toAdd; - - if (newIntegral > type(uint192).max) { - // Queue instead of accumulating - _getRewardData(token).queued += uint96(amount); - emit RewardQueuedDueToIntegralOverflow(token, exponent, amount, toAdd); - return; - } - - // Safe to accumulate - integral = uint192(newIntegral); - $.tokenToExponentToIntegral[token][exponent] = integral; -} -``` - -**Lines changed**: ~10 lines -**New event**: 1 -**Storage changes**: 0 -**Risk**: Very low - ---- - -## Monitoring & Observability - -### Events to Add - -```solidity -event RewardQueuedDueToIntegralOverflow( - address indexed token, - uint8 indexed exponent, - uint256 amount, - uint256 toAdd -); -``` - -### Metrics to Track - -1. **Queue Size**: `rewardData[token].queued` -2. **Total Deposits**: `totalAssetSupply.amount` -3. **Integral Value**: `tokenToExponentToIntegral[token][exponent]` -4. **Percentage of Max**: `(integral * 100) / type(uint192).max` - -### Dashboard Queries - -```javascript -// Check if rewards are queued -const queued = await stabilityPool.rewardData(fxSAVE).queued; - -// Check current deposits -const totalDeposits = await stabilityPool.totalAssetSupply(); - -// Calculate how much more needed -const needed = calculateRequiredDeposits(queued, totalDeposits); -``` - ---- - -## Conclusion - -**Recommended**: Implement Solution 2 (Queue) with: -- Clear user communication -- Transparent monitoring -- Incentive program for deposits if needed - -**Why**: -- ✅ Safest for UUPS upgrade -- ✅ Preserves pool functionality -- ✅ Reversible if better solution found -- ✅ Self-healing if deposits arrive -- ✅ Works with natural loss events - -**Trade-off**: -- Users experience paused fxSAVE rewards -- Depends on future deposits or loss events -- Requires good communication - -**Alternative if unacceptable**: -- Reduce fxSAVE distribution rate via governance -- Or accept risk and implement epoch system (complex) diff --git a/UNDERFLOW_BUG_TESTS_SUMMARY.md b/UNDERFLOW_BUG_TESTS_SUMMARY.md deleted file mode 100644 index 81ec49ac..00000000 --- a/UNDERFLOW_BUG_TESTS_SUMMARY.md +++ /dev/null @@ -1,182 +0,0 @@ -# LinearReward Arithmetic Underflow Bug - Test Suite Summary - -## Overview -This document describes the test suite created to demonstrate the arithmetic underflow vulnerability in `src/reward/distributor/LinearReward.sol`. - -## Bug Location -The bug exists in the `LinearReward.increase()` function at two locations: - -### Line 48 - periodStart Calculation Underflow -```solidity -// BUGGY CODE: -uint256 _elapsed = block.timestamp - (_data.finishAt - _periodLength); - -// BUG CONDITION: -// When finishAt < periodLength, (_data.finishAt - _periodLength) underflows -// OR when (finishAt - periodLength) > block.timestamp, the outer subtraction underflows -``` - -### Line 52 - Time Since Last Update Underflow -```solidity -// BUGGY CODE: -_amount = _amount + uint256(_data.rate) * (_data.finishAt - _data.lastUpdate); - -// BUG CONDITION: -// When finishAt < lastUpdate, (_data.finishAt - _data.lastUpdate) underflows -``` - -## Test Suite - -### Tests That FAIL (Demonstrating the Bug) - -These tests deliberately create conditions that trigger the underflow bugs. They will **revert with panic 0x11** when run against the buggy code, and **pass** when run against the fixed code. - -#### 1. `test_depositReward_UnderflowBug_Line48_BlockTimestampTooLow()` -**Location:** [test/reward/distributor/LinearMultipleRewardDistributor.t.sol](test/reward/distributor/LinearMultipleRewardDistributor.t.sol) - -**What it does:** -- Creates a reward period with normal initial deposit -- Uses `vm.store()` to manipulate `finishAt` to be unusually high: `finishAt = block.timestamp + periodLength + 10000` -- This creates the condition: `block.timestamp < (finishAt - periodLength)` -- Attempts another deposit, which triggers the else branch -- The buggy line 48 tries to calculate: `block.timestamp - (finishAt - periodLength)` -- Since `(finishAt - periodLength) = block.timestamp + 10000 > block.timestamp`, this underflows - -**Expected Result:** -- ❌ **FAILS with panic 0x11** (arithmetic underflow) on buggy code -- ✅ **PASSES** on fixed code (handles the edge case gracefully) - -#### 2. `test_depositReward_UnderflowBug_Line52_FinishAtLessThanLastUpdate()` -**Location:** [test/reward/distributor/LinearMultipleRewardDistributor.t.sol](test/reward/distributor/LinearMultipleRewardDistributor.t.sol) - -**What it does:** -- Creates a reward period with normal initial deposit -- Uses `vm.store()` to manipulate `lastUpdate` to be greater than `finishAt` -- Specifically: `lastUpdate = finishAt + 5000` -- Warps time to be before `finishAt` to enter the else branch -- Deposits a large amount (100,000 ether) to trigger the distribute logic -- The buggy line 52 tries to calculate: `(_data.finishAt - _data.lastUpdate)` -- Since `finishAt < lastUpdate`, this underflows - -**Expected Result:** -- ❌ **FAILS with panic 0x11** (arithmetic underflow) on buggy code -- ✅ **PASSES** on fixed code (handles the edge case gracefully) - -#### 3. `test_depositReward_UnderflowBugDemonstration()` -**Location:** [test/reward/distributor/LinearMultipleRewardDistributor_v2.t.sol](test/reward/distributor/LinearMultipleRewardDistributor_v2.t.sol) - -**What it does:** -- Similar to test #1, demonstrates the line 48 underflow -- Uses storage manipulation to create the underflow condition -- Comprehensive test with detailed validation - -**Expected Result:** -- ❌ **FAILS with panic 0x11** (arithmetic underflow) on buggy code -- ✅ **PASSES** on fixed code - -### Tests That PASS (Testing the Fix) - -These tests demonstrate scenarios where the fix is needed and verify that the fixed code handles them correctly. - -#### 4. `test_depositReward_UnderflowFix()` -**Location:** [test/reward/distributor/LinearMultipleRewardDistributor.t.sol](test/reward/distributor/LinearMultipleRewardDistributor.t.sol) - -**What it does:** -- Tests normal deposit scenario where rewards are deposited -- Time advances (but not past finishAt) -- Then more rewards are deposited -- Validates that the deposit succeeds and state is updated correctly - -**Purpose:** Ensures basic functionality works with the fix applied - -#### 5. `test_depositReward_ExtremeUnderflowFix()` -**Location:** [test/reward/distributor/LinearMultipleRewardDistributor.t.sol](test/reward/distributor/LinearMultipleRewardDistributor.t.sol) - -**What it does:** -- Tests edge case where `finishAt` might be less than `periodLength` -- Starts at a very small timestamp (half the period length) -- Makes deposits and verifies they succeed -- Tests the scenario that can occur in forked mainnet environments - -**Purpose:** Validates the fix handles extreme edge cases with small timestamps - -#### 6. `test_depositReward_AfterPeriodFinished()` -**Location:** [test/reward/distributor/LinearMultipleRewardDistributor.t.sol](test/reward/distributor/LinearMultipleRewardDistributor.t.sol) - -**What it does:** -- Deposits rewards, then warps 2 weeks ahead (past finishAt) -- Deposits again to start a new period -- Tests the `if (block.timestamp >= finishAt)` branch - -**Purpose:** Ensures normal period completion and new period start works correctly - -#### 7. `test_depositReward_AfterPeriodFinishedThenBeforeFinishAt()` -**Location:** [test/reward/distributor/LinearMultipleRewardDistributor.t.sol](test/reward/distributor/LinearMultipleRewardDistributor.t.sol) - -**What it does:** -- Comprehensive test with multiple phases: - 1. Initial deposit - 2. Warp past finishAt and deposit again (new period) - 3. Warp forward but NOT past the new finishAt - 4. Deposit again (tests the else branch with potential underflow) -- Validates state consistency throughout - -**Purpose:** Comprehensive test of the full reward lifecycle with the fix - -## Running the Tests - -### Run All Underflow Bug Tests -```bash -forge test --match-test "test_depositReward_UnderflowBug_Line" -vv -``` - -**Expected Output:** -``` -[FAIL: panic: arithmetic underflow or overflow (0x11)] test_depositReward_UnderflowBug_Line48_BlockTimestampTooLow() -[FAIL: panic: arithmetic underflow or overflow (0x11)] test_depositReward_UnderflowBug_Line52_FinishAtLessThanLastUpdate() -``` - -### Run All depositReward Tests -```bash -forge test --match-test "test_depositReward_" --match-contract "LinearMultipleRewardDistributorTest" -vv -``` - -## How the Tests Demonstrate the Bug - -1. **Storage Manipulation:** The tests use Foundry's `vm.store()` to directly manipulate contract storage, creating edge case conditions that would be difficult to reach through normal operations but can occur in forked environments or with timestamp manipulation. - -2. **Specific Conditions:** Each test creates the exact conditions needed to trigger the specific underflow: - - Test #1: Creates `block.timestamp < (finishAt - periodLength)` - - Test #2: Creates `finishAt < lastUpdate` - -3. **Panic 0x11:** When the bug is triggered, Solidity reverts with `panic: arithmetic underflow or overflow (0x11)`, which is caught by the test framework. - -4. **Fix Validation:** After applying the fix, these same tests should pass, demonstrating that the fix handles the edge cases correctly. - -## The Fix - -The fix adds safe subtraction checks before performing arithmetic: - -### Line 48-50 (Fixed): -```solidity -// Safe periodStart calculation -uint256 periodStart = _data.finishAt >= _periodLength ? _data.finishAt - _periodLength : 0; -uint256 _elapsed = block.timestamp >= periodStart ? block.timestamp - periodStart : 0; -``` - -### Line 52-54 (Fixed): -```solidity -// Safe time since last update calculation -uint256 timeSinceLastUpdate = _data.finishAt >= _data.lastUpdate ? _data.finishAt - _data.lastUpdate : 0; -_amount = _amount + uint256(_data.rate) * timeSinceLastUpdate; -``` - -## Summary - -This test suite provides: -- ✅ **2 tests that fail on buggy code** (demonstrating the bug exists) -- ✅ **4 tests that validate the fix** (ensuring correct behavior) -- ✅ **Comprehensive coverage** of edge cases -- ✅ **Clear documentation** of what each test does - -When the fix is applied, all tests should pass, confirming that the underflow vulnerability has been resolved. diff --git a/UPGRADE_TEST_SUMMARY.md b/UPGRADE_TEST_SUMMARY.md deleted file mode 100644 index ad403483..00000000 --- a/UPGRADE_TEST_SUMMARY.md +++ /dev/null @@ -1,119 +0,0 @@ -# Mainnet Upgrade Test Summary - -## What We've Accomplished - -### 1. ✅ Applied the Complete Fix - -**File**: `src/reward/distributor/LinearReward.sol` - -**Changes Made**: -- Lines 48-50: Safe subtraction for `periodStart` and `_elapsed` calculations -- Line 54: Safe subtraction for `timeSinceLastUpdate` -- Lines 79-80: Safe subtraction in `pending()` function's else branch - -**All subtractions now use ternary checks**: `a >= b ? a - b : 0` - -### 2. ✅ All Underflow Tests Pass - -- `test_depositReward_UnderflowBug_Line48_BlockTimestampTooLow()` ✅ PASS -- `test_depositReward_UnderflowBug_Line52_FinishAtLessThanLastUpdate()` ✅ PASS -- `test_DepositAfterCreatingProblematicState()` ✅ PASS -- `test_pending_WithFinishAtZero()` ✅ PASS -- `test_increase_WithFinishAtZero()` ✅ PASS - -### 3. ✅ Root Cause Identified - -**Found**: Token 1 (`0x9567c243F647f9Ac37efb7Fc26BD9551Dce0BE1B`) was registered but never received deposits. -- Every time Token 0 received deposits, `_distributePendingReward()` updated Token 1's `lastUpdate` -- But `increase()` was only called for Token 0, so Token 1's `finishAt` stayed 0 -- Result: `lastUpdate: 1769846711, finishAt: 0` - -## Issue: Mainnet Upgrade Test Fails - -### The Problem - -When we: -1. Fork mainnet at block 24404265 -2. Deploy new StabilityPool_v2 implementation with the fix -3. Upgrade the proxy to the new implementation -4. Try to deposit - -**Result**: Still fails with panic 0x11 (arithmetic underflow) - -### Tests Created - -1. **[test/MainnetUpgradeTest.t.sol](test/MainnetUpgradeTest.t.sol)** - - Comprehensive 7-test suite - - Tests both user deposit and depositReward failures - - Tests upgrade process - - ❌ test_7_CompleteEndToEnd_Test() FAILS after upgrade - -2. **[test/SimpleUpgradeTest.t.sol](test/SimpleUpgradeTest.t.sol)** - - Minimal upgrade test - - ❌ FAILS after upgrade - -3. **[test/TestDepositAfterFinishAtZero.t.sol](test/TestDepositAfterFinishAtZero.t.sol)** - - Creates problematic state in fresh deployment - - Tests deposit after creating state - - ✅ PASSES - deposits work fine! - -4. **[test/TestLinearRewardFix.t.sol](test/TestLinearRewardFix.t.sol)** - - Direct library function tests - - ✅ PASSES - `pending()` and `increase()` work correctly - -### Why This Is Confusing - -The fix **demonstrably works** in our tests: -- We can create the exact mainnet state (`finishAt=0, lastUpdate>0`) -- We can successfully deposit after creating that state -- The library functions handle the edge cases correctly - -But when we **upgrade the actual mainnet proxy**, deposits still fail. - -### Possible Explanations - -1. **UUPS Upgrade Issue**: Something about how UUPS proxies delegate to new implementations -2. **Mainnet State Difference**: There's something about the actual mainnet state we're not replicating -3. **Compilation/Linking**: The library code isn't being properly inlined in the test environment -4. **Hidden Underflow**: There's another subtraction somewhere we haven't found yet - -### What's Different? - -| Working Tests | Failing Mainnet Upgrade | -|---|---| -| Fresh MockLinearMultipleRewardDistributor deployment | Forked mainnet ERC1967 Proxy | -| Two reward tokens (token0, token1) | Two reward tokens (same concept) | -| Create problematic state, then deposit | State already exists, then upgrade, then deposit | -| Uses test mocks | Uses actual mainnet contracts | - -## Next Steps / Recommendations - -### Option 1: Manual Investigation -- Use Foundry's `forge inspect` to compare bytecode -- Add console.log statements throughout the code path -- Test upgrade on a local fork with more detailed tracing - -### Option 2: Alternative Approach -- Apply fix and test on a testnet first -- Deploy to production and monitor -- Have rollback plan ready - -### Option 3: Simpler Fix -- Instead of upgrading, unregister Token 1 if possible -- This removes the problematic token from active tokens -- Then deposits should work without upgrade - -## Files Modified - -- ✅ [src/reward/distributor/LinearReward.sol](src/reward/distributor/LinearReward.sol) - Applied fix -- ✅ [test/MainnetUpgradeTest.t.sol](test/MainnetUpgradeTest.t.sol) - Comprehensive upgrade tests -- ✅ [test/SimpleUpgradeTest.t.sol](test/SimpleUpgradeTest.t.sol) - Minimal upgrade test -- ✅ [test/TestDepositAfterFinishAtZero.t.sol](test/TestDepositAfterFinishAtZero.t.sol) - Proof fix works -- ✅ [test/TestLinearRewardFix.t.sol](test/TestLinearRewardFix.t.sol) - Library function tests -- ✅ [FINISHAT_ZERO_ROOT_CAUSE_FOUND.md](FINISHAT_ZERO_ROOT_CAUSE_FOUND.md) - Root cause analysis - -## Summary - -**The fix is correct and works** - all our tests prove this. However, there's something about upgrading the actual mainnet proxy that causes issues we haven't been able to replicate or diagnose in the test environment. - -**Recommendation**: The fix should be deployed to production, but the mainnet upgrade test failure suggests we need further investigation before deploying to mainnet. Consider testing on a testnet environment that more closely matches mainnet first. diff --git a/WHY_FINISHAT_IS_ZERO.md b/WHY_FINISHAT_IS_ZERO.md deleted file mode 100644 index 754395c5..00000000 --- a/WHY_FINISHAT_IS_ZERO.md +++ /dev/null @@ -1,162 +0,0 @@ -# Why finishAt is Zero - Investigation Report - -## Executive Summary - -**Finding**: Token 1 (`0x9567c243F647f9Ac37efb7Fc26BD9551Dce0BE1B`) has `finishAt = 0` **since its inception**, spanning at least **20,000+ blocks** (block 24300000 to 24404265, ~67 hours). - -## Timeline of Observations - -### Consistent State Across All Checked Blocks - -| Block | Timestamp | lastUpdate | finishAt | Notes | -|-------|-----------|------------|----------|-------| -| 24300000 | 1769153363 | 1769153363 | **0** | Earliest checked | -| 24320000 | 1769315855 | 1769315855 | **0** | lastUpdate updated | -| 24340000 | 1769608823 | 1769608823 | **0** | lastUpdate updated | -| 24350000 | 1769608823 | 1769608823 | **0** | (same) | -| 24355000 | 1769846711 | 1769846711 | **0** | lastUpdate updated | -| 24359000 | 1769846711 | 1769846711 | **0** | (same) | -| 24390000 | 1770286895 | 1769846711 | **0** | 7 days after lastUpdate | -| 24404265 | 1770459107 | 1769846711 | **0** | **Current problematic block** | - -### Key Observations - -1. ✅ **Token IS registered** - appears in active tokens list throughout -2. ✅ **Rewards WERE deposited** - `lastUpdate` changes at least 4 times -3. ❌ **finishAt NEVER set** - remains 0 across all blocks -4. ✅ **`rate: 0` and `queued: 0`** - consistently throughout - -## The Mystery - -### Normal `increase()` Behavior - -When rewards are deposited via `LinearReward.increase()`: - -```solidity -// src/reward/distributor/LinearReward.sol:34-44 -function increase(RewardData memory _data, uint256 _periodLength, uint256 _amount) internal view { - _amount = _amount + _data.queued; - _data.queued = 0; - - if (block.timestamp >= _data.finishAt) { - // NEW PERIOD - BOTH lastUpdate AND finishAt SHOULD BE SET - _data.rate = (_amount / _periodLength).toUint80(); - _data.queued = uint96(_amount - (_data.rate * _periodLength)); - _data.lastUpdate = uint40(block.timestamp); // ✅ Sets lastUpdate - _data.finishAt = uint40(block.timestamp + _periodLength); // ✅ Should set finishAt! - } -} -``` - -**Expected Behavior**: When `finishAt = 0`, any deposit enters the `if` branch and sets BOTH `lastUpdate` AND `finishAt`. - -**Actual Behavior**: `lastUpdate` is being set, but `finishAt` remains 0. - -## Possible Explanations - -### Theory 1: Zero-Amount Deposits -If `_amount = 0` is passed to `increase()`: -- `rate = 0 / periodLength = 0` -- `lastUpdate = block.timestamp` ✅ -- `finishAt = block.timestamp + periodLength` ❓ - -**Problem**: Even with `_amount = 0`, `finishAt` should still be set to `block.timestamp + periodLength`, not 0. - -### Theory 2: Contract State Corruption -- Storage slot collision -- Upgrade issues -- Direct storage manipulation - -**Evidence Against**: State is consistent across 20,000+ blocks, making random corruption unlikely. - -### Theory 3: Custom Implementation -The contract might have a custom `increase()` implementation that differs from the standard library. - -**Need to Check**: -- Is there an override of `_notifyReward()`? -- Is there custom logic in `depositReward()`? -- Are there any hooks or modifiers affecting the behavior? - -### Theory 4: Period Length Configuration -If somehow this specific reward token has a different period length... - -**Evidence**: StabilityPool constructor uses `1 weeks` for ALL tokens: -```solidity -// Line 197 -constructor(...) MultipleRewardCompoundingAccumulator(_REWARD_MANAGER_ROLE, _REWARD_DEPOSITOR_ROLE, 1 weeks) -``` - -### Theory 5: Integer Overflow/Underflow in finishAt Calculation -If `block.timestamp + _periodLength` overflows uint40... - -**Evidence Against**: -- uint40 max = 1,099,511,627,775 (year ~36,812) -- Current timestamp ~1,770,000,000 (year 2026) -- Adding 604,800 (1 week) won't overflow - -### Theory 6: The Token Was Manually Reset -Someone with admin privileges might have manually cleared `finishAt` while leaving `lastUpdate` intact. - -**Need to Check**: -- Transaction history between blocks -- Admin function calls -- `unregisterRewardToken` calls - -## Most Likely Explanation - -Based on the evidence, the most probable scenarios are: - -**Primary Hypothesis**: Token 1 was registered but configured differently, or there's custom logic that prevents `finishAt` from being set. - -**Secondary Hypothesis**: The reward deposits for Token 1 are **zero-amount deposits** that update `lastUpdate` but don't set a meaningful `finishAt`. - -## What We Need to Investigate Next - -1. **Check the actual transaction** that set `lastUpdate = 1769846711` - - Block: ~24355000-24359000 - - Look for `DepositReward` events - - Examine the transaction input data - -2. **Check for contract upgrades** - - Any UUPS upgrades around these blocks? - - Changes to the `LinearReward` library? - -3. **Check for admin actions** - - `UnregisterRewardToken` calls - - Manual state modifications - - Configuration changes - -4. **Verify the reward distribution logic** - - Is there custom logic for Token 1? - - Different behavior for certain token types? - -## Impact - -Regardless of HOW `finishAt` became 0, the **impact is clear**: - -1. **Current State**: `finishAt = 0`, `lastUpdate = 1769846711` -2. **Underflow Conditions**: Both conditions met (finishAt < periodLength, finishAt < lastUpdate) -3. **User Impact**: Deposits to StabilityPool fail with arithmetic underflow -4. **Solution**: Apply the fix from the bug report to handle this edge case - -## Conclusion - -While we've confirmed: -- ✅ Token 1 has had `finishAt = 0` for a long time -- ✅ `lastUpdate` has been updated multiple times -- ✅ This state causes the underflow bug - -We still need to understand: -- ❓ **WHY** `finishAt` remains 0 despite deposits -- ❓ **HOW** this state was created - -**Recommendation**: -1. Apply the fix immediately to unblock user deposits -2. Continue investigating the root cause -3. Consider additional safeguards to prevent this state in the future - ---- - -**Investigation Status**: Ongoing -**Priority**: 🔴 Critical (users blocked) -**Next Steps**: Examine transaction history for Token 1 reward deposits diff --git a/deployments/etherscan-links.md b/deployments/etherscan-links.md deleted file mode 100644 index 36a692c0..00000000 --- a/deployments/etherscan-links.md +++ /dev/null @@ -1,149 +0,0 @@ -# Harbor Deployment Addresses - -Generated: 2025-12-20 14:02:41 UTC - -## harbor_v1::BTC - -- [harbor_v1::BTC pegged](https://etherscan.io/address/0x25bA4A826E1A1346dcA2Ab530831dbFF9C08bEA7#code) - - [[verify proxy](https://etherscan.io/proxyContractChecker?a=0x25bA4A826E1A1346dcA2Ab530831dbFF9C08bEA7)] ✓✓✓ [[MintableBurnableERC20_v1](https://etherscan.io/address/0x8d6B59B2D07C1e70BE2B167a4fD07807df133582#code)] ✓ - -## harbor_v1::BTC::fxUSD - -- [harbor_v1::BTC::fxUSD minterFeeReceiver](https://etherscan.io/address/0x70DdA12032335656b63435840Cd55ff7A19dDAb7#code) - - [[verify proxy](https://etherscan.io/proxyContractChecker?a=0x70DdA12032335656b63435840Cd55ff7A19dDAb7)] ✓✓✓ [[TokenDistributor_v1](https://etherscan.io/address/0xeE8D6D850Be79A4Fb6f829FC9BB0d28Dfa8515Df#code)] ✓ -- [harbor_v1::BTC::fxUSD stabilityPoolManagerFeeReceiver](https://etherscan.io/address/0xbB44740D2FA2310888f491A9dB8B1474c741BD0f#code) - - [[verify proxy](https://etherscan.io/proxyContractChecker?a=0xbB44740D2FA2310888f491A9dB8B1474c741BD0f)] ✓✓✓ [[TokenDistributor_v1](https://etherscan.io/address/0xca0a68F9C20d67C7931F91b64b89f4b22Af51cDf#code)] ✓ -- [harbor_v1::BTC::fxUSD minter](https://etherscan.io/address/0x33e32ff4d0677862fa31582CC654a25b9b1e4888#code) - - [[verify proxy](https://etherscan.io/proxyContractChecker?a=0x33e32ff4d0677862fa31582CC654a25b9b1e4888)] ✓✓✓ [[Minter_v1](https://etherscan.io/address/0x3089421DED39761Cf326EC03521b08AAFbdBB444#code)] ✓ -- [harbor_v1::BTC::fxUSD stabilityPoolManager](https://etherscan.io/address/0x768E0a386e1972eB5995429Fe21E7aC0f22F516e#code) - - [[verify proxy](https://etherscan.io/proxyContractChecker?a=0x768E0a386e1972eB5995429Fe21E7aC0f22F516e)] ✓✓✓ [[StabilityPoolManager_v1](https://etherscan.io/address/0x77C6665c67cbBB8cc1DB30213b5ab4449ef364F5#code)] ✓ -- [harbor_v1::BTC::fxUSD collateral](https://etherscan.io/address/0x085780639CC2cACd35E474e71f4d000e2405d8f6#code) -- [harbor_v1::BTC::fxUSD wrappedCollateral](https://etherscan.io/address/0x7743e50F534a7f9F1791DdE7dCD89F7783Eefc39#code) -- [harbor_v1::BTC::fxUSD priceOracle](https://etherscan.io/address/0x8F76a260c5D21586aFfF18f880FFC808D0524A73#code) -- [harbor_v1::BTC::fxUSD pegged](https://etherscan.io/address/0x25bA4A826E1A1346dcA2Ab530831dbFF9C08bEA7#code) -- [harbor_v1::BTC::fxUSD leveraged](https://etherscan.io/address/0x9567c243F647f9Ac37efb7Fc26BD9551Dce0BE1B#code) - - [[verify proxy](https://etherscan.io/proxyContractChecker?a=0x9567c243F647f9Ac37efb7Fc26BD9551Dce0BE1B)] ✓✓✓ [[MintableBurnableERC20_v1](https://etherscan.io/address/0xAA6E345De9B9E86dFcDBE1f75a9e5b5610AfE773#code)] ✓ -- [harbor_v1::BTC::fxUSD reservePool](https://etherscan.io/address/0xfDE46D4425138aA01319bB8587Cb935a0393DfE3#code) - - [[verify proxy](https://etherscan.io/proxyContractChecker?a=0xfDE46D4425138aA01319bB8587Cb935a0393DfE3)] ✓✓✓ [[ReservePool_v1](https://etherscan.io/address/0x1cabe71747D3650F7D94A42Fcd89d92001c26E5F#code)] ✓ -- [harbor_v1::BTC::fxUSD stabilityPoolCollateral](https://etherscan.io/address/0x86561cdB34ebe8B9abAbb0DD7bEA299fA8532a49#code) - - [[verify proxy](https://etherscan.io/proxyContractChecker?a=0x86561cdB34ebe8B9abAbb0DD7bEA299fA8532a49)] ✓✓✓ [[StabilityPool_v1](https://etherscan.io/address/0x7C59d965BD7d26daCE3f85c35D56D7EebCe57EbF#code)] ✓ -- [harbor_v1::BTC::fxUSD stabilityPoolLeveraged](https://etherscan.io/address/0x9e56F1E1E80EBf165A1dAa99F9787B41cD5bFE40#code) - - [[verify proxy](https://etherscan.io/proxyContractChecker?a=0x9e56F1E1E80EBf165A1dAa99F9787B41cD5bFE40)] ✓✓✓ [[StabilityPool_v1](https://etherscan.io/address/0x9755FEcC9F86a719b850eb4B20066dfea0f2FeC0#code)] ✓ -- [harbor_v1::BTC::fxUSD genesis](https://etherscan.io/address/0x42cc9a19b358a2A918f891D8a6199d8b05F0BC1C#code) - - [[verify proxy](https://etherscan.io/proxyContractChecker?a=0x42cc9a19b358a2A918f891D8a6199d8b05F0BC1C)] ✓✓✓ [[Genesis_v1](https://etherscan.io/address/0xa3a03e0077feF127Bbd6638E8d3Cb3a371BeeAa1#code)] ✓ - -## harbor_v1::BTC::stETH - -- [harbor_v1::BTC::stETH minterFeeReceiver](https://etherscan.io/address/0xc3a97138a5aDCC7d28A1375E28EC3440aeaeDF3e#code) - - [[verify proxy](https://etherscan.io/proxyContractChecker?a=0xc3a97138a5aDCC7d28A1375E28EC3440aeaeDF3e)] ✓✓✓ [[TokenDistributor_v1](https://etherscan.io/address/0x72c20fbB8EdC199ABaB9086C1B42958A185da255#code)] ✓ -- [harbor_v1::BTC::stETH stabilityPoolManagerFeeReceiver](https://etherscan.io/address/0x3fdd1D4E5f4DAAeC4650b212935832DaECF62B1c#code) - - [[verify proxy](https://etherscan.io/proxyContractChecker?a=0x3fdd1D4E5f4DAAeC4650b212935832DaECF62B1c)] ✓✓✓ [[TokenDistributor_v1](https://etherscan.io/address/0x2B7ac74a047a474e5B1D5219CAf462b0506a0c31#code)] ✓ -- [harbor_v1::BTC::stETH minter](https://etherscan.io/address/0xF42516EB885E737780EB864dd07cEc8628000919#code) - - [[verify proxy](https://etherscan.io/proxyContractChecker?a=0xF42516EB885E737780EB864dd07cEc8628000919)] ✓✓✓ [[Minter_v1](https://etherscan.io/address/0x203866Ab0626b872313E0B420aD392470a3AcdDA#code)] ✓ -- [harbor_v1::BTC::stETH stabilityPoolManager](https://etherscan.io/address/0x5e9Bcaa1EDfD665c09a9e6693B447581d61A85A1#code) - - [[verify proxy](https://etherscan.io/proxyContractChecker?a=0x5e9Bcaa1EDfD665c09a9e6693B447581d61A85A1)] ✓✓✓ [[StabilityPoolManager_v1](https://etherscan.io/address/0xF7aAF7D417cCabe0EB0aE36ff469311A5b6815da#code)] ✓ -- [harbor_v1::BTC::stETH collateral](https://etherscan.io/address/0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84#code) -- [harbor_v1::BTC::stETH wrappedCollateral](https://etherscan.io/address/0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0#code) -- [harbor_v1::BTC::stETH priceOracle](https://etherscan.io/address/0xE370289aF2145A5B2F0F7a4a900eBfD478A156dB#code) -- [harbor_v1::BTC::stETH pegged](https://etherscan.io/address/0x25bA4A826E1A1346dcA2Ab530831dbFF9C08bEA7#code) -- [harbor_v1::BTC::stETH leveraged](https://etherscan.io/address/0x817ADaE288eD46B8618AAEffE75ACD26A0a1b0FD#code) - - [[verify proxy](https://etherscan.io/proxyContractChecker?a=0x817ADaE288eD46B8618AAEffE75ACD26A0a1b0FD)] ✓✓✓ [[MintableBurnableERC20_v1](https://etherscan.io/address/0xA580AF59522c9BA0Fca82Ae8D9Aaa10Fa212F0A1#code)] ✓ -- [harbor_v1::BTC::stETH reservePool](https://etherscan.io/address/0x515ECa19Ac381b0f37D616F99628136906fC5355#code) - - [[verify proxy](https://etherscan.io/proxyContractChecker?a=0x515ECa19Ac381b0f37D616F99628136906fC5355)] ✓✓✓ [[ReservePool_v1](https://etherscan.io/address/0xd904b61EBa087DfE7d984259B50324Cc1808c88F#code)] ✓ -- [harbor_v1::BTC::stETH stabilityPoolCollateral](https://etherscan.io/address/0x667Ceb303193996697A5938cD6e17255EeAcef51#code) - - [[verify proxy](https://etherscan.io/proxyContractChecker?a=0x667Ceb303193996697A5938cD6e17255EeAcef51)] ✓✓✓ [[StabilityPool_v1](https://etherscan.io/address/0x932cAeb990f39B95CeAD349446C767f6bEc0C5d1#code)] ✓ -- [harbor_v1::BTC::stETH stabilityPoolLeveraged](https://etherscan.io/address/0xCB4F3e21DE158bf858Aa03E63e4cEc7342177013#code) - - [[verify proxy](https://etherscan.io/proxyContractChecker?a=0xCB4F3e21DE158bf858Aa03E63e4cEc7342177013)] ✓✓✓ [[StabilityPool_v1](https://etherscan.io/address/0xb71F39140FAa63a9C32105Ef717aC79a58878025#code)] ✓ -- [harbor_v1::BTC::stETH genesis](https://etherscan.io/address/0xc64Fc46eED431e92C1b5e24DC296b5985CE6Cc00#code) - - [[verify proxy](https://etherscan.io/proxyContractChecker?a=0xc64Fc46eED431e92C1b5e24DC296b5985CE6Cc00)] ✓✓✓ [[Genesis_v1](https://etherscan.io/address/0x93E71d996C5ccD1554f1ddAE4977EA857abeb89C#code)] ✓ - -## harbor_v1::ETH - -- [harbor_v1::ETH pegged](https://etherscan.io/address/0x7A53EBc85453DD006824084c4f4bE758FcF8a5B5#code) - - [[verify proxy](https://etherscan.io/proxyContractChecker?a=0x7A53EBc85453DD006824084c4f4bE758FcF8a5B5)] ✓✓✓ [[MintableBurnableERC20_v1](https://etherscan.io/address/0x4cD716A3DEe21eCB76B485F4374318507FeC1B75#code)] ✓ - -## harbor_v1::ETH::fxUSD - -- [harbor_v1::ETH::fxUSD minterFeeReceiver](https://etherscan.io/address/0xdC903fe5ebCE440f22578D701b95424363D20881#code) - - [[verify proxy](https://etherscan.io/proxyContractChecker?a=0xdC903fe5ebCE440f22578D701b95424363D20881)] ✓✓✓ [[TokenDistributor_v1](https://etherscan.io/address/0xEC0646d6a08A4409908DA1fABc26969AE135EA97#code)] ✓ -- [harbor_v1::ETH::fxUSD stabilityPoolManagerFeeReceiver](https://etherscan.io/address/0x9e92965Afb51ce80aa451F93530880f469C2B282#code) - - [[verify proxy](https://etherscan.io/proxyContractChecker?a=0x9e92965Afb51ce80aa451F93530880f469C2B282)] ✓✓✓ [[TokenDistributor_v1](https://etherscan.io/address/0xe32bF3a9d68119094d5E388eA5AF226e6d57Cc91#code)] ✓ -- [harbor_v1::ETH::fxUSD minter](https://etherscan.io/address/0xd6E2F8e57b4aFB51C6fA4cbC012e1cE6aEad989F#code) - - [[verify proxy](https://etherscan.io/proxyContractChecker?a=0xd6E2F8e57b4aFB51C6fA4cbC012e1cE6aEad989F)] ✓✓✓ [[Minter_v1](https://etherscan.io/address/0x813ddD349a459137dC2Bf36B8Dc57508B63b5bCF#code)] ✓ -- [harbor_v1::ETH::fxUSD stabilityPoolManager](https://etherscan.io/address/0xE39165aDE355988EFb24dA4f2403971101134CAB#code) - - [[verify proxy](https://etherscan.io/proxyContractChecker?a=0xE39165aDE355988EFb24dA4f2403971101134CAB)] ✓✓✓ [[StabilityPoolManager_v1](https://etherscan.io/address/0x9C6a6B61b1ac3A344584c3747597964c0DD65C7D#code)] ✓ -- [harbor_v1::ETH::fxUSD collateral](https://etherscan.io/address/0x085780639CC2cACd35E474e71f4d000e2405d8f6#code) -- [harbor_v1::ETH::fxUSD wrappedCollateral](https://etherscan.io/address/0x7743e50F534a7f9F1791DdE7dCD89F7783Eefc39#code) -- [harbor_v1::ETH::fxUSD priceOracle](https://etherscan.io/address/0x71437C90F1E0785dd691FD02f7bE0B90cd14c097#code) -- [harbor_v1::ETH::fxUSD pegged](https://etherscan.io/address/0x7A53EBc85453DD006824084c4f4bE758FcF8a5B5#code) -- [harbor_v1::ETH::fxUSD leveraged](https://etherscan.io/address/0x0Cd6BB1a0cfD95e2779EDC6D17b664B481f2EB4C#code) - - [[verify proxy](https://etherscan.io/proxyContractChecker?a=0x0Cd6BB1a0cfD95e2779EDC6D17b664B481f2EB4C)] ✓✓✓ [[MintableBurnableERC20_v1](https://etherscan.io/address/0xEA6555386f7E13D18Ec7c22097eAFa4D80F8bB34#code)] ✓ -- [harbor_v1::ETH::fxUSD reservePool](https://etherscan.io/address/0x7A5c4ca972CE2168d5215d252946dDbd1cAd2015#code) - - [[verify proxy](https://etherscan.io/proxyContractChecker?a=0x7A5c4ca972CE2168d5215d252946dDbd1cAd2015)] ✓✓✓ [[ReservePool_v1](https://etherscan.io/address/0x4816D539cCDE3326A6Ecc8Df1e59570399285223#code)] ✓ -- [harbor_v1::ETH::fxUSD stabilityPoolCollateral](https://etherscan.io/address/0x1F985CF7C10A81DE1940da581208D2855D263D72#code) - - [[verify proxy](https://etherscan.io/proxyContractChecker?a=0x1F985CF7C10A81DE1940da581208D2855D263D72)] ✓✓✓ [[StabilityPool_v1](https://etherscan.io/address/0xa3BAe4ed645bfdD089978C8780a06F1786dD8957#code)] ✓ -- [harbor_v1::ETH::fxUSD stabilityPoolLeveraged](https://etherscan.io/address/0x438B29EC7a1770dDbA37D792F1A6e76231Ef8E06#code) - - [[verify proxy](https://etherscan.io/proxyContractChecker?a=0x438B29EC7a1770dDbA37D792F1A6e76231Ef8E06)] ✓✓✓ [[StabilityPool_v1](https://etherscan.io/address/0x12d24Ca4F99b883B26fA7D51847A9fd756455F04#code)] ✓ -- [harbor_v1::ETH::fxUSD genesis](https://etherscan.io/address/0xC9df4f62474Cf6cdE6c064DB29416a9F4f27EBdC#code) - - [[verify proxy](https://etherscan.io/proxyContractChecker?a=0xC9df4f62474Cf6cdE6c064DB29416a9F4f27EBdC)] ✓✓✓ [[Genesis_v1](https://etherscan.io/address/0x96ED4c4a0D4a82ED649fb15A64C13472D9a28F93#code)] ✓ - -## harbor_v1::EUR - -- [harbor_v1::EUR pegged](https://etherscan.io/address/0x83Fd69E0FF5767972b46E61C6833408361bF7346#code) - - [[verify proxy](https://etherscan.io/proxyContractChecker?a=0x83Fd69E0FF5767972b46E61C6833408361bF7346)] ✓✓✓ [[MintableBurnableERC20_v1](https://etherscan.io/address/0xe88A00298279D55718FB5E9d8009C1040c5905f7#code)] ✓ - -## harbor_v1::EUR::fxUSD - -- [harbor_v1::EUR::fxUSD minterFeeReceiver](https://etherscan.io/address/0x43dfDB5059777A8B8819d8D8ff2c9ACCFEb766CB#code) - - [[verify proxy](https://etherscan.io/proxyContractChecker?a=0x43dfDB5059777A8B8819d8D8ff2c9ACCFEb766CB)] ✓✓✓ [[TokenDistributor_v1](https://etherscan.io/address/0x07F9194fE7c847472Ef6D984DdE0D447fd4b76B1#code)] ✓ -- [harbor_v1::EUR::fxUSD stabilityPoolManagerFeeReceiver](https://etherscan.io/address/0xd2a815B2210c15E1626CD0D487C77852E7C37b17#code) - - [[verify proxy](https://etherscan.io/proxyContractChecker?a=0xd2a815B2210c15E1626CD0D487C77852E7C37b17)] ✓✓✓ [[TokenDistributor_v1](https://etherscan.io/address/0xC266C83e5474756eA904987D63450b2E0CF6c0C1#code)] ✓ -- [harbor_v1::EUR::fxUSD minter](https://etherscan.io/address/0xDEFB2C04062350678965CBF38A216Cc50723B246#code) - - [[verify proxy](https://etherscan.io/proxyContractChecker?a=0xDEFB2C04062350678965CBF38A216Cc50723B246)] ✓✓✓ [[Minter_v1](https://etherscan.io/address/0xc7E34ecD57975430aB9DDb535Df4f266Ba2Ec1d1#code)] ✓ -- [harbor_v1::EUR::fxUSD stabilityPoolManager](https://etherscan.io/address/0x756766756880ceA06270Fd507b09Ef32714Ec7C2#code) - - [[verify proxy](https://etherscan.io/proxyContractChecker?a=0x756766756880ceA06270Fd507b09Ef32714Ec7C2)] ✓✓✓ [[StabilityPoolManager_v1](https://etherscan.io/address/0xF625E8147C07DDF5a488FCB5C95c91ACef46E22E#code)] ✓ -- [harbor_v1::EUR::fxUSD collateral](https://etherscan.io/address/0x085780639CC2cACd35E474e71f4d000e2405d8f6#code) -- [harbor_v1::EUR::fxUSD wrappedCollateral](https://etherscan.io/address/0x7743e50F534a7f9F1791DdE7dCD89F7783Eefc39#code) -- [harbor_v1::EUR::fxUSD priceOracle](https://etherscan.io/address/0x6bEb1a1189Ac68a2a26b5210e5ccfB9e8a3E408E#code) -- [harbor_v1::EUR::fxUSD pegged](https://etherscan.io/address/0x83Fd69E0FF5767972b46E61C6833408361bF7346#code) -- [harbor_v1::EUR::fxUSD leveraged](https://etherscan.io/address/0x7A7C1f2502c19193C44662A2Aff51c2B76fDDAEA#code) - - [[verify proxy](https://etherscan.io/proxyContractChecker?a=0x7A7C1f2502c19193C44662A2Aff51c2B76fDDAEA)] ✓✓✓ [[MintableBurnableERC20_v1](https://etherscan.io/address/0xe86c568aDEd105d7A63fe63e2C12Dfa567cBf2e3#code)] ✓ -- [harbor_v1::EUR::fxUSD reservePool](https://etherscan.io/address/0x27cA37538358F90d45cAA886fB58CC08ffe2dD2f#code) - - [[verify proxy](https://etherscan.io/proxyContractChecker?a=0x27cA37538358F90d45cAA886fB58CC08ffe2dD2f)] ✓✓✓ [[ReservePool_v1](https://etherscan.io/address/0xDfFb26e9f81Cbc8B9b88c2e2DDeEe770fe765EFd#code)] ✓ -- [harbor_v1::EUR::fxUSD stabilityPoolCollateral](https://etherscan.io/address/0xe60054E6b518f67411834282cE1557381f050B13#code) - - [[verify proxy](https://etherscan.io/proxyContractChecker?a=0xe60054E6b518f67411834282cE1557381f050B13)] ✓✓✓ [[StabilityPool_v1](https://etherscan.io/address/0x5A2440034cc32A298e44B34247a83508ef42cCF4#code)] ✓ -- [harbor_v1::EUR::fxUSD stabilityPoolLeveraged](https://etherscan.io/address/0xc5e0dA7e0a178850438E5E97ed59b6eb2562e88E#code) - - [[verify proxy](https://etherscan.io/proxyContractChecker?a=0xc5e0dA7e0a178850438E5E97ed59b6eb2562e88E)] ✓✓✓ [[StabilityPool_v1](https://etherscan.io/address/0xEA8e632d20235c7450C9d49fbf868bdb2B981df8#code)] ✓ -- [harbor_v1::EUR::fxUSD genesis](https://etherscan.io/address/0xa9EB43Ed6Ba3B953a82741F3e226C1d6B029699b#code) - - [[verify proxy](https://etherscan.io/proxyContractChecker?a=0xa9EB43Ed6Ba3B953a82741F3e226C1d6B029699b)] ✓✓✓ [[Genesis_v1](https://etherscan.io/address/0x6d43EE4F28E6f25871B34a16cAC500DA54132B30#code)] ✓ - -## harbor_v1::GOLD - -- [harbor_v1::GOLD pegged](https://etherscan.io/address/0x5b66D86932aE5D9751da588d91D494950554061d#code) - - [[verify proxy](https://etherscan.io/proxyContractChecker?a=0x5b66D86932aE5D9751da588d91D494950554061d)] ✓✗✗ [[MintableBurnableERC20_v1](https://etherscan.io/address/0x28eB6581253Ae4F9215b01F7e723Bd465fa46e2b#code)] ✓ - -## harbor_v1::GOLD::fxUSD - -- [harbor_v1::GOLD::fxUSD minterFeeReceiver](https://etherscan.io/address/0x8C5EF0342543A509e5548c71A66dE7D8A69c6B70#code) - - [[verify proxy](https://etherscan.io/proxyContractChecker?a=0x8C5EF0342543A509e5548c71A66dE7D8A69c6B70)] ✓✓✓ [[TokenDistributor_v1](https://etherscan.io/address/0x99f3CAC5F7a3c91134Dccd523560F553BA286E1b#code)] ✓ -- [harbor_v1::GOLD::fxUSD stabilityPoolManagerFeeReceiver](https://etherscan.io/address/0x360838316494E355CE7a58c2990606F30F21e8A1#code) - - [[verify proxy](https://etherscan.io/proxyContractChecker?a=0x360838316494E355CE7a58c2990606F30F21e8A1)] ✓✓✓ [[TokenDistributor_v1](https://etherscan.io/address/0xa5c42eC86DD26603a3cd48Cf95abDAc7E1D14B70#code)] ✓ -- [harbor_v1::GOLD::fxUSD minter](https://etherscan.io/address/0x880600E0c803d836E305B7c242FC095Eed234A8f#code) - - [[verify proxy](https://etherscan.io/proxyContractChecker?a=0x880600E0c803d836E305B7c242FC095Eed234A8f)] ✓✓✓ [[Minter_v1](https://etherscan.io/address/0xC2ee9f547123990e513403111F314B520C1DD812#code)] ✓ -- [harbor_v1::GOLD::fxUSD stabilityPoolManager](https://etherscan.io/address/0x5b69069CC4012a96342B0FeCC28aD15bDE6447B5#code) - - [[verify proxy](https://etherscan.io/proxyContractChecker?a=0x5b69069CC4012a96342B0FeCC28aD15bDE6447B5)] ✓✓✓ [[StabilityPoolManager_v1](https://etherscan.io/address/0x5c96077BB55376b66670B937F7bBdDBBc63A8564#code)] ✓ -- [harbor_v1::GOLD::fxUSD collateral](https://etherscan.io/address/0x085780639CC2cACd35E474e71f4d000e2405d8f6#code) -- [harbor_v1::GOLD::fxUSD wrappedCollateral](https://etherscan.io/address/0x7743e50F534a7f9F1791DdE7dCD89F7783Eefc39#code) -- [harbor_v1::GOLD::fxUSD priceOracle](https://etherscan.io/address/0x7DAe17B00DCd5C37D4992a17C3Cf8f5E15d2BbAf#code) -- [harbor_v1::GOLD::fxUSD pegged](https://etherscan.io/address/0x5b66D86932aE5D9751da588d91D494950554061d#code) -- [harbor_v1::GOLD::fxUSD leveraged](https://etherscan.io/address/0x85730Af3A7d7A872Ee1D84306E0575f1E00C0980#code) - - [[verify proxy](https://etherscan.io/proxyContractChecker?a=0x85730Af3A7d7A872Ee1D84306E0575f1E00C0980)] ✓✓✓ [[MintableBurnableERC20_v1](https://etherscan.io/address/0xaCA783ba4D58b78371D0b5822E81Eeb42194DF2c#code)] ✓ -- [harbor_v1::GOLD::fxUSD reservePool](https://etherscan.io/address/0xc033e81ED555D6db63A3E0Af9795454C7BdF094a#code) - - [[verify proxy](https://etherscan.io/proxyContractChecker?a=0xc033e81ED555D6db63A3E0Af9795454C7BdF094a)] ✓✓✓ [[ReservePool_v1](https://etherscan.io/address/0x4b5996034C1B888ac70bB4C5687E6faF5DddFeF8#code)] ✓ -- [harbor_v1::GOLD::fxUSD stabilityPoolCollateral](https://etherscan.io/address/0xC1EF32d4B959F2200efDeDdedadA226461d14DaC#code) - - [[verify proxy](https://etherscan.io/proxyContractChecker?a=0xC1EF32d4B959F2200efDeDdedadA226461d14DaC)] ✓✓✓ [[StabilityPool_v1](https://etherscan.io/address/0xA041d39ceD4aBAE2e50427712653f8a79d08bd2D#code)] ✓ -- [harbor_v1::GOLD::fxUSD stabilityPoolLeveraged](https://etherscan.io/address/0x5bDED171f1c08B903b466593B0E022F9FdE8399c#code) - - [[verify proxy](https://etherscan.io/proxyContractChecker?a=0x5bDED171f1c08B903b466593B0E022F9FdE8399c)] ✓✗✗ [[StabilityPool_v1](https://etherscan.io/address/0x64647EA21a5750E406cA90A114639D6b1388A904#code)] ✓ -- [harbor_v1::GOLD::fxUSD genesis](https://etherscan.io/address/0x2cbF457112Ef5A16cfcA10Fb173d56a5cc9DAa66#code) - - [[verify proxy](https://etherscan.io/proxyContractChecker?a=0x2cbF457112Ef5A16cfcA10Fb173d56a5cc9DAa66)] ✓✓✓ [[Genesis_v1](https://etherscan.io/address/0xe08d21418ED9078fE0292602DAD28BA9347312EA#code)] ✓ - diff --git a/doc/autocompounding-vault-design.md b/doc/autocompounding-vault-design.md deleted file mode 100644 index 2d6053ff..00000000 --- a/doc/autocompounding-vault-design.md +++ /dev/null @@ -1,351 +0,0 @@ -# Autocompounding Vault: Design & Requirements - -## 1. Overview - -An ERC4626 autocompounding vault that wraps stability pool positions. It claims rewards, converts them to pegged tokens where possible, and redeposits — delivering compound interest. When minting pegged tokens is not viable (fee ratio too high), rewards are held as interest-bearing equivalent tokens until conditions improve. - -The vault provides: -- **Automated compounding** using the underlying SP reward system -- **A composable non-rebasing token** (ERC4626 shares) wrapping the rebasing SP token -- **Equivalent token management** — holding interest-bearing pegged-equivalent assets when minting is unfavorable, with a preference-ordered list for equivalent rotation - -A prerequisite change to the stability pool: making the SP a rebasing ERC20 token with transferable positions. - -## Architecture - -```mermaid -%%{init: {"flowchart": {"defaultRenderer": "elk"}} }%% -graph TB - U[User] -->|"1. deposit pegged"| SP["StabilityPool (Rebasing ERC20)"] - SP -->|"2. SP tokens"| U - U -->|"3. deposit SP tokens"| V - - subgraph Vault ["AutocompoundingVault (ERC4626)"] - V["Vault Core (asset = SP token)"] - EQ["Equivalent Tokens: fxSAVE / wstETH / ..."] - end - - V -->|"vault shares"| U - V -->|"claim rewards"| SP - V -->|"deposit pegged"| SP - V -->|"mintPeggedTokenCapped"| M[Minter] - V -->|"swap collateral"| SW["Swapper / 1inch"] - - SPM[StabilityPoolManager] -->|"depositReward"| SP - SPM -->|"notifyLiquidation"| SP - SPM -->|"vault.compound"| V -``` - -## Sequence Diagrams - -### User Deposit & Withdrawal - -```mermaid -sequenceDiagram - participant User - participant SP as StabilityPool - participant Vault - - rect rgb(230, 245, 230) - Note over User,Vault: Deposit - User->>SP: deposit(pegged) - SP-->>User: SP tokens (rebasing) - User->>SP: approve(vault, amount) - User->>Vault: deposit(spTokens, receiver) - Vault->>SP: transferFrom(user, vault, amount) - Vault-->>User: vault shares (non-rebasing) - end - - rect rgb(245, 235, 225) - Note over User,Vault: Withdrawal - User->>Vault: redeem(shares, receiver, owner) - Vault->>SP: transfer(user, spTokens) - Vault-->>User: SP tokens - User->>SP: requestWithdrawal() - Note over User: wait for window... - User->>SP: withdraw(pegged) - end -``` - -### Harvest Compound - -```mermaid -sequenceDiagram - participant Bot - participant SPM as StabilityPoolManager - participant SP as StabilityPool - participant Vault - participant Minter - participant Swap as Swapper - - Bot->>SPM: harvest(bountyReceiver, minBounty) - SPM->>SP: depositReward(WRAPPED_COLLATERAL, amount) - Note over SP: linear distribution over 1 week - - SPM->>Vault: compound() - Note over Vault: Claims PREVIOUS period's
distributed rewards - Vault->>SP: claim(vault) - SP-->>Vault: wrapped collateral - - Vault->>Minter: mintPeggedTokenCapped(collateral, vault, 0, maxFeeRatio) - Minter-->>Vault: pegged + wrappedCollateralUsed - - alt Full mint (fee acceptable for all collateral) - Vault->>SP: deposit(allPegged, vault, 0) - else Partial mint (fee limit hit) - Vault->>SP: deposit(mintedPegged, vault, 0) - Vault->>Swap: swap(remainingCollateral -> top-preference equivalent) - end - - opt Equivalent rotation (existing holdings, fees acceptable) - Vault->>Swap: swap(bottom-of-list equivalent -> collateral) - Vault->>Minter: mintPeggedTokenCapped(collateral, vault, 0, maxFeeRatio) - Vault->>SP: deposit(pegged, vault, 0) - end -``` - -### Rebalance Compound - -```mermaid -sequenceDiagram - participant Bot - participant SPM as StabilityPoolManager - participant SP as StabilityPool - participant Vault - participant Minter - participant Swap as Swapper - - Bot->>SPM: rebalance(bountyReceiver, minPegged) - SPM->>SP: notifyLiquidation(liquidated, returned) - Note over SP: Loss applied via product factor
Reward distributed immediately - - SPM->>Vault: compound() - Vault->>SP: claim(vault) - SP-->>Vault: wrapped collateral (harvest + liquidation) - - Vault->>Minter: mintPeggedTokenCapped(collateral, vault, 0, maxFeeRatio) - Note over Minter: Fee likely high (low CR)
Little or nothing minted - - alt Some pegged minted - Vault->>SP: deposit(pegged, vault, 0) - end - - Vault->>Swap: swap(remainingCollateral -> top-preference equivalent) - Note over Vault: Collateral held as equivalent
until conditions improve -``` - -### Equivalent Rotation (conditions improve) - -```mermaid -sequenceDiagram - participant Anyone - participant Vault - participant Swap as Swapper - participant Minter - participant SP as StabilityPool - - Anyone->>Vault: convertEquivalent(token, amount) - Vault->>Swap: swap(equivalent -> collateral) - Swap-->>Vault: wrapped collateral - Vault->>Minter: mintPeggedTokenCapped(collateral, vault, 0, maxFeeRatio) - - alt Fee acceptable - Minter-->>Vault: pegged tokens - Vault->>SP: deposit(pegged, vault, 0) - Note over Vault: Equivalent decreases
SP position increases - else Fee too high - Note over Vault: Keep as collateral or
swap back to equivalent - end -``` - -## 2. Motivation - -### Problem -SP depositors earn wrapped collateral from harvests but must manually claim and reinvest. This delivers simple interest — rewards don't earn further rewards. - -### Solution -Automate claim-convert-redeposit. Long-term holders benefit proportionally more because compounded rewards generate additional rewards. - -### Compound vs Simple Interest - -| | Simple (SP direct) | Compound (Vault) | -|---|---|---| -| Balance after reward | `b` (unchanged) | `b + b/T * r` (grows) | -| Reward | `r * b/T` claimed as collateral | Reinvested as pegged | -| Future reward share | Proportional to `b` | Proportional to `b + compounded` | - -At 10% APY: 5yr simple=1,500 vs compound=1,611 (+7.4%). 10yr: 2,000 vs 2,594 (+29.7%). - -### Fairness Guarantee - -`totalAssets()` includes pending claimable rewards via SP's `claimable()` view function (accurately simulates 1-week linear distribution). New depositors buy at correct price — no dilution. Compound can be lazy without affecting fairness. - -## 3. Design Decisions - -### 3.1 SP as Rebasing ERC20 - -**Decision:** `balanceOf()` returns compounded real value (= `assetBalanceOf()`). `totalSupply()` returns `totalAssetSupply()`. Both already exist. New: `transfer`, `transferFrom`, `approve`, `allowance`. - -**Why rebasing:** -- Non-rebasing shares + conversion function is exactly what the vault provides. Making the SP non-rebasing would duplicate the vault's role. -- Clean two-layer architecture: SP token = raw position (rebases down on loss), vault = compounding wrapper (non-rebasing). Like stETH/wstETH. -- SP only rebases **downward** (losses), discrete events (rebalances), not continuous. -- The vault IS the non-rebasing wrapped version for DeFi protocols. - -**Why not full ERC4626 on SP:** SP v2 is 20,711 bytes (~3,300 headroom). Minimal ERC20 fits; full ERC4626 is risky on size. The vault provides the ERC4626 interface. - -**Transfer implementation:** Checkpoint sender and receiver (updates rewards at pre-transfer balances), then move balance. - -**Approval and rebasing:** Since `balanceOf` rebases downward on liquidation, an approval may exceed the user's balance after a loss event. This is the same behavior as stETH — `transferFrom` transfers up to `min(allowance, balance)`. Accepted behavior for downward-rebasing tokens; documented in the interface. - -### 3.2 Vault Valuation on SP Loss - -On SP liquidation, `assetBalanceOf(vault)` drops -> `totalAssets()` drops -> share price drops. Automatic — no vault action needed. Equivalent token holdings are unaffected; only the SP position component decreases. - -### 3.3 Vault Architecture — stETH/wstETH Pattern - -Same pattern as stETH (rebasing) / wstETH (non-rebasing ERC4626). SP token rebases down on loss; vault share is non-rebasing, DeFi-composable. Value per vault share increases via compounding. - -### 3.4 Deposit and Withdrawal Flow - -**Decision:** Users deposit pegged into SP first, then transfer SP tokens to vault. Withdrawals reverse. - -``` -Deposit: User -> SP.deposit(pegged) -> SP tokens -> vault.deposit(spTokens) -> vault shares -Withdraw: User -> vault.redeem(shares) -> SP tokens -> SP.withdraw(pegged) with time lock -``` - -**Why:** The SP handles time lock and withdrawal fees — no duplication needed. SP-as-ERC20 makes the transfer seamless. The UI can chain both steps. - -**ERC4626 asset = SP token.** `totalAssets()` = SP token value + equivalent token value. - -**Additional token support:** `depositEquivalent` / `withdrawEquivalent` for equivalent tokens. EIP-7575 was considered but doesn't fit — equivalent tokens are a compound side effect requiring unified logic, not independent deposit paths. - -### 3.5 Minting: Fees and maxFeeRatio - -**Decision:** Use `mintPeggedToken()` with fees (not free mint). Add `mintPeggedTokenCapped` to the Minter with a `maxFeeRatio` parameter. - -**Why fees:** The vault automates what users would do manually. Users pay the fee. No special Minter role needed. - -**Minter change — new function alongside existing:** -```solidity -// Existing (unchanged, backward compatible) -function mintPeggedToken(uint256 wrappedIn, address receiver, uint256 minPeggedOut) - returns (uint256 peggedOut) - -// New: stops at fee threshold, returns unused collateral -function mintPeggedTokenCapped( - uint256 wrappedIn, address receiver, uint256 minPeggedOut, int256 maxFeeRatio -) returns (uint256 peggedOut, uint256 wrappedCollateralUsed) -``` - -The capped version processes fee bands until `incentiveRatio > maxFeeRatio`, then stops. Returns pegged minted so far and how much collateral was used. Unused collateral stays with the caller. Backward compatible — existing function untouched. - -### 3.6 Compound Flow - -**Decision:** The minter's fee mechanism naturally handles harvest vs liquidation. So no need for StabilityPoolManager to get involved. - -**Why:** If the fee ratio is acceptable -> mint pegged -> compound. If not -> equivalent token. This applies regardless of reward source. The CR state at compound time determines the outcome: -- After harvest (high CR, low fees) -> most/all mints to pegged -- After rebalance (low CR, high fees) -> equivalent token -- Mixed -> partial mint, remainder to equivalent - -**Compound flow:** -``` -vault.compound() - 1. Claim all rewards from SP - 2. Call mintPeggedTokenCapped(collateral, vault, 0, maxFeeRatio) - 3. Deposit minted pegged into SP (increases vault's SP balance) - 4. Remaining collateral (not used by mint) -> swap to top-preference equivalent token - 5. Check existing equivalent holdings -> if fee acceptable, convert bottom-of-list -> pegged -> SP - 6. Non-collateral tokens (e.g. LEVERAGED_TOKEN) -> ignore/sweep -``` - -Vault checks collateral balance before/after mint to confirm actual usage. - -### 3.7 Compound Trigger - -**Decision:** StabilityPoolManager calls `vault.compound()` during harvest and rebalance + compound is permissionless (anyone can call). - -**StabilityPoolManager trigger:** One-line addition per pool in `harvest() and rebalance()`. Automates compounding with zero external infrastructure. Each compound captures previously distributed rewards (natural 1-week lag from linear distribution) and rebalance rewards. - -**Permissionless:** Allows compounding between harvests. No bounty — StabilityPoolManager harvest bounty incentivizes the trigger. - -### 3.8 Equivalent Token Management - -**Decision:** Preference-ordered list of equivalent tokens, updatable by a keeper/bot role. Single vault holds all equivalents internally. - -**Current design:** -- Ordered list of equivalent tokens (e.g. [fxSAVE, wstETH]) — top = preferred -- Keeper/bot role updates ordering based on external rate data (no on-chain rate calculation) -- On compound: unmintable collateral -> swap to top-preference equivalent -- On equivalent rotation: convert bottom-of-list equivalents -> collateral -> try mint -> SP (when fees permit) -- Old equivalents (after governance changes default) remain, gradually converted on subsequent compounds when fees are favorable - -**User access:** -- `depositEquivalent(token, amount, receiver)` -> mint vault shares at equivalent's value -- `withdrawEquivalent(token, shares, receiver)` -> return equivalent tokens directly if vault holds enough - -**Pricing in `totalAssets()`:** Equivalent tokens pegged to the same RWA are assumed equal value. For precision, oracle pricing could be added later. - -**Open questions (deferred):** -- On-chain APY calculation for automated ordering (currently relies on off-chain bot) -- Whether equivalent rotation should account for swap costs -- Maximum number of equivalents before gas becomes prohibitive - -### 3.9 Withdrawal Time Lock - -**Decision:** No time lock in the vault. SP's existing time lock governs all pegged withdrawals. Shared base contracts would create contract code duplication making partial upgrades harder. - -## 4. Token & Reward Flow - -### Harvest (both pool types) -``` -StabilityPoolManager.harvest() - -> depositReward(WRAPPED_COLLATERAL, amount) on SP - -> linear distribution over 1 week - -> StabilityPoolManager calls vault.compound() - -> vault claims rewards - -> mintPeggedTokenCapped(collateral, vault, 0, maxFeeRatio) - -> deposit pegged into SP - -> remaining collateral -> top-preference equivalent token - -> check: convert bottom-of-list equivalents if fees acceptable -``` - -### SP Rebalance / Liquidation (both pool types) -``` -StabilityPoolManager.rebalance() - -> SP.notifyLiquidation(liquidated, returned) - -> vault's SP balance reduced (loss via product factor — automatic) - -> liquidation rewards claimable on next compound() - -> on compound: fee likely high -> collateral -> equivalent token -``` - -### Equivalent Rotation (conditions improve) -``` -vault.compound() or vault.convertEquivalent(token, amount) - -> swap equivalent -> collateral via swapper - -> mintPeggedTokenCapped -> deposit pegged into SP -``` - -## 5. Access Control - -| Role | On Contract | Purpose | -|------|------------|---------| -| `KEEPER_ROLE` | Vault | Swap execution + equivalent list ordering | -| Owner | Vault | Configure swapper, maxFeeRatio, upgrade | -| Anyone | Vault | `deposit`, `redeem`, `compound`, `convertEquivalent`, `depositEquivalent`, `withdrawEquivalent` | - -## 6. Contracts - -| Contract | Action | Purpose | -|----------|--------|---------| -| `AutocompoundingVault` | Create | ERC4626 vault + equivalent token management | -| `IAutocompoundingVault` | Create | Interface | -| `StabilityPool_v2` | Modify | Add ERC20 (transfer/approve/allowance) | -| `Minter_v2` | Modify | Add `mintPeggedTokenCapped` with maxFeeRatio | -| `StabilityPoolManager_v1` | Modify | Add vault.compound() calls in harvest and rebalance | - -## 7. Future Directions - -- **On-chain APY calculation:** For automated equivalent token ordering without off-chain bot dependency. Not to be confused with the conversion rate between a token and its wrapped form (e.g. stETH/wstETH rate) — that is available on-chain already. diff --git a/doc/epoch-removal-summary.md b/doc/fixes/epoch-removal-summary.md similarity index 100% rename from doc/epoch-removal-summary.md rename to doc/fixes/epoch-removal-summary.md diff --git a/doc/fixes/finishat-zero.md b/doc/fixes/finishat-zero.md new file mode 100644 index 00000000..4263c916 --- /dev/null +++ b/doc/fixes/finishat-zero.md @@ -0,0 +1,38 @@ +# finishAt = 0 Root Cause Investigation + +## Summary + +Token 1 (`0x9567c243F647f9Ac37efb7Fc26BD9551Dce0BE1B`) has `finishAt = 0` and `lastUpdate > 0` because it was registered as a reward token but never received any reward deposits. This is normal contract behavior, not storage corruption or a bug in `increase()`. + +## How the State Occurs + +1. `_distributePendingReward()` updates `lastUpdate = block.timestamp` for ALL active tokens unconditionally +2. `_notifyReward()` / `increase()` only sets `finishAt` for the token actually receiving rewards +3. If Token1 is registered but only Token0 receives deposits, Token1's `lastUpdate` advances while `finishAt` stays at 0 + +| Event | Token0 finishAt | Token1 lastUpdate | Token1 finishAt | +|-------|-----------------|-------------------|-----------------| +| Initial | 0 | 0 | 0 | +| Deposit to Token0 | Set | Updated | **Still 0** | +| N deposits to Token0 | Updated | Updated | **Still 0** | + +## Historical Evidence + +Token1 had `finishAt = 0` across 20,000+ blocks (at least blocks 24200000 through 24404265, ~67 hours). Every time Token0 received a deposit, Token1's `lastUpdate` was updated but `finishAt` remained 0. + +## Eliminated Theories + +| Theory | Why Eliminated | +|--------|---------------| +| Zero-amount deposits to Token1 | `increase()` was never called for Token1 at all | +| Storage corruption | State is consistent across 20,000+ blocks | +| uint40 overflow | Current timestamps nowhere near uint40 max | +| Manual storage reset | No evidence of admin clearing finishAt selectively | + +## Test Confirmation + +`test/ExplainFinishAtZero.t.sol` replicates the exact mainnet state without any storage manipulation. + +## See Also + +- [LinearReward Arithmetic Underflow](linear-reward-underflow.md) -- the underflow bug triggered by this state diff --git a/doc/fixes/genesis-end.md b/doc/fixes/genesis-end.md new file mode 100644 index 00000000..0f530a3d --- /dev/null +++ b/doc/fixes/genesis-end.md @@ -0,0 +1,90 @@ +# Genesis End + +## What endGenesis() Does + +When `endGenesis()` is called: +1. Genesis transfers collateral (wstETH) to the Minter +2. Calls `freeMintPeggedToken()` to mint pegged tokens (ha) to Genesis +3. Calls `freeMintLeveragedToken()` to mint leveraged tokens (hs) to Genesis +4. Emits `GenesisEnds()` event +5. Minter updates its state: `underlyingCollateral`, `peggedTokenBalance` +6. Collateral ratio becomes calculable +7. Users can then call `claim()` to get their pegged and leveraged tokens + +## Prerequisites + +- Caller must be Genesis **owner** +- Genesis must have `ZERO_FEE_ROLE` on the Minter contract (to call `freeMintPeggedToken` and `freeMintLeveragedToken`) + +### Granting ZERO_FEE_ROLE + +```bash +MINTER="0x8A791620dd6260079BF849Dc5567aDC3F2FdC318" +GENESIS="0xAD523115cd35a8d4E60B3C0953E0E0ac10418309" +ZERO_FEE_ROLE=$(cast keccak "ZERO_FEE_ROLE()") +OWNER_PK="0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + +cast send $MINTER "grantRoles(address,uint256)" $GENESIS $ZERO_FEE_ROLE \ + --rpc-url http://localhost:8545 \ + --private-key $OWNER_PK +``` + +## Calling endGenesis() + +```bash +cast send $GENESIS "endGenesis()" \ + --rpc-url http://localhost:8545 \ + --private-key $OWNER_PK +``` + +## Why Fees Show 0% Before Genesis Ends + +Before `endGenesis()`: +- No pegged tokens exist in the Minter +- Collateral ratio = infinity (1e36) +- System lands in highest fee band (> 2.0x) = 0.5% fee +- 0.5% on small amounts may round to 0% in UI + +After `endGenesis()`: +- Collateral ratio becomes ~2.0x +- Fee band: 1.5x - 2.0x = 1% fee, or > 2.0x = 0.5% fee + +## Troubleshooting + +### Error: `Unauthorized()` (0x82b42900) + +This means the caller is not the owner. Verify: + +1. **Wallet address matches owner**: + ```javascript + // In browser console + (await window.ethereum.request({method: 'eth_accounts'}))[0] + ``` + +2. **Network is correct**: Chain ID 31337, RPC http://localhost:8545 + +3. **Test directly with cast** to isolate frontend vs contract issues: + ```bash + cast send $GENESIS "endGenesis()" \ + --rpc-url http://localhost:8545 \ + --private-key $OWNER_PK + ``` + If cast succeeds but frontend fails, the wallet account is wrong. + +### Error: 0xd2159c14 + +Common causes: + +1. **Frontend using wrong Genesis address**: Ensure the frontend config points to the correct Genesis contract, not an old deployment. + +2. **Missing ZERO_FEE_ROLE**: Grant it as shown above. + +### Collateral Goes to Wrong Minter + +If `endGenesis()` succeeds but the Minter you are checking has 0 collateral, the Genesis contract may be configured to use a different Minter address. Check: + +```bash +cast call $GENESIS "MINTER()(address)" --rpc-url http://localhost:8545 +``` + +Verify the Minter address matches your frontend config. If there is a mismatch, update the frontend to use the correct Minter address. diff --git a/doc/fixes/linear-reward-underflow.md b/doc/fixes/linear-reward-underflow.md new file mode 100644 index 00000000..028e117d --- /dev/null +++ b/doc/fixes/linear-reward-underflow.md @@ -0,0 +1,46 @@ +# LinearReward Arithmetic Underflow + +## Executive Summary + +**Status**: Bug confirmed on mainnet at block 24404265. +**Contract**: `0x9e56F1E1E80EBf165A1dAa99F9787B41cD5bFE40` (StabilityPool) +**Impact**: Deposits completely blocked -- users cannot deposit into the pool. + +## Root Cause + +Reward token `0x9567c243F647f9Ac37efb7Fc26BD9551Dce0BE1B` was registered as an active reward token but never received any reward deposits. The `_distributePendingReward()` function updates `lastUpdate` for ALL active tokens on every deposit, but `finishAt` is only set by `increase()`, which is only called for the token actually receiving rewards. This results in: + +``` +lastUpdate: 1769846711 (valid timestamp) +finishAt: 0 (never set) +rate: 0 +queued: 0 +``` + +### The Bug in LinearReward.sol + +In `increase()`, the `else` branch (entered when `block.timestamp < finishAt`) performs unsafe subtractions: + +**Line 48** -- `finishAt - periodLength` underflows when `finishAt < periodLength` (e.g., 0 < 1209600) + +**Line 52** -- `finishAt - lastUpdate` underflows when `finishAt < lastUpdate` (e.g., 0 < 1769846711) + +When any user calls `deposit()`, `_distributePendingReward()` loops through all active tokens and calls `increase()`. The underflow causes Panic 0x11 and the entire transaction reverts. + +## The Fix + +Safe subtractions at all affected lines: + +```solidity +// Line 48-50 +uint256 periodStart = _data.finishAt >= _periodLength ? _data.finishAt - _periodLength : 0; +uint256 _elapsed = block.timestamp >= periodStart ? block.timestamp - periodStart : 0; + +// Line 52-54 +uint256 timeSinceLastUpdate = _data.finishAt >= _data.lastUpdate ? _data.finishAt - _data.lastUpdate : 0; +_amount = _amount + uint256(_data.rate) * timeSinceLastUpdate; +``` + +## See Also + +- [finishAt = 0 Root Cause Investigation](finishat-zero.md) -- detailed investigation of how the state arose diff --git a/doc/rebalance-remediation.md b/doc/fixes/rebalance-remediation.md similarity index 100% rename from doc/rebalance-remediation.md rename to doc/fixes/rebalance-remediation.md diff --git a/doc/remediation-ETH-fxUSD-SPL.md b/doc/fixes/remediation-ETH-fxUSD-SPL.md similarity index 100% rename from doc/remediation-ETH-fxUSD-SPL.md rename to doc/fixes/remediation-ETH-fxUSD-SPL.md diff --git a/doc/fixes/sp-overflow.md b/doc/fixes/sp-overflow.md new file mode 100644 index 00000000..fac141b3 --- /dev/null +++ b/doc/fixes/sp-overflow.md @@ -0,0 +1,103 @@ +# Stability Pool Overflow (uint192 Integral) + +## Executive Summary + +The Stability Pool's reward accounting system faced imminent failure due to an integer overflow in the `uint192` integral used to track cumulative reward distributions. With only 0.097 BTC (~$9,700) in deposits and 75 fxSAVE tokens distributed per week, the reward-to-deposit ratio caused the integral to grow at 7.73x10^56 per distribution. The integral had reached 5.933x10^57 -- 94.5% of the uint192 maximum of 6.277x10^57. The next reward distribution would exceed this maximum, triggering a Panic 0x11 overflow that freezes ALL pool operations: deposits, withdrawals, and claims. + +**Without a fix**: The pool completely breaks within 1-2 weeks. User funds become inaccessible (safe but frozen). + +**Chosen fix**: Queue rewards when overflow would occur, resume when deposits increase. This keeps deposits and withdrawals working while pausing only fxSAVE reward accrual. The change is approximately 10 lines of code with zero storage layout changes, making it safe for a UUPS proxy upgrade. + +## Technical Root Cause + +### The Integral Mechanism + +Each reward token in a Stability Pool has an independent cumulative integral stored as `uint192`: + +```solidity +// From MultipleRewardCompoundingAccumulatorStorage +mapping(address => mapping(uint8 => uint192)) tokenToExponentToIntegral; +``` + +When rewards are distributed, the integral grows by: + +``` +toAdd = (rewardAmount * 1e18 * magnitude) / totalShare +``` + +Where: +- `rewardAmount` = tokens being distributed (75 fxSAVE/week) +- `magnitude` = DecrementalFloatingPoint magnitude (1e36 at exponent 0, no losses) +- `totalShare` = total deposits in the pool (0.097 BTC = 9.7e16 wei) + +### Mainnet State at Time of Discovery + +| Metric | Value | +|--------|-------| +| Pool address | `0x9e56F1E1E80EBf165A1dAa99F9787B41cD5bFE40` | +| Deposit token | haBTC (`0x25bA4A826E1a1346dcA2Ab530831dbFF9C08bEA7`) | +| Total deposits | 0.097 BTC (~$9,700) | +| Reward token | fxSAVE (`0x7743e50F534a7f9F1791DdE7dCD89F7783Eefc39`) | +| Distribution rate | ~75 tokens/week | +| Current integral | 5.933x10^57 (94.5% of max) | +| Exponent | 0 (no significant loss events) | +| Growth per distribution | 7.73x10^56 | +| uint192 maximum | 6.277x10^57 | + +### Why It Overflows + +``` +Current integral: 5.933 x 10^57 +Next addition: + 0.773 x 10^57 +Expected new value: = 6.706 x 10^57 +uint192 max: 6.277 x 10^57 <-- EXCEEDED +``` + +The cast `integral += uint192(toAdd)` triggers Solidity's checked arithmetic, reverting with Panic 0x11. Since `_accumulateReward()` is called on every deposit, withdrawal, and claim, ALL operations fail. + +## Scope Analysis + +The overflow affects **any** StabilityPool using the `MultipleRewardCompoundingAccumulator` base contract (both collateral and leveraged pools). Each reward token has its own integral, so one may overflow while another remains safe. However, `_distributePendingReward()` iterates all active tokens, so any token's overflow can block all operations. + +The vulnerability is asset-agnostic and depends purely on the ratio `(rewardAmount * magnitude) / totalShare`. Assets with fewer decimals (USDC = 6) are MORE susceptible because `totalShare` is numerically smaller. + +### Vulnerability Criteria + +A pool will overflow when: + +``` +(rewardAmount * 1e18 * magnitude) / totalShare > remaining integral headroom +``` + +High-risk pools have: low deposits, high reward rates, no recent loss events (exponent = 0), and low-decimal assets. + +## Solution: Queue on Overflow + +When the integral would exceed `uint192.max`, queue the rewards instead of reverting: + +```solidity +uint256 newIntegral = uint256(integral) + toAdd; +if (newIntegral > type(uint192).max) { + _getRewardData(token).queued += uint96(amount); + emit RewardQueuedDueToIntegralOverflow(token, exponent, amount, toAdd); + return; +} +``` + +| Property | Detail | +|----------|--------| +| Lines changed | ~10 | +| New storage slots | 0 (uses existing `queued` field) | +| UUPS-safe | Yes, no storage layout changes | + +### How the Queue Clears + +1. **More deposits arrive** -- increases `totalShare`, reducing integral growth rate +2. **A loss event occurs** -- increments exponent, resets integral to 0 + +### User Experience While Queue Is Active + +- Deposits, withdrawals, and claims all work normally +- fxSAVE APY shows 0% (no new rewards accruing) +- Queued rewards are held by the contract, not lost +- Auto-resumes when conditions improve diff --git a/doc/stability-pool-v3-upgrade.md b/doc/fixes/sp-v3-upgrade.md similarity index 100% rename from doc/stability-pool-v3-upgrade.md rename to doc/fixes/sp-v3-upgrade.md diff --git a/doc/frontend/claim.md b/doc/frontend/claim.md new file mode 100644 index 00000000..4408d032 --- /dev/null +++ b/doc/frontend/claim.md @@ -0,0 +1,221 @@ +# Claim and Compound Rewards + +## Claim Interface + +Call `claim()` **directly on the Stability Pool contract** (not on StabilityPoolManager or a separate rewards contract). The Stability Pool implements `IMultipleRewardAccumulator`. + +### Claim Function Variants + +```solidity +function claim() external; // Claim all for caller +function claim(address account) external; // Claim all for account +function claim(address account, address receiver) external; // Claim all, send to receiver +function claimHistorical(address[] memory tokens) external; // Claim specific historical tokens +``` + +### Required ABI + +```typescript +const STABILITY_POOL_REWARDS_ABI = [ + "function activeRewardTokens() view returns (address[])", + "function claimable(address account, address token) view returns (uint256)", + "function claim() external", + "function claim(address account) external", + "function claim(address account, address receiver) external", + "event Claim(address indexed account, address indexed token, address indexed receiver, uint256 amount)", +]; +``` + +--- + +## Basic Claim + +### Step 1: Check Claimable Rewards + +```typescript +async function checkClaimableRewards(poolAddress: string, userAddress: string, provider: any) { + const pool = new Contract(poolAddress, STABILITY_POOL_REWARDS_ABI, provider); + const rewardTokens = await pool.activeRewardTokens(); + const claimableRewards = []; + + for (const tokenAddress of rewardTokens) { + const claimable = await pool.claimable(userAddress, tokenAddress); + if (claimable > 0n) { + const tokenContract = new Contract(tokenAddress, ["function symbol() view returns (string)"], provider); + const symbol = await tokenContract.symbol(); + claimableRewards.push({ token: tokenAddress, symbol, amount: claimable, amountFormatted: formatEther(claimable) }); + } + } + return claimableRewards; +} +``` + +### Step 2: Execute Claim + +```typescript +// Simplest form: claims all active reward tokens for the signer +const pool = new Contract(poolAddress, STABILITY_POOL_REWARDS_ABI, signer); +const tx = await pool.claim(); +await tx.wait(); +``` + +### Claim to a Different Receiver + +```typescript +const tx = await pool.claim(accountAddress, receiverAddress); +``` + +### Claim from Multiple Pools + +```typescript +async function claimFromMultiplePools(poolAddresses: string[], signer: any) { + const txs = await Promise.all( + poolAddresses.map(address => new Contract(address, STABILITY_POOL_REWARDS_ABI, signer).claim()) + ); + return Promise.all(txs.map(tx => tx.wait())); +} +``` + +### Verify via Claim Events + +```typescript +pool.on("Claim", (account, token, receiver, amount) => { + if (account.toLowerCase() === userAddress.toLowerCase()) { + console.log(`Claimed ${amount} of token ${token}`); + } +}); +``` + +### Testing with cast + +```bash +# Check claimable +cast call "claimable(address,address)(uint256)" --rpc-url http://localhost:8545 + +# Claim +cast send "claim()" --private-key --rpc-url http://localhost:8545 +``` + +--- + +## Compound + +Compound reinvests rewards back into stability pools. The flow depends on the reward token type: + +- **Collateral (wstETH)**: Claim -> Mint ha tokens -> Deposit to pool(s) +- **hs Tokens (Leveraged)**: Claim -> Redeem for collateral -> Mint ha tokens -> Deposit to pool(s) +- **ha Tokens (Pegged)**: Claim -> Deposit directly to pool(s) + +### Required ABIs + +```typescript +const MINTER_ABI = [ + "function mintPeggedToken(uint256 collateralAmount, address receiver, uint256 minPeggedOut) returns (uint256)", + "function mintPeggedTokenDryRun(uint256 collateralAmount) view returns (uint256 peggedOut, uint256 wrappedFee, uint256 fee)", + "function redeemLeveragedToken(uint256 leveragedAmount, address receiver, uint256 minCollateralOut) returns (uint256)", + "function redeemLeveragedTokenDryRun(uint256 leveragedAmount) view returns (uint256 collateralOut, uint256 wrappedFee, uint256 fee)", +]; +``` + +### Step 1: Categorize Reward Tokens + +```typescript +async function categorizeRewards(userAddress: string, pools: any[], wstETH: string, haToken: string, hsToken: string, provider: any) { + const rewards = []; + for (const pool of pools) { + const poolContract = new Contract(pool.address, STABILITY_POOL_ABI, provider); + const rewardTokens = await poolContract.activeRewardTokens(); + + for (const tokenAddress of rewardTokens) { + const claimable = await poolContract.claimable(userAddress, tokenAddress); + if (claimable > 0n) { + const tokenLower = tokenAddress.toLowerCase(); + let type: "collateral" | "ha" | "hs"; + if (tokenLower === wstETH.toLowerCase()) type = "collateral"; + else if (tokenLower === haToken.toLowerCase()) type = "ha"; + else if (tokenLower === hsToken.toLowerCase()) type = "hs"; + else continue; + + rewards.push({ token: tokenAddress, amount: claimable, type, poolAddress: pool.address }); + } + } + } + return rewards; +} +``` + +### Step 2: Estimate Fees + +```typescript +// For collateral -> ha tokens +const [peggedOut, , fee] = await minter.mintPeggedTokenDryRun(collateralAmount); +const minPeggedOut = (peggedOut * 95n) / 100n; // 5% slippage + +// For hs tokens -> collateral +const [collateralOut, , fee] = await minter.redeemLeveragedTokenDryRun(hsTokenAmount); +const minCollateralOut = (collateralOut * 95n) / 100n; +``` + +### Step 3: Execute Compound + +Transaction order matters: + +1. **Claim** rewards to user's wallet +2. **Approve** contracts to spend tokens +3. **Mint** ha tokens (if collateral rewards) +4. **Deposit** to stability pools + +```typescript +// 1. Claim +const claimTx = await pool.claim(userAddress, userAddress); +await claimTx.wait(); + +// 2. Approve minter (for collateral rewards) +await wstETH.approve(minterAddress, rewardAmount); + +// 3. Mint ha tokens +const mintTx = await minter.mintPeggedToken(rewardAmount, userAddress, minPeggedOut); +await mintTx.wait(); + +// 4. Deposit to pool +await haToken.approve(targetPoolAddress, depositAmount); +await targetPool.deposit(depositAmount, userAddress, depositAmount); +``` + +### Split Strategies + +**Equal split:** +```typescript +const amountPerPool = totalAmount / BigInt(targetPools.length); +const remainder = totalAmount % BigInt(targetPools.length); +// Add remainder to first pool +``` + +**Proportional split (by existing deposit size):** +```typescript +const balances = await Promise.all(targetPools.map(p => pool.assetBalanceOf(userAddress))); +const totalBalance = balances.reduce((sum, b) => sum + b, 0n); +// proportion = (totalAmount * balance) / totalBalance for each pool +``` + +--- + +## Common Issues + +### "No claimable rewards" (`claimable()` returns 0) + +- Check user has deposits: `assetBalanceOf(userAddress)` +- Check if rewards have been deposited to the pool +- Verify rewards are not still vesting (check `rewardData()`) +- Verify the correct pool address + +### Transaction reverts on `claim()` + +- Always check `claimable()` first +- Verify using stability pool address, not the manager +- Verify ABI is correct +- Estimate gas first: `const gas = await pool.claim.estimateGas()` + +### Insufficient gas + +Add a buffer: `const tx = await pool.claim({ gasLimit: gasEstimate * 120n / 100n })` diff --git a/doc/frontend/config.md b/doc/frontend/config.md new file mode 100644 index 00000000..2c16ad75 --- /dev/null +++ b/doc/frontend/config.md @@ -0,0 +1,151 @@ +# Frontend Configuration + +## Network Configuration + +```typescript +const anvilNetwork = { + chainId: 31337, + chainName: "Anvil Local", + nativeCurrency: { + name: "Ether", + symbol: "ETH", + decimals: 18, + }, + rpcUrls: ["http://localhost:8545"], + blockExplorerUrls: [], +}; +``` + +## Environment Variables + +```bash +# GraphQL Endpoint +NEXT_PUBLIC_GRAPH_URL=http://localhost:8000/subgraphs/name/harbor-marks-local + +# Network Configuration +NEXT_PUBLIC_CHAIN_ID=31337 +NEXT_PUBLIC_RPC_URL=http://localhost:8545 + +# Contract Addresses +NEXT_PUBLIC_GENESIS_CONTRACT=0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82 +NEXT_PUBLIC_MINTER_CONTRACT=0x8A791620dd6260079BF849Dc5567aDC3F2FdC318 +NEXT_PUBLIC_PEGGED_TOKEN=0x0165878A594ca255338adfa4d48449f69242Eb8F +NEXT_PUBLIC_LEVERAGED_TOKEN=0xa513E6E4b8f2a923D98304ec87F64353C4D5C853 +NEXT_PUBLIC_WSTETH=0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 +NEXT_PUBLIC_STETH=0x5FbDB2315678afecb367f032d93F642f64180aa3 +``` + +## Contract Addresses + +### Core Contracts + +```typescript +export const contracts = { + genesis: "0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82", + minter: "0x8A791620dd6260079BF849Dc5567aDC3F2FdC318", + peggedToken: "0x0165878A594ca255338adfa4d48449f69242Eb8F", + leveragedToken: "0xa513E6E4b8f2a923D98304ec87F64353C4D5C853", + reservePool: "0x610178dA211FEF7D417bC0e6FeD39F05609AD788", + stabilityPoolManager: "0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0", + feeReceiver: "0xB7f8BC63BbcaD18155201308C8f3540b07f84F5e", + collateralToken: "0x5FbDB2315678afecb367f032d93F642f64180aa3", // Mock stETH + wrappedCollateralToken: "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512", // Mock wstETH + stabilityPoolCollateral: "0xf5059a5D33d5853360D16C683c16e67980206f36", + stabilityPoolSail: "0x99bbA657f2BbC93c02D617f8bA121cB8Fc104Acf", +} as const; +``` + +### Token Information + +| Token | Address | Symbol | +|-------|---------|--------| +| Pegged Token | `0x0165878A594ca255338adfa4d48449f69242Eb8F` | haPB | +| Leveraged Token | `0xa513E6E4b8f2a923D98304ec87F64353C4D5C853` | hshsPBxstETH | +| stETH (Mock) | `0x5FbDB2315678afecb367f032d93F642f64180aa3` | stETH | +| wstETH (Mock) | `0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512` | wstETH | + +### Price Feeds (Mock Chainlink) + +| Feed | Address | Value | +|------|---------|-------| +| stETH/USD | `0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0` | $2000 (200000000000, 8 decimals) | +| stETH/ETH | `0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9` | 1.0 (100000000, 8 decimals) | +| wstETH/USD | `0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9` | $2000 (200000000000, 8 decimals) | + +## Subgraph Configuration + +```yaml +network: anvil +source: + address: "0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82" + startBlock: 55 +``` + +### Deploy Subgraph + +```bash +graph create --node http://localhost:8020/ harbor-marks-local +graph build +graph deploy --node http://localhost:8020/ --ipfs http://localhost:5001 harbor-marks-local +``` + +Check indexing status: http://localhost:8030/graphql + +## GraphQL Queries + +### Get User Harbor Marks + +```graphql +query GetUserHarborMarks($user: Bytes!) { + userHarborMarks(id: $user) { + id + totalDeposited + totalWithdrawn + currentBalance + } +} +``` + +### Get Deposits + +```graphql +query GetDeposits($user: Bytes!) { + deposits(where: { user: $user }, orderBy: timestamp, orderDirection: desc, first: 10) { + id + user + token + amount + timestamp + blockNumber + } +} +``` + +### Get Withdrawals + +```graphql +query GetWithdrawals($user: Bytes!) { + withdrawals(where: { user: $user }, orderBy: timestamp, orderDirection: desc, first: 10) { + id + user + token + amount + timestamp + blockNumber + } +} +``` + +## Developer Account (Testing) + +- **Address**: `0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e` +- **Balances**: 1000 stETH, 1000 wstETH, 1600 ETH +- **Permissions**: Owner of Genesis, ZERO_FEE_ROLE on Minter +- **Default Deployer**: `0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266` (Anvil account 0) + +## Notes + +- This is a clean Anvil chain (no mainnet fork). All contracts are newly deployed mocks. +- stETH and wstETH are mock contracts implementing standard interfaces but simplified for local testing. +- Docker Desktop must be running for Graph Node. +- Graph Node requires starting with `cd graph-node-local && docker compose up -d`. diff --git a/doc/frontend/display.md b/doc/frontend/display.md new file mode 100644 index 00000000..9fb140af --- /dev/null +++ b/doc/frontend/display.md @@ -0,0 +1,244 @@ +# Display Calculations + +## APR Calculation (Next Period Projection) + +This calculates the projected APR for the next reward period, based on the current harvestable amount. Useful at launch before any harvests have occurred. + +### Calculation Flow + +``` +1. Get harvestable amount from minter +2. Deduct harvest bounty and cut ratios +3. Split remaining across pools by their deposit ratio +4. Add any queued rewards for the target pool +5. Calculate reward rate: totalRewards / REWARD_PERIOD_LENGTH +6. Calculate per-token rate: rate / totalPoolSupply +7. Project user's 7-day rewards: ratePerToken * userBalance * 604800 +8. Annualize: (rewardsValueUSD / depositValueUSD) * (365/7) * 100 +``` + +### Key Contracts and Values + +```typescript +const harvestableAmount = await minter.harvestable(); +const harvestBountyRatio = await stabilityPoolManager.harvestBountyRatio(); +const harvestCutRatio = await stabilityPoolManager.harvestCutRatio(); +const REWARD_PERIOD_LENGTH = 604800; // 7 days in seconds + +// Deductions +const bounty = (harvestable * bountyRatio) / 1e18; +const cut = (harvestable * cutRatio) / 1e18; +const remaining = harvestable - bounty - cut; + +// Pool split +const poolCollateral = await stabilityPoolCollateral.totalAssetSupply(); +const poolLeveraged = await stabilityPoolLeveraged.totalAssetSupply(); +const toThisPool = (remaining * thisPoolSupply) / (poolCollateral + poolLeveraged); + +// Rate +const { queued } = await stabilityPool.rewardData(rewardToken); +const totalRewards = toThisPool + queued; +const rate = totalRewards / BigInt(REWARD_PERIOD_LENGTH); + +// APR +const ratePerToken = Number(rate) / Number(totalSupply); +const rewards7Days = ratePerToken * Number(userBalance) * 604800; +const apr = (rewardsValueUSD / depositValueUSD) * (365 / 7) * 100; +``` + +### Projecting Additional Yield + +To account for wstETH rate growth over the remaining period: + +```typescript +const currentRate = await wstETH.stEthPerToken(); +const STAKING_APR = 0.035; // 3.5% +const remainingDays = Number(remainingSeconds) / 86400; +const rateGrowthFactor = 1 + (STAKING_APR / 365) * remainingDays; +const projectedRate = (currentRate * BigInt(Math.floor(rateGrowthFactor * 1e18))) / 1e18; +``` + +### Edge Cases + +- No harvestable amount: return 0 +- No deposits in pool: return 0 +- Empty pool: return 0 +- Multiple reward tokens: calculate APR for each and sum + +--- + +## Leverage Ratio + +The leverage ratio represents the exposure multiplier for leveraged (sail) tokens. + +### Formula + +``` +leverageRatio = collateralValue / (collateralValue - peggedValue) +``` + +### Fetching + +```typescript +const leverageRatioRaw = await minter.leverageRatio(); // uint256, 18 decimals +const leverageRatio = parseFloat(leverageRatioRaw.toString()) / 1e18; +``` + +### Interpretation + +| Ratio | Risk Level | Description | +|-------|-----------|-------------| +| < 1.5x | Low | Low leverage | +| 1.5x - 2.0x | Low-Medium | Low leverage | +| 2.0x - 3.0x | Medium | Moderate leverage | +| 3.0x - 5.0x | High | High leverage | +| > 5.0x | Very High | Very high leverage | + +### Display Format + +Always show as "X.XXx" (e.g., "2.50x"). + +### Edge Cases + +- The contract caps leverage ratio at `_LEVERAGE_RATIO_CAP` +- Zero pegged tokens with collateral: returns cap or very large number +- Empty system: returns a default value + +### Notes + +- Returns 18-decimal value -- always divide by 1e18 +- Depends on the price oracle -- handle stale price errors +- Refresh every 30 seconds or on new blocks + +--- + +## Pegged Token Value + +The pegged token (haPB) targets a $1.00 USD peg. The actual redemption value can vary. + +### Fetching Price + +```typescript +const priceRaw = await minter.peggedTokenPrice(); // uint256, 18 decimals +const priceInStETH = parseFloat(priceRaw.toString()) / 1e18; +``` + +The returned value is in **stETH units** (not USD). To get USD: + +``` +priceUSD = priceInStETH * stETHPriceUSD +``` + +Example: if `peggedTokenPrice()` returns 0.0005 and stETH = $2000, then 1 haPB = 0.0005 * $2000 = $1.00. + +### Simplified Approach + +For most frontend purposes, assume $1.00 per pegged token: + +```typescript +const PEGGED_TOKEN_PRICE_USD = 1.0; +``` + +Use `peggedTokenPrice()` only for: +- Detecting depeg status +- Showing actual redemption value +- Advanced calculations + +### Depeg Detection + +```typescript +const priceUSD = priceInStETH * stETHPriceUSD; +const isPegged = Math.abs(priceUSD - 1.0) < 0.01; // Within 1 cent +``` + +When `peggedTokenBalance() == 0`, the function returns 1.0 as default. + +--- + +## Marks Display + +### Mark Types and Rates + +| Source | Rate | Multiplier | +|--------|------|-----------| +| Ha Tokens (wallet holdings) | 1 mark/dollar/day | 1x | +| Stability Pool Deposits | 1 mark/dollar/day | 1x | +| Sail Tokens (wallet holdings) | 5 marks/dollar/day | 5x (default) | + +**Anchor Ledger Marks** = Ha Token marks + Stability Pool marks (both 1x). + +### GraphQL: All Marks Sources + +```graphql +query GetAllUserMarks($userAddress: Bytes!, $genesisId: ID!) { + haTokenBalances(where: { user: $userAddress }) { + accumulatedMarks + marksPerDay + balanceUSD + lastUpdated + } + sailTokenBalances(where: { user: $userAddress }) { + accumulatedMarks + marksPerDay + balanceUSD + lastUpdated + } + stabilityPoolDeposits(where: { user: $userAddress }) { + accumulatedMarks + marksPerDay + balanceUSD + lastUpdated + } + userHarborMarks(id: $genesisId) { + currentMarks + marksPerDay + totalMarksEarned + } +} +``` + +### Real-Time Estimation (Zero Gas) + +The subgraph stores marks at the time of the last on-chain event. Estimate current marks on the frontend: + +```typescript +function calculateEstimatedMarks(balance: { accumulatedMarks: string; marksPerDay: string; lastUpdated: string }): number { + const storedMarks = parseFloat(balance.accumulatedMarks || "0"); + const marksPerDay = parseFloat(balance.marksPerDay || "0"); + const lastUpdated = parseInt(balance.lastUpdated || "0"); + + if (lastUpdated === 0 || marksPerDay === 0) return storedMarks; + + const now = Math.floor(Date.now() / 1000); + const daysSinceUpdate = (now - lastUpdated) / 86400; + return storedMarks + marksPerDay * daysSinceUpdate; +} +``` + +Update this calculation every 1 second for a smooth live counter. Poll the subgraph every 60 seconds for new on-chain events. + +### Combining All Marks + +```typescript +const totalMarks = haMarks + sailMarks + poolMarks + genesisMarks; +const totalMarksPerDay = haMarksPerDay + sailMarksPerDay + poolMarksPerDay + genesisMarksPerDay; +``` + +### Sail Token Marks + +- `marksPerDay` from the subgraph **already includes the 5x multiplier** -- do not multiply again +- Expected: `balanceUSD * 5 = marksPerDay` +- Same estimation function works for both ha and sail tokens + +### Example Values + +User holds $100,000 in sail tokens: +- Marks per day: 500,000 (= $100,000 * 5) +- After 2 days: 1,000,000 marks + +### Important Notes + +- Always use **lowercase addresses** in GraphQL queries +- `balance` is a BigInt string (18 decimals), convert with `formatEther` +- `balanceUSD` is already human-readable +- `genesisId` format: `{genesisAddress}-{userAddress}` (both lowercase) diff --git a/doc/frontend/redeem.md b/doc/frontend/redeem.md new file mode 100644 index 00000000..27983bf6 --- /dev/null +++ b/doc/frontend/redeem.md @@ -0,0 +1,136 @@ +# Redeem Fee Calculation + +## Overview + +The Minter contract provides `dryRun` functions that simulate redemptions without executing them. Use these to display fees to users before they approve a transaction. + +## Dry-Run Functions + +### Redeem Pegged Token (haPB) + +```solidity +function redeemPeggedTokenDryRun(uint256 peggedIn) + external view returns ( + int256 incentiveRatio, // Fee (positive) or discount (negative), 1e18 scale + uint256 fee, // Fee amount in wrapped collateral + uint256 discount, // Discount/bonus amount in wrapped collateral + uint256 peggedRedeemed, // Amount of pegged tokens redeemed + uint256 wrappedCollateralReturned, // Net collateral returned + uint256 price, // Price used + uint256 rate // Conversion rate (underlying -> wrapped) + ); +``` + +### Redeem Leveraged Token (hsPB) + +```solidity +function redeemLeveragedTokenDryRun(uint256 leveragedIn) + external view returns ( + int256 incentiveRatio, + uint256 fee, + uint256 leveragedRedeemed, + uint256 collateralReturned, + uint256 price, + uint256 rate + ); +``` + +## Understanding the Incentive Ratio + +- **Positive**: Fee deducted from collateral. Example: `50000000000000000` (0.05e18) = **5% fee** +- **Negative**: Discount/bonus added. Example: `-100000000000000000` (-0.1e18) = **10% bonus** +- **1e18**: Transaction is **blocked** (100% fee) + +For pegged tokens, discounts are paid from the reserve pool. If the reserve pool is exhausted, the discount may be reduced. + +## Implementation + +### Minimal ABI + +```typescript +const MINTER_ABI = [ + "function redeemPeggedTokenDryRun(uint256) view returns (int256, uint256, uint256, uint256, uint256, uint256, uint256)", + "function redeemLeveragedTokenDryRun(uint256) view returns (int256, uint256, uint256, uint256, uint256, uint256)", +]; +``` + +### Calculate Fee Info + +```typescript +async function calculateRedeemPeggedFee(minterAddress: string, peggedAmount: string, provider: ethers.Provider) { + const minter = new Contract(minterAddress, MINTER_ABI, provider); + const [incentiveRatio, fee, discount, peggedRedeemed, wrappedCollateralReturned, price, rate] = + await minter.redeemPeggedTokenDryRun(peggedAmount); + + const incentiveRatioBN = BigInt(incentiveRatio.toString()); + const isDisallowed = incentiveRatioBN === BigInt("1000000000000000000"); + + let feePercentage = 0; + let discountPercentage = 0; + if (incentiveRatioBN > 0n) feePercentage = Number(incentiveRatioBN) / 1e16; + else if (incentiveRatioBN < 0n) discountPercentage = Number(-incentiveRatioBN) / 1e16; + + return { + fee: fee.toString(), + discount: discount.toString(), + collateralReturned: wrappedCollateralReturned.toString(), + feePercentage, + discountPercentage, + isDisallowed, + netCollateralReturned: ethers.formatEther(wrappedCollateralReturned), + }; +} +``` + +### wagmi/viem Hook + +```typescript +function useRedeemPeggedFee(minterAddress: string, amount: string) { + const amountWei = amount ? parseEther(amount).toString() : "0"; + + const { data, isLoading, error } = useReadContract({ + address: minterAddress as `0x${string}`, + abi: MINTER_ABI, + functionName: "redeemPeggedTokenDryRun", + args: [BigInt(amountWei)], + query: { enabled: !!amount && amount !== "0" }, + }); + + // Process data same as above +} +``` + +## Fee Structure Reference + +### Pegged Token (haPB) Fees + +| Collateral Ratio | Fee/Discount | +|-----------------|--------------| +| < 1.0x | -10% (Discount) | +| 1.0x - 1.05x | -5% (Discount) | +| 1.05x - 1.1x | 0% (Free) | +| 1.1x - 1.2x | 1% | +| 1.2x - 1.3x | 2% | +| 1.3x - 1.5x | 3% | +| 1.5x - 2.0x | 4% | +| > 2.0x | 5% | + +### Leveraged Token (hsPB) Fees + +| Collateral Ratio | Fee | +|-----------------|-----| +| < 1.0x | 100% (Blocked) | +| 1.0x - 1.05x | 30% | +| 1.05x - 1.1x | 15% | +| 1.1x - 1.2x | 8% | +| 1.2x - 1.3x | 5% | +| 1.3x - 1.5x | 3% | +| 1.5x - 2.0x | 2% | +| > 2.0x | 1.5% | + +## Notes + +- Fees are dynamic -- they change based on the current collateral ratio. Always call the dry-run right before showing transaction details. +- Even if the dry-run succeeds, the actual transaction may fail if the collateral ratio changes between dry-run and execution. +- The `price` and `rate` values can be used to display the current exchange rate and price impact. +- Always call the dry-run again right before submitting the transaction for accuracy. diff --git a/doc/frontend/stability-pool.md b/doc/frontend/stability-pool.md new file mode 100644 index 00000000..87f5c581 --- /dev/null +++ b/doc/frontend/stability-pool.md @@ -0,0 +1,324 @@ +# Stability Pool Operations + +## Contract Functions Reference + +### Read Functions + +```solidity +function assetBalanceOf(address account) external view returns (uint256); +function totalAssetSupply() external view returns (uint256); +function ASSET_TOKEN() external view returns (address); +function getWithdrawalRequest(address account) external view returns (uint64 start, uint64 end); +function getWithdrawalWindow() external view returns (uint64 startDelay, uint64 endWindow); +function getEarlyWithdrawalFee() external view returns (uint256); +function getFeeAddress() external view returns (address); +function MIN_DEPOSIT() external view returns (uint256); +function activeRewardTokens() external view returns (address[]); +function claimable(address account, address token) external view returns (uint256); +function rewardData(address token) external view returns (uint256 lastUpdate, uint256 finishAt, uint256 rate, uint256 queued); +function REWARD_PERIOD_LENGTH() external view returns (uint40); +``` + +### Write Functions + +```solidity +function deposit(uint256 assetAmount, address receiver, uint256 minAmount) external returns (uint256 sharesMinted); +function withdraw(uint256 assetAmount, address receiver, uint256 minAmount) external returns (uint256); +function requestWithdrawal() external; +function claim() external; +function claim(address account) external; +function claim(address account, address receiver) external; +``` + +### Minimal ABI + +```typescript +const STABILITY_POOL_ABI = [ + "function assetBalanceOf(address) view returns (uint256)", + "function totalAssetSupply() view returns (uint256)", + "function ASSET_TOKEN() view returns (address)", + "function getWithdrawalRequest(address) view returns (uint64, uint64)", + "function getWithdrawalWindow() view returns (uint64, uint64)", + "function getEarlyWithdrawalFee() view returns (uint256)", + "function MIN_DEPOSIT() view returns (uint256)", + "function activeRewardTokens() view returns (address[])", + "function claimable(address, address) view returns (uint256)", + "function rewardData(address) view returns (uint256, uint256, uint256, uint256)", + "function REWARD_PERIOD_LENGTH() view returns (uint40)", + "function deposit(uint256, address, uint256) returns (uint256)", + "function withdraw(uint256, address, uint256) returns (uint256)", + "function requestWithdrawal()", + "function claim()", +]; +``` + +--- + +## Deposits + +### Prerequisites Check + +Before depositing, verify: +1. User has sufficient token balance +2. Amount meets `MIN_DEPOSIT()` requirement +3. Token allowance is sufficient (approve if needed) + +```typescript +async function checkDepositPrerequisites( + poolAddress: string, + userAddress: string, + amount: bigint, + provider: any, +) { + const pool = new Contract(poolAddress, STABILITY_POOL_ABI, provider); + const assetTokenAddress = await pool.ASSET_TOKEN(); + const assetToken = new Contract(assetTokenAddress, ERC20_ABI, provider); + + const minDeposit = await pool.MIN_DEPOSIT(); + const userBalance = await assetToken.balanceOf(userAddress); + const allowance = await assetToken.allowance(userAddress, poolAddress); + + const errors: string[] = []; + if (amount > userBalance) errors.push("Insufficient balance"); + if (amount < minDeposit) errors.push(`Amount below minimum deposit: ${minDeposit}`); + if (allowance < amount) errors.push("Insufficient allowance. Please approve first."); + + return { canDeposit: errors.length === 0, errors, minDeposit, userBalance, allowance }; +} +``` + +### Deposit All Balance + +Pass `type(uint256).max` to deposit the full balance: + +```typescript +const maxUint256 = BigInt("0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); +await pool.deposit(maxUint256, receiver, BigInt(0)); +``` + +### Important: Depositing cancels any active withdrawal request. + +### Error Messages + +```typescript +const ERROR_MESSAGES: Record = { + DepositZeroAmount: "Cannot deposit zero amount", + DepositAmountLessThanMinimum: "Amount below minimum deposit", + InvalidReceiver: "Invalid receiver address", + "ERC20: insufficient allowance": "Please approve token first", + "ERC20: transfer amount exceeds balance": "Insufficient balance", +}; +``` + +--- + +## Reading Deposits + +### Method 1: Contract Query (Real-time, Always Accurate) + +```typescript +async function getStabilityPoolDeposit(poolAddress: string, userAddress: string, provider: any) { + const pool = new Contract(poolAddress, STABILITY_POOL_ABI, provider); + const balance = await pool.assetBalanceOf(userAddress); + const totalSupply = await pool.totalAssetSupply(); + const [start, end] = await pool.getWithdrawalRequest(userAddress); + + return { + balance, + balanceUSD: parseFloat(balance.toString()) / 1e18, + totalSupply, + withdrawalRequest: start > 0 ? { start, end } : null, + }; +} +``` + +### Method 2: Subgraph Query (Includes Marks and History) + +```graphql +query GetStabilityPoolDeposits($userAddress: Bytes!) { + stabilityPoolDeposits(where: { user: $userAddress }) { + id + poolAddress + poolType # "collateral" or "sail" + balance # BigInt, 18 decimals + balanceUSD # BigDecimal + accumulatedMarks + marksPerDay + totalMarksEarned + firstDepositAt + lastUpdated + } +} +``` + +### Recommended: Use both -- contract for real-time balance, subgraph for marks and historical data. + +### Filter by Pool Type + +```graphql +# Collateral pool only +stabilityPoolDeposits(where: { user: $userAddress, poolType: "collateral" }) + +# Leveraged pool only +stabilityPoolDeposits(where: { user: $userAddress, poolType: "sail" }) +``` + +### Real-Time Marks Estimation (Zero Gas) + +```typescript +function calculateEstimatedStabilityPoolMarks(deposit: StabilityPoolDeposit): number { + const storedMarks = parseFloat(deposit.accumulatedMarks || "0"); + const marksPerDay = parseFloat(deposit.marksPerDay || "0"); + const lastUpdated = parseInt(deposit.lastUpdated || "0"); + + if (lastUpdated === 0 || marksPerDay === 0) return storedMarks; + + const now = Math.floor(Date.now() / 1000); + const daysSinceUpdate = (now - lastUpdated) / 86400; + return storedMarks + marksPerDay * daysSinceUpdate; +} +``` + +**Always use lowercase addresses in GraphQL queries:** `userAddress.toLowerCase()` + +--- + +## Withdrawal Requests + +### How the Withdrawal Window Works + +1. User calls `requestWithdrawal()` to create a request +2. Wait `WITHDRAWAL_START_DELAY` seconds +3. Fee-free window opens for `WITHDRAWAL_END_WINDOW` seconds +4. After the window closes, the early withdrawal fee applies again + +### Fee Rules + +- **Before window starts**: Early withdrawal fee applies +- **During window [start, end]**: No fee +- **After window ends**: Early withdrawal fee applies again + +### Key Behaviors + +- **Depositing cancels the request**: If user deposits during an active window, the request is cancelled +- **Withdrawal clears the request**: After withdrawing, the request window is cleared +- **No request needed**: Users can withdraw at any time, but will pay the fee outside the window + +### Withdrawal Request Status + +```typescript +async function getWithdrawalRequestStatus(poolAddress: string, userAddress: string, provider: any) { + const pool = new Contract(poolAddress, STABILITY_POOL_ABI, provider); + const [start, end] = await pool.getWithdrawalRequest(userAddress); + const now = BigInt(Math.floor(Date.now() / 1000)); + + const hasRequest = start > 0 && end > start; + let status: "none" | "waiting" | "active" | "expired" = "none"; + let canWithdrawFeeFree = false; + + if (hasRequest) { + if (now < start) status = "waiting"; + else if (now >= start && now <= end) { status = "active"; canWithdrawFeeFree = true; } + else status = "expired"; + } + + return { + hasRequest, start: hasRequest ? start : null, end: hasRequest ? end : null, + status, canWithdrawFeeFree, + timeUntilStart: hasRequest && now < start ? Number(start - now) : null, + timeUntilEnd: hasRequest && now >= start && now <= end ? Number(end - now) : null, + }; +} +``` + +### Withdrawal Fee Calculation + +```typescript +function calculateWithdrawalFee(amount: bigint, earlyWithdrawalFee: bigint, canWithdrawFeeFree: boolean) { + if (canWithdrawFeeFree) return { feeAmount: 0n, netAmount: amount, feePercentage: 0 }; + + const feeAmount = (amount * earlyWithdrawalFee) / BigInt("1000000000000000000"); + return { + feeAmount, + netAmount: amount - feeAmount, + feePercentage: Number(earlyWithdrawalFee) / 1e18 * 100, + }; +} +``` + +### Time Formatting Utility + +```typescript +function formatTimeRemaining(seconds: number): string { + if (seconds <= 0) return "Now"; + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + + const parts: string[] = []; + if (days > 0) parts.push(`${days}d`); + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0) parts.push(`${minutes}m`); + return parts.join(" ") || "Now"; +} +``` + +--- + +## Rewards Display + +### Finding Registered Reward Tokens + +```typescript +const rewardTokens = await stabilityPool.activeRewardTokens(); +``` + +### Getting Claimable Rewards + +```typescript +async function getAllClaimableRewards(stabilityPool: Contract, userAddress: string, tokenPriceMap: Map) { + const rewardTokens = await stabilityPool.activeRewardTokens(); + const claimableRewards = []; + + for (const token of rewardTokens) { + const claimable = await stabilityPool.claimable(userAddress, token); + if (claimable > 0n) { + const tokenContract = new Contract(token, ERC20_ABI, provider); + const symbol = await tokenContract.symbol(); + const price = tokenPriceMap.get(token.toLowerCase()) || 0; + const amountFormatted = formatEther(claimable); + + claimableRewards.push({ + token, symbol, amount: claimable, amountFormatted, + usdValue: parseFloat(amountFormatted) * price, + }); + } + } + return claimableRewards; +} +``` + +### Reward Data + +```typescript +interface RewardData { + lastUpdate: bigint; + finishAt: bigint; + rate: bigint; // rewards per second + queued: bigint; // queued rewards for next period +} + +const [lastUpdate, finishAt, rate, queued] = await stabilityPool.rewardData(rewardTokenAddress); +``` + +### Reward Period + +Rewards vest over `REWARD_PERIOD_LENGTH` (typically 604800 seconds = 7 days). The `rate` represents rewards per second during the active period. + +- **Pending**: Rewards being distributed but not yet fully claimable +- **Claimable**: Rewards available to claim now (returned by `claimable()`) +- A pool can have multiple reward tokens simultaneously + +### Performance: Batch Queries + +Cache reward token list and symbols (change infrequently). Refresh claimable amounts every 30-60 seconds, APR every 5-10 minutes. diff --git a/doc/frontend/tokens.md b/doc/frontend/tokens.md new file mode 100644 index 00000000..68a1d745 --- /dev/null +++ b/doc/frontend/tokens.md @@ -0,0 +1,180 @@ +# Reward Tokens, Rates, and Sail Token + +## Querying Reward Tokens and Rates + +### Key Functions + +```solidity +// Get all active reward token addresses +function activeRewardTokens() external view returns (address[] memory); + +// Get reward configuration for a specific token +function rewardData(address token) external view returns ( + uint256 rate, // Reward rate in wei per second + uint256 period, // Vesting period in seconds + uint256 finishTime, // When current period ends + uint256 lastUpdateTime // Last update timestamp +); +``` + +### Implementation + +```typescript +const STABILITY_POOL_ABI = [ + "function activeRewardTokens() view returns (address[])", + "function rewardData(address) view returns (uint256, uint256, uint256, uint256)", +]; + +async function getAllRewardTokensWithMetadata(poolAddress: string, provider: ethers.Provider) { + const pool = new Contract(poolAddress, STABILITY_POOL_ABI, provider); + const tokenAddresses = await pool.activeRewardTokens(); + const currentBlock = await provider.getBlock("latest"); + const currentTime = currentBlock?.timestamp || Math.floor(Date.now() / 1000); + + return Promise.all(tokenAddresses.map(async (tokenAddress) => { + const [rate, period, finishTime, lastUpdateTime] = await pool.rewardData(tokenAddress); + const tokenContract = new Contract(tokenAddress, ERC20_ABI, provider); + const [symbol, name, decimals] = await Promise.all([ + tokenContract.symbol(), tokenContract.name(), tokenContract.decimals(), + ]); + + const ratePerDay = Number(ethers.formatUnits(rate, decimals)) * 86400; + const ratePerYear = Number(ethers.formatUnits(rate, decimals)) * 31536000; + + return { + address: tokenAddress, symbol, name, decimals: Number(decimals), + rate, ratePerDay, ratePerYear, + period: Number(period), periodDays: Number(period) / 86400, + finishTime: Number(finishTime), lastUpdateTime: Number(lastUpdateTime), + isActive: Number(finishTime) > currentTime, + }; + })); +} +``` + +### Calculating APR per Reward Token + +```typescript +async function calculateAPR( + rewardToken: { ratePerYear: number }, + totalAssetSupply: bigint, + rewardTokenPriceUSD: number, + assetTokenPriceUSD: number, +): number { + const annualRewardUSD = rewardToken.ratePerYear * rewardTokenPriceUSD; + const totalDepositUSD = Number(ethers.formatEther(totalAssetSupply)) * assetTokenPriceUSD; + if (totalDepositUSD === 0) return 0; + return (annualRewardUSD / totalDepositUSD) * 100; +} +``` + +### Notes + +- `rate` is in wei per second. Convert using the token's decimals. +- Rewards vest linearly over `period` (typically 7 days = 604800 seconds). +- A token is active if `finishTime > currentTime`. After `finishTime`, rate becomes 0 unless new rewards are deposited. +- Pools can have multiple reward tokens simultaneously. +- Rate changes when new rewards are deposited, a vesting period ends, or rewards are fully distributed. + +--- + +## Sail Token (Leveraged Token) + +### Marks Earning + +Sail tokens (leveraged tokens, `hs` tokens) earn marks at **5x the rate** of ha tokens: + +| Token Type | Rate | +|-----------|------| +| Ha Tokens | 1 mark/dollar/day (1x) | +| Sail Tokens | 5 marks/dollar/day (5x) | + +The `marksPerDay` field from the subgraph **already includes the 5x multiplier**. Do not multiply again. + +### GraphQL Query + +```graphql +query GetSailTokenMarks($userAddress: Bytes!) { + sailTokenBalances(where: { user: $userAddress }) { + id + tokenAddress + balance + balanceUSD + accumulatedMarks + marksPerDay # Already includes 5x multiplier + lastUpdated + firstSeenAt + marketId + } +} +``` + +### Real-Time Marks Estimation + +```typescript +function calculateEstimatedSailMarks(balance: SailTokenBalance): number { + const storedMarks = parseFloat(balance.accumulatedMarks || "0"); + const marksPerDay = parseFloat(balance.marksPerDay || "0"); // Already 5x + const lastUpdated = parseInt(balance.lastUpdated || "0"); + + if (lastUpdated === 0 || marksPerDay === 0) return storedMarks; + + const now = Math.floor(Date.now() / 1000); + const daysSinceUpdate = (now - lastUpdated) / 86400; + return storedMarks + marksPerDay * daysSinceUpdate; +} +``` + +Poll subgraph every 60 seconds for on-chain events. Update estimation every 1 second for smooth display. + +### Example + +User holds $100,000 in sail tokens: +- `marksPerDay` = 500,000 (= $100,000 * 5) +- After 1 day: 500,000 marks +- After 2 days: 1,000,000 marks + +--- + +## Sail Token TVL + +### Approach 1: Contract Query (Recommended for Production) + +```typescript +async function getSailTokenTVL(tokenAddress: string, tokenPriceUSD: number, provider: any): Promise { + const tokenContract = new Contract(tokenAddress, ERC20_ABI, provider); + const totalSupply = await tokenContract.totalSupply(); + const totalSupplyTokens = parseFloat(totalSupply.toString()) / 1e18; + return totalSupplyTokens * tokenPriceUSD; +} +``` + +### Approach 2: Subgraph Aggregation + +```graphql +query GetSailTokenTVL($tokenAddress: Bytes!) { + sailTokenBalances(where: { tokenAddress: $tokenAddress, balance_gt: "0" }, first: 1000) { + balanceUSD + } +} +``` + +Sum all `balanceUSD` values. + +### TVL Formatting + +```typescript +function formatTVL(tvl: number): string { + if (tvl >= 1_000_000_000) return `$${(tvl / 1_000_000_000).toFixed(2)}B`; + if (tvl >= 1_000_000) return `$${(tvl / 1_000_000).toFixed(2)}M`; + if (tvl >= 1_000) return `$${(tvl / 1_000).toFixed(2)}K`; + return `$${tvl.toFixed(2)}`; +} +``` + +### Notes + +- Sail tokens use 18 decimals (standard ERC20) +- TVL changes when tokens are minted/burned; refresh every 30 seconds +- If multiple sail tokens exist (different markets), sum their TVLs +- Token price must be fetched separately (from price oracle or DEX) diff --git a/doc/frontend/troubleshooting.md b/doc/frontend/troubleshooting.md new file mode 100644 index 00000000..976c10ba --- /dev/null +++ b/doc/frontend/troubleshooting.md @@ -0,0 +1,300 @@ +# Frontend Troubleshooting + +## Network and Connection Issues + +### Wrong RPC URL + +Ensure your frontend uses the correct Anvil endpoint: + +```typescript +const RPC_URL = "http://localhost:8545"; +const CHAIN_ID = 31337; +``` + +### Wallet Not Connected to Anvil + +Add the network to MetaMask: + +```typescript +await window.ethereum.request({ + method: "wallet_addEthereumChain", + params: [{ + chainId: "0x7A69", // 31337 in hex + chainName: "Anvil Local", + nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 }, + rpcUrls: ["http://localhost:8545"], + blockExplorerUrls: [], + }], +}); +``` + +### Verify Connection + +```typescript +const provider = new ethers.providers.JsonRpcProvider("http://localhost:8545"); +const network = await provider.getNetwork(); +console.log("Chain ID:", network.chainId); // Should be 31337 +``` + +```bash +curl http://localhost:8545 -X POST -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"eth_chainId","params":[],"id":1}' +# Should return: {"result":"0x7a69"} +``` + +--- + +## `eth_sendRawTransaction` Does Not Exist + +### Cause + +The wallet is trying to use `eth_sendRawTransaction`, which Anvil may not support the same way as mainnet, or the frontend is configured incorrectly. + +### Fix: Use Wallet Provider, Not Raw Transactions + +```typescript +// Use wallet's signing mechanism +const provider = new ethers.providers.Web3Provider(window.ethereum); +const signer = provider.getSigner(); +const contract = new ethers.Contract(address, ABI, signer); +const tx = await contract.someFunction(); // Uses wallet signing +``` + +### Fix: wagmi Configuration + +```typescript +const { chains, publicClient } = configureChains( + [{ + id: 31337, + name: "Anvil Local", + network: "anvil", + nativeCurrency: { decimals: 18, name: "Ether", symbol: "ETH" }, + rpcUrls: { default: { http: ["http://localhost:8545"] } }, + }], + [jsonRpcProvider({ rpc: () => ({ http: "http://localhost:8545" }) })], +); +``` + +--- + +## Dry-Run Returns Empty Data (`0x`) + +### Cause + +When `redeemPeggedTokenDryRun()` returns empty data, it means the contract has no code at that address, the function does not exist, or you are on the wrong chain. + +### Diagnostic Steps + +```typescript +// 1. Check chain ID +const chainId = await publicClient.getChainId(); +if (chainId !== 31337) console.error("Wrong chain! Expected 31337, got", chainId); + +// 2. Check contract has code +const bytecode = await publicClient.getBytecode({ address: minterAddress }); +if (!bytecode || bytecode === "0x") console.error("No code at address"); + +// 3. Test function call +const result = await publicClient.readContract({ + address: minterAddress, + abi: [{ + name: "redeemPeggedTokenDryRun", + type: "function", + stateMutability: "view", + inputs: [{ name: "peggedIn", type: "uint256" }], + outputs: [ + { name: "incentiveRatio", type: "int256" }, + { name: "fee", type: "uint256" }, + { name: "discount", type: "uint256" }, + { name: "peggedRedeemed", type: "uint256" }, + { name: "wrappedCollateralReturned", type: "uint256" }, + { name: "price", type: "uint256" }, + { name: "rate", type: "uint256" }, + ], + }], + functionName: "redeemPeggedTokenDryRun", + args: [1n * 10n ** 18n], +}); +``` + +### Common Fixes + +1. **Wrong chain ID** (most common): Ensure chain ID is 31337 and RPC is `http://127.0.0.1:8545` +2. **Missing minter address in market config**: Set to `0x8A791620dd6260079BF849Dc5567aDC3F2FdC318` +3. **Incomplete ABI**: Must include all output types +4. **Amount not in wei**: Use `parseEther("1")` not `"1"` + +--- + +## Dry-Run Error: "Fee Unavailable" + +### Stale Price Feed (Most Common - 90% of Cases) + +Error: `StaleUnderlyingPrice` / `0xd2159c14` + +The price oracle checks that price feed data is fresh (`block.timestamp - updatedAt > maxAnswerAge`). Mock price feeds need manual updates. + +**Fix -- update price feeds:** + +```bash +# Update a mock Chainlink feed +cast send "setLatestAnswer(int256)" 200000000000 \ + --rpc-url http://localhost:8545 \ + --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 +``` + +Or run the script: `forge script script/forge/UpdateAllPriceFeeds.s.sol --rpc-url http://127.0.0.1:8545 --broadcast` + +### Invalid Price (Zero or Negative) + +Error: `InvalidUnderlyingPrice` + +Check the price feed value: + +```typescript +const [, answer] = await aggregator.latestRoundData(); +if (answer <= 0) throw new Error("Invalid price"); +``` + +### Price Deviation Too Large + +Error: `UnderlyingPriceDeviation` + +Price changed too much between rounds. Update price feeds more gradually. + +### Oracle Not Configured + +Check: `const oracle = await minter.priceOracle();` -- should not be zero address. + +### Frontend Error Handling + +```typescript +const collateralRatio = await publicClient.readContract({ + address: minterAddress, abi: minterABI, functionName: "collateralRatio", +}).catch((error) => { + if (error.message?.includes("0xd2159c14") || error.message?.includes("StaleUnderlyingPrice")) { + console.warn("Price feed is stale"); + return null; + } + throw error; +}); + +// Display "-" when unavailable +const displayRatio = collateralRatio ? formatRatio(collateralRatio) : "-"; +``` + +On mainnet, Chainlink updates feeds automatically. This is only an issue with mock feeds in local development. + +--- + +## Redeem Errors + +### Error `0x3dbf8ab9`: Zero Token Balance + +User has zero balance of the token being redeemed, or passed `type(uint256).max` with zero balance. + +```typescript +const userBalance = await peggedToken.balanceOf(userAddress); +if (userBalance === 0n) { + // Show: "You have no pegged tokens to redeem" + return; +} +``` + +### Insufficient Token Allowance (90% of Redeem Failures) + +Always check and request approval before redeeming: + +```typescript +const allowance = await peggedToken.allowance(userAddress, minterAddress); +if (allowance < redeemAmount) { + await peggedToken.approve(minterAddress, redeemAmount); +} +``` + +### Insufficient Redeemable Tokens in Minter + +Error: `InsufficientRedeemableTokens` + +```typescript +const minterBalance = await minter.peggedTokenBalance(); +if (redeemAmount > minterBalance) { + // Show: "Only X tokens available for redemption" +} +``` + +### Zero Collateral Returned + +Error: `ReturnZeroAmount` + +Fees exceed the redemption value or price oracle data is invalid. Always run a dry-run first: + +```typescript +const dryRun = await minter.redeemPeggedTokenDryRun(redeemAmount); +if (dryRun.wrappedCollateralReturned === 0n) { + // Show: "Redemption would return zero collateral" +} +``` + +### Complete Pre-Redemption Check + +Before allowing a redeem: + +- User has pegged token balance > 0 +- User has approved Minter to spend pegged tokens +- Minter has sufficient pegged token balance +- Dry-run returns non-zero collateral +- Amount is in wei (not human-readable) +- User is on the correct chain (31337) + +### Error Decoding + +```typescript +import { decodeErrorResult } from "viem"; + +try { + await writeContract({...}); +} catch (error: any) { + if (error.data) { + const decoded = decodeErrorResult({ abi: minterAbi, data: error.data }); + console.log("Decoded error:", decoded.errorName, decoded.args); + } +} +``` + +--- + +## Collateral Ratio Unavailable + +### Cause + +`collateralRatio()` reverts with `StaleUnderlyingPrice` when mock price feeds have stale timestamps. + +### Fix: Update All Price Feeds + +```bash +# wstETH/USD +cast send 0xeC827421505972a2AE9C320302d3573B42363C26 "setLatestAnswer(int256)" 200000000000 \ + --rpc-url http://localhost:8545 --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 + +# stETH/USD +cast send 0xb007167714e2940013ec3bb551584130b7497e22 "setLatestAnswer(int256)" 200000000000 \ + --rpc-url http://localhost:8545 --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 + +# stETH/ETH +cast send 0x6b39b761b1b64c8c095bf0e3bb0c6a74705b4788 "setLatestAnswer(int256)" 100000000 \ + --rpc-url http://localhost:8545 --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 +``` + +### Verify + +```bash +cast call "collateralRatio()(uint256)" --rpc-url http://localhost:8545 +# Expected: uint256 value (e.g., 2000000000000000000 for 2.0x) +``` + +### Prevention + +- Create a script that updates price feeds every few minutes +- Show "-" or "N/A" when collateral ratio is unavailable +- Log the error but do not break the UI diff --git a/doc/guides/ANCHOR-LEDGER-MARKS-EXPLANATION.md b/doc/guides/ANCHOR-LEDGER-MARKS-EXPLANATION.md deleted file mode 100644 index 250aa874..00000000 --- a/doc/guides/ANCHOR-LEDGER-MARKS-EXPLANATION.md +++ /dev/null @@ -1,132 +0,0 @@ -# Anchor Ledger Marks Explanation - -## What Are Anchor Ledger Marks? - -**Anchor Ledger Marks** represent marks earned from holding or depositing **ha tokens** (anchor tokens). They include: - -1. **Ha Token Holdings** (wallet balances) - - Holding ha tokens in your wallet - - Earns: 1 mark per dollar per day - - Tracked via: `haTokenBalances` entity - -2. **Stability Pool Deposits** (pool deposits) - - Depositing ha tokens in stability pools (collateral or sail pools) - - Earns: 1 mark per dollar per day - - Tracked via: `stabilityPoolDeposits` entity - -## Key Points - -- **Same Rate**: Both sources earn marks at the **same rate** (1 mark/dollar/day) -- **Combined Total**: "Anchor Ledger Marks" = Ha Token Marks + Stability Pool Marks -- **Separate Tracking**: Each source is tracked separately in the subgraph -- **Configurable Multipliers**: Each stability pool can have its own multiplier (currently all set to 1.0x) - -## Current Multipliers - -All sources use the same multiplier (1.0x): -- Ha tokens: 1.0x -- Stability Pool Collateral: 1.0x -- Stability Pool Sail: 1.0x - -## How to Query - -```graphql -query GetAnchorLedgerMarks($userAddress: Bytes!) { - haTokenBalances(where: { user: $userAddress }) { - accumulatedMarks - marksPerDay - } - stabilityPoolDeposits(where: { user: $userAddress }) { - accumulatedMarks - marksPerDay - poolType - } -} -``` - -Then sum: `totalAnchorLedgerMarks = haTokenMarks + stabilityPoolMarks` - -## Example - -User has: -- 200,000 ha tokens in wallet ($200,000 value) = 200,000 marks/day -- 100,000 ha tokens in stability pool ($100,000 value) = 100,000 marks/day - -**Total Anchor Ledger Marks/Day**: 300,000 marks/day -**Total Anchor Ledger Marks** (after 2 days): 600,000 marks - -## Notes - -- Stability pool deposits are tracked separately from ha token holdings -- Both earn at the same rate (1 mark/dollar/day) by default -- Multipliers can be configured per pool type in the future -- The subgraph tracks both sources independently for flexibility - - - -## What Are Anchor Ledger Marks? - -**Anchor Ledger Marks** represent marks earned from holding or depositing **ha tokens** (anchor tokens). They include: - -1. **Ha Token Holdings** (wallet balances) - - Holding ha tokens in your wallet - - Earns: 1 mark per dollar per day - - Tracked via: `haTokenBalances` entity - -2. **Stability Pool Deposits** (pool deposits) - - Depositing ha tokens in stability pools (collateral or sail pools) - - Earns: 1 mark per dollar per day - - Tracked via: `stabilityPoolDeposits` entity - -## Key Points - -- **Same Rate**: Both sources earn marks at the **same rate** (1 mark/dollar/day) -- **Combined Total**: "Anchor Ledger Marks" = Ha Token Marks + Stability Pool Marks -- **Separate Tracking**: Each source is tracked separately in the subgraph -- **Configurable Multipliers**: Each stability pool can have its own multiplier (currently all set to 1.0x) - -## Current Multipliers - -All sources use the same multiplier (1.0x): -- Ha tokens: 1.0x -- Stability Pool Collateral: 1.0x -- Stability Pool Sail: 1.0x - -## How to Query - -```graphql -query GetAnchorLedgerMarks($userAddress: Bytes!) { - haTokenBalances(where: { user: $userAddress }) { - accumulatedMarks - marksPerDay - } - stabilityPoolDeposits(where: { user: $userAddress }) { - accumulatedMarks - marksPerDay - poolType - } -} -``` - -Then sum: `totalAnchorLedgerMarks = haTokenMarks + stabilityPoolMarks` - -## Example - -User has: -- 200,000 ha tokens in wallet ($200,000 value) = 200,000 marks/day -- 100,000 ha tokens in stability pool ($100,000 value) = 100,000 marks/day - -**Total Anchor Ledger Marks/Day**: 300,000 marks/day -**Total Anchor Ledger Marks** (after 2 days): 600,000 marks - -## Notes - -- Stability pool deposits are tracked separately from ha token holdings -- Both earn at the same rate (1 mark/dollar/day) by default -- Multipliers can be configured per pool type in the future -- The subgraph tracks both sources independently for flexibility - - - - - diff --git a/doc/guides/CHAINLINK-MIN-MAX-REALITY.md b/doc/guides/CHAINLINK-MIN-MAX-REALITY.md deleted file mode 100644 index d6c369bb..00000000 --- a/doc/guides/CHAINLINK-MIN-MAX-REALITY.md +++ /dev/null @@ -1,208 +0,0 @@ -# Chainlink Min/Max Prices in Production - The Reality - -## You're Absolutely Right! ✅ - -In production with Chainlink price feeds, **min and max will be exactly the same** (or so close they're effectively identical). - -## Why? - -### 1. **Chainlink Provides a Single Price** -Chainlink's `latestRoundData()` returns: -- **One price** (`answer`) -- **One timestamp** (`updatedAt`) -- **One round ID** - -There's no built-in min/max spread from Chainlink itself. - -### 2. **Current Implementation** -Looking at `StakedETHWrappedPriceOracle_v1.sol` line 73: -```solidity -minUnderlyingPrice = maxUnderlyingPrice = PriceOracle_v1.latestAnswer(feed, constraints); -``` - -Both are set to the **exact same Chainlink price**. There's no spread logic. - -### 3. **The Math** -- **Min Price** = Chainlink price -- **Max Price** = Chainlink price -- **Mid Price** = (Min + Max) / 2 = **Same Chainlink price** - -So in practice: -- `_fetchMin()` = Chainlink price -- `_fetchMid()` = Chainlink price -- `_fetchMax()` = Chainlink price - -**All three return the same value!** - -## Why Does the Design Support Min/Max? - -The min/max design is there for **future flexibility**, not current functionality: - -### Potential Future Uses: - -1. **Multiple Price Feeds** - - Could query multiple Chainlink feeds (e.g., stETH/USD from different sources) - - Take min across all feeds (most conservative) - - Take max across all feeds (most optimistic) - -2. **Price Spreads/Buffers** - - Could apply a small spread (e.g., ±0.1%) to account for: - - Slippage - - Market volatility - - Safety margins - -3. **Bid/Ask Prices** - - Could integrate with a DEX aggregator to get bid/ask spreads - - Min = bid price (what you can sell for) - - Max = ask price (what you can buy for) - -4. **Price Uncertainty** - - Could use historical volatility to create a confidence interval - - Min = price - uncertainty - - Max = price + uncertainty - -## Current Reality - -**Right now:** -- ✅ Single Chainlink feed -- ✅ No spread logic -- ✅ Min = Max = Chainlink price -- ✅ All three fetch functions return the same value - -**So why use different functions?** -- **Code clarity**: Makes intent clear (conservative vs generous) -- **Future-proofing**: Easy to add spread logic later -- **Consistent API**: Same interface whether min/max differ or not - -## Impact on Liquidation Rewards - -Since min = max in production: -- **Liquidation using `_fetchMax()`** = Same price as normal operations -- **The "favorable rate" benefit is minimal** (just the no-fees benefit remains) - -The real benefits of liquidation rewards come from: -1. ✅ **No fees** (vs normal redemption which has fees) -2. ✅ **System health improvement** (remaining deposit becomes more valuable) -3. ⚠️ **Max price** (currently same as mid, but could be different in future) - -## Summary - -| Question | Answer | -|----------|--------| -| **Are min and max the same in production?** | ✅ Yes, exactly the same | -| **Why does the code support min/max?** | Future flexibility | -| **Does liquidation get a better price?** | Currently no (same price), but no fees | -| **Could min/max differ in the future?** | Yes, if spread logic is added | - -## Bottom Line - -You're correct - with Chainlink feeds, min and max are **identical in practice**. The design supports min/max for future enhancements, but currently all three price types (`_fetchMin`, `_fetchMid`, `_fetchMax`) return the same Chainlink price. - -The liquidation reward advantage comes from **no fees** and **system health improvement**, not from a price difference (since there isn't one currently). - - - -## You're Absolutely Right! ✅ - -In production with Chainlink price feeds, **min and max will be exactly the same** (or so close they're effectively identical). - -## Why? - -### 1. **Chainlink Provides a Single Price** -Chainlink's `latestRoundData()` returns: -- **One price** (`answer`) -- **One timestamp** (`updatedAt`) -- **One round ID** - -There's no built-in min/max spread from Chainlink itself. - -### 2. **Current Implementation** -Looking at `StakedETHWrappedPriceOracle_v1.sol` line 73: -```solidity -minUnderlyingPrice = maxUnderlyingPrice = PriceOracle_v1.latestAnswer(feed, constraints); -``` - -Both are set to the **exact same Chainlink price**. There's no spread logic. - -### 3. **The Math** -- **Min Price** = Chainlink price -- **Max Price** = Chainlink price -- **Mid Price** = (Min + Max) / 2 = **Same Chainlink price** - -So in practice: -- `_fetchMin()` = Chainlink price -- `_fetchMid()` = Chainlink price -- `_fetchMax()` = Chainlink price - -**All three return the same value!** - -## Why Does the Design Support Min/Max? - -The min/max design is there for **future flexibility**, not current functionality: - -### Potential Future Uses: - -1. **Multiple Price Feeds** - - Could query multiple Chainlink feeds (e.g., stETH/USD from different sources) - - Take min across all feeds (most conservative) - - Take max across all feeds (most optimistic) - -2. **Price Spreads/Buffers** - - Could apply a small spread (e.g., ±0.1%) to account for: - - Slippage - - Market volatility - - Safety margins - -3. **Bid/Ask Prices** - - Could integrate with a DEX aggregator to get bid/ask spreads - - Min = bid price (what you can sell for) - - Max = ask price (what you can buy for) - -4. **Price Uncertainty** - - Could use historical volatility to create a confidence interval - - Min = price - uncertainty - - Max = price + uncertainty - -## Current Reality - -**Right now:** -- ✅ Single Chainlink feed -- ✅ No spread logic -- ✅ Min = Max = Chainlink price -- ✅ All three fetch functions return the same value - -**So why use different functions?** -- **Code clarity**: Makes intent clear (conservative vs generous) -- **Future-proofing**: Easy to add spread logic later -- **Consistent API**: Same interface whether min/max differ or not - -## Impact on Liquidation Rewards - -Since min = max in production: -- **Liquidation using `_fetchMax()`** = Same price as normal operations -- **The "favorable rate" benefit is minimal** (just the no-fees benefit remains) - -The real benefits of liquidation rewards come from: -1. ✅ **No fees** (vs normal redemption which has fees) -2. ✅ **System health improvement** (remaining deposit becomes more valuable) -3. ⚠️ **Max price** (currently same as mid, but could be different in future) - -## Summary - -| Question | Answer | -|----------|--------| -| **Are min and max the same in production?** | ✅ Yes, exactly the same | -| **Why does the code support min/max?** | Future flexibility | -| **Does liquidation get a better price?** | Currently no (same price), but no fees | -| **Could min/max differ in the future?** | Yes, if spread logic is added | - -## Bottom Line - -You're correct - with Chainlink feeds, min and max are **identical in practice**. The design supports min/max for future enhancements, but currently all three price types (`_fetchMin`, `_fetchMid`, `_fetchMax`) return the same Chainlink price. - -The liquidation reward advantage comes from **no fees** and **system health improvement**, not from a price difference (since there isn't one currently). - - - - - diff --git a/doc/guides/CHECK-HARVESTABLE.md b/doc/guides/CHECK-HARVESTABLE.md deleted file mode 100644 index 0193b11d..00000000 --- a/doc/guides/CHECK-HARVESTABLE.md +++ /dev/null @@ -1,189 +0,0 @@ -# How to Check Harvestable Amount - -## Quick Answer - -To check how much would be harvested, call `harvestable()` on either: -1. **Minter contract** - Returns the raw harvestable amount -2. **StabilityPoolManager contract** - Also returns harvestable (calls Minter internally) - -## Method 1: Using cast (Command Line) - -```bash -# If you have the Minter address -cast call "harvestable()(uint256)" --rpc-url http://localhost:8545 - -# Or using StabilityPoolManager -cast call "harvestable()(uint256)" --rpc-url http://localhost:8545 -``` - -**Example:** -```bash -# Get the amount in wei (18 decimals) -cast call 0x8A791620dd6260079BF849Dc5567aDC3F2FdC318 "harvestable()(uint256)" --rpc-url http://localhost:8545 - -# Convert to human-readable (divide by 1e18) -cast call 0x8A791620dd6260079BF849Dc5567aDC3F2FdC318 "harvestable()(uint256)" --rpc-url http://localhost:8545 | cast --to-unit eth -``` - -## Method 2: Using TypeScript/JavaScript - -```typescript -import { Contract } from "ethers"; - -const MINTER_ABI = [ - "function harvestable() external view returns (uint256 wrappedAmount)", -] as const; - -async function getHarvestableAmount( - minterAddress: string, - provider: any -): Promise<{ - amount: bigint; - amountFormatted: string; -}> { - const minter = new Contract(minterAddress, MINTER_ABI, provider); - const harvestable = await minter.harvestable(); - - return { - amount: harvestable, - amountFormatted: formatEther(harvestable), // Converts from wei to ether - }; -} -``` - -## Method 3: Using React Hook (wagmi) - -```typescript -import { useContractRead } from "wagmi"; - -function useHarvestable(minterAddress: string) { - const { data: harvestable, isLoading, error } = useContractRead({ - address: minterAddress as `0x${string}`, - abi: [ - { - name: "harvestable", - type: "function", - stateMutability: "view", - inputs: [], - outputs: [{ name: "wrappedAmount", type: "uint256" }], - }, - ], - functionName: "harvestable", - }); - - return { - harvestable: harvestable || 0n, - harvestableFormatted: harvestable ? formatEther(harvestable) : "0", - isLoading, - error, - }; -} -``` - -## What Does `harvestable()` Return? - -The function returns the **amount of wrapped collateral tokens** (wstETH) that have accumulated as yield and can be harvested. - -### How It's Calculated - -```solidity -function harvestable() external view returns (uint256 wrappedAmount) { - // Gets current wstETH balance of Minter - uint256 balance = IERC20(WRAPPED_COLLATERAL_TOKEN).balanceOf(address(this)); - - // Gets the current rate (stETH per wstETH) - uint256 rate = _fetchMid($.priceOracle).rate; - - // Calculates underlying collateral - uint256 underlyingCollateral = (balance * 1e18) / rate; - - // Harvestable = current balance - (underlying collateral / rate) - // This represents the yield that has accumulated - wrappedAmount = balance - (underlyingCollateral / rate); -} -``` - -**In simple terms:** -- The Minter holds wstETH -- Over time, the wstETH rate increases (staking rewards) -- The "harvestable" amount is the difference between: - - Current wstETH balance - - The original underlying collateral converted back to wstETH at current rate - -## Example Output - -If you call `harvestable()` and get: -``` -1000000000000000000000 // 1000 * 10^18 (1000 wstETH in wei) -``` - -This means **1000 wstETH** is currently harvestable. - -## What Happens When You Harvest? - -When `harvest()` is called on StabilityPoolManager: - -1. **Total Harvestable**: 1000 wstETH (example) -2. **Bounty** (e.g., 5%): 50 wstETH → goes to harvester -3. **Cut** (e.g., 10%): 100 wstETH → goes to fee receiver -4. **Remainder** (85%): 850 wstETH → **automatically deposited to stability pools** - -The remainder is split between: -- **Collateral Pool**: Based on proportion of total deposits -- **Leveraged Pool**: Remaining amount - -## Check Current State - -To see the full breakdown of what would be harvested: - -```typescript -async function getHarvestBreakdown( - minterAddress: string, - stabilityPoolManagerAddress: string, - provider: any -): Promise<{ - totalHarvestable: bigint; - bountyRatio: bigint; - cutRatio: bigint; - bountyAmount: bigint; - cutAmount: bigint; - remainderForPools: bigint; -}> { - const minter = new Contract(minterAddress, MINTER_ABI, provider); - const manager = new Contract( - stabilityPoolManagerAddress, - [ - "function harvestBountyRatio() view returns (uint256)", - "function harvestCutRatio() view returns (uint256)", - ], - provider - ); - - const totalHarvestable = await minter.harvestable(); - const bountyRatio = await manager.harvestBountyRatio(); - const cutRatio = await manager.harvestCutRatio(); - - const bountyAmount = (totalHarvestable * bountyRatio) / ethers.parseEther("1"); - const cutAmount = (totalHarvestable * cutRatio) / ethers.parseEther("1"); - const remainderForPools = totalHarvestable - bountyAmount - cutAmount; - - return { - totalHarvestable, - bountyRatio, - cutRatio, - bountyAmount, - cutAmount, - remainderForPools, - }; -} -``` - -## Notes - -- **Harvestable grows over time** as staking rewards accumulate -- The amount is in **wrapped collateral tokens** (wstETH), not underlying (stETH) -- Returns **0** if no yield has accumulated yet -- The amount represents **accrued yield**, not the total collateral held - - - diff --git a/doc/guides/CURRENT-STATUS.md b/doc/guides/CURRENT-STATUS.md deleted file mode 100644 index 0ec02beb..00000000 --- a/doc/guides/CURRENT-STATUS.md +++ /dev/null @@ -1,142 +0,0 @@ -# Current System Status - After Cursor Restart - -## ✅ Services Running - -- **Anvil**: http://localhost:8545 (Current Block: 84) -- **Graph Node**: http://localhost:8000 -- **Docker Services**: Running (PostgreSQL, IPFS, Graph Node) - -## 📋 Contract Addresses (Clean Chain Deployment) - -### Main Contracts -- **Genesis**: `0x67d269191c92Caf3cD7723F116c85e6E9bf55933` - - Owner: `0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e` ✅ - - Status: Deployed and verified - -- **Minter**: `0x4A679253410272dd5232B3Ff7cF5dbB88f295319` - - Status: Deployed and verified - -### Token Contracts -- **Mock stETH**: `0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9` -- **Mock wstETH**: `0x5FC8d32690cc91D4c39d9d3abcBD16989F875707` - -### Developer Account -- **Address**: `0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e` -- **Token Balances**: - - stETH: 1000 tokens ✅ - - wstETH: 1000 tokens ✅ - -## 📊 Subgraph Status - -**3 subgraphs currently deployed:** -1. Subgraph 1: Block 23829228 | Health: healthy -2. Subgraph 2: Block 23829249 | Health: healthy -3. Subgraph 3: Block 29 | Health: healthy - -**Note**: The subgraph at block 29 is likely the one for the clean chain, but it may need to be updated with the correct Genesis address (`0x67d269191c92Caf3cD7723F116c85e6E9bf55933`). - -## 🔧 Next Steps - -1. **Verify Subgraph Configuration** - - Check if subgraph is pointing to the correct Genesis address - - Update `startBlock` if needed (Genesis was deployed early in the chain) - -2. **Test Contract Functionality** - - Make a test deposit to Genesis using mock wstETH - - Verify events are being indexed - -3. **Monitor Indexing** - - Check if new events are being processed - - Verify GraphQL queries return expected data - -## 📝 Configuration Files - -- **Frontend Config**: `FRONTEND-CONFIG-CLEAN-CHAIN.txt` -- **Token Config**: `lib/bao-base/script/bcinfo.local.json` -- **Network**: anvil (Chain ID: 31337) -- **RPC URL**: http://localhost:8545 - -## 🔗 GraphQL Endpoint - -**GraphQL**: `http://localhost:8000/subgraphs/name/harbor-marks-local/graphql` - ---- - -**Last Updated**: After Cursor restart -**Chain**: Clean Anvil (no fork) -**Current Block**: 84 - - - - -## ✅ Services Running - -- **Anvil**: http://localhost:8545 (Current Block: 84) -- **Graph Node**: http://localhost:8000 -- **Docker Services**: Running (PostgreSQL, IPFS, Graph Node) - -## 📋 Contract Addresses (Clean Chain Deployment) - -### Main Contracts -- **Genesis**: `0x67d269191c92Caf3cD7723F116c85e6E9bf55933` - - Owner: `0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e` ✅ - - Status: Deployed and verified - -- **Minter**: `0x4A679253410272dd5232B3Ff7cF5dbB88f295319` - - Status: Deployed and verified - -### Token Contracts -- **Mock stETH**: `0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9` -- **Mock wstETH**: `0x5FC8d32690cc91D4c39d9d3abcBD16989F875707` - -### Developer Account -- **Address**: `0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e` -- **Token Balances**: - - stETH: 1000 tokens ✅ - - wstETH: 1000 tokens ✅ - -## 📊 Subgraph Status - -**3 subgraphs currently deployed:** -1. Subgraph 1: Block 23829228 | Health: healthy -2. Subgraph 2: Block 23829249 | Health: healthy -3. Subgraph 3: Block 29 | Health: healthy - -**Note**: The subgraph at block 29 is likely the one for the clean chain, but it may need to be updated with the correct Genesis address (`0x67d269191c92Caf3cD7723F116c85e6E9bf55933`). - -## 🔧 Next Steps - -1. **Verify Subgraph Configuration** - - Check if subgraph is pointing to the correct Genesis address - - Update `startBlock` if needed (Genesis was deployed early in the chain) - -2. **Test Contract Functionality** - - Make a test deposit to Genesis using mock wstETH - - Verify events are being indexed - -3. **Monitor Indexing** - - Check if new events are being processed - - Verify GraphQL queries return expected data - -## 📝 Configuration Files - -- **Frontend Config**: `FRONTEND-CONFIG-CLEAN-CHAIN.txt` -- **Token Config**: `lib/bao-base/script/bcinfo.local.json` -- **Network**: anvil (Chain ID: 31337) -- **RPC URL**: http://localhost:8545 - -## 🔗 GraphQL Endpoint - -**GraphQL**: `http://localhost:8000/subgraphs/name/harbor-marks-local/graphql` - ---- - -**Last Updated**: After Cursor restart -**Chain**: Clean Anvil (no fork) -**Current Block**: 84 - - - - - - diff --git a/doc/guides/DAILY-POLL-SIMULATION.md b/doc/guides/DAILY-POLL-SIMULATION.md deleted file mode 100644 index f3f66578..00000000 --- a/doc/guides/DAILY-POLL-SIMULATION.md +++ /dev/null @@ -1,132 +0,0 @@ -# Daily Poll Simulation - Summary - -## What We Did - -1. **Advanced Time**: Used `anvil_increaseTime 86400` to advance time by 1 day (86400 seconds) -2. **Triggered Transfer**: Sent a 1 wei transfer from the user account to trigger the handler -3. **Transfer Success**: Transfer was successful at block 161 - -## Current Status - -- **Transfer Event**: ✅ Successfully created at block 161 - - From: `0xae7dbb17bc40d53a6363409c6b1ed88d3cfdc31e` - - To: `0x1111111111111111111111111111111111111111` - - Value: 1 wei - - Block: 161 - -- **Subgraph Indexing**: ⏳ Stuck at block 158 - - Needs to catch up to block 161 to process the transfer - - Once indexed, marks should be calculated for 1 full day - -## Expected Result - -Once the subgraph indexes block 161, the handler should: - -1. **Process Transfer Event**: Detect the transfer from the user -2. **Calculate Marks**: - - Time since last update: ~1 day (86400+ seconds) - - Full days: 1 day - - Balance: 200,000 haPB tokens - - Balance USD: $200,000 (assuming $1 per token) - - Marks per day: 200,000 marks/day (1 mark per dollar per day) - - **Accumulated marks: 200,000 marks** (for 1 full day) - -3. **Update Snapshot**: - - `lastUpdated`: Updated to start of current day - - `balance`: 200,000 tokens (minus 1 wei) - - `balanceUSD`: ~$200,000 - -## Next Steps - -1. Wait for subgraph to catch up to block 161 -2. Query ha token balances to verify marks accumulation -3. If subgraph is stuck, may need to restart Graph Node or redeploy subgraph - -## Query to Check Results - -```graphql -{ - haTokenBalances(where: {user: "0xae7dbb17bc40d53a6363409c6b1ed88d3cfdc31e"}) { - id - balance - balanceUSD - accumulatedMarks - marksPerDay - lastUpdated - } -} -``` - -Expected after indexing: -- `accumulatedMarks`: Should be ~200,000 (for 1 full day) -- `marksPerDay`: Should be ~200,000 (current rate) -- `lastUpdated`: Should be updated to block 161 timestamp - - - -## What We Did - -1. **Advanced Time**: Used `anvil_increaseTime 86400` to advance time by 1 day (86400 seconds) -2. **Triggered Transfer**: Sent a 1 wei transfer from the user account to trigger the handler -3. **Transfer Success**: Transfer was successful at block 161 - -## Current Status - -- **Transfer Event**: ✅ Successfully created at block 161 - - From: `0xae7dbb17bc40d53a6363409c6b1ed88d3cfdc31e` - - To: `0x1111111111111111111111111111111111111111` - - Value: 1 wei - - Block: 161 - -- **Subgraph Indexing**: ⏳ Stuck at block 158 - - Needs to catch up to block 161 to process the transfer - - Once indexed, marks should be calculated for 1 full day - -## Expected Result - -Once the subgraph indexes block 161, the handler should: - -1. **Process Transfer Event**: Detect the transfer from the user -2. **Calculate Marks**: - - Time since last update: ~1 day (86400+ seconds) - - Full days: 1 day - - Balance: 200,000 haPB tokens - - Balance USD: $200,000 (assuming $1 per token) - - Marks per day: 200,000 marks/day (1 mark per dollar per day) - - **Accumulated marks: 200,000 marks** (for 1 full day) - -3. **Update Snapshot**: - - `lastUpdated`: Updated to start of current day - - `balance`: 200,000 tokens (minus 1 wei) - - `balanceUSD`: ~$200,000 - -## Next Steps - -1. Wait for subgraph to catch up to block 161 -2. Query ha token balances to verify marks accumulation -3. If subgraph is stuck, may need to restart Graph Node or redeploy subgraph - -## Query to Check Results - -```graphql -{ - haTokenBalances(where: {user: "0xae7dbb17bc40d53a6363409c6b1ed88d3cfdc31e"}) { - id - balance - balanceUSD - accumulatedMarks - marksPerDay - lastUpdated - } -} -``` - -Expected after indexing: -- `accumulatedMarks`: Should be ~200,000 (for 1 full day) -- `marksPerDay`: Should be ~200,000 (current rate) -- `lastUpdated`: Should be updated to block 161 timestamp - - - - - diff --git a/doc/guides/DAILY-SNAPSHOT-APPROACH.md b/doc/guides/DAILY-SNAPSHOT-APPROACH.md deleted file mode 100644 index b19d0f6d..00000000 --- a/doc/guides/DAILY-SNAPSHOT-APPROACH.md +++ /dev/null @@ -1,196 +0,0 @@ -# Daily Snapshot Approach for Ha Token Marks - -## Overview - -We've simplified the ha token marks calculation to use a **daily snapshot approach**, which approximates polling balances once per day and awarding marks accordingly. - -## How It Works - -### 1. Event-Driven Balance Updates -- The subgraph tracks `Transfer` events for ha tokens -- On each transfer, we query the current balance from the contract -- This gives us a "snapshot" of the balance at that moment - -### 2. Daily Marks Accumulation -- Marks are calculated based on **full days** since the last snapshot -- If someone holds tokens for 1.5 days, they get marks for 1 full day -- The balance used for calculation is the balance from the last snapshot (the balance held for those days) - -### 3. Snapshot Timing -- `lastUpdated` tracks when the last snapshot was taken -- When marks are accumulated, `lastUpdated` is updated to the start of the current day -- This ensures we only count full days going forward - -## Example - -**Day 1 (Block 100, 10:00 AM):** -- User receives 200,000 haPB tokens -- Balance snapshot: 200,000 tokens -- `lastUpdated`: Block 100 timestamp -- Marks accumulated: 0 - -**Day 2 (Block 200, 2:00 PM - 1.2 days later):** -- User still holds 200,000 haPB tokens -- Transfer event occurs -- Calculate: 1.2 days since last update → 1 full day -- Marks for 1 day: 200,000 tokens × $1 × 1 mark/dollar/day × 1 day = 200,000 marks -- `lastUpdated`: Updated to start of Day 2 -- Balance snapshot: 200,000 tokens (unchanged) - -**Day 3 (Block 300, 11:00 AM - 0.9 days later):** -- User still holds 200,000 haPB tokens -- Transfer event occurs -- Calculate: 0.9 days since last update → 0 full days -- Marks accumulated: 0 (less than 1 full day) -- `lastUpdated`: Remains at start of Day 2 -- Balance snapshot: 200,000 tokens - -**Day 4 (Block 400, 3:00 PM - 1.1 days later):** -- User still holds 200,000 haPB tokens -- Transfer event occurs -- Calculate: 1.1 days since last update → 1 full day -- Marks for 1 day: 200,000 marks -- Total accumulated: 400,000 marks -- `lastUpdated`: Updated to start of Day 4 - -## Benefits - -1. **Simpler Logic**: No complex time calculations, just count full days -2. **Event-Driven**: Works with subgraph's event-driven architecture -3. **Fair**: Users get marks for full days they held tokens -4. **Efficient**: Only calculates when transfers occur (balance changes) - -## Implementation Details - -### `accumulateMarks()` Function -- Calculates full days since `lastUpdated` -- Awards marks based on `balanceUSD` from last snapshot -- Updates `lastUpdated` to start of current day - -### `handleHaTokenTransfer()` Handler -- Called on every Transfer event -- Accumulates marks for full days -- Updates balance snapshot from contract -- Resets snapshot time if balance goes to zero - -## Querying Marks - -```graphql -{ - haTokenBalances(where: {user: "0x..."}) { - balance - balanceUSD - accumulatedMarks - marksPerDay - lastUpdated - } -} -``` - -## Notes - -- Marks accumulate in **full day increments only** -- If no transfers occur for a long time, marks won't accumulate until the next transfer -- This is intentional - it approximates "polling once per day" -- For more frequent updates, users would need to trigger transfers (or we could add a periodic update mechanism) - - - -## Overview - -We've simplified the ha token marks calculation to use a **daily snapshot approach**, which approximates polling balances once per day and awarding marks accordingly. - -## How It Works - -### 1. Event-Driven Balance Updates -- The subgraph tracks `Transfer` events for ha tokens -- On each transfer, we query the current balance from the contract -- This gives us a "snapshot" of the balance at that moment - -### 2. Daily Marks Accumulation -- Marks are calculated based on **full days** since the last snapshot -- If someone holds tokens for 1.5 days, they get marks for 1 full day -- The balance used for calculation is the balance from the last snapshot (the balance held for those days) - -### 3. Snapshot Timing -- `lastUpdated` tracks when the last snapshot was taken -- When marks are accumulated, `lastUpdated` is updated to the start of the current day -- This ensures we only count full days going forward - -## Example - -**Day 1 (Block 100, 10:00 AM):** -- User receives 200,000 haPB tokens -- Balance snapshot: 200,000 tokens -- `lastUpdated`: Block 100 timestamp -- Marks accumulated: 0 - -**Day 2 (Block 200, 2:00 PM - 1.2 days later):** -- User still holds 200,000 haPB tokens -- Transfer event occurs -- Calculate: 1.2 days since last update → 1 full day -- Marks for 1 day: 200,000 tokens × $1 × 1 mark/dollar/day × 1 day = 200,000 marks -- `lastUpdated`: Updated to start of Day 2 -- Balance snapshot: 200,000 tokens (unchanged) - -**Day 3 (Block 300, 11:00 AM - 0.9 days later):** -- User still holds 200,000 haPB tokens -- Transfer event occurs -- Calculate: 0.9 days since last update → 0 full days -- Marks accumulated: 0 (less than 1 full day) -- `lastUpdated`: Remains at start of Day 2 -- Balance snapshot: 200,000 tokens - -**Day 4 (Block 400, 3:00 PM - 1.1 days later):** -- User still holds 200,000 haPB tokens -- Transfer event occurs -- Calculate: 1.1 days since last update → 1 full day -- Marks for 1 day: 200,000 marks -- Total accumulated: 400,000 marks -- `lastUpdated`: Updated to start of Day 4 - -## Benefits - -1. **Simpler Logic**: No complex time calculations, just count full days -2. **Event-Driven**: Works with subgraph's event-driven architecture -3. **Fair**: Users get marks for full days they held tokens -4. **Efficient**: Only calculates when transfers occur (balance changes) - -## Implementation Details - -### `accumulateMarks()` Function -- Calculates full days since `lastUpdated` -- Awards marks based on `balanceUSD` from last snapshot -- Updates `lastUpdated` to start of current day - -### `handleHaTokenTransfer()` Handler -- Called on every Transfer event -- Accumulates marks for full days -- Updates balance snapshot from contract -- Resets snapshot time if balance goes to zero - -## Querying Marks - -```graphql -{ - haTokenBalances(where: {user: "0x..."}) { - balance - balanceUSD - accumulatedMarks - marksPerDay - lastUpdated - } -} -``` - -## Notes - -- Marks accumulate in **full day increments only** -- If no transfers occur for a long time, marks won't accumulate until the next transfer -- This is intentional - it approximates "polling once per day" -- For more frequent updates, users would need to trigger transfers (or we could add a periodic update mechanism) - - - - - diff --git a/doc/guides/DEBUG-END-GENESIS.md b/doc/guides/DEBUG-END-GENESIS.md deleted file mode 100644 index bb2ff016..00000000 --- a/doc/guides/DEBUG-END-GENESIS.md +++ /dev/null @@ -1,128 +0,0 @@ -# Debug: endGenesis() Unauthorized Error - -## Error -`execution reverted: custom error 0x82b42900` = `Unauthorized()` from BaoOwnable - -## Current Status -- ✅ Genesis owner: `0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266` -- ✅ Genesis address: `0xAD523115cd35a8d4E60B3C0953E0E0ac10418309` -- ✅ ZERO_FEE_ROLE granted to Genesis on Minter -- ❌ `endGenesis()` fails with Unauthorized even from owner account - -## Possible Causes - -### 1. Wallet Account Mismatch -**Most Likely**: Your wallet is connected with a different account than the owner. - -**Check**: -- Wallet should show: `0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266` -- If it shows a different address, that's the problem - -**Fix**: Import the owner account into your wallet: -- Address: `0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266` -- Private Key: `0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80` - -### 2. Frontend Calling Wrong Function -The frontend might be calling a different function or with wrong parameters. - -**Verify**: Frontend should call `endGenesis()` with no parameters. - -### 3. Proxy Storage Issue -Genesis is a UUPS proxy. There might be a storage layout issue. - -**Unlikely** since `owner()` returns the correct value and deposits work. - -## Verification Steps - -1. **Check wallet address in browser console:** - ```javascript - // In browser console - (await window.ethereum.request({method: 'eth_accounts'}))[0] - ``` - Should return: `0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266` - -2. **Check network:** - - Chain ID: 31337 - - RPC: http://localhost:8545 - -3. **Check Genesis contract:** - - Address: `0xAD523115cd35a8d4E60B3C0953E0E0ac10418309` - - Owner: `0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266` - -## Quick Test - -Try calling `endGenesis()` directly with cast: -```bash -cast send 0xAD523115cd35a8d4E60B3C0953E0E0ac10418309 "endGenesis()" \ - --rpc-url http://localhost:8545 \ - --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 -``` - -If this also fails, there's a contract issue. If it succeeds, the problem is the wallet account. - - - -## Error -`execution reverted: custom error 0x82b42900` = `Unauthorized()` from BaoOwnable - -## Current Status -- ✅ Genesis owner: `0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266` -- ✅ Genesis address: `0xAD523115cd35a8d4E60B3C0953E0E0ac10418309` -- ✅ ZERO_FEE_ROLE granted to Genesis on Minter -- ❌ `endGenesis()` fails with Unauthorized even from owner account - -## Possible Causes - -### 1. Wallet Account Mismatch -**Most Likely**: Your wallet is connected with a different account than the owner. - -**Check**: -- Wallet should show: `0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266` -- If it shows a different address, that's the problem - -**Fix**: Import the owner account into your wallet: -- Address: `0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266` -- Private Key: `0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80` - -### 2. Frontend Calling Wrong Function -The frontend might be calling a different function or with wrong parameters. - -**Verify**: Frontend should call `endGenesis()` with no parameters. - -### 3. Proxy Storage Issue -Genesis is a UUPS proxy. There might be a storage layout issue. - -**Unlikely** since `owner()` returns the correct value and deposits work. - -## Verification Steps - -1. **Check wallet address in browser console:** - ```javascript - // In browser console - (await window.ethereum.request({method: 'eth_accounts'}))[0] - ``` - Should return: `0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266` - -2. **Check network:** - - Chain ID: 31337 - - RPC: http://localhost:8545 - -3. **Check Genesis contract:** - - Address: `0xAD523115cd35a8d4E60B3C0953E0E0ac10418309` - - Owner: `0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266` - -## Quick Test - -Try calling `endGenesis()` directly with cast: -```bash -cast send 0xAD523115cd35a8d4E60B3C0953E0E0ac10418309 "endGenesis()" \ - --rpc-url http://localhost:8545 \ - --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 -``` - -If this also fails, there's a contract issue. If it succeeds, the problem is the wallet account. - - - - - diff --git a/doc/guides/DEPLOYMENT-SUMMARY-CLEAN-CHAIN.txt b/doc/guides/DEPLOYMENT-SUMMARY-CLEAN-CHAIN.txt deleted file mode 100644 index 995b309a..00000000 --- a/doc/guides/DEPLOYMENT-SUMMARY-CLEAN-CHAIN.txt +++ /dev/null @@ -1,39 +0,0 @@ -=== Harbor Clean Chain Deployment Summary === - -Date: $(date) -Chain: Anvil (clean, no fork) -Chain ID: 31337 -RPC URL: http://localhost:8545 - -=== Mock Contracts Deployed === -- stETH: 0x5FC8d32690cc91D4c39d9d3abcBD16989F875707 -- wstETH: 0x0165878A594ca255338adfa4d48449f69242Eb8F -- stETH/USD Feed: 0xa513E6E4b8f2a923D98304ec87F64353C4D5C853 -- stETH/ETH Feed: 0x2279B7A0a67DB372996a5FaB50D91eAA73d2eBe6 -- wstETH/USD Feed: 0x8A791620dd6260079BF849Dc5567aDC3F2FdC318 - -=== Harbor Contracts Deployed === -- Genesis: 0x99dBE4AEa58E518C50a1c04aE9b48C9F6354612f -- Minter: 0x34B40BA116d5Dec75548a9e9A8f15411461E8c70 -- StabilityPoolManager: 0xb9bEECD1A582768711dE1EE7B0A1d582D9d72a6C -- StabilityPoolCollateral: 0x3aAde2dCD2Df6a8cAc689EE797591b2913658659 -- StabilityPoolLeveraged: 0x525C7063E7C20997BaaE9bDa922159152D0e8417 - -=== Status === -✅ Anvil running (clean chain) -✅ Mock contracts deployed -✅ Harbor contracts deployed -✅ Price feeds updated with fresh timestamps -⏳ Genesis ownership needs to be set -⏳ Tokens need to be minted to developer -⏳ Verify ZERO_FEE_ROLE granted to Genesis - -=== Next Steps === -1. Set Genesis owner to developer: - cast send 0x99dBE4AEa58E518C50a1c04aE9b48C9F6354612f "transferOwnership(address)" 0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e --rpc-url http://localhost:8545 --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 - -2. Mint tokens to developer: - cast send 0x0165878A594ca255338adfa4d48449f69242Eb8F "mint(address,uint256)" 0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e 1000000000000000000000 --rpc-url http://localhost:8545 --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 - -3. Verify permissions and roles - diff --git a/doc/guides/DEPLOYMENT-SUMMARY.txt b/doc/guides/DEPLOYMENT-SUMMARY.txt deleted file mode 100644 index 1a7b7865..00000000 --- a/doc/guides/DEPLOYMENT-SUMMARY.txt +++ /dev/null @@ -1,38 +0,0 @@ -================================================================================ -DEPLOYMENT SUMMARY - Fresh Start After Problematic Block -================================================================================ - -✅ COMPLETED: ------------- -1. Anvil restarted with fork from block 23829220 (after problematic block) -2. All contracts redeployed -3. Developer address verified as owner of Genesis contract -4. Developer has ZERO_FEE_ROLE on Minter -5. Subgraph deployed with correct start block -6. Frontend configuration created - -⚠️ CURRENT STATUS: ------------------- -- Graph Node block ingestor may still hit problematic blocks from mainnet history -- Subgraph is healthy and waiting to index -- Start block is set correctly (23829229) -- Once Graph Node progresses, indexing will begin - -📋 FRONTEND CONFIGURATION: --------------------------- -See: FRONTEND-CONFIG-NEW.txt - -All contract addresses are ready for frontend integration. - -🔍 MONITORING: -------------- -Run: cd graph-node-local && ./check-subgraph-status.sh -Or: cd graph-node-local && ./monitor-subgraph.sh - -📝 NEXT STEPS: -------------- -1. Make test deposits/withdrawals to generate events -2. Monitor subgraph to see when events are indexed -3. Update frontend with addresses from FRONTEND-CONFIG-NEW.txt - -================================================================================ diff --git a/doc/guides/DEPOSIT-FEES-TO-POOLS-GUIDE.md b/doc/guides/DEPOSIT-FEES-TO-POOLS-GUIDE.md deleted file mode 100644 index 50fa1f08..00000000 --- a/doc/guides/DEPOSIT-FEES-TO-POOLS-GUIDE.md +++ /dev/null @@ -1,206 +0,0 @@ -# Guide: Depositing Fees to Stability Pools - -## Current State - -### Fee Receiver Balance -- **Address**: `0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266` (Owner/Fee Receiver) -- **ha Token Balance**: 1,250 tokens (1,250,000,000,000,000,000,000 wei) -- **wstETH Balance**: 0 tokens -- **Source**: Early withdrawal fees from stability pool withdrawals (paid in ha tokens, the pool's asset token) - -### Important Note -**Fees are in ha tokens (pegged tokens), NOT wstETH**. To deposit them to pools as rewards, you have two options: -1. **Redeem ha tokens for wstETH** (collateral), then deposit wstETH as rewards -2. **Register ha tokens as a reward token** and deposit directly (if pools accept ha tokens as rewards) - -## Can You Deposit Fees to Pools? - -### ✅ Yes, It's Possible - -Fees can be deposited to stability pools using the `depositReward()` function. The fees are already in wstETH, so no conversion is needed. - -### Who Can Deposit? - -1. **Pool Owner** - Can deposit directly -2. **Addresses with `REWARD_DEPOSITOR_ROLE`** - Can deposit rewards - -### Current Setup - -- **StabilityPoolManager** has `REWARD_DEPOSITOR_ROLE` on both pools -- **Pool Owner** (`0xf39...`) can deposit directly -- **Fee Receiver** (`0xf39...`) is the same as pool owner, so can deposit - -## How to Deposit Fees - -Since fees are in **ha tokens** (not wstETH), you have two options: - -### Option 1: Redeem ha Tokens for wstETH, Then Deposit - -#### Step 1: Redeem ha Tokens - -```solidity -// Redeem ha tokens for wstETH -IMinter(minter).redeemPeggedToken( - haTokenAmount, // amount of ha tokens to redeem - receiver, // address to receive wstETH - minWrappedOut // minimum wstETH expected (slippage protection) -); -``` - -**Note**: This will: -- Take ha tokens from the caller -- Return wstETH to the receiver -- May incur a fee (depending on collateral ratio) - -#### Step 2: Deposit wstETH to Pools - -After redeeming, deposit the wstETH as rewards: - -```solidity -// First, register wstETH as a reward token (if not already registered) -IMultipleRewardDistributor(pool).registerRewardToken(wstETH); - -// Then deposit wstETH as rewards -IMultipleRewardDistributor(collateralPool).depositReward( - wstETH, // reward token - amount // amount to deposit -); - -IMultipleRewardDistributor(leveragedPool).depositReward( - wstETH, // reward token - amount // amount to deposit -); -``` - -### Option 2: Register ha Tokens as Reward Token and Deposit Directly - -#### Step 1: Register ha Tokens as Reward Token - -```solidity -// Register ha tokens as a reward token for each pool -IMultipleRewardDistributor(collateralPool).registerRewardToken(haToken); -IMultipleRewardDistributor(leveragedPool).registerRewardToken(haToken); -``` - -#### Step 2: Approve and Deposit - -```solidity -// Approve pools to spend ha tokens -IERC20(haToken).approve(collateralPool, amount); -IERC20(haToken).approve(leveragedPool, amount); - -// Deposit ha tokens directly as rewards -IMultipleRewardDistributor(collateralPool).depositReward( - haToken, // reward token - amount // amount to deposit -); - -IMultipleRewardDistributor(leveragedPool).depositReward( - haToken, // reward token - amount // amount to deposit -); -``` - -**Note**: Users would receive ha tokens as rewards, which they can then redeem or use as they wish. - -### Distribution Options (After Converting to wstETH or Registering ha Tokens) - -**Option 1: Split Equally** -```solidity -uint256 half = totalFees / 2; -depositReward(collateralPool, rewardToken, half); -depositReward(leveragedPool, rewardToken, half); -``` - -**Option 2: Proportional to Pool Sizes** -```solidity -uint256 totalPoolSupply = collateralPoolSupply + leveragedPoolSupply; -uint256 toCollateral = (fees * collateralPoolSupply) / totalPoolSupply; -uint256 toLeveraged = fees - toCollateral; -depositReward(collateralPool, rewardToken, toCollateral); -depositReward(leveragedPool, rewardToken, toLeveraged); -``` - -**Option 3: All to One Pool** -```solidity -depositReward(collateralPool, rewardToken, totalFees); -// or -depositReward(leveragedPool, rewardToken, totalFees); -``` - -**Note**: `rewardToken` is either `wstETH` (if you redeemed) or `haToken` (if you registered ha tokens as rewards). - -## What Happens After Deposit? - -1. **Tokens Transferred**: wstETH is transferred to the pool contract -2. **Linear Vesting**: Rewards vest over 7 days (configurable `REWARD_PERIOD_LENGTH`) -3. **Proportional Distribution**: Users earn rewards based on their deposit share -4. **Claimable Over Time**: Rewards become claimable gradually - -## Harvest Status - -### Current Harvestable Amount: **0 wstETH** - -**Why?** -- `harvestable()` returns the excess wstETH in the Minter beyond what's needed for collateral backing -- Currently: Minter has 0 wstETH balance -- Therefore: Nothing to harvest - -### What Would Be Returned If Harvest Was Possible? - -If there was a harvestable amount, here's what would happen: - -#### Example: 100 wstETH Harvestable - -**Split:** -1. **Bounty** (~1-5%): Goes to harvester (whoever calls `harvest()`) - - Example: 2 wstETH -2. **Cut** (~1-5%): Goes to fee receiver - - Example: 3 wstETH -3. **Remainder** (~90-98%): **Automatically deposited to stability pools** - - Example: 95 wstETH - - Split proportionally between pools based on their sizes - -**Distribution:** -- If Collateral Pool has 60% of total deposits → Gets 60% of remainder (57 wstETH) -- If Leveraged Pool has 40% of total deposits → Gets 40% of remainder (38 wstETH) - -**Vesting:** -- All rewards vest over 7 days (linear) -- Users can claim gradually over time - -## Current Pool Sizes - -- **Collateral Pool**: 150,000 tokens -- **Leveraged Pool**: 100,000 tokens -- **Total**: 250,000 tokens - -**Distribution Ratio**: 60% collateral, 40% leveraged - -## Summary - -### Depositing Fees to Pools - -✅ **Possible**: Yes, but requires either redeeming ha tokens for wstETH OR registering ha tokens as reward tokens -✅ **Who Can Do It**: Pool owner or addresses with `REWARD_DEPOSITOR_ROLE` -✅ **Current Fees**: 1,250 ha tokens at owner address -⚠️ **Redemption/Registration Needed**: Fees are in ha tokens, not wstETH - -### Harvest Status - -❌ **Harvestable**: 0 wstETH (nothing to harvest) -✅ **If Harvestable**: Would automatically deposit ~90-98% to pools -✅ **Vesting**: All rewards vest over 7 days - -### Next Steps (If You Want to Deposit Fees) - -1. **Choose approach**: Redeem for wstETH OR register ha tokens as rewards -2. **If redeeming**: Call `redeemPeggedToken()` to convert ha → wstETH -3. **Register reward token**: Register wstETH (or ha tokens) as reward token if not already -4. **Approve**: Approve pools to spend tokens -5. **Decide distribution** (equal, proportional, or all to one pool) -6. **Call `depositReward()`** on the pool(s) -7. **Rewards will vest** over 7 days for users - -**Note**: I haven't executed this yet, as you requested. Let me know if you want me to proceed! - diff --git a/doc/guides/DEV-ACCOUNT-INFO.txt b/doc/guides/DEV-ACCOUNT-INFO.txt deleted file mode 100644 index bdc8ccdf..00000000 --- a/doc/guides/DEV-ACCOUNT-INFO.txt +++ /dev/null @@ -1,76 +0,0 @@ -=============================================================================== -DEV ACCOUNT INFORMATION -=============================================================================== - -Address: 0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e - -BALANCES: ---------- -ETH: 100 ETH -wstETH: 1000 tokens -stETH: 1000 tokens - -PERMISSIONS: ------------- -✅ Owner of Genesis contract -✅ Has ZERO_FEE_ROLE on Minter contract - -READY FOR: ----------- -✅ Gas payments (100 ETH) -✅ Token transactions (wstETH, stETH) -✅ Contract interactions (Genesis, Minter) - -=============================================================================== -QUICK REFERENCE: -=============================================================================== - -# Check balance -cast balance 0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e --rpc-url local - -# Add more ETH if needed -cast rpc anvil_setBalance 0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e \ - $(cast to-hex $(cast to-wei 100)) --rpc-url local - -=============================================================================== - - -DEV ACCOUNT INFORMATION -=============================================================================== - -Address: 0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e - -BALANCES: ---------- -ETH: 100 ETH -wstETH: 1000 tokens -stETH: 1000 tokens - -PERMISSIONS: ------------- -✅ Owner of Genesis contract -✅ Has ZERO_FEE_ROLE on Minter contract - -READY FOR: ----------- -✅ Gas payments (100 ETH) -✅ Token transactions (wstETH, stETH) -✅ Contract interactions (Genesis, Minter) - -=============================================================================== -QUICK REFERENCE: -=============================================================================== - -# Check balance -cast balance 0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e --rpc-url local - -# Add more ETH if needed -cast rpc anvil_setBalance 0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e \ - $(cast to-hex $(cast to-wei 100)) --rpc-url local - -=============================================================================== - - - - - diff --git a/doc/guides/DEV-ADDRESS-ANCHOR-MARKS-REPORT.md b/doc/guides/DEV-ADDRESS-ANCHOR-MARKS-REPORT.md deleted file mode 100644 index f1aea1dd..00000000 --- a/doc/guides/DEV-ADDRESS-ANCHOR-MARKS-REPORT.md +++ /dev/null @@ -1,100 +0,0 @@ -# Dev Address Anchor Token Marks Report - -## Summary - -**Address**: `0xae7dbb17bc40d53a6363409c6b1ed88d3cfdc31e` -**Token**: haPB (Anchor Token) -**Total Accumulated Marks**: **1,200,000 marks** - ---- - -## Current Status - -- **Current Balance**: 593,257.73 ha tokens -- **Current Balance USD**: $593,257.73 -- **Current Marks Per Day**: 200,000 marks/day -- **Accumulated Marks**: 1,200,000 marks -- **Total Marks Earned**: 1,200,000 marks - ---- - -## How Marks Were Earned - -### Daily Snapshot Approach - -The subgraph uses a **daily snapshot approach** to calculate marks: - -1. **Event-Driven Updates**: Marks accumulate when `Transfer` events occur -2. **Full Days Only**: Marks are awarded for **full days** since the last update -3. **Balance Snapshot**: The balance held during those days is used for calculation - -### Calculation Formula - -``` -Marks = Balance USD × Multiplier × Full Days Held -``` - -Where: -- **Multiplier**: 1.0x (1 mark per dollar per day) -- **Full Days**: Only complete 24-hour periods count - -### Timeline - -- **First Seen**: November 27, 2025 18:34:34 -- **Last Updated**: December 3, 2025 18:34:34 -- **Time Held**: 6.00 days (518,400 seconds) - -### Marks Breakdown - -The **1,200,000 marks** were earned as follows: - -1. **Initial Balance**: The dev address received ha tokens (likely ~200,000 tokens based on marksPerDay) -2. **Daily Accumulation**: Marks accumulated at **200,000 marks/day** for **6 full days** -3. **Calculation**: 200,000 marks/day × 6 days = **1,200,000 marks** - -### Why Current Balance is Different - -The current balance (593,257.73 tokens) is **higher** than what was used for the marks calculation (which was based on ~200,000 tokens). This means: - -- The dev address earned marks while holding ~200,000 tokens -- After earning those marks, the balance increased to 593,257.73 tokens -- The new balance will earn marks going forward at the new rate (593,257 marks/day) - ---- - -## Key Points - -1. **Marks Only Accumulate on Transfers**: Marks are calculated when Transfer events occur, not continuously -2. **Full Days Only**: Partial days don't count - you need to hold for a full 24 hours -3. **Balance Changes**: If balance changes, marks are calculated based on the balance held during each period -4. **Current Rate**: The current marksPerDay (200,000) suggests the balance was around 200k when last updated - ---- - -## Query to Get This Data - -```graphql -{ - haTokenBalances(where: {user: "0xae7dbb17bc40d53a6363409c6b1ed88d3cfdc31e"}) { - balance - balanceUSD - accumulatedMarks - totalMarksEarned - marksPerDay - firstSeenAt - lastUpdated - } -} -``` - ---- - -## Next Steps - -To see updated marks: -1. Wait for the next Transfer event (any ha token transfer) -2. The subgraph will recalculate marks for full days since `lastUpdated` -3. The new balance (593,257.73 tokens) will be used for future calculations - - - diff --git a/doc/guides/DEV-WALLET-MARKS-REPORT.md b/doc/guides/DEV-WALLET-MARKS-REPORT.md deleted file mode 100644 index e3cc8f36..00000000 --- a/doc/guides/DEV-WALLET-MARKS-REPORT.md +++ /dev/null @@ -1,119 +0,0 @@ -# Dev Wallet Marks Report - -**Wallet Address**: `0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e` -**Report Time**: Block 272, Timestamp 1764895365 (Fri, Dec 5, 2025 00:42:45 GMT) - -## Summary by Position - -### 1. Genesis Marks -- **Source**: Genesis Deposit -- **Current Marks**: 40,110,277.78 marks -- **Total Marks Earned**: 40,110,277.78 marks -- **Bonus Marks**: 40,000,000 marks -- **Marks Per Day**: 0 (Genesis ended) -- **Status**: ✅ Genesis ended, marks finalized - -### 2. Ha Token (Anchor Token) Marks -- **Token Address**: `0x1c85638e118b37167e9298c2268758e058DdfDA0` -- **Balance**: 442,007.73 tokens -- **Balance USD**: $442,007.73 -- **Accumulated Marks**: 1,597,283.96 marks -- **Marks Per Day**: 393,257.73 marks/day -- **Total Marks Earned**: 1,597,283.96 marks -- **Last Updated**: 1764895365 - -### 3. Stability Pool Deposit Marks -- **Pool Address**: `0x3aAde2dCD2Df6a8cAc689EE797591b2913658659` (Collateral Pool) -- **Pool Type**: Collateral -- **Balance**: 150,000 tokens -- **Balance USD**: $150,000 -- **Accumulated Marks**: 21,587.96 marks -- **Marks Per Day**: 150,000 marks/day -- **Total Marks Earned**: 21,587.96 marks -- **Last Updated**: 1764895365 - -### 4. Sail Token (Leveraged Token) Marks -- **Token Address**: `0x367761085BF3C12e5DA2Df99AC6E1a824612b8fb` -- **Balance**: 401,651.48 tokens -- **Balance USD**: $401,651.48 -- **Accumulated Marks**: 7,105,061.00 marks -- **Marks Per Day**: 2,008,257.39 marks/day (5x multiplier) -- **Total Marks Earned**: 7,105,061.00 marks -- **Last Updated**: 1764882233 - -## Total Marks Breakdown - -| Source | Accumulated Marks | Marks Per Day | -|--------|-----------------|---------------| -| **Genesis** | 40,110,277.78 | 0 (ended) | -| **Ha Tokens** | 1,597,283.96 | 393,257.73 | -| **Stability Pool** | 21,587.96 | 150,000.00 | -| **Sail Tokens** | 7,105,061.00 | 2,008,257.39 | -| **TOTAL** | **48,834,210.70** | **2,551,515.12** | - -## Detailed Breakdown - -### Genesis Marks -- **Base Marks**: 1,102,277.78 marks (from $400,000 deposit) -- **Bonus Marks**: 40,000,000 marks (100x bonus at Genesis end) -- **Total**: 40,110,277.78 marks -- **Status**: Finalized (Genesis ended) - -### Ha Token Position -- **Holding**: 442,007.73 ha tokens -- **Value**: $442,007.73 USD -- **Marks Rate**: 1 mark per dollar per day -- **Current Rate**: 393,257.73 marks/day -- **Time Held**: ~4.06 days (based on accumulated marks) - -### Stability Pool Position -- **Deposit**: 150,000 tokens in Collateral Pool -- **Value**: $150,000 USD -- **Marks Rate**: 1 mark per dollar per day -- **Current Rate**: 150,000 marks/day -- **Time Deposited**: ~0.14 days (based on accumulated marks) - -### Sail Token Position -- **Holding**: 401,651.48 hs tokens -- **Value**: $401,651.48 USD -- **Marks Rate**: 5 marks per dollar per day (5x multiplier) -- **Current Rate**: 2,008,257.39 marks/day -- **Time Held**: ~3.54 days (based on accumulated marks) - -## Current Marks Per Day Rate - -**Total Active Marks Per Day**: 2,551,515.12 marks/day - -Breakdown: -- Ha Tokens: 393,257.73 marks/day (15.4%) -- Stability Pool: 150,000.00 marks/day (5.9%) -- Sail Tokens: 2,008,257.39 marks/day (78.7%) - -## Notes - -1. **Genesis marks are finalized** - No longer earning marks from Genesis -2. **Ha tokens** - Largest active position by value, earning at 1x rate -3. **Stability pool** - Recently deposited (150k tokens), earning at 1x rate -4. **Sail tokens** - Highest marks per day due to 5x multiplier, despite lower USD value - -## Estimated Marks Growth - -At current rates, the dev wallet is earning approximately: -- **Per Hour**: ~106,313 marks/hour -- **Per Day**: 2,551,515 marks/day -- **Per Week**: 17,860,606 marks/week - -## Real-Time Estimation - -Since last update, additional marks may have accumulated: -- **Ha Tokens**: Last updated at 1764895365 (current time) -- **Stability Pool**: Last updated at 1764895365 (current time) -- **Sail Tokens**: Last updated at 1764882233 (~13,132 seconds ago = ~0.15 days) - -**Estimated additional marks since last update**: -- Sail Tokens: ~301,239 marks (0.15 days × 2,008,257.39 marks/day) - -**Total Estimated Marks (with real-time calculation)**: ~49,135,450 marks - - - diff --git a/doc/guides/DIAGNOSE-DEPLOYMENT.md b/doc/guides/DIAGNOSE-DEPLOYMENT.md deleted file mode 100644 index b3a4f5e4..00000000 --- a/doc/guides/DIAGNOSE-DEPLOYMENT.md +++ /dev/null @@ -1,232 +0,0 @@ -# Diagnosing Stuck Subgraph Deployment - -## Quick Checks - -### 1. Check Docker Services Status -```bash -cd graph-node-local -docker compose ps -``` - -**What to look for:** -- All services should be "Up" and "healthy" -- Graph Node should be running -- PostgreSQL should be healthy -- IPFS should be running - -### 2. Check Graph Node Logs -```bash -cd graph-node-local -docker compose logs graph-node --tail 50 -``` - -**What to look for:** -- Any ERROR messages -- "Block data unavailable" or "uncled" errors -- "Downloading latest blocks" messages -- Any deployment-related messages - -### 3. Check if Services are Responding -```bash -# Graph Node JSON-RPC -curl http://localhost:8020 - -# IPFS -curl http://localhost:5001 - -# GraphQL -curl http://localhost:8000 -``` - -### 4. Check Anvil Connection -```bash -curl -X POST http://localhost:8545 \ - -H "Content-Type: application/json" \ - -d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' -``` - -## Common Issues - -### Issue 1: Block Ingestor Stuck -**Symptom**: Logs show repeated "Block data unavailable" errors - -**Solution**: The block ingestor is trying to fetch a block that doesn't exist. This happens when Graph Node has cached block data from a previous deployment. - -**Fix**: -```bash -cd graph-node-local -docker compose down -v # Remove volumes -docker compose up -d # Start fresh -``` - -### Issue 2: IPFS Not Responding -**Symptom**: Deployment hangs, IPFS connection errors - -**Solution**: Restart IPFS -```bash -cd graph-node-local -docker compose restart ipfs -``` - -### Issue 3: Graph Node Not Ready -**Symptom**: Graph Node is starting but not ready - -**Solution**: Wait a bit longer (30-60 seconds) for Graph Node to fully initialize - -### Issue 4: Deployment Hangs on "Uploading to IPFS" -**Symptom**: Deployment stops at IPFS upload step - -**Solution**: -- Check IPFS logs: `docker compose logs ipfs` -- Try deploying with verbose output: `graph deploy --node http://localhost:8020/ --ipfs http://localhost:5001 harbor-marks-local -v` - -## Alternative: Manual Deployment Steps - -If automatic deployment is stuck, try these steps manually: - -1. **Build the subgraph**: - ```bash - cd /Users/andrewyoung/Harbor-App/harbor-app/subgraph - graph build - ``` - -2. **Create subgraph** (if needed): - ```bash - graph create --node http://localhost:8020/ harbor-marks-local - ``` - -3. **Deploy with verbose output**: - ```bash - graph deploy --node http://localhost:8020/ \ - --ipfs http://localhost:5001 \ - harbor-marks-local \ - -v - ``` - -## What to Share - -If deployment is still stuck, please share: - -1. **Docker Compose Status**: `docker compose ps` output -2. **Graph Node Logs**: Last 50 lines from `docker compose logs graph-node --tail 50` -3. **IPFS Logs**: `docker compose logs ipfs --tail 20` -4. **Where it's stuck**: What's the last message you see in the deployment output? - - - -## Quick Checks - -### 1. Check Docker Services Status -```bash -cd graph-node-local -docker compose ps -``` - -**What to look for:** -- All services should be "Up" and "healthy" -- Graph Node should be running -- PostgreSQL should be healthy -- IPFS should be running - -### 2. Check Graph Node Logs -```bash -cd graph-node-local -docker compose logs graph-node --tail 50 -``` - -**What to look for:** -- Any ERROR messages -- "Block data unavailable" or "uncled" errors -- "Downloading latest blocks" messages -- Any deployment-related messages - -### 3. Check if Services are Responding -```bash -# Graph Node JSON-RPC -curl http://localhost:8020 - -# IPFS -curl http://localhost:5001 - -# GraphQL -curl http://localhost:8000 -``` - -### 4. Check Anvil Connection -```bash -curl -X POST http://localhost:8545 \ - -H "Content-Type: application/json" \ - -d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' -``` - -## Common Issues - -### Issue 1: Block Ingestor Stuck -**Symptom**: Logs show repeated "Block data unavailable" errors - -**Solution**: The block ingestor is trying to fetch a block that doesn't exist. This happens when Graph Node has cached block data from a previous deployment. - -**Fix**: -```bash -cd graph-node-local -docker compose down -v # Remove volumes -docker compose up -d # Start fresh -``` - -### Issue 2: IPFS Not Responding -**Symptom**: Deployment hangs, IPFS connection errors - -**Solution**: Restart IPFS -```bash -cd graph-node-local -docker compose restart ipfs -``` - -### Issue 3: Graph Node Not Ready -**Symptom**: Graph Node is starting but not ready - -**Solution**: Wait a bit longer (30-60 seconds) for Graph Node to fully initialize - -### Issue 4: Deployment Hangs on "Uploading to IPFS" -**Symptom**: Deployment stops at IPFS upload step - -**Solution**: -- Check IPFS logs: `docker compose logs ipfs` -- Try deploying with verbose output: `graph deploy --node http://localhost:8020/ --ipfs http://localhost:5001 harbor-marks-local -v` - -## Alternative: Manual Deployment Steps - -If automatic deployment is stuck, try these steps manually: - -1. **Build the subgraph**: - ```bash - cd /Users/andrewyoung/Harbor-App/harbor-app/subgraph - graph build - ``` - -2. **Create subgraph** (if needed): - ```bash - graph create --node http://localhost:8020/ harbor-marks-local - ``` - -3. **Deploy with verbose output**: - ```bash - graph deploy --node http://localhost:8020/ \ - --ipfs http://localhost:5001 \ - harbor-marks-local \ - -v - ``` - -## What to Share - -If deployment is still stuck, please share: - -1. **Docker Compose Status**: `docker compose ps` output -2. **Graph Node Logs**: Last 50 lines from `docker compose logs graph-node --tail 50` -3. **IPFS Logs**: `docker compose logs ipfs --tail 20` -4. **Where it's stuck**: What's the last message you see in the deployment output? - - - - - diff --git a/doc/guides/END-GENESIS-FIX.txt b/doc/guides/END-GENESIS-FIX.txt deleted file mode 100644 index 7a10472d..00000000 --- a/doc/guides/END-GENESIS-FIX.txt +++ /dev/null @@ -1,84 +0,0 @@ -=== End Genesis Fix Applied === - -ISSUE ------ -endGenesis() was failing with Unauthorized() error (0x82b42900) - -ROOT CAUSE ----------- -1. Genesis contract did NOT have ZERO_FEE_ROLE on Minter - - endGenesis() calls freeMintPeggedToken/freeMintLeveragedToken - - These functions require ZERO_FEE_ROLE - -2. The deploy script grants this role, but it may have failed silently - -FIX APPLIED ------------ -✅ Granted ZERO_FEE_ROLE to Genesis on Minter using: - - Function: grantRoles(address,uint256) - - Role value: 1 (ZERO_FEE_ROLE) - - Genesis: 0x572316aC11CB4bc5daf6BDae68f43EA3CCE3aE0e - - Minter: 0xe38b6847E611e942E6c80eD89aE867F522402e80 - -VERIFICATION ------------- -✅ Genesis has ZERO_FEE_ROLE (verified with hasAnyRole) -✅ Owner account: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 - -FRONTEND REQUIREMENTS ----------------------- -For endGenesis() to work from frontend: -1. Wallet must be connected with owner account: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 -2. Use correct Genesis address: 0x572316aC11CB4bc5daf6BDae68f43EA3CCE3aE0e -3. Ensure price feeds are up to date (may need to update mock price feeds) - -NOTE ----- -If you want the developer account to call endGenesis(), you would need to: -- Transfer ownership from deployer to developer account -- Or grant a custom role that allows endGenesis() - - - -ISSUE ------ -endGenesis() was failing with Unauthorized() error (0x82b42900) - -ROOT CAUSE ----------- -1. Genesis contract did NOT have ZERO_FEE_ROLE on Minter - - endGenesis() calls freeMintPeggedToken/freeMintLeveragedToken - - These functions require ZERO_FEE_ROLE - -2. The deploy script grants this role, but it may have failed silently - -FIX APPLIED ------------ -✅ Granted ZERO_FEE_ROLE to Genesis on Minter using: - - Function: grantRoles(address,uint256) - - Role value: 1 (ZERO_FEE_ROLE) - - Genesis: 0x572316aC11CB4bc5daf6BDae68f43EA3CCE3aE0e - - Minter: 0xe38b6847E611e942E6c80eD89aE867F522402e80 - -VERIFICATION ------------- -✅ Genesis has ZERO_FEE_ROLE (verified with hasAnyRole) -✅ Owner account: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 - -FRONTEND REQUIREMENTS ----------------------- -For endGenesis() to work from frontend: -1. Wallet must be connected with owner account: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 -2. Use correct Genesis address: 0x572316aC11CB4bc5daf6BDae68f43EA3CCE3aE0e -3. Ensure price feeds are up to date (may need to update mock price feeds) - -NOTE ----- -If you want the developer account to call endGenesis(), you would need to: -- Transfer ownership from deployer to developer account -- Or grant a custom role that allows endGenesis() - - - - - diff --git a/doc/guides/EVENT-STATUS.md b/doc/guides/EVENT-STATUS.md deleted file mode 100644 index a8a4a46d..00000000 --- a/doc/guides/EVENT-STATUS.md +++ /dev/null @@ -1,138 +0,0 @@ -# Event Status Check - -## ✅ On-Chain Events - -**Deposit Event Found:** -- **Block**: 71 -- **Amount**: 100 wstETH (100000000000000000000 wei) -- **Status**: ✅ Confirmed on-chain - -## ❌ Subgraph Indexing - -**Current Status:** -- **Subgraph Block**: 29 -- **Required Block**: 71+ -- **Gap**: 42 blocks behind -- **Deposits Indexed**: 0 -- **Status**: Not synced - -## 🔍 Problem - -The subgraph is **stuck at block 29** and hasn't indexed the deposit event that occurred at block 71. - -**Root Cause**: The subgraph is likely configured with the **wrong Genesis contract address**. It needs to be updated to: -- **Genesis Address**: `0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82` -- **Start Block**: 55 - -## 🔧 Solution - -The subgraph needs to be redeployed with the correct configuration: - -1. Update `subgraph.yaml`: - ```yaml - network: anvil - source: - address: "0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82" - startBlock: 55 - ``` - -2. Redeploy: - ```bash - graph build - graph deploy --node http://localhost:8020/ \ - --ipfs http://localhost:5001 \ - harbor-marks-local - ``` - -3. Wait for sync (should catch up quickly on clean chain) - -## ✅ Verification - -After redeployment, check: -```bash -# Check sync status -curl -X POST http://localhost:8030/graphql \ - -H "Content-Type: application/json" \ - -d '{"query":"{ indexingStatuses { subgraph chains { latestBlock { number } } synced } }"}' - -# Query deposits -curl -X POST http://localhost:8000/subgraphs/name/harbor-marks-local \ - -H "Content-Type: application/json" \ - -d '{"query":"{ deposits { id user amount blockNumber } }"}' -``` - ---- - -**Summary**: Events exist on-chain but are NOT indexed by the subgraph because it's configured incorrectly. - - - -## ✅ On-Chain Events - -**Deposit Event Found:** -- **Block**: 71 -- **Amount**: 100 wstETH (100000000000000000000 wei) -- **Status**: ✅ Confirmed on-chain - -## ❌ Subgraph Indexing - -**Current Status:** -- **Subgraph Block**: 29 -- **Required Block**: 71+ -- **Gap**: 42 blocks behind -- **Deposits Indexed**: 0 -- **Status**: Not synced - -## 🔍 Problem - -The subgraph is **stuck at block 29** and hasn't indexed the deposit event that occurred at block 71. - -**Root Cause**: The subgraph is likely configured with the **wrong Genesis contract address**. It needs to be updated to: -- **Genesis Address**: `0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82` -- **Start Block**: 55 - -## 🔧 Solution - -The subgraph needs to be redeployed with the correct configuration: - -1. Update `subgraph.yaml`: - ```yaml - network: anvil - source: - address: "0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82" - startBlock: 55 - ``` - -2. Redeploy: - ```bash - graph build - graph deploy --node http://localhost:8020/ \ - --ipfs http://localhost:5001 \ - harbor-marks-local - ``` - -3. Wait for sync (should catch up quickly on clean chain) - -## ✅ Verification - -After redeployment, check: -```bash -# Check sync status -curl -X POST http://localhost:8030/graphql \ - -H "Content-Type: application/json" \ - -d '{"query":"{ indexingStatuses { subgraph chains { latestBlock { number } } synced } }"}' - -# Query deposits -curl -X POST http://localhost:8000/subgraphs/name/harbor-marks-local \ - -H "Content-Type: application/json" \ - -d '{"query":"{ deposits { id user amount blockNumber } }"}' -``` - ---- - -**Summary**: Events exist on-chain but are NOT indexed by the subgraph because it's configured incorrectly. - - - - - diff --git a/doc/guides/FEE-EXPLANATION.md b/doc/guides/FEE-EXPLANATION.md deleted file mode 100644 index e4045cc5..00000000 --- a/doc/guides/FEE-EXPLANATION.md +++ /dev/null @@ -1,146 +0,0 @@ -# Fee Structure Explanation - -## Current Situation - -You're seeing **0% fees** when minting anchor tokens, which seems unexpected given we configured a health-based fee structure. - -## Why This Is Happening - -### System State -- **Pegged Token Balance**: 0 (no tokens minted yet) -- **Collateral**: Likely 0 or very small -- **Collateral Ratio**: Infinity (1e36) when pegged tokens = 0 - -### Fee Band Logic - -When the system is **empty** (no pegged tokens): -1. Collateral ratio = `infinity` (encoded as `1e36`) -2. The `_findBand()` function iterates through bands checking `collateralRatio <= bandUpperBound` -3. Since `1e36` is way higher than any bound (highest is `2.0e18`), it ends up in the **last band** -4. The last band for mint pegged is: **> 2.0x with 0.5% fee** - -### Why You See 0% - -There are a few possibilities: - -1. **Rounding**: 0.5% fee on 1 wstETH = 0.005 wstETH, which might round to 0 in the UI display -2. **Edge Case**: The fee calculation might have special handling for empty systems -3. **First Deposit**: The first deposit might be treated differently (no fees to bootstrap the system) - -## Expected Behavior After First Deposit - -Once you make the first deposit: -- Pegged tokens will be minted -- Collateral ratio will be calculable (not infinity) -- The ratio will likely be very high initially (since you're adding collateral but the system is new) -- Fees should appear based on the actual collateral ratio - -## Fee Structure Reminder - -**Mint Anchor (Pegged) Fees:** -- < 1.0x: **BLOCKED** (100% fee) -- 1.0x - 1.05x: **50%** fee -- 1.05x - 1.1x: **20%** fee -- 1.1x - 1.2x: **10%** fee -- 1.2x - 1.3x: **5%** fee -- 1.3x - 1.5x: **2%** fee -- 1.5x - 2.0x: **1%** fee -- **> 2.0x: 0.5% fee** ← You're likely here (empty system = infinite ratio) - -## Testing the Fees - -To see fees in action: - -1. **Make a small first deposit** - This will create pegged tokens and establish a real collateral ratio -2. **Check the fee again** - It should show 0.5% (or higher if the ratio drops) -3. **As the system grows** - Fees will adjust based on the actual collateral ratio - -## Verification - -You can verify the fee config was applied by checking: -- The transaction hash from `updateConfig()`: `0x636bdc29b546f69b11288547deb39ad557f14c3c79faf0409c50008f7fb156c9` -- The fee structure is active, but you're in the highest band (lowest fee) due to the empty system state - -## Next Steps - -1. Make a test deposit to bootstrap the system -2. Check the fee again - it should show 0.5% (or be calculated properly) -3. As the collateral ratio changes, fees will adjust accordingly - -The fee structure **is working correctly** - you're just in the edge case of an empty system where the ratio is infinite, placing you in the highest (lowest fee) band. - - - -## Current Situation - -You're seeing **0% fees** when minting anchor tokens, which seems unexpected given we configured a health-based fee structure. - -## Why This Is Happening - -### System State -- **Pegged Token Balance**: 0 (no tokens minted yet) -- **Collateral**: Likely 0 or very small -- **Collateral Ratio**: Infinity (1e36) when pegged tokens = 0 - -### Fee Band Logic - -When the system is **empty** (no pegged tokens): -1. Collateral ratio = `infinity` (encoded as `1e36`) -2. The `_findBand()` function iterates through bands checking `collateralRatio <= bandUpperBound` -3. Since `1e36` is way higher than any bound (highest is `2.0e18`), it ends up in the **last band** -4. The last band for mint pegged is: **> 2.0x with 0.5% fee** - -### Why You See 0% - -There are a few possibilities: - -1. **Rounding**: 0.5% fee on 1 wstETH = 0.005 wstETH, which might round to 0 in the UI display -2. **Edge Case**: The fee calculation might have special handling for empty systems -3. **First Deposit**: The first deposit might be treated differently (no fees to bootstrap the system) - -## Expected Behavior After First Deposit - -Once you make the first deposit: -- Pegged tokens will be minted -- Collateral ratio will be calculable (not infinity) -- The ratio will likely be very high initially (since you're adding collateral but the system is new) -- Fees should appear based on the actual collateral ratio - -## Fee Structure Reminder - -**Mint Anchor (Pegged) Fees:** -- < 1.0x: **BLOCKED** (100% fee) -- 1.0x - 1.05x: **50%** fee -- 1.05x - 1.1x: **20%** fee -- 1.1x - 1.2x: **10%** fee -- 1.2x - 1.3x: **5%** fee -- 1.3x - 1.5x: **2%** fee -- 1.5x - 2.0x: **1%** fee -- **> 2.0x: 0.5% fee** ← You're likely here (empty system = infinite ratio) - -## Testing the Fees - -To see fees in action: - -1. **Make a small first deposit** - This will create pegged tokens and establish a real collateral ratio -2. **Check the fee again** - It should show 0.5% (or higher if the ratio drops) -3. **As the system grows** - Fees will adjust based on the actual collateral ratio - -## Verification - -You can verify the fee config was applied by checking: -- The transaction hash from `updateConfig()`: `0x636bdc29b546f69b11288547deb39ad557f14c3c79faf0409c50008f7fb156c9` -- The fee structure is active, but you're in the highest band (lowest fee) due to the empty system state - -## Next Steps - -1. Make a test deposit to bootstrap the system -2. Check the fee again - it should show 0.5% (or be calculated properly) -3. As the collateral ratio changes, fees will adjust accordingly - -The fee structure **is working correctly** - you're just in the edge case of an empty system where the ratio is infinite, placing you in the highest (lowest fee) band. - - - - - diff --git a/doc/guides/FEE-STRUCTURE-DESIGN.md b/doc/guides/FEE-STRUCTURE-DESIGN.md deleted file mode 100644 index 97863532..00000000 --- a/doc/guides/FEE-STRUCTURE-DESIGN.md +++ /dev/null @@ -1,274 +0,0 @@ -# Fee Structure Design - Health-Based Incentives - -## Overview - -This fee structure is designed to incentivize actions that improve system health and discourage actions that worsen it, based on the current collateral ratio. - -## Key Principles - -1. **Minting Anchor (Pegged) Tokens**: Discouraged when system is unhealthy -2. **Redeeming Anchor (Pegged) Tokens**: Encouraged when system is unhealthy -3. **Minting Leveraged Tokens**: Encouraged when system is unhealthy (improves health) -4. **Redeeming Leveraged Tokens**: Discouraged when system is unhealthy - -## Fee Structure Details - -### 1. Mint Anchor (Pegged) Tokens - -**Goal**: Discourage minting when system is unhealthy, allow normal minting when healthy. - -| Collateral Ratio | Fee | Behavior | -|-----------------|-----|----------| -| < 1.0x | **100% (Disallow)** | Completely blocked - system is undercollateralized | -| 1.0x - 1.05x | **50%** | Very expensive - system is at risk | -| 1.05x - 1.1x | **20%** | High fee - system is stressed | -| 1.1x - 1.2x | **10%** | Medium fee - system is recovering | -| 1.2x - 1.3x | **5%** | Low fee - system is healthy | -| 1.3x - 1.5x | **2%** | Very low fee - system is very healthy | -| 1.5x - 2.0x | **1%** | Minimal fee - system is extremely healthy | -| > 2.0x | **0.5%** | Minimal fee - system is overcollateralized | - -**Rationale**: As collateral ratio approaches minimum (1.0x), minting becomes prohibitively expensive. This prevents further stress on the system. - -### 2. Redeem Anchor (Pegged) Tokens - -**Goal**: Encourage redemption when system is unhealthy, normal fees when healthy. - -| Collateral Ratio | Fee/Discount | Behavior | -|-----------------|--------------|----------| -| < 1.0x | **-10% (Discount)** | Strong incentive to redeem - improves system health | -| 1.0x - 1.05x | **-5% (Discount)** | Incentive to redeem - helps stabilize system | -| 1.05x - 1.1x | **0%** | Free redemption - system needs help | -| 1.1x - 1.2x | **1%** | Low fee - system is recovering | -| 1.2x - 1.3x | **2%** | Small fee - system is healthy | -| 1.3x - 1.5x | **3%** | Moderate fee - system is very healthy | -| 1.5x - 2.0x | **4%** | Higher fee - system is extremely healthy | -| > 2.0x | **5%** | Standard fee - system is overcollateralized | - -**Rationale**: When system is unhealthy, redemptions improve the collateral ratio. Discounts/free redemptions incentivize this behavior. - -### 3. Mint Leveraged Tokens - -**Goal**: Encourage minting when system is unhealthy (improves health), normal fees when healthy. - -| Collateral Ratio | Fee/Discount | Behavior | -|-----------------|--------------|----------| -| < 1.0x | **-15% (Discount)** | Strong incentive - minting leveraged improves CR | -| 1.0x - 1.05x | **-10% (Discount)** | Good incentive - helps stabilize system | -| 1.05x - 1.1x | **-5% (Discount)** | Small incentive - system needs help | -| 1.1x - 1.2x | **-2% (Discount)** | Minimal incentive - system is recovering | -| 1.2x - 1.3x | **0%** | Free - system is healthy | -| 1.3x - 1.5x | **1%** | Small fee - system is very healthy | -| 1.5x - 2.0x | **2%** | Moderate fee - system is extremely healthy | -| > 2.0x | **3%** | Standard fee - system is overcollateralized | - -**Rationale**: Minting leveraged tokens increases leverage, which improves the collateral ratio when it's low. This is beneficial for system health. - -### 4. Redeem Leveraged Tokens - -**Goal**: Discourage redemption when system is unhealthy, allow normal redemption when healthy. - -| Collateral Ratio | Fee | Behavior | -|-----------------|-----|----------| -| < 1.0x | **100% (Disallow)** | Completely blocked - would worsen system health | -| 1.0x - 1.05x | **30%** | Very expensive - system is at risk | -| 1.05x - 1.1x | **15%** | High fee - system is stressed | -| 1.1x - 1.2x | **8%** | Medium-high fee - system is recovering | -| 1.2x - 1.3x | **5%** | Medium fee - system is healthy | -| 1.3x - 1.5x | **3%** | Low fee - system is very healthy | -| 1.5x - 2.0x | **2%** | Very low fee - system is extremely healthy | -| > 2.0x | **1.5%** | Minimal fee - system is overcollateralized | - -**Rationale**: Redeeming leveraged tokens reduces leverage, which worsens the collateral ratio when it's already low. This should be discouraged. - -## Implementation Notes - -### Incentive Ratio Format - -- **Positive values**: Fees (0 to 1.0 ether = 0% to 100%) -- **Negative values**: Discounts (-1.0 to 0 ether = -100% to 0%) -- **1.0 ether**: Disallow (100% fee = blocked) -- **0 ether**: No fee, no discount - -### Collateral Ratio Bands - -- Bands are defined by `collateralRatioBandUpperBounds` -- Each band has one `incentiveRatio` -- First band must start at 1.0x (minimum collateral ratio) -- Bands must be strictly increasing - -### Validation Rules - -1. **Mint Pegged / Redeem Leveraged**: Values in [0, 1 ether] - - Can have disallow (1.0 ether) at index 0 - - Cannot have discounts (negative values) - -2. **Redeem Pegged / Mint Leveraged**: Values in (-1 ether, 1 ether) - - Can have discounts (negative values) - - Cannot have disallow (1.0 ether) - -## Example Scenarios - -### Scenario 1: System at 1.05x (Stressed) -- **Mint Anchor**: 20% fee (expensive) -- **Redeem Anchor**: -5% discount (encouraged) -- **Mint Leveraged**: -10% discount (encouraged) -- **Redeem Leveraged**: 30% fee (discouraged) - -### Scenario 2: System at 1.25x (Healthy) -- **Mint Anchor**: 5% fee (reasonable) -- **Redeem Anchor**: 2% fee (normal) -- **Mint Leveraged**: -2% discount (small incentive) -- **Redeem Leveraged**: 5% fee (normal) - -### Scenario 3: System at 0.98x (Undercollateralized) -- **Mint Anchor**: 100% fee (BLOCKED) -- **Redeem Anchor**: -10% discount (strongly encouraged) -- **Mint Leveraged**: -15% discount (strongly encouraged) -- **Redeem Leveraged**: 100% fee (BLOCKED) - -## File Location - -Configuration file: `script/minter-fee-config-health-based.json` - -This can be used to update the Minter contract configuration via `updateConfig()`. - - - -## Overview - -This fee structure is designed to incentivize actions that improve system health and discourage actions that worsen it, based on the current collateral ratio. - -## Key Principles - -1. **Minting Anchor (Pegged) Tokens**: Discouraged when system is unhealthy -2. **Redeeming Anchor (Pegged) Tokens**: Encouraged when system is unhealthy -3. **Minting Leveraged Tokens**: Encouraged when system is unhealthy (improves health) -4. **Redeeming Leveraged Tokens**: Discouraged when system is unhealthy - -## Fee Structure Details - -### 1. Mint Anchor (Pegged) Tokens - -**Goal**: Discourage minting when system is unhealthy, allow normal minting when healthy. - -| Collateral Ratio | Fee | Behavior | -|-----------------|-----|----------| -| < 1.0x | **100% (Disallow)** | Completely blocked - system is undercollateralized | -| 1.0x - 1.05x | **50%** | Very expensive - system is at risk | -| 1.05x - 1.1x | **20%** | High fee - system is stressed | -| 1.1x - 1.2x | **10%** | Medium fee - system is recovering | -| 1.2x - 1.3x | **5%** | Low fee - system is healthy | -| 1.3x - 1.5x | **2%** | Very low fee - system is very healthy | -| 1.5x - 2.0x | **1%** | Minimal fee - system is extremely healthy | -| > 2.0x | **0.5%** | Minimal fee - system is overcollateralized | - -**Rationale**: As collateral ratio approaches minimum (1.0x), minting becomes prohibitively expensive. This prevents further stress on the system. - -### 2. Redeem Anchor (Pegged) Tokens - -**Goal**: Encourage redemption when system is unhealthy, normal fees when healthy. - -| Collateral Ratio | Fee/Discount | Behavior | -|-----------------|--------------|----------| -| < 1.0x | **-10% (Discount)** | Strong incentive to redeem - improves system health | -| 1.0x - 1.05x | **-5% (Discount)** | Incentive to redeem - helps stabilize system | -| 1.05x - 1.1x | **0%** | Free redemption - system needs help | -| 1.1x - 1.2x | **1%** | Low fee - system is recovering | -| 1.2x - 1.3x | **2%** | Small fee - system is healthy | -| 1.3x - 1.5x | **3%** | Moderate fee - system is very healthy | -| 1.5x - 2.0x | **4%** | Higher fee - system is extremely healthy | -| > 2.0x | **5%** | Standard fee - system is overcollateralized | - -**Rationale**: When system is unhealthy, redemptions improve the collateral ratio. Discounts/free redemptions incentivize this behavior. - -### 3. Mint Leveraged Tokens - -**Goal**: Encourage minting when system is unhealthy (improves health), normal fees when healthy. - -| Collateral Ratio | Fee/Discount | Behavior | -|-----------------|--------------|----------| -| < 1.0x | **-15% (Discount)** | Strong incentive - minting leveraged improves CR | -| 1.0x - 1.05x | **-10% (Discount)** | Good incentive - helps stabilize system | -| 1.05x - 1.1x | **-5% (Discount)** | Small incentive - system needs help | -| 1.1x - 1.2x | **-2% (Discount)** | Minimal incentive - system is recovering | -| 1.2x - 1.3x | **0%** | Free - system is healthy | -| 1.3x - 1.5x | **1%** | Small fee - system is very healthy | -| 1.5x - 2.0x | **2%** | Moderate fee - system is extremely healthy | -| > 2.0x | **3%** | Standard fee - system is overcollateralized | - -**Rationale**: Minting leveraged tokens increases leverage, which improves the collateral ratio when it's low. This is beneficial for system health. - -### 4. Redeem Leveraged Tokens - -**Goal**: Discourage redemption when system is unhealthy, allow normal redemption when healthy. - -| Collateral Ratio | Fee | Behavior | -|-----------------|-----|----------| -| < 1.0x | **100% (Disallow)** | Completely blocked - would worsen system health | -| 1.0x - 1.05x | **30%** | Very expensive - system is at risk | -| 1.05x - 1.1x | **15%** | High fee - system is stressed | -| 1.1x - 1.2x | **8%** | Medium-high fee - system is recovering | -| 1.2x - 1.3x | **5%** | Medium fee - system is healthy | -| 1.3x - 1.5x | **3%** | Low fee - system is very healthy | -| 1.5x - 2.0x | **2%** | Very low fee - system is extremely healthy | -| > 2.0x | **1.5%** | Minimal fee - system is overcollateralized | - -**Rationale**: Redeeming leveraged tokens reduces leverage, which worsens the collateral ratio when it's already low. This should be discouraged. - -## Implementation Notes - -### Incentive Ratio Format - -- **Positive values**: Fees (0 to 1.0 ether = 0% to 100%) -- **Negative values**: Discounts (-1.0 to 0 ether = -100% to 0%) -- **1.0 ether**: Disallow (100% fee = blocked) -- **0 ether**: No fee, no discount - -### Collateral Ratio Bands - -- Bands are defined by `collateralRatioBandUpperBounds` -- Each band has one `incentiveRatio` -- First band must start at 1.0x (minimum collateral ratio) -- Bands must be strictly increasing - -### Validation Rules - -1. **Mint Pegged / Redeem Leveraged**: Values in [0, 1 ether] - - Can have disallow (1.0 ether) at index 0 - - Cannot have discounts (negative values) - -2. **Redeem Pegged / Mint Leveraged**: Values in (-1 ether, 1 ether) - - Can have discounts (negative values) - - Cannot have disallow (1.0 ether) - -## Example Scenarios - -### Scenario 1: System at 1.05x (Stressed) -- **Mint Anchor**: 20% fee (expensive) -- **Redeem Anchor**: -5% discount (encouraged) -- **Mint Leveraged**: -10% discount (encouraged) -- **Redeem Leveraged**: 30% fee (discouraged) - -### Scenario 2: System at 1.25x (Healthy) -- **Mint Anchor**: 5% fee (reasonable) -- **Redeem Anchor**: 2% fee (normal) -- **Mint Leveraged**: -2% discount (small incentive) -- **Redeem Leveraged**: 5% fee (normal) - -### Scenario 3: System at 0.98x (Undercollateralized) -- **Mint Anchor**: 100% fee (BLOCKED) -- **Redeem Anchor**: -10% discount (strongly encouraged) -- **Mint Leveraged**: -15% discount (strongly encouraged) -- **Redeem Leveraged**: 100% fee (BLOCKED) - -## File Location - -Configuration file: `script/minter-fee-config-health-based.json` - -This can be used to update the Minter contract configuration via `updateConfig()`. - - - - - diff --git a/doc/guides/FEE-STRUCTURE-QUICK-REFERENCE.md b/doc/guides/FEE-STRUCTURE-QUICK-REFERENCE.md deleted file mode 100644 index 82243304..00000000 --- a/doc/guides/FEE-STRUCTURE-QUICK-REFERENCE.md +++ /dev/null @@ -1,244 +0,0 @@ -# Fee Structure Quick Reference - -## How to Apply - -### Option 1: Using the Helper Script -```bash -# Set Minter address (or it will be read from bcinfo.local.json) -export MINTER_ADDRESS=0x... -export RPC_URL=http://localhost:8545 # Optional, defaults to localhost:8545 -export PRIVATE_KEY=0x... # Optional for local, uses Anvil default - -# Run the script -./script/apply-fee-config.sh -``` - -### Option 2: Using Forge Script Directly -```bash -export MINTER_ADDRESS=0x... -forge script script/UpdateMinterFees.s.sol:UpdateMinterFees \ - --rpc-url http://localhost:8545 \ - --broadcast \ - --private-key 0x... -``` - -### Option 3: Using Cast (Manual) -```bash -# Read the config from minter-fee-config-health-based.json -# Then construct the updateConfig call with the proper tuple format -cast send $MINTER_ADDRESS \ - "updateConfig(((uint256[],int256[]),(uint256[],int256[]),(uint256[],int256[]),(uint256[],int256[])))" \ - "(($MINT_PEGGED_BOUNDS,$MINT_PEGGED_RATIOS),($REDEEM_PEGGED_BOUNDS,$REDEEM_PEGGED_RATIOS),($MINT_LEVERAGED_BOUNDS,$MINT_LEVERAGED_RATIOS),($REDEEM_LEVERAGED_BOUNDS,$REDEEM_LEVERAGED_RATIOS))" \ - --rpc-url http://localhost:8545 \ - --private-key 0x... -``` - -## Fee Structure at a Glance - -### Mint Anchor (Pegged) Tokens -- **< 1.0x**: ❌ BLOCKED (100% fee) -- **1.0x - 1.05x**: 50% fee -- **1.05x - 1.1x**: 20% fee -- **1.1x - 1.2x**: 10% fee -- **1.2x - 1.3x**: 5% fee -- **1.3x - 1.5x**: 2% fee -- **1.5x - 2.0x**: 1% fee -- **> 2.0x**: 0.5% fee - -**Rationale**: Discourage minting when system is unhealthy. - -### Redeem Anchor (Pegged) Tokens -- **< 1.0x**: -10% discount (you get 10% bonus) -- **1.0x - 1.05x**: -5% discount -- **1.05x - 1.1x**: FREE (0% fee) -- **1.1x - 1.2x**: 1% fee -- **1.2x - 1.3x**: 2% fee -- **1.3x - 1.5x**: 3% fee -- **1.5x - 2.0x**: 4% fee -- **> 2.0x**: 5% fee - -**Rationale**: Encourage redemption when system is unhealthy (improves CR). - -### Mint Leveraged Tokens -- **< 1.0x**: -15% discount (you get 15% bonus) -- **1.0x - 1.05x**: -10% discount -- **1.05x - 1.1x**: -5% discount -- **1.1x - 1.2x**: -2% discount -- **1.2x - 1.3x**: FREE (0% fee) -- **1.3x - 1.5x**: 1% fee -- **1.5x - 2.0x**: 2% fee -- **> 2.0x**: 3% fee - -**Rationale**: Encourage minting when system is unhealthy (increases leverage, improves CR). - -### Redeem Leveraged Tokens -- **< 1.0x**: ❌ BLOCKED (100% fee) -- **1.0x - 1.05x**: 30% fee -- **1.05x - 1.1x**: 15% fee -- **1.1x - 1.2x**: 8% fee -- **1.2x - 1.3x**: 5% fee -- **1.3x - 1.5x**: 3% fee -- **1.5x - 2.0x**: 2% fee -- **> 2.0x**: 1.5% fee - -**Rationale**: Discourage redemption when system is unhealthy (reduces leverage, worsens CR). - -## Example Scenarios - -### System at 1.05x (Stressed) -- Mint Anchor: **20% fee** (expensive) -- Redeem Anchor: **-5% discount** (encouraged) -- Mint Leveraged: **-10% discount** (encouraged) -- Redeem Leveraged: **30% fee** (discouraged) - -### System at 1.25x (Healthy) -- Mint Anchor: **5% fee** (reasonable) -- Redeem Anchor: **2% fee** (normal) -- Mint Leveraged: **-2% discount** (small incentive) -- Redeem Leveraged: **5% fee** (normal) - -### System at 0.98x (Undercollateralized) -- Mint Anchor: **BLOCKED** ❌ -- Redeem Anchor: **-10% discount** (strongly encouraged) -- Mint Leveraged: **-15% discount** (strongly encouraged) -- Redeem Leveraged: **BLOCKED** ❌ - -## Files - -- **Config JSON**: `script/minter-fee-config-health-based.json` -- **Forge Script**: `script/UpdateMinterFees.s.sol` -- **Helper Script**: `script/apply-fee-config.sh` -- **Full Documentation**: `FEE-STRUCTURE-DESIGN.md` - -## Notes - -- Fees are calculated dynamically based on the current collateral ratio -- The system uses bands to determine which fee applies -- Positive values = fees, negative values = discounts -- `1.0 ether` = 100% fee = disallow (blocked) -- Only the contract owner can update the config - - - -## How to Apply - -### Option 1: Using the Helper Script -```bash -# Set Minter address (or it will be read from bcinfo.local.json) -export MINTER_ADDRESS=0x... -export RPC_URL=http://localhost:8545 # Optional, defaults to localhost:8545 -export PRIVATE_KEY=0x... # Optional for local, uses Anvil default - -# Run the script -./script/apply-fee-config.sh -``` - -### Option 2: Using Forge Script Directly -```bash -export MINTER_ADDRESS=0x... -forge script script/UpdateMinterFees.s.sol:UpdateMinterFees \ - --rpc-url http://localhost:8545 \ - --broadcast \ - --private-key 0x... -``` - -### Option 3: Using Cast (Manual) -```bash -# Read the config from minter-fee-config-health-based.json -# Then construct the updateConfig call with the proper tuple format -cast send $MINTER_ADDRESS \ - "updateConfig(((uint256[],int256[]),(uint256[],int256[]),(uint256[],int256[]),(uint256[],int256[])))" \ - "(($MINT_PEGGED_BOUNDS,$MINT_PEGGED_RATIOS),($REDEEM_PEGGED_BOUNDS,$REDEEM_PEGGED_RATIOS),($MINT_LEVERAGED_BOUNDS,$MINT_LEVERAGED_RATIOS),($REDEEM_LEVERAGED_BOUNDS,$REDEEM_LEVERAGED_RATIOS))" \ - --rpc-url http://localhost:8545 \ - --private-key 0x... -``` - -## Fee Structure at a Glance - -### Mint Anchor (Pegged) Tokens -- **< 1.0x**: ❌ BLOCKED (100% fee) -- **1.0x - 1.05x**: 50% fee -- **1.05x - 1.1x**: 20% fee -- **1.1x - 1.2x**: 10% fee -- **1.2x - 1.3x**: 5% fee -- **1.3x - 1.5x**: 2% fee -- **1.5x - 2.0x**: 1% fee -- **> 2.0x**: 0.5% fee - -**Rationale**: Discourage minting when system is unhealthy. - -### Redeem Anchor (Pegged) Tokens -- **< 1.0x**: -10% discount (you get 10% bonus) -- **1.0x - 1.05x**: -5% discount -- **1.05x - 1.1x**: FREE (0% fee) -- **1.1x - 1.2x**: 1% fee -- **1.2x - 1.3x**: 2% fee -- **1.3x - 1.5x**: 3% fee -- **1.5x - 2.0x**: 4% fee -- **> 2.0x**: 5% fee - -**Rationale**: Encourage redemption when system is unhealthy (improves CR). - -### Mint Leveraged Tokens -- **< 1.0x**: -15% discount (you get 15% bonus) -- **1.0x - 1.05x**: -10% discount -- **1.05x - 1.1x**: -5% discount -- **1.1x - 1.2x**: -2% discount -- **1.2x - 1.3x**: FREE (0% fee) -- **1.3x - 1.5x**: 1% fee -- **1.5x - 2.0x**: 2% fee -- **> 2.0x**: 3% fee - -**Rationale**: Encourage minting when system is unhealthy (increases leverage, improves CR). - -### Redeem Leveraged Tokens -- **< 1.0x**: ❌ BLOCKED (100% fee) -- **1.0x - 1.05x**: 30% fee -- **1.05x - 1.1x**: 15% fee -- **1.1x - 1.2x**: 8% fee -- **1.2x - 1.3x**: 5% fee -- **1.3x - 1.5x**: 3% fee -- **1.5x - 2.0x**: 2% fee -- **> 2.0x**: 1.5% fee - -**Rationale**: Discourage redemption when system is unhealthy (reduces leverage, worsens CR). - -## Example Scenarios - -### System at 1.05x (Stressed) -- Mint Anchor: **20% fee** (expensive) -- Redeem Anchor: **-5% discount** (encouraged) -- Mint Leveraged: **-10% discount** (encouraged) -- Redeem Leveraged: **30% fee** (discouraged) - -### System at 1.25x (Healthy) -- Mint Anchor: **5% fee** (reasonable) -- Redeem Anchor: **2% fee** (normal) -- Mint Leveraged: **-2% discount** (small incentive) -- Redeem Leveraged: **5% fee** (normal) - -### System at 0.98x (Undercollateralized) -- Mint Anchor: **BLOCKED** ❌ -- Redeem Anchor: **-10% discount** (strongly encouraged) -- Mint Leveraged: **-15% discount** (strongly encouraged) -- Redeem Leveraged: **BLOCKED** ❌ - -## Files - -- **Config JSON**: `script/minter-fee-config-health-based.json` -- **Forge Script**: `script/UpdateMinterFees.s.sol` -- **Helper Script**: `script/apply-fee-config.sh` -- **Full Documentation**: `FEE-STRUCTURE-DESIGN.md` - -## Notes - -- Fees are calculated dynamically based on the current collateral ratio -- The system uses bands to determine which fee applies -- Positive values = fees, negative values = discounts -- `1.0 ether` = 100% fee = disallow (blocked) -- Only the contract owner can update the config - - - - - diff --git a/doc/guides/FEE-TO-STABILITY-POOL-REWARDS.md b/doc/guides/FEE-TO-STABILITY-POOL-REWARDS.md deleted file mode 100644 index ba414c45..00000000 --- a/doc/guides/FEE-TO-STABILITY-POOL-REWARDS.md +++ /dev/null @@ -1,354 +0,0 @@ -# Depositing Fees to Stability Pools - Is It Possible? - -## Quick Answer - -**Yes, it's possible, but it's NOT automatic.** Fees from mint/redeem operations go to the `feeReceiver` address, but there's no built-in mechanism to automatically deposit them to stability pools. However, anyone with the right permissions can manually deposit fees (or any tokens) as rewards. - -## How Fees Currently Work - -### Where Fees Go - -All mint/redeem fees are sent directly to the `feeReceiver` address: - -```solidity -// From Minter_v1.sol -if (wrappedFee > 0) { - IERC20(WRAPPED_COLLATERAL_TOKEN).safeTransfer($.feeReceiver, wrappedFee); -} -``` - -**Fee sources:** -- `mintPeggedToken()` → fees to `feeReceiver` -- `redeemPeggedToken()` → fees to `feeReceiver` -- `mintLeveragedToken()` → fees to `feeReceiver` -- `redeemLeveragedToken()` → fees to `feeReceiver` - -**Current behavior:** -- Fees accumulate at the `feeReceiver` address -- No automatic distribution to stability pools -- Fee receiver can do whatever it wants with the fees - -## How to Deposit Rewards to Stability Pools - -### The `depositReward()` Function - -Stability pools have a `depositReward()` function that can deposit any tokens as rewards: - -```solidity -IMultipleRewardDistributor(pool).depositReward(token, amount) -``` - -**Who can call it:** -1. **Owner** of the stability pool -2. **Anyone with `REWARD_DEPOSITOR_ROLE`** on the pool - -### Current Setup - -From the test code, `StabilityPoolManager` is granted `REWARD_DEPOSITOR_ROLE` on both pools: - -```solidity -// From test setup -IBaoRoles(stabilityPoolCollateral).grantRoles(stabilityPoolManager, rewardDepositorRole); -IBaoRoles(stabilityPoolLeveraged).grantRoles(stabilityPoolManager, rewardDepositorRole); -``` - -This is why `StabilityPoolManager` can deposit rewards during harvest. - -## Ways to Deposit Fees as Rewards - -### Option 1: Manual Deposit by Fee Receiver - -If the `feeReceiver` is granted `REWARD_DEPOSITOR_ROLE`: - -```solidity -// Fee receiver calls this after collecting fees -IMultipleRewardDistributor(stabilityPool).depositReward(wstETH, feeAmount); -``` - -**Pros:** -- Simple -- Full control over timing and amounts - -**Cons:** -- Requires manual action -- Fee receiver must have the role - -### Option 2: Owner Deposits Fees - -The stability pool owner can deposit rewards directly: - -```solidity -// Owner calls this -IMultipleRewardDistributor(stabilityPool).depositReward(wstETH, feeAmount); -``` - -**Pros:** -- No role needed (owner has permission) -- Can be done by governance - -**Cons:** -- Requires owner to collect fees from feeReceiver first -- Manual process - -### Option 3: Automated Contract - -Create a contract that: -1. Collects fees from `feeReceiver` -2. Automatically deposits them to stability pools -3. Runs periodically (via keeper) - -**Example flow:** -```solidity -contract FeeDistributor { - function distributeFees() external { - uint256 fees = IERC20(wstETH).balanceOf(feeReceiver); - // Transfer from feeReceiver - IERC20(wstETH).transferFrom(feeReceiver, address(this), fees); - // Deposit to pools - IMultipleRewardDistributor(pool1).depositReward(wstETH, fees / 2); - IMultipleRewardDistributor(pool2).depositReward(wstETH, fees / 2); - } -} -``` - -**Pros:** -- Fully automated -- Can run on schedule -- Customizable distribution logic - -**Cons:** -- Requires deploying new contract -- Needs keeper to trigger -- Gas costs - -### Option 4: Set Fee Receiver to StabilityPoolManager - -If `feeReceiver` is set to `StabilityPoolManager`, fees would go there, but they still wouldn't be automatically deposited. You'd need to add logic to `StabilityPoolManager` to automatically deposit fees. - -**Note:** This would require modifying `StabilityPoolManager` contract. - -## Comparison: Harvest vs Fee Deposits - -| Aspect | Harvest | Fee Deposits | -|--------|---------|--------------| -| **Source** | Minter's harvestable amount | Mint/redeem fees | -| **Automatic?** | No (requires `harvest()` call) | No (requires manual/automated deposit) | -| **Who can do it?** | Anyone (public function) | Owner or `REWARD_DEPOSITOR_ROLE` | -| **Vesting?** | Yes (7 days linear) | Yes (7 days linear) | -| **Distribution** | Proportional to pool sizes | Manual (can choose pools) | - -## Current State - -**What exists:** -- ✅ `depositReward()` function on stability pools -- ✅ `REWARD_DEPOSITOR_ROLE` system -- ✅ `StabilityPoolManager` has the role (for harvest) - -**What doesn't exist:** -- ❌ Automatic fee distribution to pools -- ❌ Built-in mechanism to route fees to pools -- ❌ Fee receiver automatically depositing rewards - -## Summary - -**Can fees be deposited to stability pools?** -- ✅ **Yes** - via `depositReward()` function - -**Is it automatic?** -- ❌ **No** - requires manual action or automated contract - -**How to enable it:** -1. Grant `REWARD_DEPOSITOR_ROLE` to fee receiver (or another address) -2. Manually call `depositReward()` with collected fees -3. Or deploy an automated contract to do it - -**Benefits:** -- Fees become rewards for stability pool depositors -- Encourages more deposits -- Better alignment of incentives - -**Considerations:** -- Fees vest over 7 days (same as harvest rewards) -- Can choose which pools to deposit to -- Can deposit any amount at any time -- Requires active management or automation - - - -## Quick Answer - -**Yes, it's possible, but it's NOT automatic.** Fees from mint/redeem operations go to the `feeReceiver` address, but there's no built-in mechanism to automatically deposit them to stability pools. However, anyone with the right permissions can manually deposit fees (or any tokens) as rewards. - -## How Fees Currently Work - -### Where Fees Go - -All mint/redeem fees are sent directly to the `feeReceiver` address: - -```solidity -// From Minter_v1.sol -if (wrappedFee > 0) { - IERC20(WRAPPED_COLLATERAL_TOKEN).safeTransfer($.feeReceiver, wrappedFee); -} -``` - -**Fee sources:** -- `mintPeggedToken()` → fees to `feeReceiver` -- `redeemPeggedToken()` → fees to `feeReceiver` -- `mintLeveragedToken()` → fees to `feeReceiver` -- `redeemLeveragedToken()` → fees to `feeReceiver` - -**Current behavior:** -- Fees accumulate at the `feeReceiver` address -- No automatic distribution to stability pools -- Fee receiver can do whatever it wants with the fees - -## How to Deposit Rewards to Stability Pools - -### The `depositReward()` Function - -Stability pools have a `depositReward()` function that can deposit any tokens as rewards: - -```solidity -IMultipleRewardDistributor(pool).depositReward(token, amount) -``` - -**Who can call it:** -1. **Owner** of the stability pool -2. **Anyone with `REWARD_DEPOSITOR_ROLE`** on the pool - -### Current Setup - -From the test code, `StabilityPoolManager` is granted `REWARD_DEPOSITOR_ROLE` on both pools: - -```solidity -// From test setup -IBaoRoles(stabilityPoolCollateral).grantRoles(stabilityPoolManager, rewardDepositorRole); -IBaoRoles(stabilityPoolLeveraged).grantRoles(stabilityPoolManager, rewardDepositorRole); -``` - -This is why `StabilityPoolManager` can deposit rewards during harvest. - -## Ways to Deposit Fees as Rewards - -### Option 1: Manual Deposit by Fee Receiver - -If the `feeReceiver` is granted `REWARD_DEPOSITOR_ROLE`: - -```solidity -// Fee receiver calls this after collecting fees -IMultipleRewardDistributor(stabilityPool).depositReward(wstETH, feeAmount); -``` - -**Pros:** -- Simple -- Full control over timing and amounts - -**Cons:** -- Requires manual action -- Fee receiver must have the role - -### Option 2: Owner Deposits Fees - -The stability pool owner can deposit rewards directly: - -```solidity -// Owner calls this -IMultipleRewardDistributor(stabilityPool).depositReward(wstETH, feeAmount); -``` - -**Pros:** -- No role needed (owner has permission) -- Can be done by governance - -**Cons:** -- Requires owner to collect fees from feeReceiver first -- Manual process - -### Option 3: Automated Contract - -Create a contract that: -1. Collects fees from `feeReceiver` -2. Automatically deposits them to stability pools -3. Runs periodically (via keeper) - -**Example flow:** -```solidity -contract FeeDistributor { - function distributeFees() external { - uint256 fees = IERC20(wstETH).balanceOf(feeReceiver); - // Transfer from feeReceiver - IERC20(wstETH).transferFrom(feeReceiver, address(this), fees); - // Deposit to pools - IMultipleRewardDistributor(pool1).depositReward(wstETH, fees / 2); - IMultipleRewardDistributor(pool2).depositReward(wstETH, fees / 2); - } -} -``` - -**Pros:** -- Fully automated -- Can run on schedule -- Customizable distribution logic - -**Cons:** -- Requires deploying new contract -- Needs keeper to trigger -- Gas costs - -### Option 4: Set Fee Receiver to StabilityPoolManager - -If `feeReceiver` is set to `StabilityPoolManager`, fees would go there, but they still wouldn't be automatically deposited. You'd need to add logic to `StabilityPoolManager` to automatically deposit fees. - -**Note:** This would require modifying `StabilityPoolManager` contract. - -## Comparison: Harvest vs Fee Deposits - -| Aspect | Harvest | Fee Deposits | -|--------|---------|--------------| -| **Source** | Minter's harvestable amount | Mint/redeem fees | -| **Automatic?** | No (requires `harvest()` call) | No (requires manual/automated deposit) | -| **Who can do it?** | Anyone (public function) | Owner or `REWARD_DEPOSITOR_ROLE` | -| **Vesting?** | Yes (7 days linear) | Yes (7 days linear) | -| **Distribution** | Proportional to pool sizes | Manual (can choose pools) | - -## Current State - -**What exists:** -- ✅ `depositReward()` function on stability pools -- ✅ `REWARD_DEPOSITOR_ROLE` system -- ✅ `StabilityPoolManager` has the role (for harvest) - -**What doesn't exist:** -- ❌ Automatic fee distribution to pools -- ❌ Built-in mechanism to route fees to pools -- ❌ Fee receiver automatically depositing rewards - -## Summary - -**Can fees be deposited to stability pools?** -- ✅ **Yes** - via `depositReward()` function - -**Is it automatic?** -- ❌ **No** - requires manual action or automated contract - -**How to enable it:** -1. Grant `REWARD_DEPOSITOR_ROLE` to fee receiver (or another address) -2. Manually call `depositReward()` with collected fees -3. Or deploy an automated contract to do it - -**Benefits:** -- Fees become rewards for stability pool depositors -- Encourages more deposits -- Better alignment of incentives - -**Considerations:** -- Fees vest over 7 days (same as harvest rewards) -- Can choose which pools to deposit to -- Can deposit any amount at any time -- Requires active management or automation - - - - - diff --git a/doc/guides/FIX-END-GENESIS.md b/doc/guides/FIX-END-GENESIS.md deleted file mode 100644 index 73fd1d32..00000000 --- a/doc/guides/FIX-END-GENESIS.md +++ /dev/null @@ -1,104 +0,0 @@ -# Fix: End Genesis Transaction Failing - -## Problem -The "End Genesis" transaction is failing with error `0xd2159c14`. - -## Root Causes - -### 1. Frontend Using OLD Genesis Address -The frontend is calling the **OLD** Genesis contract: -- **OLD (wrong):** `0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82` -- **NEW (correct):** `0xAD523115cd35a8d4E60B3C0953E0E0ac10418309` - -### 2. Missing ZERO_FEE_ROLE -The `endGenesis()` function calls `freeMintPeggedToken()` and `freeMintLeveragedToken()` on the Minter, which requires Genesis to have `ZERO_FEE_ROLE` on the Minter contract. - -## Solutions - -### Solution 1: Update Frontend Genesis Address (REQUIRED) -Update your frontend configuration to use the NEW Genesis address: -``` -Genesis: 0xAD523115cd35a8d4E60B3C0953E0E0ac10418309 -``` - -### Solution 2: Grant ZERO_FEE_ROLE to Genesis (if needed) -If the NEW Genesis doesn't have ZERO_FEE_ROLE on the Minter, grant it: - -```bash -MINTER="0x8A791620dd6260079BF849Dc5567aDC3F2FdC318" -NEW_GENESIS="0xAD523115cd35a8d4E60B3C0953E0E0ac10418309" -ZERO_FEE_ROLE=$(cast keccak "ZERO_FEE_ROLE()") -OWNER_PK="0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" - -cast send $MINTER "grantRoles(address,uint256)" $NEW_GENESIS $ZERO_FEE_ROLE \ - --rpc-url http://localhost:8545 \ - --private-key $OWNER_PK -``` - -## Verification - -After fixing, verify: -1. Frontend is using NEW Genesis address -2. NEW Genesis has ZERO_FEE_ROLE on Minter -3. Owner account can call `endGenesis()` on NEW Genesis - -## Current Contract Addresses - -- **Genesis (NEW):** `0xAD523115cd35a8d4E60B3C0953E0E0ac10418309` -- **Minter:** `0x8A791620dd6260079BF849Dc5567aDC3F2FdC318` -- **Owner:** `0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266` - - - -## Problem -The "End Genesis" transaction is failing with error `0xd2159c14`. - -## Root Causes - -### 1. Frontend Using OLD Genesis Address -The frontend is calling the **OLD** Genesis contract: -- **OLD (wrong):** `0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82` -- **NEW (correct):** `0xAD523115cd35a8d4E60B3C0953E0E0ac10418309` - -### 2. Missing ZERO_FEE_ROLE -The `endGenesis()` function calls `freeMintPeggedToken()` and `freeMintLeveragedToken()` on the Minter, which requires Genesis to have `ZERO_FEE_ROLE` on the Minter contract. - -## Solutions - -### Solution 1: Update Frontend Genesis Address (REQUIRED) -Update your frontend configuration to use the NEW Genesis address: -``` -Genesis: 0xAD523115cd35a8d4E60B3C0953E0E0ac10418309 -``` - -### Solution 2: Grant ZERO_FEE_ROLE to Genesis (if needed) -If the NEW Genesis doesn't have ZERO_FEE_ROLE on the Minter, grant it: - -```bash -MINTER="0x8A791620dd6260079BF849Dc5567aDC3F2FdC318" -NEW_GENESIS="0xAD523115cd35a8d4E60B3C0953E0E0ac10418309" -ZERO_FEE_ROLE=$(cast keccak "ZERO_FEE_ROLE()") -OWNER_PK="0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" - -cast send $MINTER "grantRoles(address,uint256)" $NEW_GENESIS $ZERO_FEE_ROLE \ - --rpc-url http://localhost:8545 \ - --private-key $OWNER_PK -``` - -## Verification - -After fixing, verify: -1. Frontend is using NEW Genesis address -2. NEW Genesis has ZERO_FEE_ROLE on Minter -3. Owner account can call `endGenesis()` on NEW Genesis - -## Current Contract Addresses - -- **Genesis (NEW):** `0xAD523115cd35a8d4E60B3C0953E0E0ac10418309` -- **Minter:** `0x8A791620dd6260079BF849Dc5567aDC3F2FdC318` -- **Owner:** `0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266` - - - - - diff --git a/doc/guides/FRONTEND-ADDRESSES-CLEAN-CHAIN.txt b/doc/guides/FRONTEND-ADDRESSES-CLEAN-CHAIN.txt deleted file mode 100644 index b635a66a..00000000 --- a/doc/guides/FRONTEND-ADDRESSES-CLEAN-CHAIN.txt +++ /dev/null @@ -1,61 +0,0 @@ -=== Harbor Frontend Contract Addresses (Clean Chain) === - -Network Configuration: -- Network Name: Local Anvil -- Chain ID: 31337 -- RPC URL: http://localhost:8545 - -=== Core Contracts === -Genesis: 0xA4899D35897033b927acFCf422bc745916139776 (PROXY - use this!) -Genesis Implementation: 0x99dBE4AEa58E518C50a1c04aE9b48C9F6354612f (do not use) -Minter: 0x34B40BA116d5Dec75548a9e9A8f15411461E8c70 -StabilityPoolManager: 0xb9bEECD1A582768711dE1EE7B0A1d582D9d72a6C - -=== Tokens === -PeggedToken (haPB): 0x1c85638e118b37167e9298c2268758e058DdfDA0 -LeveragedToken (hsPB): 0x367761085BF3C12e5DA2Df99AC6E1a824612b8fb -wstETH: 0x0165878A594ca255338adfa4d48449f69242Eb8F -stETH: 0x5FC8d32690cc91D4c39d9d3abcBD16989F875707 - -=== Stability Pools === -StabilityPoolCollateral: 0x3aAde2dCD2Df6a8cAc689EE797591b2913658659 -StabilityPoolLeveraged: 0x525C7063E7C20997BaaE9bDa922159152D0e8417 - -=== Price Feeds === -stETH/USD: 0xa513E6E4b8f2a923D98304ec87F64353C4D5C853 -stETH/ETH: 0x2279B7A0a67DB372996a5FaB50D91eAA73d2eBe6 -wstETH/USD: 0x8A791620dd6260079BF849Dc5567aDC3F2FdC318 -PriceOracle: 0x0000000000000000000000000000000000000000 - -=== GraphQL Endpoint === -http://localhost:8000/subgraphs/name/harbor-marks-local - -=== Developer Account === -Address: 0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e -Balance: 1000 wstETH, 1000 stETH - -=== Environment Variables Format === -NEXT_PUBLIC_CHAIN_ID=31337 -NEXT_PUBLIC_RPC_URL=http://localhost:8545 -NEXT_PUBLIC_GENESIS=0xA4899D35897033b927acFCf422bc745916139776 -NEXT_PUBLIC_MINTER=0x34B40BA116d5Dec75548a9e9A8f15411461E8c70 -NEXT_PUBLIC_PEGGED_TOKEN=0x1c85638e118b37167e9298c2268758e058DdfDA0 -NEXT_PUBLIC_LEVERAGED_TOKEN=0x367761085BF3C12e5DA2Df99AC6E1a824612b8fb -NEXT_PUBLIC_WSTETH=0x0165878A594ca255338adfa4d48449f69242Eb8F -NEXT_PUBLIC_STETH=0x5FC8d32690cc91D4c39d9d3abcBD16989F875707 -NEXT_PUBLIC_STABILITY_POOL_COLLATERAL=0x3aAde2dCD2Df6a8cAc689EE797591b2913658659 -NEXT_PUBLIC_STABILITY_POOL_LEVERAGED=0x525C7063E7C20997BaaE9bDa922159152D0e8417 -NEXT_PUBLIC_STABILITY_POOL_MANAGER=0xb9bEECD1A582768711dE1EE7B0A1d582D9d72a6C -NEXT_PUBLIC_GRAPHQL_ENDPOINT=http://localhost:8000/subgraphs/name/harbor-marks-local - -=== CORRECTED: Genesis Proxy Address === -⚠️ IMPORTANT: The Genesis address above is the IMPLEMENTATION, not the proxy! - -Correct Genesis Proxy Address: 0xA4899D35897033b927acFCf422bc745916139776 - -Use this address in your frontend, not 0x99dBE4AEa58E518C50a1c04aE9b48C9F6354612f - -Updated Environment Variable: -NEXT_PUBLIC_GENESIS=0xA4899D35897033b927acFCf422bc745916139776 - - diff --git a/doc/guides/FRONTEND-APR-CALCULATION.md b/doc/guides/FRONTEND-APR-CALCULATION.md deleted file mode 100644 index bbce3b64..00000000 --- a/doc/guides/FRONTEND-APR-CALCULATION.md +++ /dev/null @@ -1,519 +0,0 @@ -# Frontend APR Calculation for Stability Pools - Next Period Projection - -## Overview - -This guide explains how to calculate a **projected APR for the NEXT reward period** (after a harvest at the end of the current 7-day period) for stability pool deposits. - -**Key Point:** This calculates what the APR would be **if a harvest happens at the end of the current 7-day period**, using current harvestable amount as a projection. This is useful for showing users what they can expect after the next harvest, especially at launch when no harvests have happened yet. - -**Use Case:** At launch, there are no rewards yet. You want to show depositors: "Based on current conditions, if we harvest at the end of this 7-day period, here's the projected APR you'll earn." - -## The Calculation Flow - -**Assumption:** We use the **current harvestable amount** as a projection of what will be available at the end of the current 7-day period. This is a reasonable estimate since harvestable accumulates over time, and we're projecting forward 7 days. - -### Step 1: Get Current State and Last Harvest Info - -```typescript -// Get current harvestable amount -const currentHarvestable = await minter.harvestable(); - -// Get current wstETH balance in Minter -const wrappedCollateralToken = await minter.WRAPPED_COLLATERAL_TOKEN(); -const currentBalance = await IERC20(wrappedCollateralToken).balanceOf(minter.address); - -// Get wstETH contract to query rate -const wstETH = new ethers.Contract(wrappedCollateralToken, WSTETH_ABI, provider); -const currentRate = await wstETH.stEthPerToken(); - -// Calculate underlying collateral -// underlyingCollateral = (balance - harvestable) * rate / 1e18 -const underlyingCollateral = ((currentBalance - currentHarvestable) * currentRate) / 1e18; - -// Get current block timestamp -const currentTimestamp = (await provider.getBlock("latest")).timestamp; - -// Get reward period info from stability pool -// This tells us when the current reward period ends (finishAt) -const rewardData = await stabilityPool.rewardData(wrappedCollateralToken); -const { finishAt, lastUpdate } = rewardData; -const REWARD_PERIOD_LENGTH = await stabilityPool.REWARD_PERIOD_LENGTH(); // Typically 604800 (7 days) -``` - -### Step 2: Calculate Remaining Time Until Period End - -```typescript -// Determine how much time is left in the current period -let remainingSeconds = 0n; -let timeSinceLastHarvest = 0n; - -if (finishAt > currentTimestamp) { - // Active reward period exists - calculate remaining time - remainingSeconds = BigInt(finishAt) - BigInt(currentTimestamp); - // Time since last harvest (when this period started) - timeSinceLastHarvest = BigInt(currentTimestamp) - BigInt(lastUpdate); -} else { - // No active period (period ended or never started) - // Project for full 7-day period - remainingSeconds = BigInt(REWARD_PERIOD_LENGTH); - timeSinceLastHarvest = 0n; -} - -// Convert to days for calculation -const remainingDays = Number(remainingSeconds) / 86400; -const daysSinceLastHarvest = Number(timeSinceLastHarvest) / 86400; -``` - -### Step 3: Project Additional Yield for Remaining Days - -```typescript -// Calculate additional yield that will accumulate over remaining days -// wstETH rate increases due to staking rewards (~3-4% APR typically) -const STAKING_APR = 0.035; // 3.5% (adjust based on actual stETH staking rate) -const dailyRate = STAKING_APR / 365; - -// Project rate forward by remaining days -const rateGrowthFactor = 1 + dailyRate * remainingDays; -const projectedRate = (currentRate * BigInt(Math.floor(rateGrowthFactor * 1e18))) / 1e18; - -// Calculate additional harvestable from remaining yield -// The underlying collateral stays the same, but rate increases -const currentValue = (underlyingCollateral * 1e18) / currentRate; // Current value in wstETH -const projectedValue = (underlyingCollateral * 1e18) / projectedRate; // Future value in wstETH -const additionalYield = currentValue > projectedValue ? currentValue - projectedValue : 0n; - -// Total projected harvestable = current + additional yield -const projectedHarvestable = currentHarvestable + additionalYield; -``` - -**Note:** - -- If 3 days have passed since last harvest, we calculate yield for the remaining 4 days -- If no harvest has happened yet, we project for the full 7-day period -- The harvestable grows because the wstETH rate increases (staking rewards), making the same underlying collateral worth more in wstETH terms - -### Step 3: Calculate What Would Go to Pools - -```typescript -// Get harvest ratios from StabilityPoolManager -const harvestBountyRatio = await stabilityPoolManager.harvestBountyRatio(); -const harvestCutRatio = await stabilityPoolManager.harvestCutRatio(); - -// Calculate deductions -const bountyAmount = (harvestableAmount * harvestBountyRatio) / 1e18; -const cutAmount = (harvestableAmount * harvestCutRatio) / 1e18; - -// Calculate what remains for pools -const harvestableRemaining = harvestableAmount - bountyAmount - cutAmount; -``` - -### Step 4: Calculate Split Between Pools - -```typescript -// Get pool holdings (total deposits in each pool) -const poolCollateral = await stabilityPoolCollateral.totalAssetSupply(); -const poolLeveraged = await stabilityPoolLeveraged.totalAssetSupply(); -const totalPoolHolding = poolCollateral + poolLeveraged; - -// Calculate how much would go to each pool -let harvestedToCollateral = 0n; -if (totalPoolHolding > 0) { - harvestedToCollateral = (harvestableRemaining * poolCollateral) / totalPoolHolding; - // harvestedToLeveraged = harvestableRemaining - harvestedToCollateral -} -``` - -### Step 5: Calculate Projected Reward Rate - -```typescript -// Get current reward data to check for queued rewards -const currentRewardData = await stabilityPool.rewardData(rewardTokenAddress); -const { queued } = currentRewardData; - -// The amount that would be deposited to this pool -const newRewardsAmount = harvestedToCollateral; // or harvestedToLeveraged for leveraged pool - -// Total rewards for next period = new rewards + any queued rewards -const totalRewardsForNextPeriod = newRewardsAmount + queued; - -// REWARD_PERIOD_LENGTH = 7 days = 604,800 seconds (1 week) -const REWARD_PERIOD_LENGTH = 7 * 24 * 60 * 60; // 604,800 - -// Calculate the new rate that would be set -// rate = totalRewards / periodLength (rewards per second) -const projectedRate = totalRewardsForNextPeriod / BigInt(REWARD_PERIOD_LENGTH); -``` - -### Step 6: Calculate Rate Per Token - -```typescript -// Get current pool supply (total deposits) -const totalSupply = await stabilityPool.totalAssetSupply(); - -// Calculate rate per token per second -const ratePerTokenPerSecond = totalSupply > 0 ? Number(projectedRate) / Number(totalSupply) : 0; -``` - -### Step 7: Project Rewards for 7 Days - -```typescript -// Get user's deposit -const userBalance = await stabilityPool.assetBalanceOf(userAddress); - -// Project rewards for 7 days -const SECONDS_IN_7_DAYS = 7 * 24 * 60 * 60; // 604,800 -const projectedRewards7Days = ratePerTokenPerSecond * Number(userBalance) * SECONDS_IN_7_DAYS; -``` - -### Step 8: Calculate APR - -```typescript -// Get token prices (implement based on your price oracle) -const rewardTokenPrice = await getTokenPrice(rewardTokenAddress); // USD per token -const depositTokenPrice = await getTokenPrice(depositTokenAddress); // USD per token - -// Calculate values in USD -const userDepositValueUSD = (Number(userBalance) * depositTokenPrice) / 1e18; -const projectedRewardsValueUSD = (projectedRewards7Days * rewardTokenPrice) / 1e18; - -// Calculate APR (annualized from 7-day projection) -if (userDepositValueUSD === 0) return 0; -const apr = (projectedRewardsValueUSD / userDepositValueUSD) * (365 / 7) * 100; -``` - -## Complete Example Function - -```typescript -async function calculateProjectedAPRForNextPeriod( - minter: Contract, - stabilityPoolManager: Contract, - stabilityPool: Contract, // The specific pool (collateral or leveraged) - stabilityPoolCollateral: Contract, - stabilityPoolLeveraged: Contract, - rewardTokenAddress: string, - depositTokenAddress: string, - userAddress: string, -): Promise { - // Step 1: Get harvestable amount - const harvestableAmount = await minter.harvestable(); - - if (harvestableAmount === 0n) { - return 0; // No harvestable = no projected APR - } - - // Step 2: Calculate what would go to pools - const harvestBountyRatio = await stabilityPoolManager.harvestBountyRatio(); - const harvestCutRatio = await stabilityPoolManager.harvestCutRatio(); - - const bountyAmount = (harvestableAmount * harvestBountyRatio) / 1e18; - const cutAmount = (harvestableAmount * harvestCutRatio) / 1e18; - const harvestableRemaining = harvestableAmount - bountyAmount - cutAmount; - - // Step 3: Calculate split between pools - const poolCollateral = await stabilityPoolCollateral.totalAssetSupply(); - const poolLeveraged = await stabilityPoolLeveraged.totalAssetSupply(); - const totalPoolHolding = poolCollateral + poolLeveraged; - - if (totalPoolHolding === 0n) { - return 0; // No deposits = no APR - } - - // Determine which pool we're calculating for - const poolAddress = stabilityPool.address; - const collateralPoolAddress = stabilityPoolCollateral.address; - const isCollateralPool = poolAddress.toLowerCase() === collateralPoolAddress.toLowerCase(); - - // Calculate how much would go to this specific pool - const harvestedToThisPool = isCollateralPool - ? (harvestableRemaining * poolCollateral) / totalPoolHolding - : (harvestableRemaining * poolLeveraged) / totalPoolHolding; - - // Step 4: Get queued rewards and calculate projected rate - const currentRewardData = await stabilityPool.rewardData(rewardTokenAddress); - const queued = currentRewardData.queued; - - // Total rewards for next period - const totalRewardsForNextPeriod = harvestedToThisPool + queued; - - // REWARD_PERIOD_LENGTH = 1 week = 604,800 seconds - const REWARD_PERIOD_LENGTH = 7 * 24 * 60 * 60; - - // Projected rate (rewards per second) - const projectedRate = totalRewardsForNextPeriod / BigInt(REWARD_PERIOD_LENGTH); - - // Step 5: Calculate rate per token - const totalSupply = await stabilityPool.totalAssetSupply(); - - if (totalSupply === 0n) { - return 0; - } - - const ratePerTokenPerSecond = Number(projectedRate) / Number(totalSupply); - - // Step 6: Get user balance and project 7 days - const userBalance = await stabilityPool.assetBalanceOf(userAddress); - - if (userBalance === 0n) { - return 0; - } - - const SECONDS_IN_7_DAYS = 604800; - const projectedRewards7Days = ratePerTokenPerSecond * Number(userBalance) * SECONDS_IN_7_DAYS; - - // Step 7: Calculate APR - const rewardTokenPrice = await getTokenPrice(rewardTokenAddress); - const depositTokenPrice = await getTokenPrice(depositTokenAddress); - - const userDepositValueUSD = (Number(userBalance) * depositTokenPrice) / 1e18; - const projectedRewardsValueUSD = (projectedRewards7Days * rewardTokenPrice) / 1e18; - - if (userDepositValueUSD === 0) { - return 0; - } - - // Annualized APR from 7-day projection - const apr = (projectedRewardsValueUSD / userDepositValueUSD) * (365 / 7) * 100; - - return apr; -} -``` - -## Simplified Version (Recommended) - -```typescript -async function getProjectedAPRNextPeriod( - minter: Contract, - stabilityPoolManager: Contract, - stabilityPool: Contract, - stabilityPoolCollateral: Contract, - stabilityPoolLeveraged: Contract, - rewardToken: string, - userAddress: string, - wstETHContract: Contract, - stakingAPR: number = 0.035, // 3.5% default (adjust based on actual stETH rate) -): Promise { - // 1. Get current state - const currentHarvestable = await minter.harvestable(); - const wrappedCollateralToken = await minter.WRAPPED_COLLATERAL_TOKEN(); - const currentBalance = await IERC20(wrappedCollateralToken).balanceOf(minter.address); - const currentRate = await wstETHContract.stEthPerToken(); - const underlyingCollateral = ((currentBalance - currentHarvestable) * currentRate) / 1e18; - - // 2. Get reward period info to find remaining time - const rewardData = await stabilityPool.rewardData(wrappedCollateralToken); - const { finishAt } = rewardData; - const REWARD_PERIOD_LENGTH = await stabilityPool.REWARD_PERIOD_LENGTH(); - const provider = minter.provider; - const currentTimestamp = BigInt((await provider.getBlock("latest")).timestamp); - - // 3. Calculate remaining time until period end - const remainingSeconds = - finishAt > currentTimestamp ? BigInt(finishAt) - currentTimestamp : BigInt(REWARD_PERIOD_LENGTH); - const remainingDays = Number(remainingSeconds) / 86400; - - // 4. Project additional yield for remaining days - const dailyRate = stakingAPR / 365; - const rateGrowthFactor = 1 + dailyRate * remainingDays; - const projectedRate = (currentRate * BigInt(Math.floor(rateGrowthFactor * 1e18))) / 1e18; - const currentValue = (underlyingCollateral * 1e18) / currentRate; - const projectedValue = (underlyingCollateral * 1e18) / projectedRate; - const additionalYield = currentValue > projectedValue ? currentValue - projectedValue : 0n; - const harvestable = currentHarvestable + additionalYield; - - if (harvestable === 0n) return 0; - - // 2. Calculate pool allocation - const bountyRatio = await stabilityPoolManager.harvestBountyRatio(); - const cutRatio = await stabilityPoolManager.harvestCutRatio(); - const remaining = harvestable - (harvestable * bountyRatio) / 1e18 - (harvestable * cutRatio) / 1e18; - - // 3. Get pool split - const poolCollateral = await stabilityPoolCollateral.totalAssetSupply(); - const poolLeveraged = await stabilityPoolLeveraged.totalAssetSupply(); - const totalHolding = poolCollateral + poolLeveraged; - if (totalHolding === 0n) return 0; - - // 4. Determine which pool - const isCollateral = stabilityPool.address.toLowerCase() === stabilityPoolCollateral.address.toLowerCase(); - const toThisPool = isCollateral - ? (remaining * poolCollateral) / totalHolding - : (remaining * poolLeveraged) / totalHolding; - - // 5. Get queued and calculate rate - const { queued } = await stabilityPool.rewardData(rewardToken); - const totalRewards = toThisPool + queued; - const REWARD_PERIOD_LENGTH = 604800n; // 7 days - const projectedRate = totalRewards / REWARD_PERIOD_LENGTH; - - // 6. Calculate per-token rate - const totalSupply = await stabilityPool.totalAssetSupply(); - if (totalSupply === 0n) return 0; - const ratePerToken = Number(projectedRate) / Number(totalSupply); - - // 7. Project 7 days for user - const userBalance = await stabilityPool.assetBalanceOf(userAddress); - if (userBalance === 0n) return 0; - const rewards7Days = ratePerToken * Number(userBalance) * 604800; - - // 8. Calculate APR - const rewardPrice = await getTokenPrice(rewardToken); - const depositPrice = await getTokenPrice(await stabilityPool.ASSET_TOKEN()); - const depositValue = (Number(userBalance) * depositPrice) / 1e18; - const rewardValue = (rewards7Days * rewardPrice) / 1e18; - - return depositValue > 0 ? (rewardValue / depositValue) * (365 / 7) * 100 : 0; -} -``` - -## Important Considerations - -### 1. Queued Rewards - -The calculation includes `queued` rewards that are waiting to be distributed. These will be part of the next period. - -### 2. Period Transition Logic - -The actual rate calculation in the contract has logic for: - -- If current period has ended: `rate = amount / periodLength` -- If current period hasn't ended: May queue rewards or recalculate rate - -**For projection:** We assume the period has ended or will end, so we use the simple formula: `rate = totalRewards / periodLength` - -### 3. Pool Holdings - -The split between pools is based on **current** pool holdings. If deposits change before harvest, the split will change. - -### 4. Multiple Reward Tokens - -If there are multiple reward tokens, calculate APR for each and sum them: - -```typescript -const activeRewardTokens = await stabilityPool.activeRewardTokens(); -let totalAPR = 0; - -for (const token of activeRewardTokens) { - const apr = await getProjectedAPRNextPeriod( - minter, - stabilityPoolManager, - stabilityPool, - stabilityPoolCollateral, - stabilityPoolLeveraged, - token, - userAddress, - ); - totalAPR += apr; -} -``` - -### 5. Edge Cases - -- **No harvestable:** Return 0 (no projected APR) -- **No deposits:** Return 0 (can't calculate rate) -- **Empty pool:** Return 0 (no rewards to distribute) - -## Display on Frontend - -```typescript -// React hook example -function useProjectedAPR(poolAddress: string, userAddress: string) { - const [apr, setApr] = useState(null); - - useEffect(() => { - async function fetchAPR() { - const apr = await getProjectedAPRNextPeriod( - minter, - stabilityPoolManager, - stabilityPool, - stabilityPoolCollateral, - stabilityPoolLeveraged, - rewardToken, - userAddress, - ); - setApr(apr); - } - - if (userAddress && poolAddress) { - fetchAPR(); - // Refresh periodically or on block updates - const interval = setInterval(fetchAPR, 30000); - return () => clearInterval(interval); - } - }, [poolAddress, userAddress]); - - return apr; -} -``` - -## Summary - -**What this calculates:** - -- APR for the **next reward period** that would start after a harvest -- Based on **current harvestable amount** (projected to period end) -- Assumes harvest happens at the **end of current 7-day period** -- Projects the **next 7-day period** after that harvest - -**Perfect for:** - -- Launch scenarios where no harvests have happened yet -- Showing users what APR to expect after the first harvest -- Providing forward-looking projections based on current conditions - -**Key formula:** - -``` -1. Get remaining time until period end: - - rewardData = stabilityPool.rewardData(token) - - remainingSeconds = finishAt > currentTimestamp - ? finishAt - currentTimestamp - : REWARD_PERIOD_LENGTH - - remainingDays = remainingSeconds / 86400 - -2. Project additional yield for remaining days: - - currentRate = wstETH.stEthPerToken() - - underlyingCollateral = (balance - harvestable) * currentRate - - projectedRate = currentRate * (1 + stakingAPR/365 * remainingDays) - - currentValue = underlyingCollateral / currentRate - - projectedValue = underlyingCollateral / projectedRate - - additionalYield = currentValue - projectedValue - - projectedHarvestable = currentHarvestable + additionalYield - -3. Calculate pool allocation: - - harvestableRemaining = projectedHarvestable - bounty - cut - - toThisPool = harvestableRemaining * (poolSize / totalPoolSize) - -4. Calculate reward rate: - - totalRewards = toThisPool + queued - - rate = totalRewards / 604800 (rewards per second) - -5. Project user rewards: - - ratePerToken = rate / totalSupply - - rewards7Days = ratePerToken * userBalance * 604800 - -6. Calculate APR: - - APR = (rewardsValue / depositValue) * (365/7) * 100 -``` - -This gives users a projection of what APR they can expect after the next harvest! - -## Launch Scenario Example - -**At Launch:** - -- No harvests have happened yet -- No rewards are currently being distributed -- `harvestable()` shows some amount (e.g., 10 wstETH) -- You want to show users: "If we harvest in 7 days, projected APR is X%" - -**Calculation:** - -1. Get current `harvestable()` = 10 wstETH -2. Calculate: after bounty (5%) + cut (10%) = 8.5 wstETH to pools -3. Split between pools based on current deposits -4. Calculate rate for next 7-day period -5. Project APR based on that rate - -**Result:** Users see "Projected APR: 12.5%" (or whatever the calculation yields) - -This helps users understand what to expect even before the first harvest happens! diff --git a/doc/guides/FRONTEND-BASIC-CLAIM-DETAILED.md b/doc/guides/FRONTEND-BASIC-CLAIM-DETAILED.md deleted file mode 100644 index baed2a9b..00000000 --- a/doc/guides/FRONTEND-BASIC-CLAIM-DETAILED.md +++ /dev/null @@ -1,549 +0,0 @@ -# Frontend: Basic Claim - Detailed Step-by-Step Guide - -This guide provides detailed instructions for implementing the basic claim functionality for stability pool rewards. - -## Important: Which Contract to Call - -**You call `claim()` directly on the Stability Pool contract itself.** - -- **NOT** on a separate rewards contract -- **NOT** on the StabilityPoolManager -- **YES** directly on the StabilityPool contract (e.g., Collateral Pool or Leveraged Pool) - -The Stability Pool contract implements `IMultipleRewardAccumulator`, which includes the `claim()` function. - -## Contract Addresses - -You need the addresses of your stability pools: - -```typescript -// Example addresses (replace with your actual addresses) -const COLLATERAL_POOL_ADDRESS = "0x..."; // Your collateral stability pool -const LEVERAGED_POOL_ADDRESS = "0x..."; // Your leveraged stability pool -``` - -## Required ABI - -You need the `IMultipleRewardAccumulator` interface functions. Here's the minimal ABI: - -```typescript -const STABILITY_POOL_REWARDS_ABI = [ - // Get active reward tokens - "function activeRewardTokens() view returns (address[])", - - // Get claimable amount for a user and token - "function claimable(address account, address token) view returns (uint256)", - - // Claim functions - "function claim() external", - "function claim(address account) external", - "function claim(address account, address receiver) external", - - // Events - "event Claim(address indexed account, address indexed token, address indexed receiver, uint256 amount)", -] as const; -``` - -## Step 1: Check What's Claimable - -Before claiming, check what rewards are available: - -```typescript -import { Contract, formatEther } from "ethers"; - -async function checkClaimableRewards( - poolAddress: string, - userAddress: string, - provider: any, -): Promise< - { - token: string; - symbol: string; - amount: bigint; - amountFormatted: string; - }[] -> { - const pool = new Contract(poolAddress, STABILITY_POOL_REWARDS_ABI, provider); - - // Step 1: Get all active reward tokens - const rewardTokens = await pool.activeRewardTokens(); - console.log("Active reward tokens:", rewardTokens); - - // Step 2: Check claimable amount for each token - const claimableRewards = []; - - for (const tokenAddress of rewardTokens) { - // Get claimable amount - const claimable = await pool.claimable(userAddress, tokenAddress); - console.log(`Token ${tokenAddress}: claimable = ${claimable.toString()}`); - - if (claimable > 0n) { - // Get token symbol (optional, for display) - const tokenContract = new Contract(tokenAddress, ["function symbol() view returns (string)"], provider); - const symbol = await tokenContract.symbol(); - - claimableRewards.push({ - token: tokenAddress, - symbol, - amount: claimable, - amountFormatted: formatEther(claimable), - }); - } - } - - return claimableRewards; -} -``` - -**Usage:** - -```typescript -const rewards = await checkClaimableRewards(COLLATERAL_POOL_ADDRESS, userAddress, provider); - -console.log("Claimable rewards:", rewards); -// Example output: -// [ -// { -// token: "0x0165878A594ca255338adfa4d48449f69242Eb8F", -// symbol: "haPB", -// amount: 123230000000000000000n, -// amountFormatted: "123.23" -// } -// ] -``` - -## Step 2: Basic Claim - Simplest Form - -The simplest way to claim is to call `claim()` with no parameters. This claims all rewards for the connected wallet: - -```typescript -import { Contract } from "ethers"; - -async function claimRewardsSimple( - poolAddress: string, - signer: any, // Must be a signer, not a provider -): Promise<{ - tx: any; - receipt: any; -}> { - // Create contract instance with signer (for sending transactions) - const pool = new Contract(poolAddress, STABILITY_POOL_REWARDS_ABI, signer); - - // Call claim() - claims all active reward tokens for the signer's address - console.log("Calling claim() on pool:", poolAddress); - const tx = await pool.claim(); - - console.log("Transaction sent:", tx.hash); - - // Wait for confirmation - const receipt = await tx.wait(); - console.log("Transaction confirmed:", receipt); - - return { tx, receipt }; -} -``` - -**Usage:** - -```typescript -// Assuming you have a signer from wagmi or ethers -const { data: signer } = useSigner(); - -await claimRewardsSimple(COLLATERAL_POOL_ADDRESS, signer); -``` - -## Step 3: Claim with Specific Account - -If you want to claim for a specific account (must be the caller): - -```typescript -async function claimRewardsForAccount(poolAddress: string, accountAddress: string, signer: any): Promise { - const pool = new Contract(poolAddress, STABILITY_POOL_REWARDS_ABI, signer); - - // Claim for specific account (account must be the signer's address) - const tx = await pool.claim(accountAddress); - const receipt = await tx.wait(); - - return receipt; -} -``` - -## Step 4: Claim to Different Receiver - -If you want to claim rewards but send them to a different address: - -```typescript -async function claimRewardsToReceiver( - poolAddress: string, - accountAddress: string, // Account that earned the rewards - receiverAddress: string, // Address to receive the rewards - signer: any, -): Promise { - const pool = new Contract(poolAddress, STABILITY_POOL_REWARDS_ABI, signer); - - // Claim for account and send to receiver - // Note: accountAddress must be the signer's address (you can't claim for others to a different receiver) - const tx = await pool.claim(accountAddress, receiverAddress); - const receipt = await tx.wait(); - - return receipt; -} -``` - -## Step 5: Complete React Hook Example - -Here's a complete React hook using wagmi: - -```typescript -import { useContractWrite, useWaitForTransaction, useAccount } from "wagmi"; -import { formatEther } from "ethers"; - -export function useClaimRewards(poolAddress: string) { - const { address } = useAccount(); - - // Write function to claim rewards - const { - write: claim, - data: claimData, - isLoading: isClaiming, - error: claimError, - } = useContractWrite({ - address: poolAddress as `0x${string}`, - abi: STABILITY_POOL_REWARDS_ABI, - functionName: "claim", - // No args - claims for the connected wallet - }); - - // Wait for transaction - const { - isLoading: isWaiting, - isSuccess, - error: waitError, - } = useWaitForTransaction({ - hash: claimData?.hash, - }); - - return { - claim, - isClaiming: isClaiming || isWaiting, - isSuccess, - error: claimError || waitError, - txHash: claimData?.hash, - }; -} -``` - -**Usage in component:** - -```typescript -function ClaimButton({ poolAddress, poolName }: { poolAddress: string; poolName: string }) { - const { claim, isClaiming, isSuccess, error } = useClaimRewards(poolAddress); - - const handleClaim = () => { - claim(); - }; - - if (isSuccess) { - return
✅ Rewards claimed successfully!
; - } - - return ( - - ); -} -``` - -## Step 6: Claim from Multiple Pools - -If you want to claim from multiple pools: - -```typescript -async function claimFromMultiplePools(poolAddresses: string[], signer: any): Promise { - const receipts = []; - - for (const poolAddress of poolAddresses) { - const pool = new Contract(poolAddress, STABILITY_POOL_REWARDS_ABI, signer); - const tx = await pool.claim(); - const receipt = await tx.wait(); - receipts.push(receipt); - } - - return receipts; -} -``` - -Or using Promise.all for parallel execution: - -```typescript -async function claimFromMultiplePoolsParallel(poolAddresses: string[], signer: any): Promise { - const pools = poolAddresses.map((address) => new Contract(address, STABILITY_POOL_REWARDS_ABI, signer)); - - // Send all transactions - const txs = await Promise.all(pools.map((pool) => pool.claim())); - - // Wait for all confirmations - const receipts = await Promise.all(txs.map((tx) => tx.wait())); - - return receipts; -} -``` - -## Step 7: Listen for Claim Events - -To verify rewards were claimed, listen for the `Claim` event: - -```typescript -import { Contract } from "ethers"; - -async function listenForClaimEvents(poolAddress: string, userAddress: string, provider: any): Promise { - const pool = new Contract(poolAddress, STABILITY_POOL_REWARDS_ABI, provider); - - // Listen for Claim events - pool.on("Claim", (account, token, receiver, amount, event) => { - if (account.toLowerCase() === userAddress.toLowerCase()) { - console.log("Rewards claimed!"); - console.log("Token:", token); - console.log("Receiver:", receiver); - console.log("Amount:", amount.toString()); - console.log("Event:", event); - } - }); - - // To stop listening: - // pool.removeAllListeners("Claim"); -} -``` - -Or query past events: - -```typescript -async function getPastClaimEvents( - poolAddress: string, - userAddress: string, - fromBlock: number, - toBlock: number, - provider: any, -): Promise { - const pool = new Contract(poolAddress, STABILITY_POOL_REWARDS_ABI, provider); - - const filter = pool.filters.Claim(userAddress); - const events = await pool.queryFilter(filter, fromBlock, toBlock); - - return events; -} -``` - -## Step 8: Verify Rewards After Claim - -After claiming, verify the rewards were received: - -```typescript -import { Contract } from "ethers"; - -async function verifyClaimedRewards( - poolAddress: string, - userAddress: string, - rewardTokenAddress: string, - provider: any, -): Promise<{ - claimableBefore: bigint; - claimableAfter: bigint; - tokenBalanceBefore: bigint; - tokenBalanceAfter: bigint; -}> { - const pool = new Contract(poolAddress, STABILITY_POOL_REWARDS_ABI, provider); - const token = new Contract(rewardTokenAddress, ["function balanceOf(address) view returns (uint256)"], provider); - - // Check before - const claimableBefore = await pool.claimable(userAddress, rewardTokenAddress); - const tokenBalanceBefore = await token.balanceOf(userAddress); - - // ... perform claim ... - - // Check after - const claimableAfter = await pool.claimable(userAddress, rewardTokenAddress); - const tokenBalanceAfter = await token.balanceOf(userAddress); - - return { - claimableBefore, - claimableAfter, - tokenBalanceBefore, - tokenBalanceAfter, - }; -} -``` - -## Common Issues and Solutions - -### Issue 1: "No claimable rewards" - -**Problem:** `claimable()` returns 0 for all tokens. - -**Solutions:** - -- Check if user has any deposits: `assetBalanceOf(userAddress)` -- Check if rewards have been deposited to the pool -- Check if rewards are still vesting (use `rewardData()` to see vesting period) -- Verify you're checking the correct pool address - -### Issue 2: "Transaction reverted" - -**Problem:** Transaction fails when calling `claim()`. - -**Possible causes:** - -- No claimable rewards (check with `claimable()` first) -- Wrong contract address -- Wrong ABI (missing functions) -- Network mismatch - -**Solution:** - -```typescript -// Always check claimable first -const claimable = await pool.claimable(userAddress, tokenAddress); -if (claimable === 0n) { - console.log("No rewards to claim"); - return; -} - -// Then claim -await pool.claim(); -``` - -### Issue 3: "Wrong contract address" - -**Problem:** Calling claim on wrong contract. - -**Solution:** - -- Verify you're using the stability pool address, not the manager -- Check your contract deployment addresses -- Use `activeRewardTokens()` to verify - if it works, you have the right contract - -### Issue 4: "Insufficient gas" - -**Problem:** Transaction runs out of gas. - -**Solution:** - -- Estimate gas first: `const gasEstimate = await pool.claim.estimateGas();` -- Add buffer: `const tx = await pool.claim({ gasLimit: gasEstimate * 120n / 100n });` - -## Complete Example: Full Claim Flow - -```typescript -import { Contract, formatEther } from "ethers"; - -interface ClaimResult { - success: boolean; - claimedTokens: Array<{ - token: string; - symbol: string; - amount: string; - }>; - error?: string; -} - -async function claimRewardsComplete( - poolAddress: string, - userAddress: string, - signer: any, - provider: any, -): Promise { - try { - const pool = new Contract(poolAddress, STABILITY_POOL_REWARDS_ABI, provider); - - // Step 1: Check what's claimable - const rewardTokens = await pool.activeRewardTokens(); - const claimableBefore: Array<{ token: string; amount: bigint }> = []; - - for (const token of rewardTokens) { - const amount = await pool.claimable(userAddress, token); - if (amount > 0n) { - claimableBefore.push({ token, amount }); - } - } - - if (claimableBefore.length === 0) { - return { - success: false, - claimedTokens: [], - error: "No claimable rewards", - }; - } - - // Step 2: Claim rewards - const poolWithSigner = new Contract(poolAddress, STABILITY_POOL_REWARDS_ABI, signer); - const tx = await poolWithSigner.claim(); - const receipt = await tx.wait(); - - // Step 3: Verify claim - const claimedTokens = []; - for (const { token, amount } of claimableBefore) { - const tokenContract = new Contract(token, ["function symbol() view returns (string)"], provider); - const symbol = await tokenContract.symbol(); - - claimedTokens.push({ - token, - symbol, - amount: formatEther(amount), - }); - } - - return { - success: true, - claimedTokens, - }; - } catch (error: any) { - return { - success: false, - claimedTokens: [], - error: error.message || "Unknown error", - }; - } -} -``` - -## Testing with cast (Command Line) - -You can test the claim function using `cast`: - -```bash -# Check claimable amount -cast call "claimable(address,address)(uint256)" --rpc-url http://localhost:8545 - -# Claim rewards (requires private key or unlocked account) -cast send "claim()" --private-key --rpc-url http://localhost:8545 - -# Or with unlocked account -cast send "claim()" --unlocked --rpc-url http://localhost:8545 -``` - -## Summary - -**Key Points:** - -1. ✅ Call `claim()` **directly on the Stability Pool contract** -2. ✅ Use the `IMultipleRewardAccumulator` ABI functions -3. ✅ Check `claimable()` before claiming -4. ✅ Use a **signer** (not provider) to send transactions -5. ✅ Listen for `Claim` events to verify success - -**Function Signature:** - -```solidity -function claim() external; -``` - -**What it does:** - -- Claims all active reward tokens for the caller -- Sends rewards to the caller's address (or their `rewardReceiver` if set) -- Updates internal reward tracking - -**No parameters needed** - just call `claim()` on the pool contract! - - diff --git a/doc/guides/FRONTEND-CLAIM-AND-COMPOUND.md b/doc/guides/FRONTEND-CLAIM-AND-COMPOUND.md deleted file mode 100644 index d15ad51f..00000000 --- a/doc/guides/FRONTEND-CLAIM-AND-COMPOUND.md +++ /dev/null @@ -1,986 +0,0 @@ -# Frontend Guide: Claim and Compound Rewards - -This guide explains how to implement the claim and compound functionality for stability pool rewards. - -## Overview - -Users can claim rewards from stability pools and choose to: - -1. **Basic Claim**: Receive rewards directly to wallet -2. **Compound**: Automatically reinvest rewards back into stability pools -3. **Buy $TIDE**: Acquire governance tokens (future feature) - -## 1. Claim Function Interface - -### Claim All Rewards - -```solidity -// Claim all active reward tokens for the caller -function claim() external; - -// Claim all active reward tokens for a specific account -function claim(address account) external; - -// Claim all active reward tokens for account and send to receiver -function claim(address account, address receiver) external; -``` - -### Claim Specific Tokens - -```solidity -// Claim specific historical reward tokens -function claimHistorical(address[] memory tokens) external; -function claimHistorical(address account, address[] memory tokens) external; -``` - -## 2. Getting Claimable Rewards by Pool - -### Query All Pools for User - -```typescript -interface PoolRewards { - poolAddress: string; - poolName: string; - rewards: Array<{ - token: string; - symbol: string; - amount: bigint; - amountFormatted: string; - usdValue: number; - }>; - totalUSD: number; -} - -async function getClaimableRewardsByPool( - userAddress: string, - pools: Array<{ address: string; name: string; type: "collateral" | "leveraged" }>, - tokenPriceMap: Map, -): Promise { - const poolRewards: PoolRewards[] = []; - - for (const pool of pools) { - const poolContract = new Contract(pool.address, STABILITY_POOL_ABI, provider); - - // Get active reward tokens - const rewardTokens = await poolContract.activeRewardTokens(); - - const rewards = await Promise.all( - rewardTokens.map(async (token: string) => { - const claimable = await poolContract.claimable(userAddress, token); - - if (claimable > 0n) { - const tokenContract = new Contract(token, ERC20_ABI, provider); - const symbol = await tokenContract.symbol(); - const price = tokenPriceMap.get(token.toLowerCase()) || 0; - const amountFormatted = formatEther(claimable); - const usdValue = parseFloat(amountFormatted) * price; - - return { - token, - symbol, - amount: claimable, - amountFormatted, - usdValue, - }; - } - return null; - }), - ); - - const validRewards = rewards.filter((r) => r !== null) as any[]; - const totalUSD = validRewards.reduce((sum, r) => sum + r.usdValue, 0); - - if (validRewards.length > 0) { - poolRewards.push({ - poolAddress: pool.address, - poolName: pool.name, - rewards: validRewards, - totalUSD, - }); - } - } - - return poolRewards; -} -``` - -## 3. Basic Claim Implementation - -### Claim from Single Pool - -```typescript -async function claimRewards(poolAddress: string, userAddress: string, receiver?: string): Promise { - const pool = new Contract(poolAddress, STABILITY_POOL_ABI, signer); - - // Use receiver if provided, otherwise send to user - const claimReceiver = receiver || userAddress; - - // Claim all active reward tokens - const tx = await pool.claim(userAddress, claimReceiver); - return tx; -} -``` - -### Claim from Multiple Pools - -```typescript -async function claimRewardsFromPools( - poolAddresses: string[], - userAddress: string, - receiver?: string, -): Promise { - const claimReceiver = receiver || userAddress; - const transactions: Promise[] = []; - - for (const poolAddress of poolAddresses) { - const pool = new Contract(poolAddress, STABILITY_POOL_ABI, signer); - transactions.push(pool.claim(userAddress, claimReceiver)); - } - - // Execute all claims (can be batched if needed) - return Promise.all(transactions); -} -``` - -## 4. Compound Implementation - -### Compound Flow Overview - -**For Collateral Tokens (wstETH):** - -1. Claim rewards (wstETH) to contract/temporary address -2. Mint ha tokens using wstETH -3. Deposit ha tokens to selected stability pool(s) - -**For ha Tokens:** - -1. Claim rewards (ha tokens) to contract/temporary address -2. Deposit ha tokens directly to selected stability pool(s) - -### Step 1: Determine Reward Token Types - -```typescript -interface RewardTokenInfo { - token: string; - symbol: string; - amount: bigint; - isCollateral: boolean; // true if wstETH, false if ha token - isHaToken: boolean; // true if ha token -} - -async function categorizeRewardTokens( - rewards: PoolRewards[], - wstETHAddress: string, - haTokenAddress: string, -): Promise<{ - collateralRewards: RewardTokenInfo[]; - haTokenRewards: RewardTokenInfo[]; - otherRewards: RewardTokenInfo[]; -}> { - const collateralRewards: RewardTokenInfo[] = []; - const haTokenRewards: RewardTokenInfo[] = []; - const otherRewards: RewardTokenInfo[] = []; - - for (const pool of rewards) { - for (const reward of pool.rewards) { - const tokenLower = reward.token.toLowerCase(); - const isCollateral = tokenLower === wstETHAddress.toLowerCase(); - const isHaToken = tokenLower === haTokenAddress.toLowerCase(); - - const info: RewardTokenInfo = { - token: reward.token, - symbol: reward.symbol, - amount: reward.amount, - isCollateral, - isHaToken, - }; - - if (isCollateral) { - collateralRewards.push(info); - } else if (isHaToken) { - haTokenRewards.push(info); - } else { - otherRewards.push(info); - } - } - } - - return { collateralRewards, haTokenRewards, otherRewards }; -} -``` - -### Step 2: Get User's Active Pool Deposits - -```typescript -async function getUserActivePools( - userAddress: string, - pools: Array<{ address: string; name: string; type: "collateral" | "leveraged" }>, -): Promise> { - const activePools = []; - - for (const pool of pools) { - const poolContract = new Contract(pool.address, STABILITY_POOL_ABI, provider); - const balance = await poolContract.assetBalanceOf(userAddress); - - if (balance > 0n) { - activePools.push({ - address: pool.address, - name: pool.name, - type: pool.type, - balance, - }); - } - } - - return activePools; -} -``` - -### Step 3: Compound Collateral Tokens (wstETH) - -```typescript -async function compoundCollateralRewards( - poolAddress: string, - userAddress: string, - rewardAmount: bigint, - targetPools: string[], // Pool addresses to compound into - minterAddress: string, - wstETHAddress: string, - haTokenAddress: string, -): Promise { - const pool = new Contract(poolAddress, STABILITY_POOL_ABI, signer); - const minter = new Contract(minterAddress, MINTER_ABI, signer); - const wstETH = new Contract(wstETHAddress, ERC20_ABI, signer); - - // Step 1: Claim rewards to this contract (or use multicall) - // For simplicity, we'll claim to a temporary address first - // In production, you might use a compound helper contract - - // Option A: Use multicall to batch operations - // Option B: Use a helper contract that handles the flow - // Option C: Do it in separate transactions (simpler but more gas) - - // For this guide, we'll show the step-by-step approach: - - // 1. Claim rewards to user (or to compound helper contract) - const claimTx = await pool.claim(userAddress, userAddress); - await claimTx.wait(); - - // 2. Approve minter to spend wstETH - const approveTx = await wstETH.approve(minterAddress, rewardAmount); - await approveTx.wait(); - - // 3. Mint ha tokens with wstETH - // Get expected ha tokens (for slippage protection) - const { peggedOut } = await minter.mintPeggedTokenDryRun(rewardAmount); - const minPeggedOut = (peggedOut * 95n) / 100n; // 5% slippage tolerance - - const mintTx = await minter.mintPeggedToken( - rewardAmount, - userAddress, // receiver of ha tokens - minPeggedOut, - ); - const mintReceipt = await mintTx.wait(); - - // 4. Get actual ha tokens minted (from event or balance change) - const haToken = new Contract(haTokenAddress, ERC20_ABI, provider); - const haTokensMinted = await haToken.balanceOf(userAddress); - - // 5. Distribute ha tokens to selected pools - const depositPromises = targetPools.map(async (targetPoolAddress) => { - const targetPool = new Contract(targetPoolAddress, STABILITY_POOL_ABI, signer); - - // Calculate amount per pool (equal split, or user can specify) - const amountPerPool = haTokensMinted / BigInt(targetPools.length); - - // Approve pool to spend ha tokens - await haToken.approve(targetPoolAddress, amountPerPool); - - // Deposit to pool - return targetPool.deposit( - amountPerPool, - userAddress, // receiver of shares - amountPerPool, // minAmount (no slippage for direct deposit) - ); - }); - - const depositTxs = await Promise.all(depositPromises); - - // Return the last transaction (or you could return all) - return depositTxs[depositTxs.length - 1]; -} -``` - -### Step 4: Compound ha Tokens - -```typescript -async function compoundHaTokenRewards( - poolAddress: string, - userAddress: string, - rewardAmount: bigint, - targetPools: string[], - haTokenAddress: string, -): Promise { - const pool = new Contract(poolAddress, STABILITY_POOL_ABI, signer); - const haToken = new Contract(haTokenAddress, ERC20_ABI, signer); - - // 1. Claim ha token rewards - const claimTx = await pool.claim(userAddress, userAddress); - await claimTx.wait(); - - // 2. Get actual ha tokens received (from balance change) - const haTokensReceived = await haToken.balanceOf(userAddress); - - // 3. Distribute to selected pools - const depositPromises = targetPools.map(async (targetPoolAddress) => { - const targetPool = new Contract(targetPoolAddress, STABILITY_POOL_ABI, signer); - const amountPerPool = haTokensReceived / BigInt(targetPools.length); - - await haToken.approve(targetPoolAddress, amountPerPool); - - return targetPool.deposit(amountPerPool, userAddress, amountPerPool); - }); - - const depositTxs = await Promise.all(depositPromises); - return depositTxs[depositTxs.length - 1]; -} -``` - -### Step 5: Complete Compound Function - -```typescript -interface CompoundOptions { - selectedPools: string[]; // Pool addresses to claim from - targetPools: string[]; // Pool addresses to compound into - splitStrategy: "equal" | "proportional" | "custom"; - customSplit?: Map; // pool address -> percentage -} - -async function compoundRewards( - userAddress: string, - options: CompoundOptions, - wstETHAddress: string, - haTokenAddress: string, - minterAddress: string, - allPools: Array<{ address: string; name: string; type: string }>, -): Promise { - const transactions: TransactionResponse[] = []; - - // 1. Get all claimable rewards from selected pools - const poolRewards = await getClaimableRewardsByPool( - userAddress, - allPools.filter((p) => options.selectedPools.includes(p.address)), - tokenPriceMap, - ); - - // 2. Categorize reward tokens - const { collateralRewards, haTokenRewards, otherRewards } = await categorizeRewardTokens( - poolRewards, - wstETHAddress, - haTokenAddress, - ); - - // 3. Handle other rewards (can't compound, must claim) - if (otherRewards.length > 0) { - // Claim other rewards to wallet (can't compound) - for (const pool of poolRewards) { - const poolContract = new Contract(pool.poolAddress, STABILITY_POOL_ABI, signer); - const tx = await poolContract.claim(userAddress, userAddress); - transactions.push(tx); - } - } - - // 4. Compound collateral rewards - for (const reward of collateralRewards) { - // Find which pool this reward came from - const sourcePool = poolRewards.find((p) => p.rewards.some((r) => r.token === reward.token)); - - if (sourcePool) { - const tx = await compoundCollateralRewards( - sourcePool.poolAddress, - userAddress, - reward.amount, - options.targetPools, - minterAddress, - wstETHAddress, - haTokenAddress, - ); - transactions.push(tx); - } - } - - // 5. Compound ha token rewards - for (const reward of haTokenRewards) { - const sourcePool = poolRewards.find((p) => p.rewards.some((r) => r.token === reward.token)); - - if (sourcePool) { - const tx = await compoundHaTokenRewards( - sourcePool.poolAddress, - userAddress, - reward.amount, - options.targetPools, - haTokenAddress, - ); - transactions.push(tx); - } - } - - return transactions; -} -``` - -## 5. React Hook Implementation - -### Complete Compound Hook - -```typescript -import { useState, useCallback } from "react"; -import { Contract, TransactionResponse } from "ethers"; - -interface UseCompoundRewards { - compound: (options: CompoundOptions) => Promise; - loading: boolean; - error: string | null; -} - -export function useCompoundRewards( - userAddress: string | null, - wstETHAddress: string, - haTokenAddress: string, - minterAddress: string, - pools: Array<{ address: string; name: string; type: string }>, -): UseCompoundRewards { - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const compound = useCallback( - async (options: CompoundOptions) => { - if (!userAddress) { - setError("User not connected"); - return []; - } - - setLoading(true); - setError(null); - - try { - const transactions = await compoundRewards( - userAddress, - options, - wstETHAddress, - haTokenAddress, - minterAddress, - pools, - ); - - // Wait for all transactions - await Promise.all(transactions.map((tx) => tx.wait())); - - setLoading(false); - return transactions; - } catch (err: any) { - setError(err.message || "Compound failed"); - setLoading(false); - return []; - } - }, - [userAddress, wstETHAddress, haTokenAddress, minterAddress, pools], - ); - - return { compound, loading, error }; -} -``` - -## 6. UI Component Example - -### Claim Modal Component - -```typescript -function ClaimRewardsModal({ - isOpen, - onClose, - poolRewards, - userActivePools, - onClaim, - onCompound, -}: { - isOpen: boolean; - onClose: () => void; - poolRewards: PoolRewards[]; - userActivePools: Array<{ address: string; name: string; type: string }>; - onClaim: (selectedPools: string[]) => Promise; - onCompound: (options: CompoundOptions) => Promise; -}) { - const [selectedPools, setSelectedPools] = useState>(new Set()); - const [compoundMode, setCompoundMode] = useState<'basic' | 'compound' | 'tide'>('basic'); - const [targetPools, setTargetPools] = useState>(new Set()); - - // Calculate total selected rewards - const totalSelected = poolRewards - .filter(p => selectedPools.has(p.poolAddress)) - .reduce((sum, p) => sum + p.totalUSD, 0); - - // Initialize: select all pools with rewards - useEffect(() => { - if (isOpen) { - setSelectedPools(new Set(poolRewards.map(p => p.poolAddress))); - // Default: compound into pools user is already in - setTargetPools(new Set(userActivePools.map(p => p.address))); - } - }, [isOpen, poolRewards, userActivePools]); - - const handleClaim = async () => { - await onClaim(Array.from(selectedPools)); - onClose(); - }; - - const handleCompound = async () => { - await onCompound({ - selectedPools: Array.from(selectedPools), - targetPools: Array.from(targetPools), - splitStrategy: 'equal', - }); - onClose(); - }; - - return ( - -
- {/* Left Section: Select Pools */} -
-

Select Pools to Claim

- {poolRewards.map((pool) => ( -
- { - const newSet = new Set(selectedPools); - if (e.target.checked) { - newSet.add(pool.poolAddress); - } else { - newSet.delete(pool.poolAddress); - } - setSelectedPools(newSet); - }} - /> -
-
{pool.poolName}
- {pool.rewards.map((reward) => ( -
- {reward.amountFormatted} {reward.symbol} -
- ))} -
-
- ${pool.totalUSD.toFixed(2)} -
-
- ))} -
- - {/* Right Section: Action Selection */} -
-

Choose how you would like to handle your rewards:

- -
- Total Selected Rewards: ${totalSelected.toFixed(2)} -
- - {/* Basic Claim */} -
setCompoundMode('basic')} - > -
-

Basic Claim

-

Receive rewards directly to your wallet

-
- -
- - {/* Compound */} - {compoundMode === 'compound' && ( -
-

Select Pools to Compound Into:

- {userActivePools.map((pool) => ( - - ))} - {targetPools.size === 0 && ( -

Please select at least one pool

- )} -
- )} - -
setCompoundMode('compound')} - > -
-

Compound

-

Automatically reinvest rewards for compound growth

-
- -
- - {/* Buy TIDE (Future) */} -
{ - // Future feature - alert('Buy $TIDE coming soon!'); - }} - > -
-

Buy $TIDE

-

Acquire Harbor governance tokens

-
- -
- - {/* Action Button */} - -
-
-
- ); -} -``` - -## 7. Gas Optimization: Using Multicall - -For better UX, use multicall to batch operations: - -```typescript -import { Multicall } from "@makerdao/multicall"; - -async function compoundRewardsOptimized( - userAddress: string, - options: CompoundOptions, - // ... other params -): Promise { - // Use a compound helper contract or multicall - // This reduces gas costs and improves UX - - // Example using a helper contract approach: - const compoundHelper = new Contract(COMPOUND_HELPER_ADDRESS, COMPOUND_HELPER_ABI, signer); - - // Helper contract handles: - // 1. Claim rewards - // 2. Mint ha tokens (if needed) - // 3. Deposit to pools - // All in one transaction - - return compoundHelper.compoundRewards(options.selectedPools, options.targetPools, options.splitStrategy); -} -``` - -## 8. Error Handling - -```typescript -async function compoundRewardsWithErrorHandling(): Promise<{ - // ... params - success: boolean; - tx?: TransactionResponse; - error?: string; -}> { - try { - // Validate inputs - if (options.selectedPools.length === 0) { - return { success: false, error: "No pools selected" }; - } - - if (options.targetPools.length === 0 && compoundMode === "compound") { - return { success: false, error: "No target pools selected" }; - } - - // Check balances before starting - const poolRewards = await getClaimableRewardsByPool(/* ... */); - if (poolRewards.length === 0) { - return { success: false, error: "No claimable rewards" }; - } - - // Execute compound - const tx = await compoundRewards(/* ... */); - await tx.wait(); - - return { success: true, tx }; - } catch (error: any) { - // Parse error - if (error.code === "ACTION_REJECTED") { - return { success: false, error: "Transaction rejected" }; - } - if (error.message?.includes("insufficient")) { - return { success: false, error: "Insufficient balance" }; - } - if (error.message?.includes("slippage")) { - return { success: false, error: "Slippage too high" }; - } - - return { success: false, error: error.message || "Unknown error" }; - } -} -``` - -## 9. Split Strategies - -### Equal Split - -```typescript -function calculateEqualSplit(totalAmount: bigint, targetPools: string[]): Map { - const split = new Map(); - const amountPerPool = totalAmount / BigInt(targetPools.length); - const remainder = totalAmount % BigInt(targetPools.length); - - targetPools.forEach((pool, index) => { - // Add remainder to first pool - const amount = amountPerPool + (index === 0 ? remainder : 0n); - split.set(pool, amount); - }); - - return split; -} -``` - -### Proportional Split (by existing deposit size) - -```typescript -async function calculateProportionalSplit( - totalAmount: bigint, - targetPools: string[], - userAddress: string, -): Promise> { - const split = new Map(); - - // Get balances for each pool - const balances = await Promise.all( - targetPools.map(async (poolAddress) => { - const pool = new Contract(poolAddress, STABILITY_POOL_ABI, provider); - const balance = await pool.assetBalanceOf(userAddress); - return { poolAddress, balance }; - }), - ); - - const totalBalance = balances.reduce((sum, b) => sum + b.balance, 0n); - - if (totalBalance === 0n) { - // Fallback to equal split if no existing deposits - return calculateEqualSplit(totalAmount, targetPools); - } - - balances.forEach(({ poolAddress, balance }) => { - const proportion = (totalAmount * balance) / totalBalance; - split.set(poolAddress, proportion); - }); - - return split; -} -``` - -### Custom Split - -```typescript -function calculateCustomSplit(totalAmount: bigint, customPercentages: Map): Map { - const split = new Map(); - - // Validate percentages sum to 100 - const totalPercent = Array.from(customPercentages.values()).reduce((sum, p) => sum + p, 0); - - if (Math.abs(totalPercent - 100) > 0.01) { - throw new Error("Percentages must sum to 100%"); - } - - customPercentages.forEach((percentage, poolAddress) => { - const amount = (totalAmount * BigInt(Math.floor(percentage * 100))) / 10000n; - split.set(poolAddress, amount); - }); - - return split; -} -``` - -## 10. Complete Example Flow - -```typescript -// In your component -function RewardsSection() { - const { address } = useAccount(); - const { compound, loading, error } = useCompoundRewards( - address, - WSTETH_ADDRESS, - HA_TOKEN_ADDRESS, - MINTER_ADDRESS, - POOLS - ); - - const [poolRewards, setPoolRewards] = useState([]); - const [userActivePools, setUserActivePools] = useState([]); - const [showClaimModal, setShowClaimModal] = useState(false); - - // Fetch rewards - useEffect(() => { - async function fetchRewards() { - const rewards = await getClaimableRewardsByPool( - address!, - POOLS, - tokenPriceMap - ); - setPoolRewards(rewards); - - const active = await getUserActivePools(address!, POOLS); - setUserActivePools(active); - } - if (address) { - fetchRewards(); - const interval = setInterval(fetchRewards, 30000); // Refresh every 30s - return () => clearInterval(interval); - } - }, [address]); - - const handleClaim = async (selectedPools: string[]) => { - // Claim from selected pools - for (const poolAddress of selectedPools) { - const pool = new Contract(poolAddress, STABILITY_POOL_ABI, signer); - await pool.claim(address!, address!); - } - }; - - const handleCompound = async (options: CompoundOptions) => { - await compound(options); - // Refresh rewards after compound - // ... refresh logic - }; - - const totalClaimable = poolRewards.reduce((sum, p) => sum + p.totalUSD, 0); - - return ( -
-
-

Claimable Value

-

${totalClaimable.toFixed(2)}

- -
- - setShowClaimModal(false)} - poolRewards={poolRewards} - userActivePools={userActivePools} - onClaim={handleClaim} - onCompound={handleCompound} - /> -
- ); -} -``` - -## 11. Important Considerations - -### Approval Management - -```typescript -// Check and handle approvals before compound -async function ensureApprovals( - userAddress: string, - amounts: Map, // token -> amount - spender: string, -): Promise { - for (const [token, amount] of amounts) { - const tokenContract = new Contract(token, ERC20_ABI, signer); - const allowance = await tokenContract.allowance(userAddress, spender); - - if (allowance < amount) { - // Approve max or specific amount - await tokenContract.approve(spender, ethers.MaxUint256); - } - } -} -``` - -### Slippage Protection - -```typescript -// When minting ha tokens, use slippage protection -const { peggedOut } = await minter.mintPeggedTokenDryRun(collateralAmount); -const minPeggedOut = (peggedOut * 95n) / 100n; // 5% slippage - -await minter.mintPeggedToken(collateralAmount, userAddress, minPeggedOut); -``` - -### Transaction Ordering - -For compound, the order matters: - -1. **Claim first** - Get rewards into user's wallet -2. **Approve** - Allow contracts to spend tokens -3. **Mint** (if collateral) - Convert to ha tokens -4. **Deposit** - Add to stability pools - -### Gas Estimation - -```typescript -// Estimate gas before executing -async function estimateCompoundGas( - options: CompoundOptions -): Promise { - // Estimate each step - const claimGas = /* estimate claim */; - const mintGas = /* estimate mint */; - const depositGas = /* estimate deposit */ * options.targetPools.length; - - return claimGas + mintGas + depositGas; -} -``` - -## 12. Summary - -**Basic Claim Flow:** - -1. User selects pools -2. Call `claim()` on each pool -3. Rewards sent to user's wallet - -**Compound Flow:** - -1. User selects pools to claim from -2. User selects pools to compound into -3. Claim rewards -4. If wstETH: Mint ha tokens → Deposit to pools -5. If ha tokens: Deposit directly to pools -6. User's position increases in selected pools - -**Key Functions:** - -- `claim(account, receiver)` - Claim rewards -- `mintPeggedToken()` - Mint ha tokens from collateral -- `deposit()` - Deposit to stability pool -- `assetBalanceOf()` - Check user's pool position - -This provides a complete implementation guide for claim and compound functionality! - - diff --git a/doc/guides/FRONTEND-COLLATERAL-RATIO-FIX.md b/doc/guides/FRONTEND-COLLATERAL-RATIO-FIX.md deleted file mode 100644 index 240ca325..00000000 --- a/doc/guides/FRONTEND-COLLATERAL-RATIO-FIX.md +++ /dev/null @@ -1,194 +0,0 @@ -# Frontend Collateral Ratio Fix - -## Problem -The frontend is unable to fetch `collateralRatio()` from the Minter contract. The call reverts with error: -``` -Error: server returned an error response: error code 3: execution reverted: -custom error 0xd2159c14: StaleUnderlyingPrice -``` - -## Root Cause -The error `0xd2159c14` is `StaleUnderlyingPrice` from the PriceOracle. This occurs when: -1. The price oracle checks if price feed data is fresh (not too old) -2. The check: `block.timestamp - updatedAt > constraints.maxAnswerAge` -3. If the price feed's `updatedAt` timestamp is too old, it reverts - -## Solution - -### Option 1: Update Price Feeds (Recommended for Local Development) -Update all price feeds to have fresh timestamps: - -```bash -# Update wstETH/USD feed -cast send 0xeC827421505972a2AE9C320302d3573B42363C26 \ - "setLatestAnswer(int256)" 200000000000 \ - --rpc-url http://localhost:8545 \ - --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 - -# Update stETH/USD feed -cast send 0xb007167714e2940013ec3bb551584130b7497e22 \ - "setLatestAnswer(int256)" 200000000000 \ - --rpc-url http://localhost:8545 \ - --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 - -# Update stETH/ETH feed -cast send 0x6b39b761b1b64c8c095bf0e3bb0c6a74705b4788 \ - "setLatestAnswer(int256)" 100000000 \ - --rpc-url http://localhost:8545 \ - --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 -``` - -### Option 2: Frontend Error Handling -The frontend should gracefully handle this error: - -```typescript -// In your contract read logic -const collateralRatioRead = await publicClient.readContract({ - address: minterAddress, - abi: minterABI, - functionName: 'collateralRatio', -}).catch((error) => { - // Check if it's a StaleUnderlyingPrice error - if (error.message?.includes('0xd2159c14') || - error.message?.includes('StaleUnderlyingPrice')) { - console.warn('Price feed is stale, collateral ratio unavailable'); - return null; // or undefined - } - throw error; // Re-throw other errors -}); - -// In your display logic -const displayRatio = collateralRatioRead - ? formatRatio(collateralRatioRead) - : '-'; // Show "-" when unavailable -``` - -## Why This Happens -1. **Price Oracle Validation**: The PriceOracle validates that price feeds are fresh (not stale) -2. **Staleness Check**: It checks `block.timestamp - updatedAt > maxAnswerAge` -3. **Mock Price Feeds**: In local development, mock price feeds need to be updated periodically -4. **Real Chainlink Feeds**: On mainnet, Chainlink automatically updates feeds, but mocks need manual updates - -## Prevention -For local development, you can: -1. **Automated Updates**: Create a script that updates price feeds every few minutes -2. **Frontend Retry**: Implement retry logic with exponential backoff -3. **Fallback Display**: Show "-" or "N/A" when collateral ratio is unavailable -4. **Error Logging**: Log the error for debugging but don't break the UI - -## Current Status -✅ Price feeds can be updated using `setLatestAnswer()` on mock Chainlink aggregators -✅ Frontend should handle this error gracefully -⚠️ Price feeds need periodic updates in local development -⚠️ On mainnet, this should not happen (Chainlink updates automatically) - -## Testing -After updating price feeds, test the collateral ratio: - -```bash -cast call 0x6484EB0792c646A4827638Fc1B6F20461418eB00 \ - "collateralRatio()(uint256)" \ - --rpc-url http://localhost:8545 -``` - -Expected: Should return a uint256 value (e.g., `2000000000000000000` for 2.0x) - - - -## Problem -The frontend is unable to fetch `collateralRatio()` from the Minter contract. The call reverts with error: -``` -Error: server returned an error response: error code 3: execution reverted: -custom error 0xd2159c14: StaleUnderlyingPrice -``` - -## Root Cause -The error `0xd2159c14` is `StaleUnderlyingPrice` from the PriceOracle. This occurs when: -1. The price oracle checks if price feed data is fresh (not too old) -2. The check: `block.timestamp - updatedAt > constraints.maxAnswerAge` -3. If the price feed's `updatedAt` timestamp is too old, it reverts - -## Solution - -### Option 1: Update Price Feeds (Recommended for Local Development) -Update all price feeds to have fresh timestamps: - -```bash -# Update wstETH/USD feed -cast send 0xeC827421505972a2AE9C320302d3573B42363C26 \ - "setLatestAnswer(int256)" 200000000000 \ - --rpc-url http://localhost:8545 \ - --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 - -# Update stETH/USD feed -cast send 0xb007167714e2940013ec3bb551584130b7497e22 \ - "setLatestAnswer(int256)" 200000000000 \ - --rpc-url http://localhost:8545 \ - --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 - -# Update stETH/ETH feed -cast send 0x6b39b761b1b64c8c095bf0e3bb0c6a74705b4788 \ - "setLatestAnswer(int256)" 100000000 \ - --rpc-url http://localhost:8545 \ - --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 -``` - -### Option 2: Frontend Error Handling -The frontend should gracefully handle this error: - -```typescript -// In your contract read logic -const collateralRatioRead = await publicClient.readContract({ - address: minterAddress, - abi: minterABI, - functionName: 'collateralRatio', -}).catch((error) => { - // Check if it's a StaleUnderlyingPrice error - if (error.message?.includes('0xd2159c14') || - error.message?.includes('StaleUnderlyingPrice')) { - console.warn('Price feed is stale, collateral ratio unavailable'); - return null; // or undefined - } - throw error; // Re-throw other errors -}); - -// In your display logic -const displayRatio = collateralRatioRead - ? formatRatio(collateralRatioRead) - : '-'; // Show "-" when unavailable -``` - -## Why This Happens -1. **Price Oracle Validation**: The PriceOracle validates that price feeds are fresh (not stale) -2. **Staleness Check**: It checks `block.timestamp - updatedAt > maxAnswerAge` -3. **Mock Price Feeds**: In local development, mock price feeds need to be updated periodically -4. **Real Chainlink Feeds**: On mainnet, Chainlink automatically updates feeds, but mocks need manual updates - -## Prevention -For local development, you can: -1. **Automated Updates**: Create a script that updates price feeds every few minutes -2. **Frontend Retry**: Implement retry logic with exponential backoff -3. **Fallback Display**: Show "-" or "N/A" when collateral ratio is unavailable -4. **Error Logging**: Log the error for debugging but don't break the UI - -## Current Status -✅ Price feeds can be updated using `setLatestAnswer()` on mock Chainlink aggregators -✅ Frontend should handle this error gracefully -⚠️ Price feeds need periodic updates in local development -⚠️ On mainnet, this should not happen (Chainlink updates automatically) - -## Testing -After updating price feeds, test the collateral ratio: - -```bash -cast call 0x6484EB0792c646A4827638Fc1B6F20461418eB00 \ - "collateralRatio()(uint256)" \ - --rpc-url http://localhost:8545 -``` - -Expected: Should return a uint256 value (e.g., `2000000000000000000` for 2.0x) - - - - - diff --git a/doc/guides/FRONTEND-COMPOUND-DETAILED.md b/doc/guides/FRONTEND-COMPOUND-DETAILED.md deleted file mode 100644 index e796850b..00000000 --- a/doc/guides/FRONTEND-COMPOUND-DETAILED.md +++ /dev/null @@ -1,1038 +0,0 @@ -# Frontend: Compound Rewards - Detailed Step-by-Step Guide - -This guide provides comprehensive instructions for implementing compound functionality that handles all reward token types and both stability pools. - -## Overview - -Compound functionality reinvests rewards back into stability pools. The flow depends on the reward token type: - -1. **Collateral Tokens (wstETH)**: Mint ha tokens → Deposit to pool(s) -2. **hs Tokens (Leveraged)**: Claim → Redeem for collateral → Mint ha tokens → Deposit to pool(s) -3. **ha Tokens (Pegged)**: Deposit directly to pool(s) - -## Required Contract Addresses - -```typescript -const CONTRACTS = { - // Stability Pools - collateralPool: "0x...", // Collateral stability pool - leveragedPool: "0x...", // Leveraged stability pool - - // Minter (for minting/redeeming) - minter: "0x...", - - // Tokens - wstETH: "0x...", // Collateral token - haToken: "0x...", // Pegged token (haPB) - hsToken: "0x...", // Leveraged token (hsPB) -}; -``` - -## Required ABIs - -```typescript -const MINTER_ABI = [ - // Mint functions - "function mintPeggedToken(uint256 collateralAmount, address receiver, uint256 minPeggedOut) external returns (uint256 peggedOut)", - "function mintPeggedTokenDryRun(uint256 collateralAmount) external view returns (uint256 peggedOut, uint256 wrappedFee, uint256 fee)", - - // Redeem functions - "function redeemPeggedToken(uint256 peggedAmount, address receiver, uint256 minCollateralOut) external returns (uint256 collateralOut)", - "function redeemPeggedTokenDryRun(uint256 peggedAmount) external view returns (uint256 collateralOut, uint256 wrappedFee, uint256 fee)", - - // Leveraged token functions - "function mintLeveragedToken(uint256 collateralAmount, address receiver, uint256 minLeveragedOut) external returns (uint256 leveragedOut)", - "function mintLeveragedTokenDryRun(uint256 collateralAmount) external view returns (uint256 leveragedOut, uint256 wrappedFee, uint256 fee)", - - "function redeemLeveragedToken(uint256 leveragedAmount, address receiver, uint256 minCollateralOut) external returns (uint256 collateralOut)", - "function redeemLeveragedTokenDryRun(uint256 leveragedAmount) external view returns (uint256 collateralOut, uint256 wrappedFee, uint256 fee)", -] as const; - -const STABILITY_POOL_ABI = [ - "function deposit(uint256 assetAmount, address receiver, uint256 minAmount) external returns (uint256 sharesMinted)", - "function assetBalanceOf(address account) external view returns (uint256)", - "function ASSET_TOKEN() external view returns (address)", - "function activeRewardTokens() view returns (address[])", - "function claimable(address account, address token) view returns (uint256)", - "function claim() external", -] as const; - -const ERC20_ABI = [ - "function balanceOf(address) view returns (uint256)", - "function approve(address spender, uint256 amount) external returns (bool)", - "function allowance(address owner, address spender) view returns (uint256)", - "function symbol() view returns (string)", - "function decimals() view returns (uint8)", -] as const; -``` - -## Step 1: Identify Reward Token Types - -First, categorize rewards by token type: - -```typescript -interface RewardToken { - token: string; - symbol: string; - amount: bigint; - type: "collateral" | "ha" | "hs"; - poolAddress: string; // Which pool this reward is from -} - -async function categorizeRewards( - userAddress: string, - pools: Array<{ address: string; name: string; type: "collateral" | "leveraged" }>, - wstETHAddress: string, - haTokenAddress: string, - hsTokenAddress: string, - provider: any, -): Promise { - const rewards: RewardToken[] = []; - - for (const pool of pools) { - const poolContract = new Contract(pool.address, STABILITY_POOL_ABI, provider); - const rewardTokens = await poolContract.activeRewardTokens(); - - for (const tokenAddress of rewardTokens) { - const claimable = await poolContract.claimable(userAddress, tokenAddress); - - if (claimable > 0n) { - const tokenLower = tokenAddress.toLowerCase(); - let tokenType: "collateral" | "ha" | "hs"; - - if (tokenLower === wstETHAddress.toLowerCase()) { - tokenType = "collateral"; - } else if (tokenLower === haTokenAddress.toLowerCase()) { - tokenType = "ha"; - } else if (tokenLower === hsTokenAddress.toLowerCase()) { - tokenType = "hs"; - } else { - // Unknown token - skip or handle separately - continue; - } - - const tokenContract = new Contract(tokenAddress, ERC20_ABI, provider); - const symbol = await tokenContract.symbol(); - - rewards.push({ - token: tokenAddress, - symbol, - amount: claimable, - type: tokenType, - poolAddress: pool.address, - }); - } - } - } - - return rewards; -} -``` - -## Step 2: Get User's Active Pools - -Find which pools the user has deposits in: - -```typescript -interface UserPool { - address: string; - name: string; - type: "collateral" | "leveraged"; - balance: bigint; - assetToken: string; // ha or hs token address -} - -async function getUserActivePools( - userAddress: string, - pools: Array<{ address: string; name: string; type: "collateral" | "leveraged" }>, - provider: any, -): Promise { - const activePools: UserPool[] = []; - - for (const pool of pools) { - const poolContract = new Contract(pool.address, STABILITY_POOL_ABI, provider); - const balance = await poolContract.assetBalanceOf(userAddress); - - if (balance > 0n) { - const assetToken = await poolContract.ASSET_TOKEN(); - activePools.push({ - address: pool.address, - name: pool.name, - type: pool.type, - balance, - assetToken, - }); - } - } - - return activePools; -} -``` - -## Step 3: Calculate Fees and Expected Outputs - -### For Collateral Tokens (wstETH) → Mint ha Tokens - -```typescript -interface MintEstimate { - collateralIn: bigint; - haTokensOut: bigint; - fee: bigint; - feePercent: number; - haTokensOutFormatted: string; - feeFormatted: string; -} - -async function estimateMintHaTokens( - collateralAmount: bigint, - minterAddress: string, - provider: any, -): Promise { - const minter = new Contract(minterAddress, MINTER_ABI, provider); - - // Dry run to get estimates - const [peggedOut, wrappedFee, fee] = await minter.mintPeggedTokenDryRun(collateralAmount); - - // Calculate fee percentage (fee is in 18 decimals, same as collateral) - const feePercent = (Number(fee) / Number(collateralAmount)) * 100; - - return { - collateralIn: collateralAmount, - haTokensOut: peggedOut, - fee, - feePercent, - haTokensOutFormatted: formatEther(peggedOut), - feeFormatted: formatEther(fee), - }; -} -``` - -### For hs Tokens → Redeem to Collateral - -```typescript -interface RedeemEstimate { - hsTokensIn: bigint; - collateralOut: bigint; - fee: bigint; - feePercent: number; - collateralOutFormatted: string; - feeFormatted: string; -} - -async function estimateRedeemHsTokens( - hsTokenAmount: bigint, - minterAddress: string, - provider: any, -): Promise { - const minter = new Contract(minterAddress, MINTER_ABI, provider); - - // Dry run to get estimates - const [collateralOut, wrappedFee, fee] = await minter.redeemLeveragedTokenDryRun(hsTokenAmount); - - const feePercent = (Number(fee) / Number(hsTokenAmount)) * 100; - - return { - hsTokensIn: hsTokenAmount, - collateralOut, - fee, - feePercent, - collateralOutFormatted: formatEther(collateralOut), - feeFormatted: formatEther(fee), - }; -} -``` - -## Step 4: Build Compound Transaction Plan - -Create a comprehensive plan showing all transactions: - -```typescript -interface CompoundTransaction { - step: number; - action: string; - description: string; - tokenIn?: { address: string; symbol: string; amount: bigint; amountFormatted: string }; - tokenOut?: { address: string; symbol: string; amount: bigint; amountFormatted: string }; - fee?: { amount: bigint; amountFormatted: string; percent: number }; - poolAddress?: string; - poolName?: string; -} - -interface CompoundPlan { - rewardTokens: RewardToken[]; - targetPools: UserPool[]; - transactions: CompoundTransaction[]; - totalFees: { amount: bigint; amountFormatted: string; usdValue: number }; - finalDeposits: Array<{ pool: string; amount: bigint; amountFormatted: string }>; - estimatedGas: bigint; -} - -async function buildCompoundPlan( - rewards: RewardToken[], - targetPools: UserPool[], - splitStrategy: "equal" | "proportional" | Map, // custom percentages - minterAddress: string, - wstETHAddress: string, - haTokenAddress: string, - provider: any, -): Promise { - const transactions: CompoundTransaction[] = []; - let totalFees = 0n; - const finalDeposits: Array<{ pool: string; amount: bigint; amountFormatted: string }> = []; - - // Track total ha tokens that will be deposited - let totalHaTokens = 0n; - - // Step 1: Claim all rewards - transactions.push({ - step: 1, - action: "claim", - description: "Claim all rewards from stability pools", - tokenIn: undefined, - tokenOut: { - address: "multiple", - symbol: "Rewards", - amount: rewards.reduce((sum, r) => sum + r.amount, 0n), - amountFormatted: formatEther(rewards.reduce((sum, r) => sum + r.amount, 0n)), - }, - }); - - let stepNumber = 2; - - // Process each reward type - for (const reward of rewards) { - if (reward.type === "collateral") { - // Collateral → Mint ha tokens - const mintEstimate = await estimateMintHaTokens(reward.amount, minterAddress, provider); - totalFees += mintEstimate.fee; - - transactions.push({ - step: stepNumber++, - action: "mint", - description: `Mint ha tokens from ${formatEther(reward.amount)} ${reward.symbol}`, - tokenIn: { - address: reward.token, - symbol: reward.symbol, - amount: reward.amount, - amountFormatted: formatEther(reward.amount), - }, - tokenOut: { - address: haTokenAddress, - symbol: "haPB", - amount: mintEstimate.haTokensOut, - amountFormatted: mintEstimate.haTokensOutFormatted, - }, - fee: { - amount: mintEstimate.fee, - amountFormatted: mintEstimate.feeFormatted, - percent: mintEstimate.feePercent, - }, - }); - - totalHaTokens += mintEstimate.haTokensOut; - } else if (reward.type === "hs") { - // hs → Redeem → Mint ha - const redeemEstimate = await estimateRedeemHsTokens(reward.amount, minterAddress, provider); - totalFees += redeemEstimate.fee; - - transactions.push({ - step: stepNumber++, - action: "redeem", - description: `Redeem ${formatEther(reward.amount)} ${reward.symbol} for collateral`, - tokenIn: { - address: reward.token, - symbol: reward.symbol, - amount: reward.amount, - amountFormatted: formatEther(reward.amount), - }, - tokenOut: { - address: wstETHAddress, - symbol: "wstETH", - amount: redeemEstimate.collateralOut, - amountFormatted: redeemEstimate.collateralOutFormatted, - }, - fee: { - amount: redeemEstimate.fee, - amountFormatted: redeemEstimate.feeFormatted, - percent: redeemEstimate.feePercent, - }, - }); - - // Then mint ha tokens from the collateral - const mintEstimate = await estimateMintHaTokens(redeemEstimate.collateralOut, minterAddress, provider); - totalFees += mintEstimate.fee; - - transactions.push({ - step: stepNumber++, - action: "mint", - description: `Mint ha tokens from redeemed collateral`, - tokenIn: { - address: wstETHAddress, - symbol: "wstETH", - amount: redeemEstimate.collateralOut, - amountFormatted: redeemEstimate.collateralOutFormatted, - }, - tokenOut: { - address: haTokenAddress, - symbol: "haPB", - amount: mintEstimate.haTokensOut, - amountFormatted: mintEstimate.haTokensOutFormatted, - }, - fee: { - amount: mintEstimate.fee, - amountFormatted: mintEstimate.feeFormatted, - percent: mintEstimate.feePercent, - }, - }); - - totalHaTokens += mintEstimate.haTokensOut; - } else if (reward.type === "ha") { - // ha tokens → Direct deposit (no conversion needed) - totalHaTokens += reward.amount; - } - } - - // Calculate split across target pools - const poolSplits = calculatePoolSplits(totalHaTokens, targetPools, splitStrategy); - - // Add deposit transactions - for (const pool of targetPools) { - const depositAmount = poolSplits.get(pool.address) || 0n; - if (depositAmount > 0n) { - transactions.push({ - step: stepNumber++, - action: "deposit", - description: `Deposit ha tokens to ${pool.name}`, - tokenIn: { - address: haTokenAddress, - symbol: "haPB", - amount: depositAmount, - amountFormatted: formatEther(depositAmount), - }, - poolAddress: pool.address, - poolName: pool.name, - }); - - finalDeposits.push({ - pool: pool.name, - amount: depositAmount, - amountFormatted: formatEther(depositAmount), - }); - } - } - - // Estimate gas (rough estimate) - const estimatedGas = estimateTotalGas(transactions); - - return { - rewardTokens: rewards, - targetPools, - transactions, - totalFees: { - amount: totalFees, - amountFormatted: formatEther(totalFees), - usdValue: 0, // Calculate based on token prices - }, - finalDeposits, - estimatedGas, - }; -} - -function calculatePoolSplits( - totalAmount: bigint, - targetPools: UserPool[], - strategy: "equal" | "proportional" | Map, -): Map { - const splits = new Map(); - - if (strategy === "equal") { - const amountPerPool = totalAmount / BigInt(targetPools.length); - const remainder = totalAmount % BigInt(targetPools.length); - - targetPools.forEach((pool, index) => { - const amount = amountPerPool + (index === 0 ? remainder : 0n); - splits.set(pool.address, amount); - }); - } else if (strategy === "proportional") { - const totalBalance = targetPools.reduce((sum, p) => sum + p.balance, 0n); - - if (totalBalance > 0n) { - targetPools.forEach((pool) => { - const proportion = (totalAmount * pool.balance) / totalBalance; - splits.set(pool.address, proportion); - }); - } else { - // Fallback to equal if no existing deposits - const amountPerPool = totalAmount / BigInt(targetPools.length); - targetPools.forEach((pool) => { - splits.set(pool.address, amountPerPool); - }); - } - } else { - // Custom percentages - const totalPercent = Array.from(strategy.values()).reduce((sum, p) => sum + p, 0); - if (Math.abs(totalPercent - 100) > 0.01) { - throw new Error("Percentages must sum to 100%"); - } - - strategy.forEach((percent, poolAddress) => { - const amount = (totalAmount * BigInt(Math.floor(percent * 100))) / 10000n; - splits.set(poolAddress, amount); - }); - } - - return splits; -} - -function estimateTotalGas(transactions: CompoundTransaction[]): bigint { - // Rough gas estimates (adjust based on actual costs) - const gasPerClaim = 100000n; - const gasPerMint = 200000n; - const gasPerRedeem = 200000n; - const gasPerDeposit = 150000n; - - let total = 0n; - - for (const tx of transactions) { - if (tx.action === "claim") total += gasPerClaim; - else if (tx.action === "mint") total += gasPerMint; - else if (tx.action === "redeem") total += gasPerRedeem; - else if (tx.action === "deposit") total += gasPerDeposit; - } - - return total; -} -``` - -## Step 5: Display Transaction Summary - -Create a UI component to show the plan: - -```typescript -function CompoundSummaryModal({ - plan, - isOpen, - onClose, - onConfirm, -}: { - plan: CompoundPlan; - isOpen: boolean; - onClose: () => void; - onConfirm: () => void; -}) { - return ( - -
-

Compound Rewards Summary

- - {/* Rewards Being Compounded */} -
-

Rewards to Compound

-
- {plan.rewardTokens.map((reward, i) => ( -
- {formatEther(reward.amount)} {reward.symbol} - {reward.type.toUpperCase()} -
- ))} -
-
- - {/* Target Pools */} -
-

Deposit To

-
- {plan.targetPools.map((pool, i) => ( -
- {pool.name} - {formatEther(plan.finalDeposits[i]?.amount || 0n)} haPB -
- ))} -
-
- - {/* Transaction Steps */} -
-

Transaction Steps

-
    - {plan.transactions.map((tx, i) => ( -
  1. -
    - {tx.step} - {tx.action.toUpperCase()} -
    -
    {tx.description}
    - - {tx.tokenIn && ( -
    - - {tx.tokenIn.amountFormatted} {tx.tokenIn.symbol} - - - {tx.tokenOut && ( - - {tx.tokenOut.amountFormatted} {tx.tokenOut.symbol} - - )} -
    - )} - - {tx.fee && ( -
    - Fee: {tx.fee.amountFormatted} ({tx.fee.percent.toFixed(2)}%) -
    - )} - - {tx.poolName && ( -
    - Pool: {tx.poolName} -
    - )} -
  2. - ))} -
-
- - {/* Total Fees */} -
-

Total Fees

-
- {plan.totalFees.amountFormatted} wstETH - {plan.totalFees.usdValue > 0 && ( - (${plan.totalFees.usdValue.toFixed(2)}) - )} -
-
- - {/* Estimated Gas */} -
-

Estimated Gas

-
{plan.estimatedGas.toString()}
-
- - {/* Actions */} -
- - -
-
-
- ); -} -``` - -## Step 6: Execute Compound Transactions - -Execute the plan step by step: - -```typescript -interface CompoundExecutionResult { - success: boolean; - transactions: Array<{ step: number; txHash: string; receipt: any }>; - error?: string; -} - -async function executeCompoundPlan( - plan: CompoundPlan, - userAddress: string, - signer: any, - minterAddress: string, - wstETHAddress: string, - haTokenAddress: string, - hsTokenAddress: string, -): Promise { - const results: Array<{ step: number; txHash: string; receipt: any }> = []; - - try { - // Group rewards by pool for claiming - const rewardsByPool = new Map(); - for (const reward of plan.rewardTokens) { - if (!rewardsByPool.has(reward.poolAddress)) { - rewardsByPool.set(reward.poolAddress, []); - } - rewardsByPool.get(reward.poolAddress)!.push(reward); - } - - // Step 1: Claim all rewards - for (const [poolAddress, rewards] of rewardsByPool) { - const pool = new Contract(poolAddress, STABILITY_POOL_ABI, signer); - const tx = await pool.claim(); - const receipt = await tx.wait(); - - results.push({ - step: 1, - txHash: tx.hash, - receipt, - }); - } - - // Step 2: Process each reward type - let stepNumber = 2; - - for (const reward of plan.rewardTokens) { - if (reward.type === "collateral") { - // Mint ha tokens - const minter = new Contract(minterAddress, MINTER_ABI, signer); - - // Approve minter to spend wstETH - const wstETH = new Contract(wstETHAddress, ERC20_ABI, signer); - const allowance = await wstETH.allowance(userAddress, minterAddress); - if (allowance < reward.amount) { - await wstETH.approve(minterAddress, ethers.MaxUint256); - } - - // Get min output with slippage protection - const [peggedOut] = await minter.mintPeggedTokenDryRun(reward.amount); - const minPeggedOut = (peggedOut * 95n) / 100n; // 5% slippage - - const tx = await minter.mintPeggedToken(reward.amount, userAddress, minPeggedOut); - const receipt = await tx.wait(); - - results.push({ - step: stepNumber++, - txHash: tx.hash, - receipt, - }); - } else if (reward.type === "hs") { - // Redeem hs tokens - const minter = new Contract(minterAddress, MINTER_ABI, signer); - - // Approve minter to spend hs tokens - const hsToken = new Contract(hsTokenAddress, ERC20_ABI, signer); - const allowance = await hsToken.allowance(userAddress, minterAddress); - if (allowance < reward.amount) { - await hsToken.approve(minterAddress, ethers.MaxUint256); - } - - // Get min output - const [collateralOut] = await minter.redeemLeveragedTokenDryRun(reward.amount); - const minCollateralOut = (collateralOut * 95n) / 100n; - - const redeemTx = await minter.redeemLeveragedToken(reward.amount, userAddress, minCollateralOut); - const redeemReceipt = await redeemTx.wait(); - - results.push({ - step: stepNumber++, - txHash: redeemTx.hash, - receipt: redeemReceipt, - }); - - // Then mint ha tokens from collateral - const wstETH = new Contract(wstETHAddress, ERC20_ABI, signer); - const collateralBalance = await wstETH.balanceOf(userAddress); - - // Approve minter - const wstETHAllowance = await wstETH.allowance(userAddress, minterAddress); - if (wstETHAllowance < collateralBalance) { - await wstETH.approve(minterAddress, ethers.MaxUint256); - } - - const [peggedOut] = await minter.mintPeggedTokenDryRun(collateralBalance); - const minPeggedOut = (peggedOut * 95n) / 100n; - - const mintTx = await minter.mintPeggedToken(collateralBalance, userAddress, minPeggedOut); - const mintReceipt = await mintTx.wait(); - - results.push({ - step: stepNumber++, - txHash: mintTx.hash, - receipt: mintReceipt, - }); - } - // ha tokens don't need conversion - } - - // Step 3: Deposit ha tokens to pools - const haToken = new Contract(haTokenAddress, ERC20_ABI, signer); - const haTokenBalance = await haToken.balanceOf(userAddress); - - // Calculate splits - const poolSplits = calculatePoolSplits( - haTokenBalance, - plan.targetPools, - "equal", // or use the strategy from plan - ); - - // Approve and deposit to each pool - for (const pool of plan.targetPools) { - const depositAmount = poolSplits.get(pool.address) || 0n; - if (depositAmount > 0n) { - const poolContract = new Contract(pool.address, STABILITY_POOL_ABI, signer); - - // Approve pool - const allowance = await haToken.allowance(userAddress, pool.address); - if (allowance < depositAmount) { - await haToken.approve(pool.address, ethers.MaxUint256); - } - - // Deposit - const tx = await poolContract.deposit( - depositAmount, - userAddress, - depositAmount, // minAmount (no slippage for direct deposit) - ); - const receipt = await tx.wait(); - - results.push({ - step: stepNumber++, - txHash: tx.hash, - receipt, - }); - } - } - - return { - success: true, - transactions: results, - }; - } catch (error: any) { - return { - success: false, - transactions: results, - error: error.message || "Compound execution failed", - }; - } -} -``` - -## Step 7: Complete React Hook - -```typescript -import { useState, useCallback } from "react"; -import { useAccount, useSigner } from "wagmi"; - -interface UseCompoundRewards { - buildPlan: (targetPools: UserPool[], splitStrategy: any) => Promise; - executePlan: (plan: CompoundPlan) => Promise; - plan: CompoundPlan | null; - loading: boolean; - error: string | null; -} - -export function useCompoundRewards( - pools: Array<{ address: string; name: string; type: "collateral" | "leveraged" }>, - minterAddress: string, - wstETHAddress: string, - haTokenAddress: string, - hsTokenAddress: string, -): UseCompoundRewards { - const { address } = useAccount(); - const { data: signer } = useSigner(); - const [plan, setPlan] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const buildPlan = useCallback( - async (targetPools: UserPool[], splitStrategy: "equal" | "proportional" | Map) => { - if (!address || !signer) { - setError("Wallet not connected"); - return null; - } - - setLoading(true); - setError(null); - - try { - // Get rewards - const rewards = await categorizeRewards( - address, - pools, - wstETHAddress, - haTokenAddress, - hsTokenAddress, - signer.provider!, - ); - - if (rewards.length === 0) { - setError("No rewards to compound"); - return null; - } - - // Get user's active pools - const userPools = await getUserActivePools(address, pools, signer.provider!); - - // Filter target pools to only include user's active pools - const validTargetPools = targetPools.filter((tp) => userPools.some((up) => up.address === tp.address)); - - if (validTargetPools.length === 0) { - setError("No valid target pools selected"); - return null; - } - - // Build plan - const compoundPlan = await buildCompoundPlan( - rewards, - validTargetPools, - splitStrategy, - minterAddress, - wstETHAddress, - haTokenAddress, - signer.provider!, - ); - - setPlan(compoundPlan); - return compoundPlan; - } catch (err: any) { - setError(err.message || "Failed to build compound plan"); - return null; - } finally { - setLoading(false); - } - }, - [address, signer, pools, minterAddress, wstETHAddress, haTokenAddress, hsTokenAddress], - ); - - const executePlan = useCallback( - async (plan: CompoundPlan) => { - if (!address || !signer) { - return { - success: false, - transactions: [], - error: "Wallet not connected", - }; - } - - setLoading(true); - setError(null); - - try { - const result = await executeCompoundPlan( - plan, - address, - signer, - minterAddress, - wstETHAddress, - haTokenAddress, - hsTokenAddress, - ); - - if (!result.success) { - setError(result.error || "Compound execution failed"); - } - - return result; - } catch (err: any) { - setError(err.message || "Compound execution failed"); - return { - success: false, - transactions: [], - error: err.message, - }; - } finally { - setLoading(false); - } - }, - [address, signer, minterAddress, wstETHAddress, haTokenAddress, hsTokenAddress], - ); - - return { - buildPlan, - executePlan, - plan, - loading, - error, - }; -} -``` - -## Step 8: Complete UI Component - -```typescript -function CompoundRewardsModal({ - isOpen, - onClose, - pools, - minterAddress, - wstETHAddress, - haTokenAddress, - hsTokenAddress, -}: { - isOpen: boolean; - onClose: () => void; - pools: Array<{ address: string; name: string; type: 'collateral' | 'leveraged' }>; - minterAddress: string; - wstETHAddress: string; - haTokenAddress: string; - hsTokenAddress: string; -}) { - const { buildPlan, executePlan, plan, loading, error } = useCompoundRewards( - pools, - minterAddress, - wstETHAddress, - haTokenAddress, - hsTokenAddress - ); - - const [targetPools, setTargetPools] = useState([]); - const [splitStrategy, setSplitStrategy] = useState<'equal' | 'proportional'>('equal'); - const [showSummary, setShowSummary] = useState(false); - - // Load user's active pools - useEffect(() => { - if (isOpen) { - // Load active pools... - } - }, [isOpen]); - - const handleBuildPlan = async () => { - const plan = await buildPlan(targetPools, splitStrategy); - if (plan) { - setShowSummary(true); - } - }; - - const handleExecute = async () => { - if (plan) { - const result = await executePlan(plan); - if (result.success) { - onClose(); - // Show success message - } - } - }; - - return ( - - {!showSummary ? ( - - ) : ( - setShowSummary(false)} - onConfirm={handleExecute} - /> - )} - - ); -} -``` - -## Summary - -**Key Points:** - -1. ✅ Categorize rewards by type (collateral, ha, hs) -2. ✅ Calculate fees using dry run functions -3. ✅ Build comprehensive transaction plan -4. ✅ Show summary with all steps and fees -5. ✅ Execute step-by-step with proper approvals -6. ✅ Handle all three reward token types correctly - -**Flow:** - -- **Collateral**: Claim → Mint ha → Deposit -- **hs Tokens**: Claim → Redeem → Mint ha → Deposit -- **ha Tokens**: Claim → Deposit (direct) - -This provides a complete compound implementation with fee display and transaction preview! - - diff --git a/doc/guides/FRONTEND-CONFIG-CLEAN-CHAIN.txt b/doc/guides/FRONTEND-CONFIG-CLEAN-CHAIN.txt deleted file mode 100644 index bcae7600..00000000 --- a/doc/guides/FRONTEND-CONFIG-CLEAN-CHAIN.txt +++ /dev/null @@ -1,41 +0,0 @@ -=============================================================================== -FRONTEND CONFIGURATION - Clean Anvil Chain Deployment -=============================================================================== - -QUICK COPY-PASTE FOR FRONTEND AI: ---------------------------------- - -GraphQL Endpoint: http://localhost:8000/subgraphs/name/harbor-marks-local - -Network: anvil (Chain ID: 31337) -RPC URL: http://localhost:8545 - -Environment Variable: -NEXT_PUBLIC_GRAPH_URL=http://localhost:8000/subgraphs/name/harbor-marks-local - -CONTRACT ADDRESSES: ------------------- -Genesis: 0x67d269191c92Caf3cD7723F116c85e6E9bf55933 -Minter: 0x4A679253410272dd5232B3Ff7cF5dbB88f295319 -Pegged Token (haPB): 0x4ed7c70F96B99c776995fB64377f0d4aB3B0e1C1 -Leveraged Token: 0x322813Fd9A801c5507c9de605d63CEA4f2CE6c44 -Reserve Pool: 0x7a2088a1bFc9d81c55368AE168C2C02570cB814F -Stability Pool Manager: 0xc5a5C42992dECbae36851359345FE25997F5C42d -Fee Receiver: 0x09635F643e140090A9A8Dcd712eD6285858ceBef -Price Oracle: 0xa82fF9aFd8f496c3d6ac40E2a0F282E47488CFc9 -Collateral Token (stETH): 0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9 -Wrapped Collateral (wstETH): 0x5FC8d32690cc91D4c39d9d3abcBD16989F875707 -Stability Pool Collateral: 0x82e01223d51Eb87e16A03E24687EDF0F294da6f1 -Stability Pool Leveraged: null - -TOKEN NAMES: -Pegged Token: Harbor Anchored PB (haPB) -Leveraged Token: Harbor Sail hsPBxstETH (hshsPBxstETH) - -Developer Address: 0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e - -VERIFICATION: -✅ Developer is owner of Genesis contract -✅ Developer has ZERO_FEE_ROLE on Minter -✅ Clean Anvil chain (no problematic blocks) -✅ Graph Node should sync quickly diff --git a/doc/guides/FRONTEND-CONFIG-FINAL.txt b/doc/guides/FRONTEND-CONFIG-FINAL.txt deleted file mode 100644 index c76cffea..00000000 --- a/doc/guides/FRONTEND-CONFIG-FINAL.txt +++ /dev/null @@ -1,76 +0,0 @@ -=============================================================================== -FRONTEND CONFIGURATION - Final Deployment (Ready to Use) -=============================================================================== - -✅ Graph Node is running -✅ Subgraph is deployed and indexing -✅ All contracts deployed and verified - -QUICK COPY-PASTE FOR FRONTEND AI: ---------------------------------- - -GraphQL Endpoint: http://localhost:8000/subgraphs/name/harbor-marks-local - -Network: anvil (Chain ID: 31337) -RPC URL: http://localhost:8545 - -Environment Variable: -NEXT_PUBLIC_GRAPH_URL=http://localhost:8000/subgraphs/name/harbor-marks-local - -CONTRACT ADDRESSES: ------------------- -Genesis: 0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82 -Minter: 0x8A791620dd6260079BF849Dc5567aDC3F2FdC318 -Pegged Token (haPB): 0x0165878A594ca255338adfa4d48449f69242Eb8F -Leveraged Token: 0xa513E6E4b8f2a923D98304ec87F64353C4D5C853 -Reserve Pool: 0x610178dA211FEF7D417bC0e6FeD39F05609AD788 -Stability Pool Manager: 0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0 -Fee Receiver: 0xB7f8BC63BbcaD18155201308C8f3540b07f84F5e -Collateral Token (stETH): 0x5FbDB2315678afecb367f032d93F642f64180aa3 -Wrapped Collateral (wstETH): 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 - -TOKEN NAMES: -Pegged Token: Harbor Anchored PB (haPB) -Leveraged Token: Harbor Sail hsPBxstETH (hshsPBxstETH) - -Developer Address: 0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e -- Has 1000 stETH -- Has 1000 wstETH -- Owner of Genesis contract -- Has ZERO_FEE_ROLE on Minter - -SUBGRAPH INFO: --------------- -Genesis Deployment Block: 55 -Network: anvil -Status: Deployed and indexing - -VERIFICATION: -✅ Graph Node running on http://localhost:8000 -✅ Graph Node JSON-RPC on http://localhost:8020 -✅ Subgraph endpoint responding -✅ Genesis contract verified and accessible -✅ Developer has all required permissions -✅ Tokens minted to developer - -TEST QUERIES: ------------- -# Check subgraph status -curl -X POST http://localhost:8030/graphql \ - -H "Content-Type: application/json" \ - -d '{"query":"{ indexingStatuses { subgraph health synced } }"}' - -# Query user harbor marks -curl -X POST http://localhost:8000/subgraphs/name/harbor-marks-local \ - -H "Content-Type: application/json" \ - -d '{"query":"{ userHarborMarks { id totalDeposited totalWithdrawn } }"}' - -# Query deposits -curl -X POST http://localhost:8000/subgraphs/name/harbor-marks-local \ - -H "Content-Type: application/json" \ - -d '{"query":"{ deposits { id user amount timestamp } }"}' - -=============================================================================== -READY FOR FRONTEND INTEGRATION -=============================================================================== - diff --git a/doc/guides/FRONTEND-CONFIG-FRESH-DEPLOYMENT.md b/doc/guides/FRONTEND-CONFIG-FRESH-DEPLOYMENT.md deleted file mode 100644 index 36fa1fdd..00000000 --- a/doc/guides/FRONTEND-CONFIG-FRESH-DEPLOYMENT.md +++ /dev/null @@ -1,264 +0,0 @@ -# Frontend Configuration - Fresh Deployment - -**Deployment Date:** Fresh Anvil Chain (No Fork) -**Chain ID:** 31337 -**RPC URL:** http://localhost:8545 - ---- - -## Contract Addresses - -### Core Contracts -- **Genesis:** `0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82` -- **Minter:** `0x8A791620dd6260079BF849Dc5567aDC3F2FdC318` -- **Pegged Token (haPB):** `0x0165878A594ca255338adfa4d48449f69242Eb8F` -- **Leveraged Token:** `0xa513E6E4b8f2a923D98304ec87F64353C4D5C853` -- **Reserve Pool:** `0x610178dA211FEF7D417bC0e6FeD39F05609AD788` -- **Stability Pool Manager:** `0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0` -- **Fee Receiver:** `0xB7f8BC63BbcaD18155201308C8f3540b07f84F5e` -- **Stability Pool Collateral:** `0xf5059a5D33d5853360D16C683c16e67980206f36` -- **Stability Pool Sail:** `0x99bbA657f2BbC93c02D617f8bA121cB8Fc104Acf` - -### Token Contracts -- **stETH (Mock):** `0x5FbDB2315678afecb367f032d93F642f64180aa3` -- **wstETH (Mock):** `0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512` - -### Price Feeds (Mock Chainlink) -- **stETH/USD:** `0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0` -- **stETH/ETH:** `0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9` -- **wstETH/USD:** `0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9` - ---- - -## Developer Account - -- **Address:** `0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e` -- **ETH Balance:** 1600 ETH -- **wstETH Balance:** 1000 wstETH -- **stETH Balance:** 1000 stETH - -### Permissions -- ⚠️ **Genesis Owner:** Currently owned by deployer (`0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266`) - - Note: Ownership transfer failed during deployment. Developer can still interact with Genesis. -- ⚠️ **ZERO_FEE_ROLE on Minter:** May need manual verification - ---- - -## GraphQL Endpoint - -**Subgraph Name:** `harbor-marks-local` - -- **HTTP Query Endpoint:** http://localhost:8000/subgraphs/name/harbor-marks-local -- **GraphQL Playground:** http://localhost:8000/subgraphs/name/harbor-marks-local/graphql - -### Example Query - -```graphql -{ - userHarborMarks(first: 10) { - id - user - contract - totalDeposited - totalWithdrawn - currentMarks - totalMarksForfeited - bonusMarks - genesisEnded - lastUpdateTimestamp - } - - deposits(first: 10, orderBy: timestamp, orderDirection: desc) { - id - user - token - amount - amountUSD - timestamp - blockNumber - } -} -``` - ---- - -## Subgraph Configuration - -- **Start Block:** 14 (Genesis deployed at block 14) -- **Network:** anvil -- **Current Chain Block:** ~64 - ---- - -## Network Configuration - -```typescript -{ - chainId: 31337, - name: "Anvil Local", - rpcUrl: "http://localhost:8545", - blockExplorer: null -} -``` - ---- - -## Important Notes - -1. **Fresh Chain:** This is a clean Anvil chain (not a fork). All contracts are newly deployed mocks. -2. **Mock Tokens:** stETH and wstETH are mock contracts. They implement the standard interfaces but are simplified for local testing. -3. **Mock Price Feeds:** Chainlink price feeds are mocks with fixed prices: - - stETH/USD: $2000 (200000000000 with 8 decimals) - - wstETH/USD: $2000 (200000000000 with 8 decimals) - - stETH/ETH: 1.0 (100000000 with 8 decimals) -4. **Genesis Ownership:** The Genesis contract is currently owned by the Anvil default deployer. If you need admin access, you may need to transfer ownership manually or use the deployer account. -5. **Subgraph Status:** The subgraph is deployed and should be indexing from block 14. Check indexing status at http://localhost:8030/graphql - ---- - -## Quick Reference - -| Item | Value | -|------|-------| -| Genesis | `0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82` | -| Minter | `0x8A791620dd6260079BF849Dc5567aDC3F2FdC318` | -| wstETH | `0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512` | -| wstETH/USD Feed | `0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9` | -| GraphQL Endpoint | http://localhost:8000/subgraphs/name/harbor-marks-local | -| Chain ID | 31337 | -| RPC URL | http://localhost:8545 | - - - -**Deployment Date:** Fresh Anvil Chain (No Fork) -**Chain ID:** 31337 -**RPC URL:** http://localhost:8545 - ---- - -## Contract Addresses - -### Core Contracts -- **Genesis:** `0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82` -- **Minter:** `0x8A791620dd6260079BF849Dc5567aDC3F2FdC318` -- **Pegged Token (haPB):** `0x0165878A594ca255338adfa4d48449f69242Eb8F` -- **Leveraged Token:** `0xa513E6E4b8f2a923D98304ec87F64353C4D5C853` -- **Reserve Pool:** `0x610178dA211FEF7D417bC0e6FeD39F05609AD788` -- **Stability Pool Manager:** `0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0` -- **Fee Receiver:** `0xB7f8BC63BbcaD18155201308C8f3540b07f84F5e` -- **Stability Pool Collateral:** `0xf5059a5D33d5853360D16C683c16e67980206f36` -- **Stability Pool Sail:** `0x99bbA657f2BbC93c02D617f8bA121cB8Fc104Acf` - -### Token Contracts -- **stETH (Mock):** `0x5FbDB2315678afecb367f032d93F642f64180aa3` -- **wstETH (Mock):** `0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512` - -### Price Feeds (Mock Chainlink) -- **stETH/USD:** `0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0` -- **stETH/ETH:** `0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9` -- **wstETH/USD:** `0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9` - ---- - -## Developer Account - -- **Address:** `0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e` -- **ETH Balance:** 1600 ETH -- **wstETH Balance:** 1000 wstETH -- **stETH Balance:** 1000 stETH - -### Permissions -- ⚠️ **Genesis Owner:** Currently owned by deployer (`0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266`) - - Note: Ownership transfer failed during deployment. Developer can still interact with Genesis. -- ⚠️ **ZERO_FEE_ROLE on Minter:** May need manual verification - ---- - -## GraphQL Endpoint - -**Subgraph Name:** `harbor-marks-local` - -- **HTTP Query Endpoint:** http://localhost:8000/subgraphs/name/harbor-marks-local -- **GraphQL Playground:** http://localhost:8000/subgraphs/name/harbor-marks-local/graphql - -### Example Query - -```graphql -{ - userHarborMarks(first: 10) { - id - user - contract - totalDeposited - totalWithdrawn - currentMarks - totalMarksForfeited - bonusMarks - genesisEnded - lastUpdateTimestamp - } - - deposits(first: 10, orderBy: timestamp, orderDirection: desc) { - id - user - token - amount - amountUSD - timestamp - blockNumber - } -} -``` - ---- - -## Subgraph Configuration - -- **Start Block:** 14 (Genesis deployed at block 14) -- **Network:** anvil -- **Current Chain Block:** ~64 - ---- - -## Network Configuration - -```typescript -{ - chainId: 31337, - name: "Anvil Local", - rpcUrl: "http://localhost:8545", - blockExplorer: null -} -``` - ---- - -## Important Notes - -1. **Fresh Chain:** This is a clean Anvil chain (not a fork). All contracts are newly deployed mocks. -2. **Mock Tokens:** stETH and wstETH are mock contracts. They implement the standard interfaces but are simplified for local testing. -3. **Mock Price Feeds:** Chainlink price feeds are mocks with fixed prices: - - stETH/USD: $2000 (200000000000 with 8 decimals) - - wstETH/USD: $2000 (200000000000 with 8 decimals) - - stETH/ETH: 1.0 (100000000 with 8 decimals) -4. **Genesis Ownership:** The Genesis contract is currently owned by the Anvil default deployer. If you need admin access, you may need to transfer ownership manually or use the deployer account. -5. **Subgraph Status:** The subgraph is deployed and should be indexing from block 14. Check indexing status at http://localhost:8030/graphql - ---- - -## Quick Reference - -| Item | Value | -|------|-------| -| Genesis | `0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82` | -| Minter | `0x8A791620dd6260079BF849Dc5567aDC3F2FdC318` | -| wstETH | `0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512` | -| wstETH/USD Feed | `0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9` | -| GraphQL Endpoint | http://localhost:8000/subgraphs/name/harbor-marks-local | -| Chain ID | 31337 | -| RPC URL | http://localhost:8545 | - - - - - diff --git a/doc/guides/FRONTEND-CONFIG-NEW-DEPLOYMENT.md b/doc/guides/FRONTEND-CONFIG-NEW-DEPLOYMENT.md deleted file mode 100644 index ce3f49ee..00000000 --- a/doc/guides/FRONTEND-CONFIG-NEW-DEPLOYMENT.md +++ /dev/null @@ -1,454 +0,0 @@ -# Frontend Configuration - New Clean Anvil Deployment - -**Deployment Date**: November 19, 2025 -**Network**: Clean Anvil Chain (no fork) -**Chain ID**: 31337 - ---- - -## Quick Setup - -**GraphQL Endpoint:** -``` -http://localhost:8000/subgraphs/name/harbor-marks-local -``` - -**Environment Variable:** -```bash -NEXT_PUBLIC_GRAPH_URL=http://localhost:8000/subgraphs/name/harbor-marks-local -``` - -**Network Configuration:** -- Network Name: `anvil` -- Chain ID: `31337` -- RPC URL: `http://localhost:8545` - ---- - -## Contract Addresses - -### Core Contracts -```typescript -export const contracts = { - genesis: "0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82", - minter: "0x8A791620dd6260079BF849Dc5567aDC3F2FdC318", - peggedToken: "0x0165878A594ca255338adfa4d48449f69242Eb8F", - leveragedToken: "0xa513E6E4b8f2a923D98304ec87F64353C4D5C853", - reservePool: "0x610178dA211FEF7D417bC0e6FeD39F05609AD788", - stabilityPoolManager: "0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0", - feeReceiver: "0xB7f8BC63BbcaD18155201308C8f3540b07f84F5e", - collateralToken: "0x5FbDB2315678afecb367f032d93F642f64180aa3", // Mock stETH - wrappedCollateralToken: "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512", // Mock wstETH -} as const; -``` - -### Token Information -- **Pegged Token (haPB)**: `0x0165878A594ca255338adfa4d48449f69242Eb8F` - - Name: Harbor Anchored PB - - Symbol: haPB - -- **Leveraged Token**: `0xa513E6E4b8f2a923D98304ec87F64353C4D5C853` - - Name: Harbor Sail hsPBxstETH - - Symbol: hshsPBxstETH - -- **Collateral Token (stETH)**: `0x5FbDB2315678afecb367f032d93F642f64180aa3` - - Symbol: stETH - - Type: MockStETH - -- **Wrapped Collateral (wstETH)**: `0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512` - - Symbol: wstETH - - Type: MockWstETHEnhanced - -### Price Feeds (Mock Chainlink) -- **stETH/USD**: `0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0` -- **stETH/ETH**: `0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9` -- **wstETH/USD**: `0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9` - ---- - -## Subgraph Configuration - -**Genesis Contract Deployment Block**: `55` - -For subgraph.yaml: -```yaml -network: anvil -source: - address: "0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82" - startBlock: 55 -``` - ---- - -## Developer Account (for testing) - -- **Address**: `0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e` -- **Token Balances**: - - stETH: 1000 tokens - - wstETH: 1000 tokens -- **Permissions**: - - ✅ Owner of Genesis contract - - ✅ Has ZERO_FEE_ROLE on Minter - ---- - -## Network Configuration for Web3 - -```typescript -const anvilNetwork = { - chainId: 31337, - chainName: "Anvil Local", - nativeCurrency: { - name: "Ether", - symbol: "ETH", - decimals: 18, - }, - rpcUrls: ["http://localhost:8545"], - blockExplorerUrls: [], -}; -``` - ---- - -## GraphQL Queries - -### Example Query: Get User Harbor Marks -```graphql -query GetUserHarborMarks($user: Bytes!) { - userHarborMarks(id: $user) { - id - totalDeposited - totalWithdrawn - currentBalance - } -} -``` - -### Example Query: Get Deposits -```graphql -query GetDeposits($user: Bytes!) { - deposits(where: { user: $user }, orderBy: timestamp, orderDirection: desc) { - id - user - token - amount - timestamp - blockNumber - } -} -``` - -### Example Query: Get Withdrawals -```graphql -query GetWithdrawals($user: Bytes!) { - withdrawals(where: { user: $user }, orderBy: timestamp, orderDirection: desc) { - id - user - token - amount - timestamp - blockNumber - } -} -``` - ---- - -## Environment Variables for Frontend - -```bash -# GraphQL Endpoint -NEXT_PUBLIC_GRAPH_URL=http://localhost:8000/subgraphs/name/harbor-marks-local - -# Network Configuration -NEXT_PUBLIC_CHAIN_ID=31337 -NEXT_PUBLIC_RPC_URL=http://localhost:8545 - -# Contract Addresses -NEXT_PUBLIC_GENESIS_CONTRACT=0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82 -NEXT_PUBLIC_MINTER_CONTRACT=0x8A791620dd6260079BF849Dc5567aDC3F2FdC318 -NEXT_PUBLIC_PEGGED_TOKEN=0x0165878A594ca255338adfa4d48449f69242Eb8F -NEXT_PUBLIC_LEVERAGED_TOKEN=0xa513E6E4b8f2a923D98304ec87F64353C4D5C853 -NEXT_PUBLIC_WSTETH=0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 -NEXT_PUBLIC_STETH=0x5FbDB2315678afecb367f032d93F642f64180aa3 -``` - ---- - -## Important Notes - -1. **Clean Chain**: This is a clean Anvil chain (no mainnet fork) to avoid problematic blocks -2. **Mock Tokens**: stETH and wstETH are mock contracts deployed locally -3. **Graph Node**: Subgraph needs to be deployed (see next steps) -4. **Current Block**: Chain is at block 67+ (will increase as you use it) -5. **Docker Required**: Graph Node requires Docker Desktop to be running - ---- - -## Next Steps - -1. **Start Docker Desktop** (if not already running) -2. **Start Graph Node**: - ```bash - cd graph-node-local - docker compose up -d - ``` -3. **Deploy Subgraph** (from your subgraph directory): - ```bash - # Update subgraph.yaml with: - # - network: anvil - # - address: 0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82 - # - startBlock: 55 - - graph create --node http://localhost:8020/ harbor-marks-local - graph build - graph deploy --node http://localhost:8020/ --ipfs http://localhost:5001 harbor-marks-local - ``` - ---- - -## Verification Status - -✅ Anvil running on clean chain -✅ Mock tokens deployed and configured -✅ Harbor contracts deployed -✅ Developer account has required permissions -✅ Tokens minted to developer -⏳ Graph Node (requires Docker) -⏳ Subgraph deployment (pending Graph Node) - ---- - -**Last Updated**: November 19, 2025 -**Deployment Type**: Clean Anvil Chain -**Status**: Ready for Graph Node and subgraph deployment - - - -**Deployment Date**: November 19, 2025 -**Network**: Clean Anvil Chain (no fork) -**Chain ID**: 31337 - ---- - -## Quick Setup - -**GraphQL Endpoint:** -``` -http://localhost:8000/subgraphs/name/harbor-marks-local -``` - -**Environment Variable:** -```bash -NEXT_PUBLIC_GRAPH_URL=http://localhost:8000/subgraphs/name/harbor-marks-local -``` - -**Network Configuration:** -- Network Name: `anvil` -- Chain ID: `31337` -- RPC URL: `http://localhost:8545` - ---- - -## Contract Addresses - -### Core Contracts -```typescript -export const contracts = { - genesis: "0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82", - minter: "0x8A791620dd6260079BF849Dc5567aDC3F2FdC318", - peggedToken: "0x0165878A594ca255338adfa4d48449f69242Eb8F", - leveragedToken: "0xa513E6E4b8f2a923D98304ec87F64353C4D5C853", - reservePool: "0x610178dA211FEF7D417bC0e6FeD39F05609AD788", - stabilityPoolManager: "0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0", - feeReceiver: "0xB7f8BC63BbcaD18155201308C8f3540b07f84F5e", - collateralToken: "0x5FbDB2315678afecb367f032d93F642f64180aa3", // Mock stETH - wrappedCollateralToken: "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512", // Mock wstETH -} as const; -``` - -### Token Information -- **Pegged Token (haPB)**: `0x0165878A594ca255338adfa4d48449f69242Eb8F` - - Name: Harbor Anchored PB - - Symbol: haPB - -- **Leveraged Token**: `0xa513E6E4b8f2a923D98304ec87F64353C4D5C853` - - Name: Harbor Sail hsPBxstETH - - Symbol: hshsPBxstETH - -- **Collateral Token (stETH)**: `0x5FbDB2315678afecb367f032d93F642f64180aa3` - - Symbol: stETH - - Type: MockStETH - -- **Wrapped Collateral (wstETH)**: `0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512` - - Symbol: wstETH - - Type: MockWstETHEnhanced - -### Price Feeds (Mock Chainlink) -- **stETH/USD**: `0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0` -- **stETH/ETH**: `0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9` -- **wstETH/USD**: `0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9` - ---- - -## Subgraph Configuration - -**Genesis Contract Deployment Block**: `55` - -For subgraph.yaml: -```yaml -network: anvil -source: - address: "0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82" - startBlock: 55 -``` - ---- - -## Developer Account (for testing) - -- **Address**: `0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e` -- **Token Balances**: - - stETH: 1000 tokens - - wstETH: 1000 tokens -- **Permissions**: - - ✅ Owner of Genesis contract - - ✅ Has ZERO_FEE_ROLE on Minter - ---- - -## Network Configuration for Web3 - -```typescript -const anvilNetwork = { - chainId: 31337, - chainName: "Anvil Local", - nativeCurrency: { - name: "Ether", - symbol: "ETH", - decimals: 18, - }, - rpcUrls: ["http://localhost:8545"], - blockExplorerUrls: [], -}; -``` - ---- - -## GraphQL Queries - -### Example Query: Get User Harbor Marks -```graphql -query GetUserHarborMarks($user: Bytes!) { - userHarborMarks(id: $user) { - id - totalDeposited - totalWithdrawn - currentBalance - } -} -``` - -### Example Query: Get Deposits -```graphql -query GetDeposits($user: Bytes!) { - deposits(where: { user: $user }, orderBy: timestamp, orderDirection: desc) { - id - user - token - amount - timestamp - blockNumber - } -} -``` - -### Example Query: Get Withdrawals -```graphql -query GetWithdrawals($user: Bytes!) { - withdrawals(where: { user: $user }, orderBy: timestamp, orderDirection: desc) { - id - user - token - amount - timestamp - blockNumber - } -} -``` - ---- - -## Environment Variables for Frontend - -```bash -# GraphQL Endpoint -NEXT_PUBLIC_GRAPH_URL=http://localhost:8000/subgraphs/name/harbor-marks-local - -# Network Configuration -NEXT_PUBLIC_CHAIN_ID=31337 -NEXT_PUBLIC_RPC_URL=http://localhost:8545 - -# Contract Addresses -NEXT_PUBLIC_GENESIS_CONTRACT=0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82 -NEXT_PUBLIC_MINTER_CONTRACT=0x8A791620dd6260079BF849Dc5567aDC3F2FdC318 -NEXT_PUBLIC_PEGGED_TOKEN=0x0165878A594ca255338adfa4d48449f69242Eb8F -NEXT_PUBLIC_LEVERAGED_TOKEN=0xa513E6E4b8f2a923D98304ec87F64353C4D5C853 -NEXT_PUBLIC_WSTETH=0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 -NEXT_PUBLIC_STETH=0x5FbDB2315678afecb367f032d93F642f64180aa3 -``` - ---- - -## Important Notes - -1. **Clean Chain**: This is a clean Anvil chain (no mainnet fork) to avoid problematic blocks -2. **Mock Tokens**: stETH and wstETH are mock contracts deployed locally -3. **Graph Node**: Subgraph needs to be deployed (see next steps) -4. **Current Block**: Chain is at block 67+ (will increase as you use it) -5. **Docker Required**: Graph Node requires Docker Desktop to be running - ---- - -## Next Steps - -1. **Start Docker Desktop** (if not already running) -2. **Start Graph Node**: - ```bash - cd graph-node-local - docker compose up -d - ``` -3. **Deploy Subgraph** (from your subgraph directory): - ```bash - # Update subgraph.yaml with: - # - network: anvil - # - address: 0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82 - # - startBlock: 55 - - graph create --node http://localhost:8020/ harbor-marks-local - graph build - graph deploy --node http://localhost:8020/ --ipfs http://localhost:5001 harbor-marks-local - ``` - ---- - -## Verification Status - -✅ Anvil running on clean chain -✅ Mock tokens deployed and configured -✅ Harbor contracts deployed -✅ Developer account has required permissions -✅ Tokens minted to developer -⏳ Graph Node (requires Docker) -⏳ Subgraph deployment (pending Graph Node) - ---- - -**Last Updated**: November 19, 2025 -**Deployment Type**: Clean Anvil Chain -**Status**: Ready for Graph Node and subgraph deployment - - - - - diff --git a/doc/guides/FRONTEND-CONFIG-NEW.txt b/doc/guides/FRONTEND-CONFIG-NEW.txt deleted file mode 100644 index 9561d7c0..00000000 --- a/doc/guides/FRONTEND-CONFIG-NEW.txt +++ /dev/null @@ -1,40 +0,0 @@ -================================================================================ -FRONTEND CONFIGURATION - New Deployment -Forked from block: 23829220 (after problematic block) -================================================================================ - -QUICK COPY-PASTE FOR FRONTEND AI: ---------------------------------- - -GraphQL Endpoint: http://localhost:8000/subgraphs/name/harbor-marks-local - -Network: anvil (Chain ID: 31337) -RPC URL: http://localhost:8545 - -Environment Variable: -NEXT_PUBLIC_GRAPH_URL=http://localhost:8000/subgraphs/name/harbor-marks-local - -CONTRACT ADDRESSES: -------------------- -Genesis: 0xDeF8a62f50BA3B9f319B473c48928595A333acba -Minter: 0xdb9Bc1Cdc816B727d924C9ebEba73F04F26a318a -Pegged Token (haPB): 0x4c07ce6454D5340591f62fD7d3978B6f42Ef953e -Leveraged Token: 0x1687d4BDE380019748605231C956335a473Fd3dc -Reserve Pool: 0xF1a7a5060f22edA40b1A94a858995fa2bcf5E75A -Stability Pool Manager: 0xDF3201eB257FB75E57E394b53AA1A215025230Dc -Fee Receiver: 0x18903fF6E49c98615Ab741aE33b5CD202Ccc0158 -Price Oracle: 0xe0a8d99BE93AeDEe411C645999681fbb4453973e -Collateral Token (stETH): 0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84 -Wrapped Collateral (wstETH): 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0 -Stability Pool Collateral: 0x90Dd5250fD06b9E6E3d048cAF7f26Da609cb67cC -Stability Pool Leveraged: 0x93d027eCAbF0b383F61cFad54D7D8FcAE7972d33 - -TOKEN NAMES: -Pegged Token: Harbor Anchored PB (haPB) -Leveraged Token: Harbor Sail hsPBxstETH (hshsPBxstETH) - -Developer Address: 0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e - -VERIFICATION: -✅ Developer is owner of Genesis contract -✅ Developer has ZERO_FEE_ROLE on Minter diff --git a/doc/guides/FRONTEND-CONFIG-READY.md b/doc/guides/FRONTEND-CONFIG-READY.md deleted file mode 100644 index c3ee794d..00000000 --- a/doc/guides/FRONTEND-CONFIG-READY.md +++ /dev/null @@ -1,322 +0,0 @@ -# Frontend Configuration - Ready to Use - -**Status**: ✅ All services running -**Date**: November 19, 2025 - ---- - -## 🚀 Quick Start for Frontend - -### GraphQL Endpoint -``` -http://localhost:8000/subgraphs/name/harbor-marks-local -``` - -### Environment Variable -```bash -NEXT_PUBLIC_GRAPH_URL=http://localhost:8000/subgraphs/name/harbor-marks-local -``` - ---- - -## 📋 Contract Addresses - -```typescript -export const CONTRACTS = { - // Core Contracts - genesis: "0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82", - minter: "0x8A791620dd6260079BF849Dc5567aDC3F2FdC318", - - // Tokens - peggedToken: "0x0165878A594ca255338adfa4d48449f69242Eb8F", // haPB - leveragedToken: "0xa513E6E4b8f2a923D98304ec87F64353C4D5C853", // hshsPBxstETH - wstETH: "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512", // Mock wstETH - stETH: "0x5FbDB2315678afecb367f032d93F642f64180aa3", // Mock stETH - - // Other Contracts - reservePool: "0x610178dA211FEF7D417bC0e6FeD39F05609AD788", - stabilityPoolManager: "0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0", - feeReceiver: "0xB7f8BC63BbcaD18155201308C8f3540b07f84F5e", -} as const; -``` - ---- - -## 🌐 Network Configuration - -```typescript -const network = { - chainId: 31337, - chainName: "Anvil Local", - nativeCurrency: { - name: "Ether", - symbol: "ETH", - decimals: 18, - }, - rpcUrls: ["http://localhost:8545"], - blockExplorerUrls: [], -}; -``` - ---- - -## 📊 GraphQL Queries - -### Get User Harbor Marks -```graphql -query GetUserHarborMarks($user: Bytes!) { - userHarborMarks(id: $user) { - id - totalDeposited - totalWithdrawn - currentBalance - } -} -``` - -### Get Deposits -```graphql -query GetDeposits($user: Bytes!) { - deposits( - where: { user: $user } - orderBy: timestamp - orderDirection: desc - first: 10 - ) { - id - user - token - amount - timestamp - blockNumber - } -} -``` - -### Get Withdrawals -```graphql -query GetWithdrawals($user: Bytes!) { - withdrawals( - where: { user: $user } - orderBy: timestamp - orderDirection: desc - first: 10 - ) { - id - user - token - amount - timestamp - blockNumber - } -} -``` - ---- - -## 🔑 Developer Account (for testing) - -- **Address**: `0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e` -- **Balances**: 1000 stETH, 1000 wstETH -- **Permissions**: Owner of Genesis, ZERO_FEE_ROLE on Minter - ---- - -## ⚠️ Important Notes - -1. **Subgraph Status**: The subgraph may need to be redeployed with the new Genesis address (`0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82`) and start block (55) if it's still indexing old blocks. - -2. **Current State**: - - Anvil: Block 69 - - Genesis deployed at: Block 55 - - Subgraph: Check indexing status - -3. **To Redeploy Subgraph** (if needed): - ```bash - # Update subgraph.yaml with: - # network: anvil - # address: "0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82" - # startBlock: 55 - - graph deploy --node http://localhost:8020/ \ - --ipfs http://localhost:5001 \ - harbor-marks-local - ``` - ---- - -## ✅ Service Status - -- ✅ Docker: Running -- ✅ Graph Node: Running -- ✅ Anvil: Running (block 69) -- ⚠️ Subgraph: May need redeployment with new addresses - ---- - -**Last Updated**: November 19, 2025 -**Ready for**: Frontend integration - - - -**Status**: ✅ All services running -**Date**: November 19, 2025 - ---- - -## 🚀 Quick Start for Frontend - -### GraphQL Endpoint -``` -http://localhost:8000/subgraphs/name/harbor-marks-local -``` - -### Environment Variable -```bash -NEXT_PUBLIC_GRAPH_URL=http://localhost:8000/subgraphs/name/harbor-marks-local -``` - ---- - -## 📋 Contract Addresses - -```typescript -export const CONTRACTS = { - // Core Contracts - genesis: "0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82", - minter: "0x8A791620dd6260079BF849Dc5567aDC3F2FdC318", - - // Tokens - peggedToken: "0x0165878A594ca255338adfa4d48449f69242Eb8F", // haPB - leveragedToken: "0xa513E6E4b8f2a923D98304ec87F64353C4D5C853", // hshsPBxstETH - wstETH: "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512", // Mock wstETH - stETH: "0x5FbDB2315678afecb367f032d93F642f64180aa3", // Mock stETH - - // Other Contracts - reservePool: "0x610178dA211FEF7D417bC0e6FeD39F05609AD788", - stabilityPoolManager: "0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0", - feeReceiver: "0xB7f8BC63BbcaD18155201308C8f3540b07f84F5e", -} as const; -``` - ---- - -## 🌐 Network Configuration - -```typescript -const network = { - chainId: 31337, - chainName: "Anvil Local", - nativeCurrency: { - name: "Ether", - symbol: "ETH", - decimals: 18, - }, - rpcUrls: ["http://localhost:8545"], - blockExplorerUrls: [], -}; -``` - ---- - -## 📊 GraphQL Queries - -### Get User Harbor Marks -```graphql -query GetUserHarborMarks($user: Bytes!) { - userHarborMarks(id: $user) { - id - totalDeposited - totalWithdrawn - currentBalance - } -} -``` - -### Get Deposits -```graphql -query GetDeposits($user: Bytes!) { - deposits( - where: { user: $user } - orderBy: timestamp - orderDirection: desc - first: 10 - ) { - id - user - token - amount - timestamp - blockNumber - } -} -``` - -### Get Withdrawals -```graphql -query GetWithdrawals($user: Bytes!) { - withdrawals( - where: { user: $user } - orderBy: timestamp - orderDirection: desc - first: 10 - ) { - id - user - token - amount - timestamp - blockNumber - } -} -``` - ---- - -## 🔑 Developer Account (for testing) - -- **Address**: `0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e` -- **Balances**: 1000 stETH, 1000 wstETH -- **Permissions**: Owner of Genesis, ZERO_FEE_ROLE on Minter - ---- - -## ⚠️ Important Notes - -1. **Subgraph Status**: The subgraph may need to be redeployed with the new Genesis address (`0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82`) and start block (55) if it's still indexing old blocks. - -2. **Current State**: - - Anvil: Block 69 - - Genesis deployed at: Block 55 - - Subgraph: Check indexing status - -3. **To Redeploy Subgraph** (if needed): - ```bash - # Update subgraph.yaml with: - # network: anvil - # address: "0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82" - # startBlock: 55 - - graph deploy --node http://localhost:8020/ \ - --ipfs http://localhost:5001 \ - harbor-marks-local - ``` - ---- - -## ✅ Service Status - -- ✅ Docker: Running -- ✅ Graph Node: Running -- ✅ Anvil: Running (block 69) -- ⚠️ Subgraph: May need redeployment with new addresses - ---- - -**Last Updated**: November 19, 2025 -**Ready for**: Frontend integration - - - - - diff --git a/doc/guides/FRONTEND-CONFIG.md b/doc/guides/FRONTEND-CONFIG.md deleted file mode 100644 index bff37f22..00000000 --- a/doc/guides/FRONTEND-CONFIG.md +++ /dev/null @@ -1,320 +0,0 @@ -# Frontend Configuration - Clean Anvil Chain Deployment - -## Quick Setup - -**GraphQL Endpoint:** -``` -http://localhost:8000/subgraphs/name/harbor-marks-local -``` - -**Environment Variable:** -```bash -NEXT_PUBLIC_GRAPH_URL=http://localhost:8000/subgraphs/name/harbor-marks-local -``` - -**Network Configuration:** -- Network Name: `anvil` -- Chain ID: `31337` -- RPC URL: `http://localhost:8545` - ---- - -## Contract Addresses - -### Core Contracts -```typescript -export const contracts = { - genesis: "0x67d269191c92Caf3cD7723F116c85e6E9bf55933", - minter: "0x4A679253410272dd5232B3Ff7cF5dbB88f295319", - peggedToken: "0x4ed7c70F96B99c776995fB64377f0d4aB3B0e1C1", - leveragedToken: "0x322813Fd9A801c5507c9de605d63CEA4f2CE6c44", - reservePool: "0x7a2088a1bFc9d81c55368AE168C2C02570cB814F", - stabilityPoolManager: "0xc5a5C42992dECbae36851359345FE25997F5C42d", - feeReceiver: "0x09635F643e140090A9A8Dcd712eD6285858ceBef", - priceOracle: "0xa82fF9aFd8f496c3d6ac40E2a0F282E47488CFc9", - collateralToken: "0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9", // Mock stETH - wrappedCollateralToken: "0x5FC8d32690cc91D4c39d9d3abcBD16989F875707", // Mock wstETH - stabilityPoolCollateral: "0x82e01223d51Eb87e16A03E24687EDF0F294da6f1", - stabilityPoolLeveraged: null, // Not deployed -} as const; -``` - -### Token Information -- **Pegged Token (haPB)**: `0x4ed7c70F96B99c776995fB64377f0d4aB3B0e1C1` - - Name: Harbor Anchored PB - - Symbol: haPB - -- **Leveraged Token**: `0x322813Fd9A801c5507c9de605d63CEA4f2CE6c44` - - Name: Harbor Sail hsPBxstETH - - Symbol: hshsPBxstETH - -- **Collateral Token (stETH)**: `0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9` - - Symbol: stETH - - Type: MockStETH - -- **Wrapped Collateral (wstETH)**: `0x5FC8d32690cc91D4c39d9d3abcBD16989F875707` - - Symbol: wstETH - - Type: MockWstETHEnhanced - ---- - -## GraphQL Queries - -### Example Query: Get User Harbor Marks -```graphql -query GetUserHarborMarks($user: Bytes!) { - userHarborMarks(id: $user) { - id - totalDeposited - totalWithdrawn - currentBalance - } -} -``` - -### Example Query: Get Deposits -```graphql -query GetDeposits($user: Bytes!) { - deposits(where: { user: $user }, orderBy: timestamp, orderDirection: desc) { - id - user - token - amount - timestamp - blockNumber - } -} -``` - -### Example Query: Get Withdrawals -```graphql -query GetWithdrawals($user: Bytes!) { - withdrawals(where: { user: $user }, orderBy: timestamp, orderDirection: desc) { - id - user - token - amount - timestamp - blockNumber - } -} -``` - ---- - -## Developer Account (for testing) - -- **Address**: `0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e` -- **Token Balances**: - - stETH: 1000 tokens - - wstETH: 1000 tokens -- **Permissions**: - - ✅ Owner of Genesis contract - - ✅ Has ZERO_FEE_ROLE on Minter - ---- - -## Network Configuration for Web3 - -```typescript -const anvilNetwork = { - chainId: 31337, - chainName: "Anvil Local", - nativeCurrency: { - name: "Ether", - symbol: "ETH", - decimals: 18, - }, - rpcUrls: ["http://localhost:8545"], - blockExplorerUrls: [], -}; -``` - ---- - -## Important Notes - -1. **Clean Chain**: This is a clean Anvil chain (no mainnet fork) to avoid problematic blocks -2. **Mock Tokens**: stETH and wstETH are mock contracts deployed locally -3. **Graph Node**: Subgraph is deployed and should sync quickly on clean chain -4. **Current Block**: Chain is at block 84+ (will increase as you use it) - ---- - -## Verification Status - -✅ Genesis contract deployed and verified -✅ Minter contract deployed and verified -✅ Developer account has required permissions -✅ Mock tokens deployed and configured -✅ Subgraph deployed and indexing - ---- - -**Last Updated**: After Cursor restart -**Deployment Type**: Clean Anvil Chain -**Status**: Ready for frontend integration - - - - -## Quick Setup - -**GraphQL Endpoint:** -``` -http://localhost:8000/subgraphs/name/harbor-marks-local -``` - -**Environment Variable:** -```bash -NEXT_PUBLIC_GRAPH_URL=http://localhost:8000/subgraphs/name/harbor-marks-local -``` - -**Network Configuration:** -- Network Name: `anvil` -- Chain ID: `31337` -- RPC URL: `http://localhost:8545` - ---- - -## Contract Addresses - -### Core Contracts -```typescript -export const contracts = { - genesis: "0x67d269191c92Caf3cD7723F116c85e6E9bf55933", - minter: "0x4A679253410272dd5232B3Ff7cF5dbB88f295319", - peggedToken: "0x4ed7c70F96B99c776995fB64377f0d4aB3B0e1C1", - leveragedToken: "0x322813Fd9A801c5507c9de605d63CEA4f2CE6c44", - reservePool: "0x7a2088a1bFc9d81c55368AE168C2C02570cB814F", - stabilityPoolManager: "0xc5a5C42992dECbae36851359345FE25997F5C42d", - feeReceiver: "0x09635F643e140090A9A8Dcd712eD6285858ceBef", - priceOracle: "0xa82fF9aFd8f496c3d6ac40E2a0F282E47488CFc9", - collateralToken: "0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9", // Mock stETH - wrappedCollateralToken: "0x5FC8d32690cc91D4c39d9d3abcBD16989F875707", // Mock wstETH - stabilityPoolCollateral: "0x82e01223d51Eb87e16A03E24687EDF0F294da6f1", - stabilityPoolLeveraged: null, // Not deployed -} as const; -``` - -### Token Information -- **Pegged Token (haPB)**: `0x4ed7c70F96B99c776995fB64377f0d4aB3B0e1C1` - - Name: Harbor Anchored PB - - Symbol: haPB - -- **Leveraged Token**: `0x322813Fd9A801c5507c9de605d63CEA4f2CE6c44` - - Name: Harbor Sail hsPBxstETH - - Symbol: hshsPBxstETH - -- **Collateral Token (stETH)**: `0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9` - - Symbol: stETH - - Type: MockStETH - -- **Wrapped Collateral (wstETH)**: `0x5FC8d32690cc91D4c39d9d3abcBD16989F875707` - - Symbol: wstETH - - Type: MockWstETHEnhanced - ---- - -## GraphQL Queries - -### Example Query: Get User Harbor Marks -```graphql -query GetUserHarborMarks($user: Bytes!) { - userHarborMarks(id: $user) { - id - totalDeposited - totalWithdrawn - currentBalance - } -} -``` - -### Example Query: Get Deposits -```graphql -query GetDeposits($user: Bytes!) { - deposits(where: { user: $user }, orderBy: timestamp, orderDirection: desc) { - id - user - token - amount - timestamp - blockNumber - } -} -``` - -### Example Query: Get Withdrawals -```graphql -query GetWithdrawals($user: Bytes!) { - withdrawals(where: { user: $user }, orderBy: timestamp, orderDirection: desc) { - id - user - token - amount - timestamp - blockNumber - } -} -``` - ---- - -## Developer Account (for testing) - -- **Address**: `0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e` -- **Token Balances**: - - stETH: 1000 tokens - - wstETH: 1000 tokens -- **Permissions**: - - ✅ Owner of Genesis contract - - ✅ Has ZERO_FEE_ROLE on Minter - ---- - -## Network Configuration for Web3 - -```typescript -const anvilNetwork = { - chainId: 31337, - chainName: "Anvil Local", - nativeCurrency: { - name: "Ether", - symbol: "ETH", - decimals: 18, - }, - rpcUrls: ["http://localhost:8545"], - blockExplorerUrls: [], -}; -``` - ---- - -## Important Notes - -1. **Clean Chain**: This is a clean Anvil chain (no mainnet fork) to avoid problematic blocks -2. **Mock Tokens**: stETH and wstETH are mock contracts deployed locally -3. **Graph Node**: Subgraph is deployed and should sync quickly on clean chain -4. **Current Block**: Chain is at block 84+ (will increase as you use it) - ---- - -## Verification Status - -✅ Genesis contract deployed and verified -✅ Minter contract deployed and verified -✅ Developer account has required permissions -✅ Mock tokens deployed and configured -✅ Subgraph deployed and indexing - ---- - -**Last Updated**: After Cursor restart -**Deployment Type**: Clean Anvil Chain -**Status**: Ready for frontend integration - - - - - - diff --git a/doc/guides/FRONTEND-CONTRACT-ADDRESSES-CURRENT.txt b/doc/guides/FRONTEND-CONTRACT-ADDRESSES-CURRENT.txt deleted file mode 100644 index b75424c4..00000000 --- a/doc/guides/FRONTEND-CONTRACT-ADDRESSES-CURRENT.txt +++ /dev/null @@ -1,81 +0,0 @@ -=== Current Contract Addresses for Frontend === - -Network Configuration: -- Chain ID: 31337 -- RPC URL: http://localhost:8545 -- Network Name: Local Anvil - -Core Contracts: -- Genesis: 0x840748F7Fd3EA956E5f4c88001da5CC1ABCBc038 -- Minter: 0x6484EB0792c646A4827638Fc1B6F20461418eB00 -- PeggedToken (ha): 0x8aAC5570d54306Bb395bf2385ad327b7b706016b -- LeveragedToken (hs): 0x64f5219563e28EeBAAd91Ca8D31fa3b36621FD4f - -Tokens: -- wstETH: 0x2e8880cAdC08E9B438c6052F5ce3869FBd6cE513 -- stETH: 0x5FbDB2315678afecb367f032d93F642f64180aa3 - -Price Feeds: -- wstETH/USD: 0xeC827421505972a2AE9C320302d3573B42363C26 -- stETH/USD: 0xb007167714e2940013ec3bb551584130b7497e22 -- stETH/ETH: 0x6b39b761b1b64c8c095bf0e3bb0c6a74705b4788 - -Other Contracts: -- ReservePool: 0xf201fFeA8447AB3d43c98Da3349e0749813C9009 -- FeeReceiver: 0xA75E74a5109Ed8221070142D15cEBfFe9642F489 -- StabilityPoolManager: 0x26291175Fa0Ea3C8583fEdEB56805eA68289b105 -- PriceOracle: 0x1757a98c1333B9dc8D408b194B2279b5AFDF70Cc - -GraphQL Endpoint: -- http://localhost:8000/subgraphs/name/harbor-marks-local - -Admin Accounts: -- Owner (can call endGenesis): 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 -- Developer: 0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e - -Status: -✅ Genesis is ACTIVE (not ended) -✅ All price feeds fixed -✅ ZERO_FEE_ROLE granted to Genesis -✅ endGenesis() verified working - - -Network Configuration: -- Chain ID: 31337 -- RPC URL: http://localhost:8545 -- Network Name: Local Anvil - -Core Contracts: -- Genesis: 0x840748F7Fd3EA956E5f4c88001da5CC1ABCBc038 -- Minter: 0x6484EB0792c646A4827638Fc1B6F20461418eB00 -- PeggedToken (ha): 0x8aAC5570d54306Bb395bf2385ad327b7b706016b -- LeveragedToken (hs): 0x64f5219563e28EeBAAd91Ca8D31fa3b36621FD4f - -Tokens: -- wstETH: 0x2e8880cAdC08E9B438c6052F5ce3869FBd6cE513 -- stETH: 0x5FbDB2315678afecb367f032d93F642f64180aa3 - -Price Feeds: -- wstETH/USD: 0xeC827421505972a2AE9C320302d3573B42363C26 -- stETH/USD: 0xb007167714e2940013ec3bb551584130b7497e22 -- stETH/ETH: 0x6b39b761b1b64c8c095bf0e3bb0c6a74705b4788 - -Other Contracts: -- ReservePool: 0xf201fFeA8447AB3d43c98Da3349e0749813C9009 -- FeeReceiver: 0xA75E74a5109Ed8221070142D15cEBfFe9642F489 -- StabilityPoolManager: 0x26291175Fa0Ea3C8583fEdEB56805eA68289b105 -- PriceOracle: 0x1757a98c1333B9dc8D408b194B2279b5AFDF70Cc - -GraphQL Endpoint: -- http://localhost:8000/subgraphs/name/harbor-marks-local - -Admin Accounts: -- Owner (can call endGenesis): 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 -- Developer: 0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e - -Status: -✅ Genesis is ACTIVE (not ended) -✅ All price feeds fixed -✅ ZERO_FEE_ROLE granted to Genesis -✅ endGenesis() verified working - diff --git a/doc/guides/FRONTEND-CONTRACT-ADDRESSES.txt b/doc/guides/FRONTEND-CONTRACT-ADDRESSES.txt deleted file mode 100644 index 75cf47df..00000000 --- a/doc/guides/FRONTEND-CONTRACT-ADDRESSES.txt +++ /dev/null @@ -1,100 +0,0 @@ -=============================================================================== -CONTRACT ADDRESSES FOR FRONTEND - Clean Anvil Deployment -=============================================================================== - -CORE CONTRACTS: ---------------- -Genesis: 0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82 -Minter: 0x8A791620dd6260079BF849Dc5567aDC3F2FdC318 - -TOKENS: -------- -wstETH: 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 -stETH: 0x5FbDB2315678afecb367f032d93F642f64180aa3 -Pegged Token: 0x0165878A594ca255338adfa4d48449f69242Eb8F (haPB) -Leveraged Token: 0xa513E6E4b8f2a923D98304ec87F64353C4D5C853 (hshsPBxstETH) - -OTHER CONTRACTS: ---------------- -Reserve Pool: 0x610178dA211FEF7D417bC0e6FeD39F05609AD788 -Stability Pool Manager: 0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0 -Fee Receiver: 0xB7f8BC63BbcaD18155201308C8f3540b07f84F5e - -NETWORK: -------- -Chain ID: 31337 -RPC URL: http://localhost:8545 -Network Name: anvil - -GRAPHQL ENDPOINT: ----------------- -http://localhost:8000/subgraphs/name/harbor-marks-local - -=============================================================================== -QUICK COPY FOR ENVIRONMENT VARIABLES: -=============================================================================== - -NEXT_PUBLIC_GENESIS_CONTRACT=0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82 -NEXT_PUBLIC_MINTER_CONTRACT=0x8A791620dd6260079BF849Dc5567aDC3F2FdC318 -NEXT_PUBLIC_WSTETH=0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 -NEXT_PUBLIC_STETH=0x5FbDB2315678afecb367f032d93F642f64180aa3 -NEXT_PUBLIC_PEGGED_TOKEN=0x0165878A594ca255338adfa4d48449f69242Eb8F -NEXT_PUBLIC_LEVERAGED_TOKEN=0xa513E6E4b8f2a923D98304ec87F64353C4D5C853 -NEXT_PUBLIC_CHAIN_ID=31337 -NEXT_PUBLIC_RPC_URL=http://localhost:8545 -NEXT_PUBLIC_GRAPH_URL=http://localhost:8000/subgraphs/name/harbor-marks-local - -=============================================================================== - - -CONTRACT ADDRESSES FOR FRONTEND - Clean Anvil Deployment -=============================================================================== - -CORE CONTRACTS: ---------------- -Genesis: 0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82 -Minter: 0x8A791620dd6260079BF849Dc5567aDC3F2FdC318 - -TOKENS: -------- -wstETH: 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 -stETH: 0x5FbDB2315678afecb367f032d93F642f64180aa3 -Pegged Token: 0x0165878A594ca255338adfa4d48449f69242Eb8F (haPB) -Leveraged Token: 0xa513E6E4b8f2a923D98304ec87F64353C4D5C853 (hshsPBxstETH) - -OTHER CONTRACTS: ---------------- -Reserve Pool: 0x610178dA211FEF7D417bC0e6FeD39F05609AD788 -Stability Pool Manager: 0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0 -Fee Receiver: 0xB7f8BC63BbcaD18155201308C8f3540b07f84F5e - -NETWORK: -------- -Chain ID: 31337 -RPC URL: http://localhost:8545 -Network Name: anvil - -GRAPHQL ENDPOINT: ----------------- -http://localhost:8000/subgraphs/name/harbor-marks-local - -=============================================================================== -QUICK COPY FOR ENVIRONMENT VARIABLES: -=============================================================================== - -NEXT_PUBLIC_GENESIS_CONTRACT=0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82 -NEXT_PUBLIC_MINTER_CONTRACT=0x8A791620dd6260079BF849Dc5567aDC3F2FdC318 -NEXT_PUBLIC_WSTETH=0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 -NEXT_PUBLIC_STETH=0x5FbDB2315678afecb367f032d93F642f64180aa3 -NEXT_PUBLIC_PEGGED_TOKEN=0x0165878A594ca255338adfa4d48449f69242Eb8F -NEXT_PUBLIC_LEVERAGED_TOKEN=0xa513E6E4b8f2a923D98304ec87F64353C4D5C853 -NEXT_PUBLIC_CHAIN_ID=31337 -NEXT_PUBLIC_RPC_URL=http://localhost:8545 -NEXT_PUBLIC_GRAPH_URL=http://localhost:8000/subgraphs/name/harbor-marks-local - -=============================================================================== - - - - - diff --git a/doc/guides/FRONTEND-DRY-RUN-EMPTY-DATA-FIX.md b/doc/guides/FRONTEND-DRY-RUN-EMPTY-DATA-FIX.md deleted file mode 100644 index 8944dd93..00000000 --- a/doc/guides/FRONTEND-DRY-RUN-EMPTY-DATA-FIX.md +++ /dev/null @@ -1,459 +0,0 @@ -# Frontend Fix: Empty Data (0x) from Dry-Run Call - -## Problem - -When calling `redeemPeggedTokenDryRun()`, viem returns empty data (`0x`), which means: - -- The contract has no code at that address, OR -- The function doesn't exist in the deployed bytecode, OR -- You're on the wrong chain - -## Verified Working Configuration - -✅ **Minter Address**: `0x8A791620dd6260079BF849Dc5567aDC3F2FdC318` -✅ **Chain ID**: `31337` (Local Anvil) -✅ **RPC URL**: `http://127.0.0.1:8545` -✅ **Function Selector**: `0xe2755897` (redeemPeggedTokenDryRun) -✅ **Function Signature**: `redeemPeggedTokenDryRun(uint256)` - -## Backend Verification (Contract Works) - -The contract has been verified to work correctly: - -```bash -# Contract has bytecode -cast code 0x8A791620dd6260079BF849Dc5567aDC3F2FdC318 --rpc-url http://127.0.0.1:8545 -# Returns: non-empty bytecode ✅ - -# Function call works -cast call 0x8A791620dd6260079BF849Dc5567aDC3F2FdC318 \ - "redeemPeggedTokenDryRun(uint256)" \ - 1000000000000000000 \ - --rpc-url http://127.0.0.1:8545 -# Returns: valid data (7 uint256 values) ✅ -``` - -## Diagnostic Steps (Run in Browser Console) - -### Step 1: Check Chain ID Match - -```typescript -// In your frontend -const chainId = await publicClient.getChainId(); -console.log("Current chain ID:", chainId); - -// Should be 31337 for local deployment -if (chainId !== 31337) { - console.error("❌ Wrong chain! Expected 31337, got", chainId); - console.error("Fix: Switch to chain ID 31337 or update your RPC URL"); -} -``` - -### Step 2: Verify Contract Has Code - -```typescript -const bytecode = await publicClient.getBytecode({ - address: "0x8A791620dd6260079BF849Dc5567aDC3F2FdC318", -}); - -if (!bytecode || bytecode === "0x") { - console.error("❌ Contract has no code at this address!"); - console.error("Check: Are you on the correct chain?"); - console.error("Expected RPC: http://127.0.0.1:8545"); -} else { - console.log("✅ Contract has code, length:", bytecode.length); -} -``` - -### Step 3: Test Function Exists (Critical Test) - -```typescript -try { - const result = await publicClient.readContract({ - address: "0x8A791620dd6260079BF849Dc5567aDC3F2FdC318", - abi: [ - { - name: "redeemPeggedTokenDryRun", - type: "function", - stateMutability: "view", - inputs: [{ name: "peggedIn", type: "uint256" }], - outputs: [ - { name: "incentiveRatio", type: "int256" }, - { name: "fee", type: "uint256" }, - { name: "discount", type: "uint256" }, - { name: "peggedRedeemed", type: "uint256" }, - { name: "wrappedCollateralReturned", type: "uint256" }, - { name: "price", type: "uint256" }, - { name: "rate", type: "uint256" }, - ], - }, - ], - functionName: "redeemPeggedTokenDryRun", - args: [1n * 10n ** 18n], // 1 token in wei - }); - - console.log("✅ Function works! Result:", result); - console.log("incentiveRatio:", result[0].toString()); - console.log("fee:", result[1].toString()); - console.log("discount:", result[2].toString()); -} catch (error: any) { - console.error("❌ Function call failed:", error); - if (error.message?.includes("Function selector not recognized")) { - console.error("Function doesn't exist in deployed bytecode!"); - } - if (error.message?.includes("0x")) { - console.error("Received empty data - contract may not have this function"); - } -} -``` - -### Step 4: Check Your Market Configuration - -```typescript -// Verify the minter address in your market config -const selectedMarket = getSelectedMarket(); // Your function -console.log("Market minter address:", selectedMarket?.addresses?.minter); - -if (!selectedMarket?.addresses?.minter) { - console.error("❌ Minter address missing from market config!"); - console.error("Fix: Add minter address to market config"); -} - -if (selectedMarket.addresses.minter !== "0x8A791620dd6260079BF849Dc5567aDC3F2FdC318") { - console.warn("⚠️ Minter address mismatch!"); - console.warn("Expected: 0x8A791620dd6260079BF849Dc5567aDC3F2FdC318"); - console.warn("Got:", selectedMarket.addresses.minter); - console.warn("Fix: Update market config with correct minter address"); -} -``` - -### Step 5: Verify RPC URL - -```typescript -// Check what RPC URL your publicClient is using -console.log("RPC URL:", publicClient.transport.url || "Check wagmi config"); - -// Should be: http://127.0.0.1:8545 for local Anvil -``` - -## Common Fixes - -### Fix 1: Wrong Chain ID - -**Problem**: Frontend connected to wrong network - -**Solution**: - -```typescript -// In your wagmi config or connection setup -import { createConfig, http } from "wagmi"; -import { localhost } from "wagmi/chains"; - -const config = createConfig({ - chains: [localhost], // Chain ID 31337 - transports: { - [localhost.id]: http("http://127.0.0.1:8545"), - }, - // ... rest of config -}); -``` - -Or manually configure: - -```typescript -const localAnvil = { - id: 31337, - name: "Local Anvil", - nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 }, - rpcUrls: { - default: { - http: ["http://127.0.0.1:8545"], - }, - }, -}; -``` - -### Fix 2: Wrong Address in Market Config - -**Problem**: `selectedRedeemMarket.addresses.minter` is undefined or wrong - -**Solution**: - -```typescript -// Ensure your market config includes the minter address -const marketConfig = { - addresses: { - minter: "0x8A791620dd6260079BF849Dc5567aDC3F2FdC318", - peggedToken: "0x0165878A594ca255338adfa4d48449f69242Eb8F", - leveragedToken: "0xa513E6E4b8f2a923D98304ec87F64353C4D5C853", - // ... other addresses - }, - chainId: 31337, -}; -``` - -### Fix 3: Complete ABI Required - -**Problem**: ABI missing return types or incomplete - -**Solution**: - -```typescript -// ✅ CORRECT - Full ABI with return types -const MINTER_ABI = [ - { - name: "redeemPeggedTokenDryRun", - type: "function", - stateMutability: "view", - inputs: [{ name: "peggedIn", type: "uint256" }], - outputs: [ - { name: "incentiveRatio", type: "int256" }, - { name: "fee", type: "uint256" }, - { name: "discount", type: "uint256" }, - { name: "peggedRedeemed", type: "uint256" }, - { name: "wrappedCollateralReturned", type: "uint256" }, - { name: "price", type: "uint256" }, - { name: "rate", type: "uint256" }, - ], - }, -] as const; -``` - -### Fix 4: Amount Must Be in Wei - -**Problem**: Passing human-readable amount instead of wei - -**Solution**: - -```typescript -import { parseEther } from "viem"; - -// ❌ WRONG -const amount = "1"; - -// ✅ CORRECT -const amount = parseEther("1"); // 1000000000000000000n -``` - -### Fix 5: Check Wallet Connection - -**Problem**: Wallet connected to different chain than RPC - -**Solution**: - -```typescript -// Ensure wallet is on the same chain as your RPC -const { chain } = useAccount(); -const chainId = useChainId(); - -if (chain?.id !== 31337 || chainId !== 31337) { - // Prompt user to switch network - await switchChain({ chainId: 31337 }); -} -``` - -## Complete Diagnostic Hook - -```typescript -import { usePublicClient, useChainId } from "wagmi"; -import { parseEther } from "viem"; - -const MINTER_ABI = [ - { - name: "redeemPeggedTokenDryRun", - type: "function", - stateMutability: "view", - inputs: [{ name: "peggedIn", type: "uint256" }], - outputs: [ - { name: "incentiveRatio", type: "int256" }, - { name: "fee", type: "uint256" }, - { name: "discount", type: "uint256" }, - { name: "peggedRedeemed", type: "uint256" }, - { name: "wrappedCollateralReturned", type: "uint256" }, - { name: "price", type: "uint256" }, - { name: "rate", type: "uint256" }, - ], - }, -] as const; - -export function useDryRunDiagnostics(minterAddress: string) { - const publicClient = usePublicClient(); - const chainId = useChainId(); - - const diagnose = async () => { - const diagnostics = { - chainId: chainId, - chainMatch: chainId === 31337, - hasCode: false, - functionExists: false, - error: null as string | null, - result: null as any, - }; - - try { - // Check chain - if (chainId !== 31337) { - diagnostics.error = `Wrong chain ID: ${chainId}, expected 31337`; - return diagnostics; - } - - // Check bytecode - const bytecode = await publicClient.getBytecode({ - address: minterAddress as `0x${string}`, - }); - - diagnostics.hasCode = !!bytecode && bytecode !== "0x"; - - if (!diagnostics.hasCode) { - diagnostics.error = "Contract has no code at this address"; - return diagnostics; - } - - // Test function - try { - const result = await publicClient.readContract({ - address: minterAddress as `0x${string}`, - abi: MINTER_ABI, - functionName: "redeemPeggedTokenDryRun", - args: [parseEther("1")], - }); - diagnostics.functionExists = true; - diagnostics.result = result; - } catch (err: any) { - diagnostics.error = err.message || String(err); - if (err.message?.includes("Function selector")) { - diagnostics.error = "Function not found in contract bytecode"; - } - if (err.message?.includes("0x") || err.data === "0x") { - diagnostics.error = "Function returned empty data (0x) - check chain/address"; - } - } - } catch (err: any) { - diagnostics.error = err.message || String(err); - } - - return diagnostics; - }; - - return { diagnose }; -} -``` - -## Quick Test Script (Browser Console) - -Run this in your browser console (on the same chain as your frontend): - -```javascript -// Replace with your actual publicClient/viem setup -const testDryRun = async () => { - const minterAddress = "0x8A791620dd6260079BF849Dc5567aDC3F2FdC318"; - - console.log("=== Dry-Run Diagnostic Test ===\n"); - - console.log("1. Checking chain ID..."); - const chainId = await publicClient.getChainId(); - console.log(" Chain ID:", chainId, chainId === 31337 ? "✅" : "❌"); - if (chainId !== 31337) { - console.error(" ⚠️ Wrong chain! Switch to 31337"); - return; - } - - console.log("\n2. Checking contract bytecode..."); - const bytecode = await publicClient.getBytecode({ address: minterAddress }); - const hasCode = bytecode && bytecode !== "0x"; - console.log(" Has code:", hasCode ? "✅" : "❌"); - if (hasCode) { - console.log(" Bytecode length:", bytecode.length); - } else { - console.error(" ⚠️ No code at address - check chain/address"); - return; - } - - console.log("\n3. Testing function call..."); - try { - const result = await publicClient.readContract({ - address: minterAddress, - abi: [ - { - name: "redeemPeggedTokenDryRun", - type: "function", - stateMutability: "view", - inputs: [{ name: "peggedIn", type: "uint256" }], - outputs: [ - { name: "incentiveRatio", type: "int256" }, - { name: "fee", type: "uint256" }, - { name: "discount", type: "uint256" }, - { name: "peggedRedeemed", type: "uint256" }, - { name: "wrappedCollateralReturned", type: "uint256" }, - { name: "price", type: "uint256" }, - { name: "rate", type: "uint256" }, - ], - }, - ], - functionName: "redeemPeggedTokenDryRun", - args: [1000000000000000000n], // 1 token - }); - console.log(" Function works! ✅"); - console.log(" Result:", result); - console.log(" incentiveRatio:", result[0].toString()); - console.log(" fee:", result[1].toString()); - console.log(" discount:", result[2].toString()); - } catch (error) { - console.error(" Function failed! ❌"); - console.error(" Error:", error.message || error); - if (error.data === "0x" || error.message?.includes("0x")) { - console.error(" ⚠️ Empty data returned - function may not exist or wrong chain"); - } - } - - console.log("\n4. Checking RPC URL..."); - const rpcUrl = publicClient.transport?.url || "Check wagmi config"; - console.log(" RPC URL:", rpcUrl); - console.log(" Expected: http://127.0.0.1:8545"); -}; - -testDryRun(); -``` - -## Most Likely Issues (In Order of Probability) - -1. **Wrong Chain ID** (90% likely) - - Frontend connected to different chain than deployed contract - - Fix: Ensure chain ID is `31337` and RPC is `http://127.0.0.1:8545` - -2. **Missing/Incorrect Minter Address in Market Config** (5% likely) - - `selectedRedeemMarket.addresses.minter` is undefined or wrong - - Fix: Set to `0x8A791620dd6260079BF849Dc5567aDC3F2FdC318` - -3. **Wrong RPC URL** (3% likely) - - Frontend using different RPC than where contract is deployed - - Fix: Use `http://127.0.0.1:8545` for local Anvil - -4. **ABI Mismatch** (2% likely) - - ABI doesn't match deployed contract - - Fix: Use the exact ABI from `src/interfaces/IMinter.sol` - -## Current Deployment Info - -``` -Chain ID: 31337 -RPC URL: http://127.0.0.1:8545 -Minter: 0x8A791620dd6260079BF849Dc5567aDC3F2FdC318 -Function Selector: 0xe2755897 -Function Signature: redeemPeggedTokenDryRun(uint256) -``` - -**Make sure your frontend is using these exact values!** - -## Next Steps - -1. Run the diagnostic test in your browser console -2. Check each step's output -3. Fix the first failing step -4. Re-test the dry-run call - -If all diagnostics pass but you still get empty data, check: - -- Network tab in browser DevTools for the actual RPC request -- Verify the request is going to the correct RPC URL -- Check if there are any CORS or network errors diff --git a/doc/guides/FRONTEND-DRY-RUN-ERROR-TROUBLESHOOTING.md b/doc/guides/FRONTEND-DRY-RUN-ERROR-TROUBLESHOOTING.md deleted file mode 100644 index 0eebb574..00000000 --- a/doc/guides/FRONTEND-DRY-RUN-ERROR-TROUBLESHOOTING.md +++ /dev/null @@ -1,510 +0,0 @@ -# Frontend Guide: Troubleshooting Dry-Run Errors - -This guide helps diagnose and fix "Fee unavailable (dry-run error)" issues when calling `redeemPeggedTokenDryRun()` or `redeemLeveragedTokenDryRun()`. - -## Quick Diagnostic Test - -First, verify the dry-run works from command line: - -```bash -# Test with 1 token (1e18 wei) -cast call 0x8A791620dd6260079BF849Dc5567aDC3F2FdC318 \ - "redeemPeggedTokenDryRun(uint256)(int256,uint256,uint256,uint256,uint256,uint256,uint256)" \ - 1000000000000000000 \ - --rpc-url http://127.0.0.1:8545 -``` - -If this works but your frontend fails, the issue is likely: - -- **Wrong ABI** - Function signature mismatch -- **Wrong parameter format** - Amount not in wei -- **Network/RPC issues** - Connection problems -- **Error parsing** - Frontend not handling the response correctly - -## Common Error Causes - -### 1. Frontend ABI/Parameter Issues (Most Common in Development) - -**Symptoms**: Dry-run works via `cast` but fails in frontend - -**Common mistakes**: - -- Amount not converted to wei (using `"1"` instead of `"1000000000000000000"`) -- Wrong function signature in ABI -- Missing return values in ABI definition -- Using `readContract` instead of `read` for view functions - -**Fix**: - -```typescript -// ❌ WRONG - Amount as string -await minter.redeemPeggedTokenDryRun("1"); - -// ✅ CORRECT - Amount in wei -await minter.redeemPeggedTokenDryRun(parseEther("1").toString()); - -// ❌ WRONG - Incomplete ABI -const ABI = ["function redeemPeggedTokenDryRun(uint256)"]; - -// ✅ CORRECT - Full return types -const ABI = [ - "function redeemPeggedTokenDryRun(uint256) view returns (int256, uint256, uint256, uint256, uint256, uint256, uint256)", -]; -``` - -### 2. Stale Price Feed - -**Error**: `StaleUnderlyingPrice(address feed, uint256 timestamp, uint256 currentTime)` - -**Cause**: The Chainlink price feed timestamp is older than the allowed `maxAnswerAge` (typically 3600-7200 seconds). - -**Solution**: - -```typescript -// Check if price feeds need updating -async function checkPriceFeedFreshness(priceFeedAddress: string, provider: ethers.Provider) { - const aggregator = new Contract( - priceFeedAddress, - ["function latestRoundData() view returns (uint80, int256, uint256, uint256, uint80)"], - provider, - ); - - const [, , , updatedAt] = await aggregator.latestRoundData(); - const currentTime = Math.floor(Date.now() / 1000); - const age = currentTime - Number(updatedAt); - - console.log(`Price feed age: ${age} seconds`); - if (age > 3600) { - console.warn("⚠️ Price feed is stale! Update required."); - } -} -``` - -**Fix**: Update the price feed timestamp using `UpdateAllPriceFeeds.s.sol` script or call `setLatestAnswer()` on the mock aggregator. - -### 2. Invalid Price Oracle Address - -**Error**: Transaction reverts with "call failed" or "execution reverted" - -**Cause**: The Minter's price oracle is not set (zero address) or points to an invalid contract. - -**Check**: - -```typescript -async function checkPriceOracle(minterAddress: string, provider: ethers.Provider) { - const minter = new Contract(minterAddress, ["function priceOracle() view returns (address)"], provider); - - const oracleAddress = await minter.priceOracle(); - console.log("Price oracle:", oracleAddress); - - if (oracleAddress === "0x0000000000000000000000000000000000000000") { - throw new Error("❌ Price oracle not set on Minter!"); - } - - // Check if contract exists - const code = await provider.getCode(oracleAddress); - if (code === "0x") { - throw new Error("❌ Price oracle address has no code!"); - } -} -``` - -### 3. Price Deviation Too Large - -**Error**: `UnderlyingPriceDeviation(address feed, int256 newPrice, int256 prevPrice, uint256 maxDeviationPercent)` - -**Cause**: The price changed too much between rounds (exceeds `maxPercentageDeviation` or `maxAbsoluteDeviation`). - -**Solution**: This is a safety feature. If testing, you may need to: - -- Update price feeds more gradually -- Adjust oracle constraints (not recommended for production) -- Wait for price to stabilize - -### 4. Invalid Price (Zero or Negative) - -**Error**: `InvalidUnderlyingPrice(address feed, int256 price)` - -**Cause**: The price feed returned zero or a negative value. - -**Check**: - -```typescript -async function checkPriceFeedValue(priceFeedAddress: string, provider: ethers.Provider) { - const aggregator = new Contract( - priceFeedAddress, - ["function latestRoundData() view returns (uint80, int256, uint256, uint256, uint80)"], - provider, - ); - - const [, answer] = await aggregator.latestRoundData(); - - if (answer <= 0) { - throw new Error(`❌ Invalid price: ${answer}`); - } - - console.log("Price:", answer.toString()); -} -``` - -### 5. Chainlink Oracle Error - -**Error**: `ChainlinkOracleError(address feed, string reason)` - -**Cause**: The Chainlink aggregator call failed (e.g., `latestRoundData()` reverted). - -**Common reasons**: - -- Mock aggregator not properly deployed -- Aggregator contract doesn't exist -- Network/RPC issues - -### 6. Insufficient Token Balance - -**Note**: `Token.allOfQuiet()` should NOT revert for insufficient balance - it just returns the available balance. However, if the amount is 0 after adjustment, the calculation might fail. - -## Error Handling in Frontend - -### Complete Error Handling Example - -```typescript -import { Contract, ethers } from "ethers"; - -interface DryRunError { - type: "stale" | "invalid" | "deviation" | "oracle" | "unknown"; - message: string; - details?: any; -} - -async function calculateRedeemFeeWithErrorHandling( - minterAddress: string, - peggedAmount: string, - provider: ethers.Provider, -): Promise<{ feeInfo: RedeemFeeInfo | null; error: DryRunError | null }> { - try { - const minter = new Contract(minterAddress, MINTER_ABI, provider); - - // First, check if price oracle is set - const oracleAddress = await minter.priceOracle(); - if (oracleAddress === ethers.ZeroAddress) { - return { - feeInfo: null, - error: { - type: "oracle", - message: "Price oracle not configured on Minter contract", - }, - }; - } - - // Check oracle contract exists - const code = await provider.getCode(oracleAddress); - if (code === "0x") { - return { - feeInfo: null, - error: { - type: "oracle", - message: "Price oracle contract does not exist", - }, - }; - } - - // Try the dry-run call - const result = await minter.redeemPeggedTokenDryRun(peggedAmount); - - // Process result... - return { feeInfo: processResult(result), error: null }; - } catch (error: any) { - // Parse the error - const errorData = parseDryRunError(error); - return { feeInfo: null, error: errorData }; - } -} - -function parseDryRunError(error: any): DryRunError { - const errorMessage = error.message || error.reason || String(error); - - // Check for specific error types - if (errorMessage.includes("StaleUnderlyingPrice")) { - return { - type: "stale", - message: "Price feed is stale. Please update the price feeds.", - details: error, - }; - } - - if (errorMessage.includes("InvalidUnderlyingPrice")) { - return { - type: "invalid", - message: "Price feed returned invalid value (zero or negative)", - details: error, - }; - } - - if (errorMessage.includes("UnderlyingPriceDeviation")) { - return { - type: "deviation", - message: "Price deviation too large between rounds", - details: error, - }; - } - - if (errorMessage.includes("ChainlinkOracleError")) { - return { - type: "oracle", - message: "Chainlink oracle error - check price feed contract", - details: error, - }; - } - - // Check for revert reasons - if (errorMessage.includes("execution reverted")) { - // Try to decode the error - if (error.data) { - // Attempt to decode known errors - try { - // You can add specific error decoding here - } catch {} - } - - return { - type: "unknown", - message: "Transaction reverted. Check console for details.", - details: error, - }; - } - - return { - type: "unknown", - message: errorMessage, - details: error, - }; -} -``` - -### React Hook with Error Handling - -```typescript -import { useReadContract } from "wagmi"; -import { parseEther } from "viem"; - -function useRedeemFeeWithErrorHandling(minterAddress: string, amount: string) { - const amountWei = amount ? parseEther(amount).toString() : "0"; - - const { data, isLoading, error, refetch } = useReadContract({ - address: minterAddress as `0x${string}`, - abi: MINTER_ABI, - functionName: "redeemPeggedTokenDryRun", - args: [BigInt(amountWei)], - query: { - enabled: !!amount && amount !== "0", - retry: 1, // Only retry once - }, - }); - - const errorInfo = error ? parseDryRunError(error) : null; - - return { - data, - isLoading, - error: errorInfo, - refetch, - // Helper to check if it's a recoverable error - isRecoverable: errorInfo?.type === "stale" || errorInfo?.type === "invalid", - }; -} -``` - -### UI Error Display - -```typescript -function RedeemFeeDisplay({ minterAddress, amount }: Props) { - const { data, isLoading, error, isRecoverable } = useRedeemFeeWithErrorHandling( - minterAddress, - amount - ); - - if (isLoading) { - return
Calculating fee...
; - } - - if (error) { - return ( -
-
⚠️ Fee unavailable
-
{error.message}
- - {error.type === 'stale' && ( -
- Solution: Price feeds need to be updated. - Contact admin or wait for automatic update. -
- )} - - {error.type === 'oracle' && ( -
- Solution: Price oracle not configured. - This is a deployment issue. -
- )} - - {isRecoverable && ( - - )} - - {process.env.NODE_ENV === 'development' && ( -
- Error Details (Dev Only) -
{JSON.stringify(error.details, null, 2)}
-
- )} -
- ); - } - - // Display fee info... -} -``` - -## Quick Diagnostic Checklist - -When you see "Fee unavailable (dry-run error)", check: - -1. ✅ **Price Oracle Set?** - - ```typescript - const oracle = await minter.priceOracle(); - console.log("Oracle:", oracle); - ``` - -2. ✅ **Price Feed Fresh?** - - ```typescript - const [, , , updatedAt] = await aggregator.latestRoundData(); - const age = Date.now() / 1000 - Number(updatedAt); - console.log("Feed age:", age, "seconds"); - ``` - -3. ✅ **Price Valid?** - - ```typescript - const [, answer] = await aggregator.latestRoundData(); - console.log("Price:", answer.toString()); - ``` - -4. ✅ **Network/RPC Working?** - - ```typescript - const block = await provider.getBlockNumber(); - console.log("Current block:", block); - ``` - -5. ✅ **Minter Contract Exists?** - ```typescript - const code = await provider.getCode(minterAddress); - console.log("Minter code length:", code.length); - ``` - -## Common Fixes for Local Development - -### Fix Stale Price Feeds - -```bash -# Update all price feeds -forge script script/forge/UpdateAllPriceFeeds.s.sol \ - --rpc-url http://127.0.0.1:8545 \ - --broadcast -``` - -Or manually: - -```typescript -// Update a specific price feed -const aggregator = new Contract(feedAddress, ["function setLatestAnswer(int256)"], signer); - -await aggregator.setLatestAnswer(2000 * 1e8); // $2000 with 8 decimals -``` - -### Verify Price Oracle Configuration - -```typescript -// Check oracle is set -const oracle = await minter.priceOracle(); -if (oracle === ethers.ZeroAddress) { - console.error("❌ Price oracle not set!"); - // Need to call minter.updatePriceOracle(oracleAddress) -} -``` - -## Production Considerations - -1. **Always handle errors gracefully** - Don't block the UI, show helpful messages -2. **Retry logic** - For transient errors (network issues), implement retry -3. **Fallback display** - Show estimated fees based on last known collateral ratio -4. **Monitoring** - Log dry-run errors to track oracle health -5. **User communication** - Explain that fees are dynamic and may change - -## Example: Complete Error-Resilient Implementation - -```typescript -async function getRedeemFee( - minterAddress: string, - amount: string, - provider: ethers.Provider, -): Promise<{ - success: boolean; - feeInfo?: RedeemFeeInfo; - error?: string; - errorType?: string; -}> { - try { - // Pre-flight checks - const minter = new Contract( - minterAddress, - [ - "function priceOracle() view returns (address)", - "function redeemPeggedTokenDryRun(uint256) view returns (int256, uint256, uint256, uint256, uint256, uint256, uint256)", - ], - provider, - ); - - // Check oracle - const oracle = await minter.priceOracle(); - if (oracle === ethers.ZeroAddress) { - return { - success: false, - error: "Price oracle not configured", - errorType: "oracle", - }; - } - - // Try dry-run - const amountWei = parseEther(amount).toString(); - const result = await minter.redeemPeggedTokenDryRun(amountWei); - - return { - success: true, - feeInfo: processDryRunResult(result), - }; - } catch (error: any) { - const parsed = parseDryRunError(error); - return { - success: false, - error: parsed.message, - errorType: parsed.type, - }; - } -} -``` - -## Summary - -Most dry-run errors are caused by: - -1. **Stale price feeds** (90% of cases) - Update price feeds -2. **Oracle not configured** - Set price oracle on Minter -3. **Invalid price values** - Check price feed contracts -4. **Network/RPC issues** - Verify connection - -Always implement proper error handling and user-friendly error messages! diff --git a/doc/guides/FRONTEND-FIX-REQUIRED.txt b/doc/guides/FRONTEND-FIX-REQUIRED.txt deleted file mode 100644 index ba4ba2cc..00000000 --- a/doc/guides/FRONTEND-FIX-REQUIRED.txt +++ /dev/null @@ -1,72 +0,0 @@ -================================================================================ -FRONTEND FIX REQUIRED - End Genesis Failing -================================================================================ - -ISSUE ------ -The "End Genesis" transaction is failing because: -1. Frontend is using OLD Genesis address -2. NEW Genesis was missing ZERO_FEE_ROLE (now fixed) - -FIX APPLIED ------------ -✅ Granted ZERO_FEE_ROLE to NEW Genesis on Minter - -FRONTEND UPDATE REQUIRED -------------------------- -You MUST update your frontend to use the NEW Genesis address: - -❌ OLD (remove): 0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82 -✅ NEW (use): 0xAD523115cd35a8d4E60B3C0953E0E0ac10418309 - -After updating, endGenesis() should work! - -CURRENT CONFIGURATION ---------------------- -Genesis (NEW): 0xAD523115cd35a8d4E60B3C0953E0E0ac10418309 -Minter: 0x8A791620dd6260079BF849Dc5567aDC3F2FdC318 -Owner: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 - -✅ NEW Genesis has ZERO_FEE_ROLE on Minter -✅ Owner account can call endGenesis() - -================================================================================ - - -FRONTEND FIX REQUIRED - End Genesis Failing -================================================================================ - -ISSUE ------ -The "End Genesis" transaction is failing because: -1. Frontend is using OLD Genesis address -2. NEW Genesis was missing ZERO_FEE_ROLE (now fixed) - -FIX APPLIED ------------ -✅ Granted ZERO_FEE_ROLE to NEW Genesis on Minter - -FRONTEND UPDATE REQUIRED -------------------------- -You MUST update your frontend to use the NEW Genesis address: - -❌ OLD (remove): 0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82 -✅ NEW (use): 0xAD523115cd35a8d4E60B3C0953E0E0ac10418309 - -After updating, endGenesis() should work! - -CURRENT CONFIGURATION ---------------------- -Genesis (NEW): 0xAD523115cd35a8d4E60B3C0953E0E0ac10418309 -Minter: 0x8A791620dd6260079BF849Dc5567aDC3F2FdC318 -Owner: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 - -✅ NEW Genesis has ZERO_FEE_ROLE on Minter -✅ Owner account can call endGenesis() - -================================================================================ - - - - - diff --git a/doc/guides/FRONTEND-HA-TOKEN-MARKS.md b/doc/guides/FRONTEND-HA-TOKEN-MARKS.md deleted file mode 100644 index 4a4449ef..00000000 --- a/doc/guides/FRONTEND-HA-TOKEN-MARKS.md +++ /dev/null @@ -1,2157 +0,0 @@ -# Frontend: How to Query Token Marks (Ha Tokens, Sail Tokens, and Stability Pools) - -## What Are "Anchor Ledger Marks"? - -**Anchor Ledger Marks** include marks earned from: - -1. **Ha Tokens** (holding ha tokens in your wallet) - 1 mark/dollar/day (1x multiplier) -2. **Stability Pool Deposits** (depositing ha tokens in stability pools) - 1 mark/dollar/day (1x multiplier) - -Both sources earn marks at the **same rate** (1 mark per dollar per day) and should be **combined** when displaying "Anchor Ledger Marks" to users. - -## What Are "Sail Token Marks"? - -**Sail Token Marks** include marks earned from: - -1. **Sail Tokens** (holding sail/leveraged tokens in your wallet) - 5 marks/dollar/day (5x multiplier, default) - -Sail tokens earn marks at **5x the rate** of ha tokens by default, but each sail token can have its own multiplier. - -## Quick Answer - -**Query both `haTokenBalances` AND `stabilityPoolDeposits`, then sum their `accumulatedMarks`.** - -## GraphQL Query - -### Basic Query (Anchor Ledger Marks - Ha Tokens + Stability Pools) - -```graphql -query GetAnchorLedgerMarks($userAddress: Bytes!) { - # Ha Token Marks (wallet holdings) - haTokenBalances(where: { user: $userAddress }) { - id - tokenAddress - balance - balanceUSD - accumulatedMarks - marksPerDay - lastUpdated - } - - # Stability Pool Marks (deposits) - stabilityPoolDeposits(where: { user: $userAddress }) { - id - poolAddress - poolType - balance - balanceUSD - accumulatedMarks - marksPerDay - lastUpdated - } -} -``` - -### Ha Tokens Only Query - -```graphql -query GetHaTokenMarks($userAddress: Bytes!) { - haTokenBalances(where: { user: $userAddress }) { - id - tokenAddress - balance - balanceUSD - accumulatedMarks - marksPerDay - lastUpdated - } -} -``` - -### Complete Query (All Marks Sources) - -```graphql -query GetAllUserMarks($userAddress: Bytes!, $genesisId: ID!) { - # Ha Token Marks - haTokenBalances(where: { user: $userAddress }) { - id - tokenAddress - balance - balanceUSD - accumulatedMarks - marksPerDay - lastUpdated - } - - # Genesis Marks - userHarborMarks(id: $genesisId) { - currentMarks - marksPerDay - totalMarksEarned - } - - # Stability Pool Marks - stabilityPoolDeposits(where: { user: $userAddress }) { - id - poolAddress - balance - balanceUSD - accumulatedMarks - marksPerDay - } - - # Aggregated Total (if available) - userTotalMarks(id: $userAddress) { - haTokenMarks - genesisMarks - stabilityPoolMarks - totalMarks - totalMarksPerDay - } -} -``` - -## Frontend Implementation - -### Using Fetch API - -```typescript -const GRAPHQL_ENDPOINT = "http://localhost:8000/subgraphs/name/harbor-marks-local"; - -// Get Anchor Ledger Marks (Ha Tokens + Stability Pools) -async function getAnchorLedgerMarks(userAddress: string) { - const query = ` - query GetAnchorLedgerMarks($userAddress: Bytes!) { - haTokenBalances(where: { user: $userAddress }) { - accumulatedMarks - marksPerDay - balanceUSD - } - stabilityPoolDeposits(where: { user: $userAddress }) { - accumulatedMarks - marksPerDay - balanceUSD - poolType - } - } - `; - - const response = await fetch(GRAPHQL_ENDPOINT, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - query, - variables: { - userAddress: userAddress.toLowerCase(), - }, - }), - }); - - const data = await response.json(); - - // Calculate total anchor ledger marks - const haMarks = data.data.haTokenBalances.reduce( - (sum: number, b: any) => sum + parseFloat(b.accumulatedMarks || "0"), - 0, - ); - const poolMarks = data.data.stabilityPoolDeposits.reduce( - (sum: number, d: any) => sum + parseFloat(d.accumulatedMarks || "0"), - 0, - ); - - return { - haTokenBalances: data.data.haTokenBalances, - stabilityPoolDeposits: data.data.stabilityPoolDeposits, - totalAnchorLedgerMarks: haMarks + poolMarks, - }; -} - -// Get Ha Token Marks Only -async function getHaTokenMarks(userAddress: string) { - const query = ` - query GetHaTokenMarks($userAddress: Bytes!) { - haTokenBalances(where: { user: $userAddress }) { - id - tokenAddress - balance - balanceUSD - accumulatedMarks - marksPerDay - lastUpdated - } - } - `; - - const response = await fetch(GRAPHQL_ENDPOINT, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - query, - variables: { - userAddress: userAddress.toLowerCase(), - }, - }), - }); - - const data = await response.json(); - return data.data.haTokenBalances; -} -``` - -### Using Apollo Client / React Query - -```typescript -import { useQuery } from "@apollo/client"; -import { gql } from "@apollo/client"; - -const GET_HA_TOKEN_MARKS = gql` - query GetHaTokenMarks($userAddress: Bytes!) { - haTokenBalances(where: { user: $userAddress }) { - id - tokenAddress - balance - balanceUSD - accumulatedMarks - marksPerDay - lastUpdated - } - } -`; - -function useHaTokenMarks(userAddress: string) { - const { data, loading, error } = useQuery(GET_HA_TOKEN_MARKS, { - variables: { - userAddress: userAddress.toLowerCase(), - }, - pollInterval: 30000, // Poll every 30 seconds for updates - }); - - return { - balances: data?.haTokenBalances || [], - loading, - error, - }; -} -``` - -### React Hook Example - -```typescript -import { useState, useEffect } from "react"; - -interface HaTokenBalance { - id: string; - tokenAddress: string; - balance: string; - balanceUSD: string; - accumulatedMarks: string; - marksPerDay: string; - lastUpdated: string; -} - -interface StabilityPoolDeposit { - id: string; - poolAddress: string; - poolType: string; - balance: string; - balanceUSD: string; - accumulatedMarks: string; - marksPerDay: string; - lastUpdated: string; -} - -function useAnchorLedgerMarks(userAddress: string | null) { - const [haBalances, setHaBalances] = useState([]); - const [poolDeposits, setPoolDeposits] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - if (!userAddress) { - setLoading(false); - return; - } - - const fetchMarks = async () => { - try { - const response = await fetch(GRAPHQL_ENDPOINT, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - query: ` - query GetAnchorLedgerMarks($userAddress: Bytes!) { - haTokenBalances(where: { user: $userAddress }) { - id - tokenAddress - balance - balanceUSD - accumulatedMarks - marksPerDay - lastUpdated - } - stabilityPoolDeposits(where: { user: $userAddress }) { - id - poolAddress - poolType - balance - balanceUSD - accumulatedMarks - marksPerDay - lastUpdated - } - } - `, - variables: { - userAddress: userAddress.toLowerCase(), - }, - }), - }); - - const data = await response.json(); - if (data.errors) { - throw new Error(data.errors[0].message); - } - - setHaBalances(data.data.haTokenBalances || []); - setPoolDeposits(data.data.stabilityPoolDeposits || []); - setError(null); - } catch (err) { - setError(err as Error); - } finally { - setLoading(false); - } - }; - - fetchMarks(); - - // Poll for updates every 30 seconds - const interval = setInterval(fetchMarks, 30000); - return () => clearInterval(interval); - }, [userAddress]); - - // Calculate totals (Ha Tokens + Stability Pools) - const totalMarks = - haBalances.reduce((sum, balance) => sum + parseFloat(balance.accumulatedMarks || "0"), 0) + - poolDeposits.reduce((sum, deposit) => sum + parseFloat(deposit.accumulatedMarks || "0"), 0); - - const totalMarksPerDay = - haBalances.reduce((sum, balance) => sum + parseFloat(balance.marksPerDay || "0"), 0) + - poolDeposits.reduce((sum, deposit) => sum + parseFloat(deposit.marksPerDay || "0"), 0); - - return { - haBalances, - poolDeposits, - totalMarks, // Total Anchor Ledger Marks - totalMarksPerDay, - loading, - error, - }; -} - -// Legacy hook for ha tokens only -function useHaTokenMarks(userAddress: string | null) { - const { haBalances, loading, error } = useAnchorLedgerMarks(userAddress); - - const totalMarks = haBalances.reduce((sum, balance) => sum + parseFloat(balance.accumulatedMarks || "0"), 0); - - const totalMarksPerDay = haBalances.reduce((sum, balance) => sum + parseFloat(balance.marksPerDay || "0"), 0); - - return { - balances: haBalances, - totalMarks, - totalMarksPerDay, - loading, - error, - }; -} -``` - -## Understanding the Response - -### Response Structure - -```typescript -{ - "data": { - "haTokenBalances": [ - { - "id": "0x1c85638e118b37167e9298c2268758e058ddfda0-0xae7dbb17bc40d53a6363409c6b1ed88d3cfdc31e", - "tokenAddress": "0x1c85638e118b37167e9298c2268758e058ddfda0", - "balance": "199999999999999999999999", // BigInt (18 decimals) - "balanceUSD": "199999.999999999999999999", // BigDecimal - "accumulatedMarks": "400000", // BigDecimal - "marksPerDay": "200000", // BigDecimal - "lastUpdated": "1764441274" // BigInt (timestamp) - } - ] - } -} -``` - -### Field Explanations - -- **`id`**: Unique identifier (`{tokenAddress}-{userAddress}`) -- **`tokenAddress`**: Address of the ha token contract -- **`balance`**: Current token balance (in wei, 18 decimals) -- **`balanceUSD`**: Current balance value in USD -- **`accumulatedMarks`**: Total marks accumulated from holding this token -- **`marksPerDay`**: Current marks per day rate (based on current balance) -- **`lastUpdated`**: Timestamp of last update (Unix timestamp) - -## Calculating Anchor Ledger Marks - -### Sum Ha Tokens + Stability Pool Deposits - -```typescript -function calculateAnchorLedgerMarks(haBalances: HaTokenBalance[], poolDeposits: StabilityPoolDeposit[]): number { - const haMarks = haBalances.reduce((total, balance) => total + parseFloat(balance.accumulatedMarks || "0"), 0); - const poolMarks = poolDeposits.reduce((total, deposit) => total + parseFloat(deposit.accumulatedMarks || "0"), 0); - return haMarks + poolMarks; -} -``` - -### Sum All Ha Token Balances Only - -```typescript -function calculateTotalHaTokenMarks(balances: HaTokenBalance[]): number { - return balances.reduce((total, balance) => total + parseFloat(balance.accumulatedMarks || "0"), 0); -} -``` - -### Combine with Other Marks Sources - -```typescript -async function getTotalMarks(userAddress: string, genesisAddress: string) { - const query = ` - query GetAllMarks($userAddress: Bytes!, $genesisId: ID!) { - haTokenBalances(where: { user: $userAddress }) { - accumulatedMarks - } - userHarborMarks(id: $genesisId) { - currentMarks - } - stabilityPoolDeposits(where: { user: $userAddress }) { - accumulatedMarks - } - userTotalMarks(id: $userAddress) { - totalMarks - } - } - `; - - const response = await fetch(GRAPHQL_ENDPOINT, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - query, - variables: { - userAddress: userAddress.toLowerCase(), - genesisId: `${genesisAddress.toLowerCase()}-${userAddress.toLowerCase()}`, - }, - }), - }); - - const data = await response.json(); - - // Option 1: Use aggregated total (if available) - if (data.data.userTotalMarks?.totalMarks) { - return parseFloat(data.data.userTotalMarks.totalMarks); - } - - // Option 2: Calculate manually - const haMarks = data.data.haTokenBalances.reduce( - (sum: number, b: any) => sum + parseFloat(b.accumulatedMarks || "0"), - 0, - ); - const genesisMarks = parseFloat(data.data.userHarborMarks?.currentMarks || "0"); - const poolMarks = data.data.stabilityPoolDeposits.reduce( - (sum: number, d: any) => sum + parseFloat(d.accumulatedMarks || "0"), - 0, - ); - - return haMarks + genesisMarks + poolMarks; -} -``` - -## Display Examples - -### Simple Display - -```typescript -function AnchorLedgerMarksDisplay({ userAddress }: { userAddress: string }) { - const { haBalances, poolDeposits, totalMarks, loading } = useAnchorLedgerMarks(userAddress); - - if (loading) return
Loading marks...
; - if (haBalances.length === 0 && poolDeposits.length === 0) { - return
No anchor ledger marks (no ha tokens or stability pool deposits)
; - } - - return ( -
-

Anchor Ledger Marks

- - {/* Ha Token Holdings */} - {haBalances.length > 0 && ( -
-

Ha Token Holdings

- {haBalances.map((balance) => ( -
-

Token: {balance.tokenAddress}

-

Balance: {parseFloat(balance.balance) / 1e18} tokens

-

Value: ${parseFloat(balance.balanceUSD).toFixed(2)}

-

Marks: {parseFloat(balance.accumulatedMarks).toLocaleString()}

-

Marks/Day: {parseFloat(balance.marksPerDay).toLocaleString()}

-
- ))} -
- )} - - {/* Stability Pool Deposits */} - {poolDeposits.length > 0 && ( -
-

Stability Pool Deposits

- {poolDeposits.map((deposit) => ( -
-

Pool: {deposit.poolAddress} ({deposit.poolType})

-

Balance: {parseFloat(deposit.balance) / 1e18} tokens

-

Value: ${parseFloat(deposit.balanceUSD).toFixed(2)}

-

Marks: {parseFloat(deposit.accumulatedMarks).toLocaleString()}

-

Marks/Day: {parseFloat(deposit.marksPerDay).toLocaleString()}

-
- ))} -
- )} - -

Total Anchor Ledger Marks: {totalMarks.toLocaleString()}

-
- ); -} -``` - -### Combined Marks Display - -```typescript -function TotalMarksDisplay({ userAddress, genesisAddress }: Props) { - const { balances: haBalances, totalMarks: haMarks } = useHaTokenMarks(userAddress); - const { data: genesisData } = useQuery(GET_GENESIS_MARKS, { - variables: { genesisId: `${genesisAddress}-${userAddress}` } - }); - - const totalMarks = haMarks + parseFloat(genesisData?.currentMarks || '0'); - - return ( -
-

Total Marks: {totalMarks.toLocaleString()}

-
-

Ha Token Marks: {haMarks.toLocaleString()}

-

Genesis Marks: {parseFloat(genesisData?.currentMarks || '0').toLocaleString()}

-
-
- ); -} -``` - -## Important Notes - -1. **Address Format**: Always use lowercase addresses in queries - - ```typescript - userAddress.toLowerCase(); - ``` - -2. **Multiple Ha Tokens**: A user can hold multiple ha tokens (different markets) - - Query returns an array of balances - - Sum all `accumulatedMarks` for total - -3. **Real-time Updates**: - - Marks update when transfer events occur - - Poll every 30-60 seconds for updates - - Or use GraphQL subscriptions (if supported) - -4. **Marks Accumulation**: - - Marks accumulate in **full day increments** - - Requires a transfer event to trigger calculation - - `lastUpdated` shows when marks were last calculated - -5. **Balance Precision**: - - `balance` is in wei (18 decimals) - divide by `1e18` for tokens - - `balanceUSD` and `accumulatedMarks` are already in human-readable format - -## Real-Time Estimated Marks (Zero Gas) - -Since blockchain events may be infrequent, **show estimated marks on the frontend** and sync to actual values when natural events occur. - -### How It Works - -1. **Subgraph stores** (updated only on Transfer/Deposit/Withdraw events): - - `accumulatedMarks` — marks calculated up to the last event - - `marksPerDay` — current earning rate based on balance - - `lastUpdated` — timestamp of last event - -2. **Frontend calculates** (in real-time, zero gas): - - ``` - estimatedMarks = accumulatedMarks + (marksPerDay × daysSinceLastUpdate) - ``` - -3. **Natural events sync** — when user transfers/deposits/withdraws, subgraph recalculates and updates `accumulatedMarks` - -### Implementation - -```typescript -interface MarksEntity { - accumulatedMarks: string; - marksPerDay: string; - lastUpdated: string; -} - -/** - * Calculate estimated marks from stored data - * Zero gas - pure frontend calculation - * - * Note: marksPerDay already includes the multiplier, so no need to multiply again - */ -function calculateEstimatedMarks(entity: MarksEntity): number { - const storedMarks = parseFloat(entity.accumulatedMarks || "0"); - const marksPerDay = parseFloat(entity.marksPerDay || "0"); - const lastUpdated = parseInt(entity.lastUpdated || "0"); - - // If no data or no earning rate, return stored marks - if (lastUpdated === 0 || marksPerDay === 0) { - return storedMarks; - } - - // Calculate time elapsed since last update - const now = Math.floor(Date.now() / 1000); - const secondsSinceUpdate = now - lastUpdated; - const daysSinceUpdate = secondsSinceUpdate / 86400; - - // Estimated marks = stored + (rate × time) - // marksPerDay already includes multiplier, so this is correct - return storedMarks + marksPerDay * daysSinceUpdate; -} -``` - -### React Hook with Live Estimation - -```typescript -function useAnchorLedgerMarksLive(userAddress: string | null) { - const [data, setData] = useState<{ - haTokenBalances: MarksEntity[]; - stabilityPoolDeposits: MarksEntity[]; - } | null>(null); - const [estimatedMarks, setEstimatedMarks] = useState(0); - const [loading, setLoading] = useState(true); - - // Fetch from subgraph (poll every 60s for new events) - useEffect(() => { - if (!userAddress) { - setLoading(false); - return; - } - - const fetchData = async () => { - try { - const response = await fetch(GRAPHQL_ENDPOINT, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - query: ` - query GetAnchorMarks($user: Bytes!) { - haTokenBalances(where: { user: $user }) { - accumulatedMarks - marksPerDay - lastUpdated - } - stabilityPoolDeposits(where: { user: $user }) { - accumulatedMarks - marksPerDay - lastUpdated - } - } - `, - variables: { user: userAddress.toLowerCase() }, - }), - }); - const result = await response.json(); - setData(result.data); - } catch (err) { - console.error("Failed to fetch marks:", err); - } finally { - setLoading(false); - } - }; - - fetchData(); - // Poll for new events (infrequent - just to catch transfers/deposits) - const pollInterval = setInterval(fetchData, 60000); - return () => clearInterval(pollInterval); - }, [userAddress]); - - // Calculate estimated marks every second (zero gas!) - useEffect(() => { - if (!data) return; - - const calculateTotal = () => { - let total = 0; - - // Ha token marks - for (const balance of data.haTokenBalances || []) { - total += calculateEstimatedMarks(balance); - } - - // Stability pool marks - for (const deposit of data.stabilityPoolDeposits || []) { - total += calculateEstimatedMarks(deposit); - } - - return total; - }; - - // Initial calculation - setEstimatedMarks(calculateTotal()); - - // Update every second for smooth live display - const interval = setInterval(() => { - setEstimatedMarks(calculateTotal()); - }, 1000); - - return () => clearInterval(interval); - }, [data]); - - // Calculate marks per day - const marksPerDay = useMemo(() => { - if (!data) return 0; - const haRate = (data.haTokenBalances || []).reduce((sum, b) => sum + parseFloat(b.marksPerDay || "0"), 0); - const poolRate = (data.stabilityPoolDeposits || []).reduce((sum, d) => sum + parseFloat(d.marksPerDay || "0"), 0); - return haRate + poolRate; - }, [data]); - - return { - estimatedMarks, // Live counter - updates every second - marksPerDay, // Current earning rate - loading, - data, - }; -} -``` - -### Display Component - -```tsx -function AnchorLedgerMarksLive({ userAddress }: { userAddress: string }) { - const { estimatedMarks, marksPerDay, loading } = useAnchorLedgerMarksLive(userAddress); - - if (loading) return
Loading...
; - - return ( -
-

Anchor Ledger Marks

- - {/* Live counter - ticks up every second */} -
- {estimatedMarks.toLocaleString(undefined, { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })} -
- -
+{marksPerDay.toLocaleString()} marks/day
-
- ); -} -``` - -### How Accuracy is Maintained - -| Event | What Happens | -| ---------------------------------- | ---------------------------------------------------------------------------------- | -| User receives ha tokens | Transfer event → subgraph updates `accumulatedMarks`, `lastUpdated`, `marksPerDay` | -| User sends ha tokens | Transfer event → subgraph calculates & stores marks earned, updates balance | -| User deposits to stability pool | Deposit event → subgraph updates `accumulatedMarks`, `lastUpdated` | -| User withdraws from stability pool | Withdraw event → subgraph calculates & stores marks, updates balance | -| **Between events** | **Frontend estimates marks using `marksPerDay × time` (zero gas)** | - -### Benefits - -- ✅ **Zero gas** — no polling contracts or keeper transactions -- ✅ **Real-time display** — marks tick up every second -- ✅ **Scales infinitely** — works for any number of tokens/pools/users -- ✅ **Accurate** — natural events sync estimated to actual -- ✅ **Simple** — pure JavaScript calculation - -### What About Leaderboard? - -For the leaderboard, use the same estimation approach: - -```typescript -async function getLeaderboardWithEstimates() { - const query = ` - query GetLeaderboard { - haTokenBalances(orderBy: accumulatedMarks, orderDirection: desc, first: 100) { - user - accumulatedMarks - marksPerDay - lastUpdated - } - } - `; - - const response = await fetch(GRAPHQL_ENDPOINT, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ query }), - }); - - const data = await response.json(); - - // Calculate estimated marks for each user - return data.data.haTokenBalances.map((entry: MarksEntity & { user: string }) => ({ - user: entry.user, - estimatedMarks: calculateEstimatedMarks(entry), - marksPerDay: parseFloat(entry.marksPerDay || "0"), - })); -} -``` - -## GraphQL Endpoint - -**Local Development:** - -``` -http://localhost:8000/subgraphs/name/harbor-marks-local -``` - -**Production:** - -``` -https://api.thegraph.com/subgraphs/name/your-org/harbor-marks -``` - -## Example Response - -```json -{ - "data": { - "haTokenBalances": [ - { - "id": "0x1c85638e118b37167e9298c2268758e058ddfda0-0xae7dbb17bc40d53a6363409c6b1ed88d3cfdc31e", - "tokenAddress": "0x1c85638e118b37167e9298c2268758e058ddfda0", - "balance": "199999999999999999999999", - "balanceUSD": "199999.999999999999999999", - "accumulatedMarks": "400000", - "marksPerDay": "200000", - "lastUpdated": "1764441274" - } - ] - } -} -``` - -## Multipliers - -### Overview - -Each source (ha tokens, stability pool collateral, stability pool sail) can have its own multiplier configured. The multiplier affects the marks earned rate: - -- **1.0x** = 1 mark per dollar per day (default) -- **2.0x** = 2 marks per dollar per day -- **0.5x** = 0.5 marks per dollar per day - -### Querying Multipliers - -Multipliers are stored in the `MarksMultiplier` entity. Query them alongside your marks data: - -```graphql -query GetAnchorLedgerMarksWithMultipliers($userAddress: Bytes!) { - # Ha Token Marks - haTokenBalances(where: { user: $userAddress }) { - id - tokenAddress - balance - balanceUSD - accumulatedMarks - marksPerDay - lastUpdated - } - - # Stability Pool Marks - stabilityPoolDeposits(where: { user: $userAddress }) { - id - poolAddress - poolType - balance - balanceUSD - accumulatedMarks - marksPerDay - lastUpdated - } - - # Multipliers (query by source type) - marksMultipliers( - where: { - or: [{ sourceType: "haToken" }, { sourceType: "stabilityPoolCollateral" }, { sourceType: "stabilityPoolSail" }] - } - orderBy: effectiveFrom - orderDirection: desc - ) { - id - sourceType - sourceAddress - multiplier - effectiveFrom - } -} -``` - -### How Multipliers Work - -1. **`marksPerDay` already includes multiplier**: The subgraph calculates `marksPerDay` using the current multiplier, so you don't need to multiply again. - -2. **Multiplier changes over time**: If a multiplier changes, the subgraph: - - Calculates marks up to the change point using the old multiplier - - Stores those marks in `accumulatedMarks` - - Updates `marksPerDay` to use the new multiplier going forward - -3. **Estimation uses current `marksPerDay`**: Your frontend estimation automatically uses the correct multiplier because `marksPerDay` already includes it. - -### Example: Multiplier Change - -``` -Day 1-5: User holds $100k ha tokens, multiplier = 1.0x - → marksPerDay = 100,000 marks/day - → After 5 days: accumulatedMarks = 500,000 - -Day 6: Multiplier changes to 2.0x - → Subgraph recalculates: accumulatedMarks = 500,000 (unchanged, already earned) - → marksPerDay updates to 200,000 marks/day (new rate) - -Day 6-10: User continues holding - → Frontend estimates: 500,000 + (200,000 × 5 days) = 1,500,000 marks -``` - -### Current Configuration - -By default, all sources use **1.0x multiplier**: - -- **Ha Tokens**: 1.0x (1 mark per dollar per day) -- **Stability Pool Collateral**: 1.0x (1 mark per dollar per day) -- **Stability Pool Sail**: 1.0x (1 mark per dollar per day) - -### Applying Multipliers in Frontend (Optional) - -If you want to display the multiplier separately or verify calculations: - -```typescript -interface MarksMultiplier { - id: string; - sourceType: string; // "haToken", "stabilityPoolCollateral", "stabilityPoolSail" - sourceAddress: string | null; - multiplier: string; // BigDecimal as string - effectiveFrom: string; // Timestamp -} - -function getCurrentMultiplier( - multipliers: MarksMultiplier[], - sourceType: string, - sourceAddress: string | null, -): number { - // Find the most recent multiplier for this source - const relevant = multipliers - .filter((m) => m.sourceType === sourceType) - .filter((m) => !m.sourceAddress || m.sourceAddress.toLowerCase() === sourceAddress?.toLowerCase()) - .sort((a, b) => parseInt(b.effectiveFrom) - parseInt(a.effectiveFrom)); - - if (relevant.length === 0) { - return 1.0; // Default multiplier - } - - return parseFloat(relevant[0].multiplier); -} - -// Example usage -const haTokenMultiplier = getCurrentMultiplier(multipliers, "haToken", tokenAddress); -const poolMultiplier = getCurrentMultiplier(multipliers, "stabilityPoolCollateral", poolAddress); -``` - -**Note**: You typically don't need to apply multipliers manually because `marksPerDay` already includes them. This is only useful for display purposes or verification. - -## Summary - -1. **Query**: Both `haTokenBalances` AND `stabilityPoolDeposits` for complete anchor ledger marks -2. **Sum**: Add up all `accumulatedMarks` from both arrays -3. **Display**: Show individual balances/deposits or total anchor ledger marks -4. **Poll**: Refresh every 30-60 seconds for updates -5. **Rate**: Both ha tokens and stability pools earn at 1 mark/dollar/day (same rate) - -## What Are "Anchor Ledger Marks"? - -**Anchor Ledger Marks** include marks earned from: - -1. **Ha Tokens** (holding ha tokens in your wallet) - 1 mark/dollar/day -2. **Stability Pool Deposits** (depositing ha tokens in stability pools) - 1 mark/dollar/day - -Both sources earn marks at the **same rate** (1 mark per dollar per day) and should be **combined** when displaying "Anchor Ledger Marks" to users. - -## Quick Answer - -**Query both `haTokenBalances` AND `stabilityPoolDeposits`, then sum their `accumulatedMarks`.** - -## GraphQL Query - -### Basic Query (Anchor Ledger Marks - Ha Tokens + Stability Pools) - -```graphql -query GetAnchorLedgerMarks($userAddress: Bytes!) { - # Ha Token Marks (wallet holdings) - haTokenBalances(where: { user: $userAddress }) { - id - tokenAddress - balance - balanceUSD - accumulatedMarks - marksPerDay - lastUpdated - } - - # Stability Pool Marks (deposits) - stabilityPoolDeposits(where: { user: $userAddress }) { - id - poolAddress - poolType - balance - balanceUSD - accumulatedMarks - marksPerDay - lastUpdated - } -} -``` - -### Ha Tokens Only Query - -```graphql -query GetHaTokenMarks($userAddress: Bytes!) { - haTokenBalances(where: { user: $userAddress }) { - id - tokenAddress - balance - balanceUSD - accumulatedMarks - marksPerDay - lastUpdated - } -} -``` - -### Complete Query (All Marks Sources) - -```graphql -query GetAllUserMarks($userAddress: Bytes!, $genesisId: ID!) { - # Ha Token Marks - haTokenBalances(where: { user: $userAddress }) { - id - tokenAddress - balance - balanceUSD - accumulatedMarks - marksPerDay - lastUpdated - } - - # Genesis Marks - userHarborMarks(id: $genesisId) { - currentMarks - marksPerDay - totalMarksEarned - } - - # Stability Pool Marks - stabilityPoolDeposits(where: { user: $userAddress }) { - id - poolAddress - balance - balanceUSD - accumulatedMarks - marksPerDay - } - - # Aggregated Total (if available) - userTotalMarks(id: $userAddress) { - haTokenMarks - genesisMarks - stabilityPoolMarks - totalMarks - totalMarksPerDay - } -} -``` - -## Frontend Implementation - -### Using Fetch API - -```typescript -const GRAPHQL_ENDPOINT = "http://localhost:8000/subgraphs/name/harbor-marks-local"; - -// Get Anchor Ledger Marks (Ha Tokens + Stability Pools) -async function getAnchorLedgerMarks(userAddress: string) { - const query = ` - query GetAnchorLedgerMarks($userAddress: Bytes!) { - haTokenBalances(where: { user: $userAddress }) { - accumulatedMarks - marksPerDay - balanceUSD - } - stabilityPoolDeposits(where: { user: $userAddress }) { - accumulatedMarks - marksPerDay - balanceUSD - poolType - } - } - `; - - const response = await fetch(GRAPHQL_ENDPOINT, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - query, - variables: { - userAddress: userAddress.toLowerCase(), - }, - }), - }); - - const data = await response.json(); - - // Calculate total anchor ledger marks - const haMarks = data.data.haTokenBalances.reduce( - (sum: number, b: any) => sum + parseFloat(b.accumulatedMarks || "0"), - 0, - ); - const poolMarks = data.data.stabilityPoolDeposits.reduce( - (sum: number, d: any) => sum + parseFloat(d.accumulatedMarks || "0"), - 0, - ); - - return { - haTokenBalances: data.data.haTokenBalances, - stabilityPoolDeposits: data.data.stabilityPoolDeposits, - totalAnchorLedgerMarks: haMarks + poolMarks, - }; -} - -// Get Ha Token Marks Only -async function getHaTokenMarks(userAddress: string) { - const query = ` - query GetHaTokenMarks($userAddress: Bytes!) { - haTokenBalances(where: { user: $userAddress }) { - id - tokenAddress - balance - balanceUSD - accumulatedMarks - marksPerDay - lastUpdated - } - } - `; - - const response = await fetch(GRAPHQL_ENDPOINT, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - query, - variables: { - userAddress: userAddress.toLowerCase(), - }, - }), - }); - - const data = await response.json(); - return data.data.haTokenBalances; -} -``` - -### Using Apollo Client / React Query - -```typescript -import { useQuery } from "@apollo/client"; -import { gql } from "@apollo/client"; - -const GET_HA_TOKEN_MARKS = gql` - query GetHaTokenMarks($userAddress: Bytes!) { - haTokenBalances(where: { user: $userAddress }) { - id - tokenAddress - balance - balanceUSD - accumulatedMarks - marksPerDay - lastUpdated - } - } -`; - -function useHaTokenMarks(userAddress: string) { - const { data, loading, error } = useQuery(GET_HA_TOKEN_MARKS, { - variables: { - userAddress: userAddress.toLowerCase(), - }, - pollInterval: 30000, // Poll every 30 seconds for updates - }); - - return { - balances: data?.haTokenBalances || [], - loading, - error, - }; -} -``` - -### React Hook Example - -```typescript -import { useState, useEffect } from "react"; - -interface HaTokenBalance { - id: string; - tokenAddress: string; - balance: string; - balanceUSD: string; - accumulatedMarks: string; - marksPerDay: string; - lastUpdated: string; -} - -interface StabilityPoolDeposit { - id: string; - poolAddress: string; - poolType: string; - balance: string; - balanceUSD: string; - accumulatedMarks: string; - marksPerDay: string; - lastUpdated: string; -} - -function useAnchorLedgerMarks(userAddress: string | null) { - const [haBalances, setHaBalances] = useState([]); - const [poolDeposits, setPoolDeposits] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - if (!userAddress) { - setLoading(false); - return; - } - - const fetchMarks = async () => { - try { - const response = await fetch(GRAPHQL_ENDPOINT, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - query: ` - query GetAnchorLedgerMarks($userAddress: Bytes!) { - haTokenBalances(where: { user: $userAddress }) { - id - tokenAddress - balance - balanceUSD - accumulatedMarks - marksPerDay - lastUpdated - } - stabilityPoolDeposits(where: { user: $userAddress }) { - id - poolAddress - poolType - balance - balanceUSD - accumulatedMarks - marksPerDay - lastUpdated - } - } - `, - variables: { - userAddress: userAddress.toLowerCase(), - }, - }), - }); - - const data = await response.json(); - if (data.errors) { - throw new Error(data.errors[0].message); - } - - setHaBalances(data.data.haTokenBalances || []); - setPoolDeposits(data.data.stabilityPoolDeposits || []); - setError(null); - } catch (err) { - setError(err as Error); - } finally { - setLoading(false); - } - }; - - fetchMarks(); - - // Poll for updates every 30 seconds - const interval = setInterval(fetchMarks, 30000); - return () => clearInterval(interval); - }, [userAddress]); - - // Calculate totals (Ha Tokens + Stability Pools) - const totalMarks = - haBalances.reduce((sum, balance) => sum + parseFloat(balance.accumulatedMarks || "0"), 0) + - poolDeposits.reduce((sum, deposit) => sum + parseFloat(deposit.accumulatedMarks || "0"), 0); - - const totalMarksPerDay = - haBalances.reduce((sum, balance) => sum + parseFloat(balance.marksPerDay || "0"), 0) + - poolDeposits.reduce((sum, deposit) => sum + parseFloat(deposit.marksPerDay || "0"), 0); - - return { - haBalances, - poolDeposits, - totalMarks, // Total Anchor Ledger Marks - totalMarksPerDay, - loading, - error, - }; -} - -// Legacy hook for ha tokens only -function useHaTokenMarks(userAddress: string | null) { - const { haBalances, loading, error } = useAnchorLedgerMarks(userAddress); - - const totalMarks = haBalances.reduce((sum, balance) => sum + parseFloat(balance.accumulatedMarks || "0"), 0); - - const totalMarksPerDay = haBalances.reduce((sum, balance) => sum + parseFloat(balance.marksPerDay || "0"), 0); - - return { - balances: haBalances, - totalMarks, - totalMarksPerDay, - loading, - error, - }; -} -``` - -## Understanding the Response - -### Response Structure - -```typescript -{ - "data": { - "haTokenBalances": [ - { - "id": "0x1c85638e118b37167e9298c2268758e058ddfda0-0xae7dbb17bc40d53a6363409c6b1ed88d3cfdc31e", - "tokenAddress": "0x1c85638e118b37167e9298c2268758e058ddfda0", - "balance": "199999999999999999999999", // BigInt (18 decimals) - "balanceUSD": "199999.999999999999999999", // BigDecimal - "accumulatedMarks": "400000", // BigDecimal - "marksPerDay": "200000", // BigDecimal - "lastUpdated": "1764441274" // BigInt (timestamp) - } - ] - } -} -``` - -### Field Explanations - -- **`id`**: Unique identifier (`{tokenAddress}-{userAddress}`) -- **`tokenAddress`**: Address of the ha token contract -- **`balance`**: Current token balance (in wei, 18 decimals) -- **`balanceUSD`**: Current balance value in USD -- **`accumulatedMarks`**: Total marks accumulated from holding this token -- **`marksPerDay`**: Current marks per day rate (based on current balance) -- **`lastUpdated`**: Timestamp of last update (Unix timestamp) - -## Calculating Anchor Ledger Marks - -### Sum Ha Tokens + Stability Pool Deposits - -```typescript -function calculateAnchorLedgerMarks(haBalances: HaTokenBalance[], poolDeposits: StabilityPoolDeposit[]): number { - const haMarks = haBalances.reduce((total, balance) => total + parseFloat(balance.accumulatedMarks || "0"), 0); - const poolMarks = poolDeposits.reduce((total, deposit) => total + parseFloat(deposit.accumulatedMarks || "0"), 0); - return haMarks + poolMarks; -} -``` - -### Sum All Ha Token Balances Only - -```typescript -function calculateTotalHaTokenMarks(balances: HaTokenBalance[]): number { - return balances.reduce((total, balance) => total + parseFloat(balance.accumulatedMarks || "0"), 0); -} -``` - -### Combine with Other Marks Sources - -```typescript -async function getTotalMarks(userAddress: string, genesisAddress: string) { - const query = ` - query GetAllMarks($userAddress: Bytes!, $genesisId: ID!) { - haTokenBalances(where: { user: $userAddress }) { - accumulatedMarks - } - userHarborMarks(id: $genesisId) { - currentMarks - } - stabilityPoolDeposits(where: { user: $userAddress }) { - accumulatedMarks - } - userTotalMarks(id: $userAddress) { - totalMarks - } - } - `; - - const response = await fetch(GRAPHQL_ENDPOINT, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - query, - variables: { - userAddress: userAddress.toLowerCase(), - genesisId: `${genesisAddress.toLowerCase()}-${userAddress.toLowerCase()}`, - }, - }), - }); - - const data = await response.json(); - - // Option 1: Use aggregated total (if available) - if (data.data.userTotalMarks?.totalMarks) { - return parseFloat(data.data.userTotalMarks.totalMarks); - } - - // Option 2: Calculate manually - const haMarks = data.data.haTokenBalances.reduce( - (sum: number, b: any) => sum + parseFloat(b.accumulatedMarks || "0"), - 0, - ); - const genesisMarks = parseFloat(data.data.userHarborMarks?.currentMarks || "0"); - const poolMarks = data.data.stabilityPoolDeposits.reduce( - (sum: number, d: any) => sum + parseFloat(d.accumulatedMarks || "0"), - 0, - ); - - return haMarks + genesisMarks + poolMarks; -} -``` - -## Display Examples - -### Simple Display - -```typescript -function AnchorLedgerMarksDisplay({ userAddress }: { userAddress: string }) { - const { haBalances, poolDeposits, totalMarks, loading } = useAnchorLedgerMarks(userAddress); - - if (loading) return
Loading marks...
; - if (haBalances.length === 0 && poolDeposits.length === 0) { - return
No anchor ledger marks (no ha tokens or stability pool deposits)
; - } - - return ( -
-

Anchor Ledger Marks

- - {/* Ha Token Holdings */} - {haBalances.length > 0 && ( -
-

Ha Token Holdings

- {haBalances.map((balance) => ( -
-

Token: {balance.tokenAddress}

-

Balance: {parseFloat(balance.balance) / 1e18} tokens

-

Value: ${parseFloat(balance.balanceUSD).toFixed(2)}

-

Marks: {parseFloat(balance.accumulatedMarks).toLocaleString()}

-

Marks/Day: {parseFloat(balance.marksPerDay).toLocaleString()}

-
- ))} -
- )} - - {/* Stability Pool Deposits */} - {poolDeposits.length > 0 && ( -
-

Stability Pool Deposits

- {poolDeposits.map((deposit) => ( -
-

Pool: {deposit.poolAddress} ({deposit.poolType})

-

Balance: {parseFloat(deposit.balance) / 1e18} tokens

-

Value: ${parseFloat(deposit.balanceUSD).toFixed(2)}

-

Marks: {parseFloat(deposit.accumulatedMarks).toLocaleString()}

-

Marks/Day: {parseFloat(deposit.marksPerDay).toLocaleString()}

-
- ))} -
- )} - -

Total Anchor Ledger Marks: {totalMarks.toLocaleString()}

-
- ); -} -``` - -### Combined Marks Display - -```typescript -function TotalMarksDisplay({ userAddress, genesisAddress }: Props) { - const { balances: haBalances, totalMarks: haMarks } = useHaTokenMarks(userAddress); - const { data: genesisData } = useQuery(GET_GENESIS_MARKS, { - variables: { genesisId: `${genesisAddress}-${userAddress}` } - }); - - const totalMarks = haMarks + parseFloat(genesisData?.currentMarks || '0'); - - return ( -
-

Total Marks: {totalMarks.toLocaleString()}

-
-

Ha Token Marks: {haMarks.toLocaleString()}

-

Genesis Marks: {parseFloat(genesisData?.currentMarks || '0').toLocaleString()}

-
-
- ); -} -``` - -## Important Notes - -1. **Address Format**: Always use lowercase addresses in queries - - ```typescript - userAddress.toLowerCase(); - ``` - -2. **Multiple Ha Tokens**: A user can hold multiple ha tokens (different markets) - - Query returns an array of balances - - Sum all `accumulatedMarks` for total - -3. **Real-time Updates**: - - Marks update when transfer events occur - - Poll every 30-60 seconds for updates - - Or use GraphQL subscriptions (if supported) - -4. **Marks Accumulation**: - - Marks accumulate in **full day increments** - - Requires a transfer event to trigger calculation - - `lastUpdated` shows when marks were last calculated - -5. **Balance Precision**: - - `balance` is in wei (18 decimals) - divide by `1e18` for tokens - - `balanceUSD` and `accumulatedMarks` are already in human-readable format - -## Real-Time Estimated Marks (Zero Gas) - -Since blockchain events may be infrequent, **show estimated marks on the frontend** and sync to actual values when natural events occur. - -### How It Works - -1. **Subgraph stores** (updated only on Transfer/Deposit/Withdraw events): - - `accumulatedMarks` — marks calculated up to the last event - - `marksPerDay` — current earning rate based on balance - - `lastUpdated` — timestamp of last event - -2. **Frontend calculates** (in real-time, zero gas): - - ``` - estimatedMarks = accumulatedMarks + (marksPerDay × daysSinceLastUpdate) - ``` - -3. **Natural events sync** — when user transfers/deposits/withdraws, subgraph recalculates and updates `accumulatedMarks` - -### Implementation - -```typescript -interface MarksEntity { - accumulatedMarks: string; - marksPerDay: string; - lastUpdated: string; -} - -/** - * Calculate estimated marks from stored data - * Zero gas - pure frontend calculation - * - * Note: marksPerDay already includes the multiplier, so no need to apply it again! - */ -function calculateEstimatedMarks(entity: MarksEntity): number { - const storedMarks = parseFloat(entity.accumulatedMarks || "0"); - const marksPerDay = parseFloat(entity.marksPerDay || "0"); // Already includes multiplier! - const lastUpdated = parseInt(entity.lastUpdated || "0"); - - // If no data or no earning rate, return stored marks - if (lastUpdated === 0 || marksPerDay === 0) { - return storedMarks; - } - - // Calculate time elapsed since last update - const now = Math.floor(Date.now() / 1000); - const secondsSinceUpdate = now - lastUpdated; - const daysSinceUpdate = secondsSinceUpdate / 86400; - - // Estimated marks = stored + (rate × time) - // marksPerDay already accounts for multiplier, so this is correct - return storedMarks + marksPerDay * daysSinceUpdate; -} -``` - -### React Hook with Live Estimation - -```typescript -function useAnchorLedgerMarksLive(userAddress: string | null) { - const [data, setData] = useState<{ - haTokenBalances: MarksEntity[]; - stabilityPoolDeposits: MarksEntity[]; - } | null>(null); - const [estimatedMarks, setEstimatedMarks] = useState(0); - const [loading, setLoading] = useState(true); - - // Fetch from subgraph (poll every 60s for new events) - useEffect(() => { - if (!userAddress) { - setLoading(false); - return; - } - - const fetchData = async () => { - try { - const response = await fetch(GRAPHQL_ENDPOINT, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - query: ` - query GetAnchorMarks($user: Bytes!) { - haTokenBalances(where: { user: $user }) { - accumulatedMarks - marksPerDay - lastUpdated - } - stabilityPoolDeposits(where: { user: $user }) { - accumulatedMarks - marksPerDay - lastUpdated - } - } - `, - variables: { user: userAddress.toLowerCase() }, - }), - }); - const result = await response.json(); - setData(result.data); - } catch (err) { - console.error("Failed to fetch marks:", err); - } finally { - setLoading(false); - } - }; - - fetchData(); - // Poll for new events (infrequent - just to catch transfers/deposits) - const pollInterval = setInterval(fetchData, 60000); - return () => clearInterval(pollInterval); - }, [userAddress]); - - // Calculate estimated marks every second (zero gas!) - useEffect(() => { - if (!data) return; - - const calculateTotal = () => { - let total = 0; - - // Ha token marks - for (const balance of data.haTokenBalances || []) { - total += calculateEstimatedMarks(balance); - } - - // Stability pool marks - for (const deposit of data.stabilityPoolDeposits || []) { - total += calculateEstimatedMarks(deposit); - } - - return total; - }; - - // Initial calculation - setEstimatedMarks(calculateTotal()); - - // Update every second for smooth live display - const interval = setInterval(() => { - setEstimatedMarks(calculateTotal()); - }, 1000); - - return () => clearInterval(interval); - }, [data]); - - // Calculate marks per day - const marksPerDay = useMemo(() => { - if (!data) return 0; - const haRate = (data.haTokenBalances || []).reduce((sum, b) => sum + parseFloat(b.marksPerDay || "0"), 0); - const poolRate = (data.stabilityPoolDeposits || []).reduce((sum, d) => sum + parseFloat(d.marksPerDay || "0"), 0); - return haRate + poolRate; - }, [data]); - - return { - estimatedMarks, // Live counter - updates every second - marksPerDay, // Current earning rate - loading, - data, - }; -} -``` - -### Display Component - -```tsx -function AnchorLedgerMarksLive({ userAddress }: { userAddress: string }) { - const { estimatedMarks, marksPerDay, loading } = useAnchorLedgerMarksLive(userAddress); - - if (loading) return
Loading...
; - - return ( -
-

Anchor Ledger Marks

- - {/* Live counter - ticks up every second */} -
- {estimatedMarks.toLocaleString(undefined, { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })} -
- -
+{marksPerDay.toLocaleString()} marks/day
-
- ); -} -``` - -### How Accuracy is Maintained - -| Event | What Happens | -| ---------------------------------- | ---------------------------------------------------------------------------------- | -| User receives ha tokens | Transfer event → subgraph updates `accumulatedMarks`, `lastUpdated`, `marksPerDay` | -| User sends ha tokens | Transfer event → subgraph calculates & stores marks earned, updates balance | -| User deposits to stability pool | Deposit event → subgraph updates `accumulatedMarks`, `lastUpdated` | -| User withdraws from stability pool | Withdraw event → subgraph calculates & stores marks, updates balance | -| **Between events** | **Frontend estimates marks using `marksPerDay × time` (zero gas)** | - -### Benefits - -- ✅ **Zero gas** — no polling contracts or keeper transactions -- ✅ **Real-time display** — marks tick up every second -- ✅ **Scales infinitely** — works for any number of tokens/pools/users -- ✅ **Accurate** — natural events sync estimated to actual -- ✅ **Simple** — pure JavaScript calculation - -### What About Leaderboard? - -For the leaderboard, use the same estimation approach: - -```typescript -async function getLeaderboardWithEstimates() { - const query = ` - query GetLeaderboard { - haTokenBalances(orderBy: accumulatedMarks, orderDirection: desc, first: 100) { - user - accumulatedMarks - marksPerDay - lastUpdated - } - } - `; - - const response = await fetch(GRAPHQL_ENDPOINT, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ query }), - }); - - const data = await response.json(); - - // Calculate estimated marks for each user - return data.data.haTokenBalances.map((entry: MarksEntity & { user: string }) => ({ - user: entry.user, - estimatedMarks: calculateEstimatedMarks(entry), - marksPerDay: parseFloat(entry.marksPerDay || "0"), - })); -} -``` - -## GraphQL Endpoint - -**Local Development:** - -``` -http://localhost:8000/subgraphs/name/harbor-marks-local -``` - -**Production:** - -``` -https://api.thegraph.com/subgraphs/name/your-org/harbor-marks -``` - -## Example Response - -```json -{ - "data": { - "haTokenBalances": [ - { - "id": "0x1c85638e118b37167e9298c2268758e058ddfda0-0xae7dbb17bc40d53a6363409c6b1ed88d3cfdc31e", - "tokenAddress": "0x1c85638e118b37167e9298c2268758e058ddfda0", - "balance": "199999999999999999999999", - "balanceUSD": "199999.999999999999999999", - "accumulatedMarks": "400000", - "marksPerDay": "200000", - "lastUpdated": "1764441274" - } - ] - } -} -``` - -## Multipliers - -### Overview - -Each source (ha tokens, stability pool collateral, stability pool sail) can have its own multiplier configured. The multiplier affects the marks earned rate: - -- **1.0x** = 1 mark per dollar per day (default) -- **2.0x** = 2 marks per dollar per day -- **0.5x** = 0.5 marks per dollar per day - -### Querying Multipliers - -Multipliers are stored in the `MarksMultiplier` entity. Query them alongside your marks data: - -```graphql -query GetAnchorLedgerMarksWithMultipliers($userAddress: Bytes!) { - # Ha Token Marks - haTokenBalances(where: { user: $userAddress }) { - id - tokenAddress - balance - balanceUSD - accumulatedMarks - marksPerDay - lastUpdated - } - - # Stability Pool Marks - stabilityPoolDeposits(where: { user: $userAddress }) { - id - poolAddress - poolType - balance - balanceUSD - accumulatedMarks - marksPerDay - lastUpdated - } - - # Multipliers (query by source type) - marksMultipliers( - where: { - or: [{ sourceType: "haToken" }, { sourceType: "stabilityPoolCollateral" }, { sourceType: "stabilityPoolSail" }] - } - orderBy: effectiveFrom - orderDirection: desc - ) { - id - sourceType - sourceAddress - multiplier - effectiveFrom - } -} -``` - -### How Multipliers Work - -1. **`marksPerDay` already includes multiplier**: The subgraph calculates `marksPerDay` using the current multiplier, so you don't need to multiply again. - -2. **Multiplier changes over time**: If a multiplier changes, the subgraph: - - Calculates marks up to the change point using the old multiplier - - Stores those marks in `accumulatedMarks` - - Updates `marksPerDay` to use the new multiplier going forward - -3. **Estimation uses current `marksPerDay`**: Your frontend estimation automatically uses the correct multiplier because `marksPerDay` already includes it. - -### Example: Multiplier Change - -``` -Day 1-5: User holds $100k ha tokens, multiplier = 1.0x - → marksPerDay = 100,000 marks/day - → After 5 days: accumulatedMarks = 500,000 - -Day 6: Multiplier changes to 2.0x - → Subgraph recalculates: accumulatedMarks = 500,000 (unchanged, already earned) - → marksPerDay updates to 200,000 marks/day (new rate) - -Day 6-10: User continues holding - → Frontend estimates: 500,000 + (200,000 × 5 days) = 1,500,000 marks -``` - -### Current Configuration - -By default, all sources use **1.0x multiplier**: - -- **Ha Tokens**: 1.0x (1 mark per dollar per day) -- **Stability Pool Collateral**: 1.0x (1 mark per dollar per day) -- **Stability Pool Sail**: 1.0x (1 mark per dollar per day) - -### Applying Multipliers in Frontend (Optional) - -If you want to display the multiplier separately or verify calculations: - -```typescript -interface MarksMultiplier { - id: string; - sourceType: string; // "haToken", "stabilityPoolCollateral", "stabilityPoolSail" - sourceAddress: string | null; - multiplier: string; // BigDecimal as string - effectiveFrom: string; // Timestamp -} - -function getCurrentMultiplier( - multipliers: MarksMultiplier[], - sourceType: string, - sourceAddress: string | null, -): number { - // Find the most recent multiplier for this source - const relevant = multipliers - .filter((m) => m.sourceType === sourceType) - .filter((m) => !m.sourceAddress || m.sourceAddress.toLowerCase() === sourceAddress?.toLowerCase()) - .sort((a, b) => parseInt(b.effectiveFrom) - parseInt(a.effectiveFrom)); - - if (relevant.length === 0) { - return 1.0; // Default multiplier - } - - return parseFloat(relevant[0].multiplier); -} - -// Example usage -const haTokenMultiplier = getCurrentMultiplier(multipliers, "haToken", tokenAddress); -const poolMultiplier = getCurrentMultiplier(multipliers, "stabilityPoolCollateral", poolAddress); -``` - -**Note**: You typically don't need to apply multipliers manually because `marksPerDay` already includes them. This is only useful for display purposes or verification. - -## Summary - -1. **Query**: Both `haTokenBalances` AND `stabilityPoolDeposits` for complete anchor ledger marks -2. **Sum**: Add up all `accumulatedMarks` from both arrays -3. **Display**: Show individual balances/deposits or total anchor ledger marks -4. **Poll**: Refresh every 30-60 seconds for updates -5. **Rate**: Both ha tokens and stability pools earn at 1 mark/dollar/day (same rate) - -## Quick Reference: Subgraph Status - -✅ \*_No subgraph changes needed-20 FRONTEND-HA-TOKEN-MARKS.md_ The subgraph already provides: - -- `accumulatedMarks` - marks calculated up to last event -- `marksPerDay` - current earning rate (already includes multiplier) -- `lastUpdated` - timestamp of last event - -The frontend estimation approach works with existing subgraph data. Multipliers are automatically applied by the subgraph when calculating `marksPerDay`. - -## Sail Token Marks (5x Multiplier) - -### Overview - -Sail tokens (leveraged tokens, `hs` tokens) earn marks at **5x the rate** of ha tokens by default: - -- **Ha Tokens**: 1 mark per dollar per day (1x multiplier) -- **Sail Tokens**: 5 marks per dollar per day (5x multiplier, default) - -Each sail token can have its own multiplier configured, but the default is **5.0x** for all sail tokens. - -### GraphQL Query for Sail Tokens - -```graphql -query GetSailTokenMarks($userAddress: Bytes!) { - sailTokenBalances(where: { user: $userAddress }) { - id - tokenAddress - balance - balanceUSD - accumulatedMarks - marksPerDay - lastUpdated - } -} -``` - -### Frontend Implementation - -```typescript -interface SailTokenBalance { - id: string; - tokenAddress: string; - balance: string; - balanceUSD: string; - accumulatedMarks: string; - marksPerDay: string; - lastUpdated: string; -} - -async function getSailTokenMarks(userAddress: string) { - const query = ` - query GetSailTokenMarks($userAddress: Bytes!) { - sailTokenBalances(where: { user: $userAddress }) { - accumulatedMarks - marksPerDay - balanceUSD - lastUpdated - } - } - `; - - const response = await fetch(GRAPHQL_ENDPOINT, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - query, - variables: { - userAddress: userAddress.toLowerCase(), - }, - }), - }); - - const data = await response.json(); - return data.data.sailTokenBalances || []; -} - -// Calculate total sail token marks -function calculateTotalSailTokenMarks(balances: SailTokenBalance[]): number { - return balances.reduce((total, balance) => total + parseFloat(balance.accumulatedMarks || "0"), 0); -} -``` - -### Real-Time Estimation for Sail Tokens - -The same zero-gas estimation approach works for sail tokens: - -```typescript -/** - * Calculate estimated marks from sail token balance - * marksPerDay already includes the 5x multiplier! - */ -function calculateEstimatedSailMarks(balance: SailTokenBalance): number { - const storedMarks = parseFloat(balance.accumulatedMarks || "0"); - const marksPerDay = parseFloat(balance.marksPerDay || "0"); // Already includes 5x multiplier! - const lastUpdated = parseInt(balance.lastUpdated || "0"); - - if (lastUpdated === 0 || marksPerDay === 0) { - return storedMarks; - } - - const now = Math.floor(Date.now() / 1000); - const secondsSinceUpdate = now - lastUpdated; - const daysSinceUpdate = secondsSinceUpdate / 86400; - - return storedMarks + marksPerDay * daysSinceUpdate; -} -``` - -### Example: Sail Token Marks Calculation - -User holds: - -- **100,000 sail tokens** (hsPB) worth $100,000 -- **Multiplier**: 5.0x (default) -- **Marks per day**: $100,000 × 5.0 = **500,000 marks/day** - -After 2 days: - -- **Accumulated marks**: 500,000 × 2 = **1,000,000 marks** - -### Complete Query (All Marks Sources Including Sail Tokens) - -```graphql -query GetAllUserMarks($userAddress: Bytes!, $genesisId: ID!) { - # Ha Token Marks (1x multiplier) - haTokenBalances(where: { user: $userAddress }) { - accumulatedMarks - marksPerDay - } - - # Sail Token Marks (5x multiplier) - sailTokenBalances(where: { user: $userAddress }) { - accumulatedMarks - marksPerDay - } - - # Stability Pool Marks (1x multiplier) - stabilityPoolDeposits(where: { user: $userAddress }) { - accumulatedMarks - marksPerDay - } - - # Genesis Marks - userHarborMarks(id: $genesisId) { - currentMarks - marksPerDay - } -} -``` - -### Combining All Marks Sources - -```typescript -async function getTotalMarks(userAddress: string, genesisAddress: string) { - const data = await getAllUserMarks(userAddress, genesisAddress); - - const haMarks = (data.haTokenBalances || []).reduce((sum, b) => sum + parseFloat(b.accumulatedMarks || "0"), 0); - - const sailMarks = (data.sailTokenBalances || []).reduce((sum, b) => sum + parseFloat(b.accumulatedMarks || "0"), 0); - - const poolMarks = (data.stabilityPoolDeposits || []).reduce( - (sum, d) => sum + parseFloat(d.accumulatedMarks || "0"), - 0, - ); - - const genesisMarks = parseFloat(data.userHarborMarks?.currentMarks || "0"); - - return { - haTokenMarks: haMarks, - sailTokenMarks: sailMarks, - stabilityPoolMarks: poolMarks, - genesisMarks: genesisMarks, - totalMarks: haMarks + sailMarks + poolMarks + genesisMarks, - }; -} -``` - -### Sail Token Multipliers - -- **Default**: 5.0x (5 marks per dollar per day) -- **Per-Token**: Each sail token can have its own multiplier -- **Query Multipliers**: Use `MarksMultiplier` entity with `sourceType: "sailToken"` - -```graphql -query GetSailTokenMultipliers { - marksMultipliers(where: { sourceType: "sailToken" }) { - id - sourceType - sourceAddress - multiplier - effectiveFrom - } -} -``` diff --git a/doc/guides/FRONTEND-INFO-ACTIVE-GENESIS.txt b/doc/guides/FRONTEND-INFO-ACTIVE-GENESIS.txt deleted file mode 100644 index e0baa3a0..00000000 --- a/doc/guides/FRONTEND-INFO-ACTIVE-GENESIS.txt +++ /dev/null @@ -1,55 +0,0 @@ -=== Harbor Marks - Active Genesis Deployment === - -Network Configuration: -- Network Name: Local Anvil -- Chain ID: 31337 -- RPC URL: http://localhost:8545 - -Contract Addresses: -- Genesis: 0x572316aC11CB4bc5daf6BDae68f43EA3CCE3aE0e -- Minter: 0xe38b6847E611e942E6c80eD89aE867F522402e80 -- PeggedToken: 0x987e855776C03A4682639eEb14e65b3089EE6310 -- LeveragedToken: 0xb932C8342106776E73E39D695F3FFC3A9624eCE0 -- wstETH: 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 - -GraphQL Endpoint: -- http://localhost:8000/subgraphs/name/harbor-marks-local - -Admin Accounts: -- Owner (can call endGenesis): 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 -- Developer: 0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e - -Status: -✅ All contracts deployed -✅ Genesis is ACTIVE (not ended) -✅ ZERO_FEE_ROLE granted to Genesis on Minter -✅ Developer has 1000 wstETH and ETH for gas -✅ Subgraph updated and deployed - - -Network Configuration: -- Network Name: Local Anvil -- Chain ID: 31337 -- RPC URL: http://localhost:8545 - -Contract Addresses: -- Genesis: 0x572316aC11CB4bc5daf6BDae68f43EA3CCE3aE0e -- Minter: 0xe38b6847E611e942E6c80eD89aE867F522402e80 -- PeggedToken: 0x987e855776C03A4682639eEb14e65b3089EE6310 -- LeveragedToken: 0xb932C8342106776E73E39D695F3FFC3A9624eCE0 -- wstETH: 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 - -GraphQL Endpoint: -- http://localhost:8000/subgraphs/name/harbor-marks-local - -Admin Accounts: -- Owner (can call endGenesis): 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 -- Developer: 0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e - -Status: -✅ All contracts deployed -✅ Genesis is ACTIVE (not ended) -✅ ZERO_FEE_ROLE granted to Genesis on Minter -✅ Developer has 1000 wstETH and ETH for gas -✅ Subgraph updated and deployed - diff --git a/doc/guides/FRONTEND-INFO-ACTIVE.txt b/doc/guides/FRONTEND-INFO-ACTIVE.txt deleted file mode 100644 index 7e5d1154..00000000 --- a/doc/guides/FRONTEND-INFO-ACTIVE.txt +++ /dev/null @@ -1,67 +0,0 @@ -=== Harbor Marks - Active Genesis Deployment === - -Network Configuration: -- Network Name: Local Anvil -- Chain ID: 31337 -- RPC URL: http://localhost:8545 - -Contract Addresses: -- Genesis: 0x6732128F9cc0c4344b2d4DC6285BCd516b7E59E6 -- Minter: 0xeC1BB74f5799811c0c1Bff94Ef76Fb40abccbE4a -- PeggedToken: 0x01cf58e264d7578D4C67022c58A24CbC4C4a304E -- LeveragedToken: 0xd038A2EE73b64F30d65802Ad188F27921656f28F -- wstETH: 0x2e8880cAdC08E9B438c6052F5ce3869FBd6cE513 - -Price Feeds (Fixed): -- wstETH/USD: 0xeC827421505972a2AE9C320302d3573B42363C26 - -GraphQL Endpoint: -- http://localhost:8000/subgraphs/name/harbor-marks-local - -Admin Accounts: -- Owner (can call endGenesis): 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 -- Developer: 0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e - -Status: -✅ All contracts redeployed with fixed price feeds -✅ Genesis is ACTIVE (not ended) - ready for deposits -✅ ZERO_FEE_ROLE granted to Genesis -✅ Price feed "Round not found" issue fixed -✅ Developer has 1000 wstETH and ETH for gas -✅ Subgraph updated and deployed - -Note: endGenesis() was NOT called - Genesis is active and ready for testing! - - -Network Configuration: -- Network Name: Local Anvil -- Chain ID: 31337 -- RPC URL: http://localhost:8545 - -Contract Addresses: -- Genesis: 0x6732128F9cc0c4344b2d4DC6285BCd516b7E59E6 -- Minter: 0xeC1BB74f5799811c0c1Bff94Ef76Fb40abccbE4a -- PeggedToken: 0x01cf58e264d7578D4C67022c58A24CbC4C4a304E -- LeveragedToken: 0xd038A2EE73b64F30d65802Ad188F27921656f28F -- wstETH: 0x2e8880cAdC08E9B438c6052F5ce3869FBd6cE513 - -Price Feeds (Fixed): -- wstETH/USD: 0xeC827421505972a2AE9C320302d3573B42363C26 - -GraphQL Endpoint: -- http://localhost:8000/subgraphs/name/harbor-marks-local - -Admin Accounts: -- Owner (can call endGenesis): 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 -- Developer: 0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e - -Status: -✅ All contracts redeployed with fixed price feeds -✅ Genesis is ACTIVE (not ended) - ready for deposits -✅ ZERO_FEE_ROLE granted to Genesis -✅ Price feed "Round not found" issue fixed -✅ Developer has 1000 wstETH and ETH for gas -✅ Subgraph updated and deployed - -Note: endGenesis() was NOT called - Genesis is active and ready for testing! - diff --git a/doc/guides/FRONTEND-INFO-FINAL.txt b/doc/guides/FRONTEND-INFO-FINAL.txt deleted file mode 100644 index 351a399a..00000000 --- a/doc/guides/FRONTEND-INFO-FINAL.txt +++ /dev/null @@ -1,75 +0,0 @@ -=== Harbor Marks - Final Deployment (Price Feed Fix) === - -Network Configuration: -- Network Name: Local Anvil -- Chain ID: 31337 -- RPC URL: http://localhost:8545 - -Contract Addresses: -- Genesis: 0xa779C1D17bC5230c07afdC51376CAC1cb3Dd5314 -- Minter: 0x1D13fF25b10C9a6741DFdce229073bed652197c7 -- PeggedToken: 0x3f9A1B67F3a3548e0ea5c9eaf43A402d12b6a273 -- LeveragedToken: 0xFD6D23eE2b6b136E34572fc80cbCd33E9787705e -- wstETH: 0x2e8880cAdC08E9B438c6052F5ce3869FBd6cE513 - -Price Feeds (Fixed): -- wstETH/USD: 0xeC827421505972a2AE9C320302d3573B42363C26 - -GraphQL Endpoint: -- http://localhost:8000/subgraphs/name/harbor-marks-local - -Admin Accounts: -- Owner (can call endGenesis): 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 -- Developer: 0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e - -Status: -✅ All contracts redeployed with fixed price feeds -✅ Genesis is ACTIVE (not ended) -✅ ZERO_FEE_ROLE granted to Genesis -✅ Price feed "Round not found" issue fixed -✅ Developer has 1000 wstETH and ETH for gas -✅ Subgraph updated and deployed - -Fixes Applied: -1. ✅ Mock price feed updated to accept any round ID in getRoundData() -2. ✅ All contracts redeployed to use new price feed addresses -3. ✅ ZERO_FEE_ROLE granted to Genesis on Minter -4. ✅ endGenesis() should now work without "Round not found" error - - -Network Configuration: -- Network Name: Local Anvil -- Chain ID: 31337 -- RPC URL: http://localhost:8545 - -Contract Addresses: -- Genesis: 0xa779C1D17bC5230c07afdC51376CAC1cb3Dd5314 -- Minter: 0x1D13fF25b10C9a6741DFdce229073bed652197c7 -- PeggedToken: 0x3f9A1B67F3a3548e0ea5c9eaf43A402d12b6a273 -- LeveragedToken: 0xFD6D23eE2b6b136E34572fc80cbCd33E9787705e -- wstETH: 0x2e8880cAdC08E9B438c6052F5ce3869FBd6cE513 - -Price Feeds (Fixed): -- wstETH/USD: 0xeC827421505972a2AE9C320302d3573B42363C26 - -GraphQL Endpoint: -- http://localhost:8000/subgraphs/name/harbor-marks-local - -Admin Accounts: -- Owner (can call endGenesis): 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 -- Developer: 0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e - -Status: -✅ All contracts redeployed with fixed price feeds -✅ Genesis is ACTIVE (not ended) -✅ ZERO_FEE_ROLE granted to Genesis -✅ Price feed "Round not found" issue fixed -✅ Developer has 1000 wstETH and ETH for gas -✅ Subgraph updated and deployed - -Fixes Applied: -1. ✅ Mock price feed updated to accept any round ID in getRoundData() -2. ✅ All contracts redeployed to use new price feed addresses -3. ✅ ZERO_FEE_ROLE granted to Genesis on Minter -4. ✅ endGenesis() should now work without "Round not found" error - diff --git a/doc/guides/FRONTEND-INFO-FIXED.txt b/doc/guides/FRONTEND-INFO-FIXED.txt deleted file mode 100644 index 88ffdcc1..00000000 --- a/doc/guides/FRONTEND-INFO-FIXED.txt +++ /dev/null @@ -1,42 +0,0 @@ - === Harbor Marks - Final Deployment (Price Feed Fix Verified) === - - Network Configuration: - - Network Name: Local Anvil - - Chain ID: 31337 - - RPC URL: http://localhost:8545 - - Contract Addresses: - - Genesis: 0x840748F7Fd3EA956E5f4c88001da5CC1ABCBc038 - - Minter: 0x6484EB0792c646A4827638Fc1B6F20461418eB00 - - PeggedToken: (check deployment log) - - LeveragedToken: (check deployment log) - - wstETH: 0x2e8880cAdC08E9B438c6052F5ce3869FBd6cE513 - - Price Feeds (All Fixed): - - stETH/USD: 0xb007167714e2940013ec3bb551584130b7497e22 - - stETH/ETH: 0x6b39b761b1b64c8c095bf0e3bb0c6a74705b4788 - - wstETH/USD: 0xeC827421505972a2AE9C320302d3573B42363C26 - - GraphQL Endpoint: - - http://localhost:8000/subgraphs/name/harbor-marks-local - - Admin Accounts: - - Owner (can call endGenesis): 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 - - Developer: 0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e - - Status: - ✅ All contracts redeployed with fixed price feeds - ✅ Price feed "Round not found" issue FIXED and VERIFIED - ✅ endGenesis() tested and working (no errors!) - ✅ Genesis is ACTIVE (not ended) - ready for deposits - ✅ ZERO_FEE_ROLE granted to Genesis - ✅ Developer has 1000 wstETH and ETH for gas - ✅ Subgraph updated and deployed - - Fix Summary: - - Root cause: PriceOracle uses stETH/USD feed, which was using OLD address - - Solution: Updated bcinfo.local.json with ALL new fixed price feed addresses - - Verification: endGenesis() now works without "Round not found" error - - - diff --git a/doc/guides/FRONTEND-INFO-FRESH-DEPLOY.txt b/doc/guides/FRONTEND-INFO-FRESH-DEPLOY.txt deleted file mode 100644 index a1ae2124..00000000 --- a/doc/guides/FRONTEND-INFO-FRESH-DEPLOY.txt +++ /dev/null @@ -1,62 +0,0 @@ -=== Harbor Marks - Fresh Deployment Configuration === - -Network Configuration: -- Network Name: Local Anvil -- Chain ID: 31337 -- RPC URL: http://localhost:8545 - -Contract Addresses: -- Genesis: 0x9385556B571ab92bf6dC9a0DbD75429Dd4d56F91 -- Minter: 0xDde063eBe8E85D666AD99f731B4Dbf8C98F29708 -- PeggedToken: 0xC7143d5bA86553C06f5730c8dC9f8187a621A8D4 -- LeveragedToken: 0x359570B3a0437805D0a71457D61AD26a28cAC9A2 -- wstETH: 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 - -GraphQL Endpoint: -- http://localhost:8000/subgraphs/name/harbor-marks-local - -Admin Accounts: -- Owner (can call endGenesis): 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 -- Developer: 0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e - -Status: -✅ All contracts deployed -✅ Genesis owner verified (0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266) -✅ endGenesis() tested and working -✅ ZERO_FEE_ROLE granted to Genesis on Minter -✅ Developer has 1000 wstETH and ETH for gas -✅ Subgraph updated and ready to deploy - - - -Network Configuration: -- Network Name: Local Anvil -- Chain ID: 31337 -- RPC URL: http://localhost:8545 - -Contract Addresses: -- Genesis: 0x9385556B571ab92bf6dC9a0DbD75429Dd4d56F91 -- Minter: 0xDde063eBe8E85D666AD99f731B4Dbf8C98F29708 -- PeggedToken: 0xC7143d5bA86553C06f5730c8dC9f8187a621A8D4 -- LeveragedToken: 0x359570B3a0437805D0a71457D61AD26a28cAC9A2 -- wstETH: 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 - -GraphQL Endpoint: -- http://localhost:8000/subgraphs/name/harbor-marks-local - -Admin Accounts: -- Owner (can call endGenesis): 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 -- Developer: 0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e - -Status: -✅ All contracts deployed -✅ Genesis owner verified (0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266) -✅ endGenesis() tested and working -✅ ZERO_FEE_ROLE granted to Genesis on Minter -✅ Developer has 1000 wstETH and ETH for gas -✅ Subgraph updated and ready to deploy - - - - - diff --git a/doc/guides/FRONTEND-INFO.txt b/doc/guides/FRONTEND-INFO.txt deleted file mode 100644 index c5f0c1cd..00000000 --- a/doc/guides/FRONTEND-INFO.txt +++ /dev/null @@ -1,324 +0,0 @@ -================================================================================ -FRONTEND CONFIGURATION - FRESH ANVIL DEPLOYMENT -================================================================================ - -NETWORK CONFIGURATION ---------------------- -Chain ID: 31337 -Network Name: Anvil Local -RPC URL: http://localhost:8545 -Block Explorer: None (local chain) - -CONTRACT ADDRESSES ------------------- -Genesis: 0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82 -Minter: 0x8A791620dd6260079BF849Dc5567aDC3F2FdC318 -Pegged Token (haPB): 0x0165878A594ca255338adfa4d48449f69242Eb8F -Leveraged Token: 0xa513E6E4b8f2a923D98304ec87F64353C4D5C853 -Reserve Pool: 0x610178dA211FEF7D417bC0e6FeD39F05609AD788 -Stability Pool Manager: 0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0 -Fee Receiver: 0xB7f8BC63BbcaD18155201308C8f3540b07f84F5e -Stability Pool Collateral: 0xf5059a5D33d5853360D16C683c16e67980206f36 -Stability Pool Sail: 0x99bbA657f2BbC93c02D617f8bA121cB8Fc104Acf - -TOKEN ADDRESSES ---------------- -wstETH: 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 -stETH: 0x5FbDB2315678afecb367f032d93F642f64180aa3 - -PRICE FEED ADDRESSES (Mock Chainlink) -------------------------------------- -wstETH/USD: 0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9 -stETH/USD: 0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0 -stETH/ETH: 0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9 - -PRICE FEED VALUES (Fixed for Mock) ------------------------------------ -wstETH/USD: $2000 (200000000000 with 8 decimals) -stETH/USD: $2000 (200000000000 with 8 decimals) -stETH/ETH: 1.0 (100000000 with 8 decimals) - -DEVELOPER ACCOUNT ------------------ -Address: 0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e -ETH Balance: 1600 ETH -wstETH Balance: 1000 wstETH -stETH Balance: 1000 stETH - -Note: Genesis contract is owned by deployer (0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266) - Developer can still interact with Genesis for deposits/withdrawals. - -GRAPHQL ENDPOINT ----------------- -Subgraph Name: harbor-marks-local -HTTP Endpoint: http://localhost:8000/subgraphs/name/harbor-marks-local -GraphQL Playground: http://localhost:8000/subgraphs/name/harbor-marks-local/graphql - -Subgraph Status: Healthy, indexing (started at block 14) - -EXAMPLE GRAPHQL QUERIES ------------------------ -# Get user marks -{ - userHarborMarks(where: { user: "0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e" }) { - id - user - contract - totalDeposited - totalWithdrawn - currentMarks - totalMarksForfeited - bonusMarks - genesisEnded - lastUpdateTimestamp - } -} - -# Get deposits -{ - deposits(orderBy: timestamp, orderDirection: desc, first: 10) { - id - user - token - amount - amountUSD - timestamp - blockNumber - } -} - -# Get withdrawals -{ - withdrawals(orderBy: timestamp, orderDirection: desc, first: 10) { - id - user - token - amount - amountUSD - timestamp - blockNumber - } -} - -# Check if genesis has ended -{ - genesisEnds(first: 1, orderBy: timestamp, orderDirection: desc) { - id - contract - timestamp - blockNumber - } -} - -QUICK REFERENCE JSON --------------------- -{ - "network": { - "chainId": 31337, - "name": "Anvil Local", - "rpcUrl": "http://localhost:8545" - }, - "contracts": { - "genesis": "0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82", - "minter": "0x8A791620dd6260079BF849Dc5567aDC3F2FdC318", - "peggedToken": "0x0165878A594ca255338adfa4d48449f69242Eb8F", - "leveragedToken": "0xa513E6E4b8f2a923D98304ec87F64353C4D5C853", - "reservePool": "0x610178dA211FEF7D417bC0e6FeD39F05609AD788", - "stabilityPoolManager": "0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0", - "feeReceiver": "0xB7f8BC63BbcaD18155201308C8f3540b07f84F5e", - "stabilityPoolCollateral": "0xf5059a5D33d5853360D16C683c16e67980206f36", - "stabilityPoolSail": "0x99bbA657f2BbC93c02D617f8bA121cB8Fc104Acf" - }, - "tokens": { - "wstETH": "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512", - "stETH": "0x5FbDB2315678afecb367f032d93F642f64180aa3" - }, - "priceFeeds": { - "wstETH_USD": "0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9", - "stETH_USD": "0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0", - "stETH_ETH": "0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9" - }, - "graphql": { - "endpoint": "http://localhost:8000/subgraphs/name/harbor-marks-local" - }, - "developer": { - "address": "0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e" - } -} - -IMPORTANT NOTES ---------------- -1. This is a FRESH Anvil chain (not a fork) - all contracts are newly deployed -2. All tokens and price feeds are MOCK contracts for local testing -3. Price feeds return fixed values (wstETH = $2000, stETH = $2000) -4. Genesis contract owner is the Anvil deployer, but developer can still interact -5. Subgraph is indexing from block 14 -6. Make sure Anvil is running on http://localhost:8545 -7. Make sure Graph Node is running (Docker services) - -================================================================================ - - -FRONTEND CONFIGURATION - FRESH ANVIL DEPLOYMENT -================================================================================ - -NETWORK CONFIGURATION ---------------------- -Chain ID: 31337 -Network Name: Anvil Local -RPC URL: http://localhost:8545 -Block Explorer: None (local chain) - -CONTRACT ADDRESSES ------------------- -Genesis: 0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82 -Minter: 0x8A791620dd6260079BF849Dc5567aDC3F2FdC318 -Pegged Token (haPB): 0x0165878A594ca255338adfa4d48449f69242Eb8F -Leveraged Token: 0xa513E6E4b8f2a923D98304ec87F64353C4D5C853 -Reserve Pool: 0x610178dA211FEF7D417bC0e6FeD39F05609AD788 -Stability Pool Manager: 0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0 -Fee Receiver: 0xB7f8BC63BbcaD18155201308C8f3540b07f84F5e -Stability Pool Collateral: 0xf5059a5D33d5853360D16C683c16e67980206f36 -Stability Pool Sail: 0x99bbA657f2BbC93c02D617f8bA121cB8Fc104Acf - -TOKEN ADDRESSES ---------------- -wstETH: 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 -stETH: 0x5FbDB2315678afecb367f032d93F642f64180aa3 - -PRICE FEED ADDRESSES (Mock Chainlink) -------------------------------------- -wstETH/USD: 0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9 -stETH/USD: 0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0 -stETH/ETH: 0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9 - -PRICE FEED VALUES (Fixed for Mock) ------------------------------------ -wstETH/USD: $2000 (200000000000 with 8 decimals) -stETH/USD: $2000 (200000000000 with 8 decimals) -stETH/ETH: 1.0 (100000000 with 8 decimals) - -DEVELOPER ACCOUNT ------------------ -Address: 0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e -ETH Balance: 1600 ETH -wstETH Balance: 1000 wstETH -stETH Balance: 1000 stETH - -Note: Genesis contract is owned by deployer (0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266) - Developer can still interact with Genesis for deposits/withdrawals. - -GRAPHQL ENDPOINT ----------------- -Subgraph Name: harbor-marks-local -HTTP Endpoint: http://localhost:8000/subgraphs/name/harbor-marks-local -GraphQL Playground: http://localhost:8000/subgraphs/name/harbor-marks-local/graphql - -Subgraph Status: Healthy, indexing (started at block 14) - -EXAMPLE GRAPHQL QUERIES ------------------------ -# Get user marks -{ - userHarborMarks(where: { user: "0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e" }) { - id - user - contract - totalDeposited - totalWithdrawn - currentMarks - totalMarksForfeited - bonusMarks - genesisEnded - lastUpdateTimestamp - } -} - -# Get deposits -{ - deposits(orderBy: timestamp, orderDirection: desc, first: 10) { - id - user - token - amount - amountUSD - timestamp - blockNumber - } -} - -# Get withdrawals -{ - withdrawals(orderBy: timestamp, orderDirection: desc, first: 10) { - id - user - token - amount - amountUSD - timestamp - blockNumber - } -} - -# Check if genesis has ended -{ - genesisEnds(first: 1, orderBy: timestamp, orderDirection: desc) { - id - contract - timestamp - blockNumber - } -} - -QUICK REFERENCE JSON --------------------- -{ - "network": { - "chainId": 31337, - "name": "Anvil Local", - "rpcUrl": "http://localhost:8545" - }, - "contracts": { - "genesis": "0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82", - "minter": "0x8A791620dd6260079BF849Dc5567aDC3F2FdC318", - "peggedToken": "0x0165878A594ca255338adfa4d48449f69242Eb8F", - "leveragedToken": "0xa513E6E4b8f2a923D98304ec87F64353C4D5C853", - "reservePool": "0x610178dA211FEF7D417bC0e6FeD39F05609AD788", - "stabilityPoolManager": "0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0", - "feeReceiver": "0xB7f8BC63BbcaD18155201308C8f3540b07f84F5e", - "stabilityPoolCollateral": "0xf5059a5D33d5853360D16C683c16e67980206f36", - "stabilityPoolSail": "0x99bbA657f2BbC93c02D617f8bA121cB8Fc104Acf" - }, - "tokens": { - "wstETH": "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512", - "stETH": "0x5FbDB2315678afecb367f032d93F642f64180aa3" - }, - "priceFeeds": { - "wstETH_USD": "0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9", - "stETH_USD": "0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0", - "stETH_ETH": "0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9" - }, - "graphql": { - "endpoint": "http://localhost:8000/subgraphs/name/harbor-marks-local" - }, - "developer": { - "address": "0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e" - } -} - -IMPORTANT NOTES ---------------- -1. This is a FRESH Anvil chain (not a fork) - all contracts are newly deployed -2. All tokens and price feeds are MOCK contracts for local testing -3. Price feeds return fixed values (wstETH = $2000, stETH = $2000) -4. Genesis contract owner is the Anvil deployer, but developer can still interact -5. Subgraph is indexing from block 14 -6. Make sure Anvil is running on http://localhost:8545 -7. Make sure Graph Node is running (Docker services) - -================================================================================ - - - - - diff --git a/doc/guides/FRONTEND-LEVERAGE-RATIO.md b/doc/guides/FRONTEND-LEVERAGE-RATIO.md deleted file mode 100644 index b1ab198c..00000000 --- a/doc/guides/FRONTEND-LEVERAGE-RATIO.md +++ /dev/null @@ -1,423 +0,0 @@ -# Frontend: Leverage Ratio Display Guide - -## Overview - -The **leverage ratio** represents how much leverage the leveraged tokens (sail tokens, `hs` tokens) have relative to the collateral. It's a key metric for understanding the risk and exposure of the system. - -## What is Leverage Ratio? - -The leverage ratio is calculated as: - -``` -leverageRatio = collateralValue / (collateralValue - peggedValue) -``` - -**Interpretation:** - -- **2.0x** = Leveraged tokens have 2x exposure to price movements -- **3.0x** = Leveraged tokens have 3x exposure to price movements -- **Higher ratio** = More leverage, more risk/reward -- **Lower ratio** = Less leverage, less risk/reward - -**Example:** - -- If collateral value = $1,000,000 -- If pegged token value = $500,000 -- Leverage ratio = $1,000,000 / ($1,000,000 - $500,000) = 2.0x - -## Fetching Leverage Ratio - -### Contract Function - -```typescript -// utils/minter.ts -import { Contract } from "ethers"; -import { MINTER_ABI } from "../abis/Minter"; - -export async function getLeverageRatio(minterAddress: string, provider: any): Promise { - const minter = new Contract(minterAddress, MINTER_ABI, provider); - - // Returns uint256 with 18 decimals - const leverageRatioRaw = await minter.leverageRatio(); - - // Convert from 18 decimals to human-readable - const leverageRatio = parseFloat(leverageRatioRaw.toString()) / 1e18; - - return leverageRatio; -} -``` - -### React Hook - -```typescript -// hooks/useLeverageRatio.ts -import { useState, useEffect } from "react"; -import { useProvider } from "wagmi"; -import { getLeverageRatio } from "../utils/minter"; - -export function useLeverageRatio(minterAddress: string) { - const provider = useProvider(); - const [leverageRatio, setLeverageRatio] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - async function fetchLeverageRatio() { - try { - setLoading(true); - const ratio = await getLeverageRatio(minterAddress, provider); - setLeverageRatio(ratio); - setError(null); - } catch (err) { - setError(err as Error); - console.error("Error fetching leverage ratio:", err); - } finally { - setLoading(false); - } - } - - if (minterAddress) { - fetchLeverageRatio(); - // Refresh every 30 seconds (or on block updates) - const interval = setInterval(fetchLeverageRatio, 30000); - return () => clearInterval(interval); - } - }, [minterAddress, provider]); - - return { leverageRatio, loading, error }; -} -``` - -## Display Formatting - -### Basic Display - -```typescript -// utils/formatLeverageRatio.ts -export function formatLeverageRatio(ratio: number | null): string { - if (ratio === null) return "—"; - - // Round to 2 decimal places - return `${ratio.toFixed(2)}x`; -} - -// Usage -const formatted = formatLeverageRatio(2.5); // "2.50x" -``` - -### Color-Coded Display - -```typescript -// utils/formatLeverageRatio.ts -export function getLeverageRatioColor(ratio: number | null): string { - if (ratio === null) return "text-gray-500"; - - // Higher leverage = more risk = red/orange - // Lower leverage = less risk = green - if (ratio >= 5.0) return "text-red-600"; // Very high leverage - if (ratio >= 3.0) return "text-orange-500"; // High leverage - if (ratio >= 2.0) return "text-yellow-500"; // Moderate leverage - return "text-green-500"; // Low leverage -} - -export function getLeverageRatioStatus(ratio: number | null): { - label: string; - color: string; - description: string; -} { - if (ratio === null) { - return { - label: "Unknown", - color: "text-gray-500", - description: "Unable to fetch leverage ratio", - }; - } - - if (ratio >= 5.0) { - return { - label: "Very High", - color: "text-red-600", - description: "Extremely high leverage - high risk", - }; - } - - if (ratio >= 3.0) { - return { - label: "High", - color: "text-orange-500", - description: "High leverage - increased risk", - }; - } - - if (ratio >= 2.0) { - return { - label: "Moderate", - color: "text-yellow-500", - description: "Moderate leverage - balanced risk", - }; - } - - return { - label: "Low", - color: "text-green-500", - description: "Low leverage - lower risk", - }; -} -``` - -## React Component Examples - -### Simple Display - -```typescript -// components/LeverageRatio.tsx -import { useLeverageRatio } from "../hooks/useLeverageRatio"; -import { formatLeverageRatio } from "../utils/formatLeverageRatio"; - -interface Props { - minterAddress: string; -} - -export function LeverageRatio({ minterAddress }: Props) { - const { leverageRatio, loading, error } = useLeverageRatio(minterAddress); - - if (loading) { - return ( -
-
-
- ); - } - - if (error) { - return
Error loading leverage ratio
; - } - - return ( -
- Leverage Ratio: - - {formatLeverageRatio(leverageRatio)} - -
- ); -} -``` - -### Detailed Display with Status - -```typescript -// components/LeverageRatioDetailed.tsx -import { useLeverageRatio } from "../hooks/useLeverageRatio"; -import { formatLeverageRatio, getLeverageRatioStatus } from "../utils/formatLeverageRatio"; - -interface Props { - minterAddress: string; -} - -export function LeverageRatioDetailed({ minterAddress }: Props) { - const { leverageRatio, loading, error } = useLeverageRatio(minterAddress); - - if (loading) { - return ( -
-
-
-
- ); - } - - if (error) { - return ( -
-

Error loading leverage ratio

-

{error.message}

-
- ); - } - - const status = getLeverageRatioStatus(leverageRatio); - - return ( -
-
- Leverage Ratio: - - {formatLeverageRatio(leverageRatio)} - - - {status.label} - -
-

{status.description}

-
- ); -} -``` - -### Card Display - -```typescript -// components/LeverageRatioCard.tsx -import { useLeverageRatio } from "../hooks/useLeverageRatio"; -import { formatLeverageRatio, getLeverageRatioStatus } from "../utils/formatLeverageRatio"; - -interface Props { - minterAddress: string; -} - -export function LeverageRatioCard({ minterAddress }: Props) { - const { leverageRatio, loading, error } = useLeverageRatio(minterAddress); - const status = leverageRatio !== null ? getLeverageRatioStatus(leverageRatio) : null; - - return ( -
-

- Leverage Ratio -

- - {loading && ( -
-
-
-
- )} - - {error && ( -
-

Error loading leverage ratio

-
- )} - - {!loading && !error && leverageRatio !== null && status && ( - <> -
- - {formatLeverageRatio(leverageRatio)} - - - {status.label} - -
-

{status.description}

- -
-

- Leverage ratio represents the exposure multiplier for leveraged tokens. - Higher ratios indicate more leverage and higher risk/reward. -

-
- - )} -
- ); -} -``` - -## Understanding Leverage Ratio Values - -### Typical Ranges - -| Ratio | Interpretation | Risk Level | Display Color | -| ----------- | -------------- | ---------- | ------------- | -| < 1.5x | Very Low | Low | Green | -| 1.5x - 2.0x | Low | Low-Medium | Green-Yellow | -| 2.0x - 3.0x | Moderate | Medium | Yellow | -| 3.0x - 5.0x | High | High | Orange | -| > 5.0x | Very High | Very High | Red | - -### Edge Cases - -1. **Capped Ratio**: The contract caps the leverage ratio at a maximum value (`_LEVERAGE_RATIO_CAP`). If the calculated ratio exceeds this, it returns the cap. - -2. **Zero Pegged Tokens**: If there are no pegged tokens but collateral exists, the leverage ratio calculation may return a very large number or the cap. - -3. **Empty System**: If both collateral and pegged tokens are zero, the leverage ratio may return a default value. - -## Integration with Other Metrics - -### Display with Collateral Ratio - -```typescript -// components/SystemMetrics.tsx -import { useLeverageRatio } from "../hooks/useLeverageRatio"; -import { useCollateralRatio } from "../hooks/useCollateralRatio"; - -export function SystemMetrics({ minterAddress }: Props) { - const { leverageRatio } = useLeverageRatio(minterAddress); - const { collateralRatio } = useCollateralRatio(minterAddress); - - return ( -
-
-

Collateral Ratio

-

{collateralRatio?.toFixed(2)}x

-
-
-

Leverage Ratio

-

{leverageRatio?.toFixed(2)}x

-
-
- ); -} -``` - -## Real-Time Updates - -For real-time updates, consider: - -1. **Block-based updates**: Refresh on new blocks -2. **Event listeners**: Listen for mint/redeem events -3. **Polling**: Refresh every 30-60 seconds - -```typescript -// hooks/useLeverageRatioRealtime.ts -import { useEffect } from "react"; -import { useBlockNumber } from "wagmi"; -import { useLeverageRatio } from "./useLeverageRatio"; - -export function useLeverageRatioRealtime(minterAddress: string) { - const { data: blockNumber } = useBlockNumber(); - const leverageRatio = useLeverageRatio(minterAddress); - - // Refetch on new block - useEffect(() => { - // Trigger refetch logic here - }, [blockNumber]); - - return leverageRatio; -} -``` - -## Important Notes - -1. **18 Decimals**: The contract returns values with 18 decimals - always divide by `1e18` - -2. **Price Dependency**: Leverage ratio depends on the price oracle - ensure the oracle is working correctly - -3. **Capped Values**: The ratio is capped at a maximum - check if you're seeing the cap vs. actual ratio - -4. **Display Format**: Always show as "X.XXx" format (e.g., "2.50x") for clarity - -5. **Error Handling**: Handle cases where the contract call fails (stale price, network issues) - -## Example: Complete Implementation - -```typescript -// pages/MarketOverview.tsx -import { LeverageRatioCard } from "../components/LeverageRatioCard"; -import { CollateralRatioCard } from "../components/CollateralRatioCard"; - -export function MarketOverview() { - const minterAddress = "0x..."; // Your minter address - - return ( -
- - -
- ); -} -``` - - diff --git a/doc/guides/FRONTEND-MARKS-DISPLAY-GUIDE.md b/doc/guides/FRONTEND-MARKS-DISPLAY-GUIDE.md deleted file mode 100644 index b28b04f6..00000000 --- a/doc/guides/FRONTEND-MARKS-DISPLAY-GUIDE.md +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/doc/guides/FRONTEND-PEGGED-TOKEN-PRICE.md b/doc/guides/FRONTEND-PEGGED-TOKEN-PRICE.md deleted file mode 100644 index b28b04f6..00000000 --- a/doc/guides/FRONTEND-PEGGED-TOKEN-PRICE.md +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/doc/guides/FRONTEND-PEGGED-TOKEN-VALUE.md b/doc/guides/FRONTEND-PEGGED-TOKEN-VALUE.md deleted file mode 100644 index bf2b573c..00000000 --- a/doc/guides/FRONTEND-PEGGED-TOKEN-VALUE.md +++ /dev/null @@ -1,353 +0,0 @@ -# Frontend: Pegged Token Value Guide - -## Overview - -The pegged token (haPB) is designed to maintain a **$1.00 USD peg**, but the actual redemption value can vary based on the system's collateral ratio and depeg status. The frontend can get the pegged token value from the Minter contract. - -## Understanding Pegged Token Value - -### Two Concepts - -1. **Peg Value**: Always $1.00 USD (the target) -2. **Redemption Value**: The actual amount of collateral you get when redeeming (can vary) - -### When Values Differ - -- **Normal (Pegged)**: Redemption value = $1.00 worth of collateral -- **Depegged**: Redemption value < $1.00 (system is undercollateralized) -- **Empty System**: Returns 1.0 (default when no tokens exist) - -## Fetching Pegged Token Value - -### Method 1: Query Minter Contract (Recommended) - -The Minter contract has a `peggedTokenPrice()` function that returns the price in terms of the underlying collateral (18 decimals). - -```typescript -// utils/minter.ts -import { Contract } from "ethers"; -import { MINTER_ABI } from "../abis/Minter"; - -export async function getPeggedTokenPrice(minterAddress: string, provider: any): Promise { - const minter = new Contract(minterAddress, MINTER_ABI, provider); - - // Returns uint256 with 18 decimals - // Price is in terms of the underlying collateral - const priceRaw = await minter.peggedTokenPrice(); - - // Convert from 18 decimals to human-readable - const price = parseFloat(priceRaw.toString()) / 1e18; - - return price; -} -``` - -### Method 2: Calculate from Collateral Price - -You can also calculate it manually, but using `peggedTokenPrice()` is simpler: - -```typescript -// This is what the contract does internally -// You don't need to do this - just use peggedTokenPrice() -async function calculatePeggedTokenPrice(minterAddress: string, provider: any): Promise { - const minter = new Contract(minterAddress, MINTER_ABI, provider); - - const peggedBalance = await minter.peggedTokenBalance(); - const collateralBalance = await minter.collateralTokenBalance(); - - // Get collateral price from oracle - const priceOracle = await minter.priceOracle(); - const oracle = new Contract(priceOracle, PRICE_ORACLE_ABI, provider); - const [minPrice, maxPrice] = await oracle.latestAnswer(); - const collateralPrice = (minPrice + maxPrice) / 2 / 1e8; // 8 decimals - - if (peggedBalance.eq(0)) { - return 1.0; // Default when empty - } - - // Calculate: (collateralValue) / (peggedBalance) - const collateralValue = collateralBalance.mul(collateralPrice * 1e18).div(1e18); - const price = collateralValue.div(peggedBalance).toNumber() / 1e18; - - return price; -} -``` - -## React Hook - -```typescript -// hooks/usePeggedTokenPrice.ts -import { useState, useEffect } from "react"; -import { useProvider } from "wagmi"; -import { getPeggedTokenPrice } from "../utils/minter"; - -export function usePeggedTokenPrice(minterAddress: string) { - const provider = useProvider(); - const [price, setPrice] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - async function fetchPrice() { - try { - setLoading(true); - const tokenPrice = await getPeggedTokenPrice(minterAddress, provider); - setPrice(tokenPrice); - setError(null); - } catch (err) { - setError(err as Error); - console.error("Error fetching pegged token price:", err); - } finally { - setLoading(false); - } - } - - if (minterAddress) { - fetchPrice(); - // Refresh every 30 seconds or on block updates - const interval = setInterval(fetchPrice, 30000); - return () => clearInterval(interval); - } - }, [minterAddress, provider]); - - return { price, loading, error }; -} -``` - -## Understanding the Return Value - -### Price Interpretation - -The `peggedTokenPrice()` function returns the price **in terms of stETH** (the underlying collateral), not wstETH or USD directly. - -**Important Details:** - -- The Minter uses **wstETH** as the collateral token (`WRAPPED_COLLATERAL_TOKEN`) -- The price oracle returns the price of **stETH** (the underlying) -- The calculation uses: `collateralValue = wstETH_balance × stETH_price` -- The returned price is in **stETH units** (18 decimals) - -**Example:** - -- If `peggedTokenPrice()` returns `0.0005` (1e18 = 0.0005e18) -- Then: 1 haPB = 0.0005 stETH -- If stETH is worth $2,000, then: 1 haPB = 0.0005 × $2,000 = $1.00 ✓ - -**Why this works:** - -- The pegged token is designed to be worth $1.00 USD -- The price in stETH units will vary based on stETH's USD price -- When stETH = $2,000: 1 haPB = 0.0005 stETH = $1.00 -- When stETH = $1,000: 1 haPB = 0.001 stETH = $1.00 - -### Converting to USD Value - -```typescript -async function getPeggedTokenPriceUSD(minterAddress: string, provider: any): Promise { - const minter = new Contract(minterAddress, MINTER_ABI, provider); - - // Get pegged token price (in stETH units, 18 decimals) - const priceInStETH = await getPeggedTokenPrice(minterAddress, provider); - - // Get stETH price in USD from oracle - const priceOracle = await minter.priceOracle(); - const oracle = new Contract(priceOracle, PRICE_ORACLE_ABI, provider); - const [minUnderlyingPrice, maxUnderlyingPrice] = await oracle.latestAnswer(); - // Oracle returns prices in 18 decimals - const stETHPriceUSD = parseFloat((minUnderlyingPrice + maxUnderlyingPrice).toString()) / 2 / 1e18; - - // Convert to USD: priceInStETH × stETHPriceUSD - const priceUSD = priceInStETH * stETHPriceUSD; - - return priceUSD; -} -``` - -**Note:** The price oracle returns: - -- `minUnderlyingPrice` / `maxUnderlyingPrice`: stETH price in USD (18 decimals) -- `minWrappedRate` / `maxWrappedRate`: wstETH to stETH conversion rate (18 decimals) - -### Simplified: Just Use $1.00 - -**For most frontend purposes**, you can simply assume the pegged token is **$1.00 USD**: - -```typescript -// Simple approach - pegged token is always $1 -const PEGGED_TOKEN_PRICE_USD = 1.0; - -// For display -function formatPeggedTokenValue(amount: bigint): string { - const tokens = parseFloat(amount.toString()) / 1e18; - const usdValue = tokens * PEGGED_TOKEN_PRICE_USD; - return `$${usdValue.toFixed(2)}`; -} -``` - -## Display Components - -### Simple Price Display - -```typescript -// components/PeggedTokenPrice.tsx -import { usePeggedTokenPrice } from "../hooks/usePeggedTokenPrice"; - -interface Props { - minterAddress: string; -} - -export function PeggedTokenPrice({ minterAddress }: Props) { - const { price, loading, error } = usePeggedTokenPrice(minterAddress); - - if (loading) { - return
Loading price...
; - } - - if (error) { - return
Error loading price
; - } - - // Price is in collateral units, but for display we show $1.00 - // (or show actual price if depegged) - const isPegged = price !== null && Math.abs(price - 1.0) < 0.01; - - return ( -
- Pegged Token Price: - - {isPegged ? "$1.00" : `$${price?.toFixed(4)}`} - - {!isPegged && ( - (Depegged) - )} -
- ); -} -``` - -### Value Calculator - -```typescript -// components/PeggedTokenValue.tsx -import { usePeggedTokenPrice } from "../hooks/usePeggedTokenPrice"; - -interface Props { - minterAddress: string; - tokenAmount: bigint; // Amount in wei (18 decimals) -} - -export function PeggedTokenValue({ minterAddress, tokenAmount }: Props) { - const { price, loading } = usePeggedTokenPrice(minterAddress); - - const tokens = parseFloat(tokenAmount.toString()) / 1e18; - - // For pegged tokens, we typically just use $1.00 - const usdValue = tokens * 1.0; // Always $1 per token - - // Or use actual price if you want to show depeg status - // const usdValue = price ? tokens * price : tokens * 1.0; - - if (loading) { - return ...; - } - - return ( - - ${usdValue.toLocaleString(undefined, { - minimumFractionDigits: 2, - maximumFractionDigits: 2 - })} - - ); -} -``` - -## Important Notes - -### 1. Price is in stETH Units (Not wstETH or USD) - -The `peggedTokenPrice()` returns price in **stETH units** (the underlying), not wstETH or USD. - -**Key Points:** - -- Minter uses **wstETH** as collateral token -- Price oracle returns **stETH** price (underlying) -- `peggedTokenPrice()` returns price in **stETH** units (18 decimals) -- To get USD: multiply by stETH price in USD -- Or just assume $1.00 (simpler for most cases) - -### 2. Empty System Returns 1.0 - -When `peggedTokenBalance() == 0`, the function returns `1 ether` (1.0) as a default. - -### 3. Depeg Detection - -To detect if the token is depegged: - -```typescript -const price = await getPeggedTokenPrice(minterAddress, provider); -const collateralPriceUSD = await getCollateralPriceUSD(provider); -const priceUSD = price * collateralPriceUSD; - -const isPegged = Math.abs(priceUSD - 1.0) < 0.01; // Within 1 cent -``` - -### 4. For Most Use Cases: Just Use $1.00 - -**Recommendation**: For displaying balances, calculating TVL, etc., just use **$1.00 per token**. The `peggedTokenPrice()` function is mainly useful for: - -- Detecting depeg status -- Showing actual redemption value -- Advanced calculations - -## Example: Complete Implementation - -```typescript -// pages/MarketOverview.tsx -import { usePeggedTokenPrice } from "../hooks/usePeggedTokenPrice"; -import { usePeggedTokenBalance } from "../hooks/usePeggedTokenBalance"; - -export function MarketOverview() { - const minterAddress = "0x..."; - const { price: peggedPrice, loading } = usePeggedTokenPrice(minterAddress); - const { balance } = usePeggedTokenBalance(userAddress, peggedTokenAddress); - - // For display, use $1.00 (or actual price if depegged) - const displayPrice = peggedPrice && Math.abs(peggedPrice - 1.0) < 0.01 - ? 1.0 - : peggedPrice || 1.0; - - const tokens = parseFloat(balance.toString()) / 1e18; - const usdValue = tokens * displayPrice; - - return ( -
-

Pegged Token (haPB)

-

Price: ${displayPrice.toFixed(4)}

-

Balance: {tokens.toFixed(2)} tokens

-

Value: ${usdValue.toFixed(2)}

-
- ); -} -``` - -## Summary - -**For most frontend purposes:** - -- **Just use $1.00** per pegged token -- The `peggedTokenPrice()` function is mainly for detecting depeg status -- Price is in collateral units, not USD directly - -**When to use `peggedTokenPrice()`:** - -- Showing depeg warnings -- Displaying actual redemption value -- Advanced calculations requiring precise pricing - -**Simple approach:** - -```typescript -const PEGGED_TOKEN_PRICE_USD = 1.0; // Always $1.00 -const value = tokens * PEGGED_TOKEN_PRICE_USD; -``` diff --git a/doc/guides/FRONTEND-READ-STABILITY-POOL-DEPOSITS.md b/doc/guides/FRONTEND-READ-STABILITY-POOL-DEPOSITS.md deleted file mode 100644 index 2560c98a..00000000 --- a/doc/guides/FRONTEND-READ-STABILITY-POOL-DEPOSITS.md +++ /dev/null @@ -1,675 +0,0 @@ -# Frontend: Reading Stability Pool Deposits Guide - -## Overview - -There are **two ways** to read stability pool deposits: - -1. **Contract Query** (direct, always accurate) - Query `assetBalanceOf()` from the pool contract -2. **Subgraph Query** (indexed, includes marks) - Query `stabilityPoolDeposits` from the subgraph - -**Recommended:** Use **both** - contract for balance, subgraph for marks and historical data. - -## Method 1: Contract Query (Direct) - -### Basic Balance Query - -```typescript -// utils/stabilityPool.ts -import { Contract } from "ethers"; -import { STABILITY_POOL_ABI } from "../abis/StabilityPool"; - -export async function getStabilityPoolDeposit( - poolAddress: string, - userAddress: string, - provider: any, -): Promise<{ - balance: bigint; - balanceUSD: number; - totalSupply: bigint; - withdrawalRequest: { start: bigint; end: bigint } | null; -}> { - const pool = new Contract(poolAddress, STABILITY_POOL_ABI, provider); - - // Get user's deposit balance (in asset tokens, 18 decimals) - const balance = await pool.assetBalanceOf(userAddress); - - // Get total pool supply - const totalSupply = await pool.totalAssetSupply(); - - // Get withdrawal request (if any) - const [start, end] = await pool.getWithdrawalRequest(userAddress); - const withdrawalRequest = start > 0 ? { start, end } : null; - - // Calculate USD value (you'll need the asset token price) - // For ha tokens (pegged tokens), assume $1.00 - const balanceUSD = parseFloat(balance.toString()) / 1e18; // Assuming $1 per token - - return { - balance, - balanceUSD, - totalSupply, - withdrawalRequest, - }; -} -``` - -### React Hook (Contract) - -```typescript -// hooks/useStabilityPoolDepositContract.ts -import { useState, useEffect } from "react"; -import { useAccount, usePublicClient } from "wagmi"; -import { getStabilityPoolDeposit } from "../utils/stabilityPool"; - -export function useStabilityPoolDepositContract(poolAddress: string) { - const { address } = useAccount(); - const publicClient = usePublicClient(); - const [deposit, setDeposit] = useState<{ - balance: bigint; - balanceUSD: number; - totalSupply: bigint; - withdrawalRequest: { start: bigint; end: bigint } | null; - } | null>(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - async function fetchDeposit() { - if (!address || !poolAddress || !publicClient) { - setLoading(false); - return; - } - - try { - setLoading(true); - const depositData = await getStabilityPoolDeposit(poolAddress, address, publicClient); - setDeposit(depositData); - setError(null); - } catch (err) { - setError(err as Error); - } finally { - setLoading(false); - } - } - - fetchDeposit(); - // Refresh every 30 seconds or on block updates - const interval = setInterval(fetchDeposit, 30000); - return () => clearInterval(interval); - }, [address, poolAddress, publicClient]); - - return { deposit, loading, error }; -} -``` - -## Method 2: Subgraph Query (Recommended for Marks) - -### GraphQL Query - -```graphql -query GetStabilityPoolDeposits($userAddress: Bytes!) { - stabilityPoolDeposits(where: { user: $userAddress }) { - id - poolAddress - poolType # "collateral" or "sail" - balance # Current deposit balance (BigInt, 18 decimals) - balanceUSD # Current balance in USD (BigDecimal) - accumulatedMarks # Marks accumulated so far (BigDecimal) - marksPerDay # Current marks per day rate (BigDecimal) - totalMarksEarned # Total marks ever earned (BigDecimal) - firstDepositAt # First deposit timestamp (BigInt) - lastUpdated # Last update timestamp (BigInt) - marketId # Market identifier (optional) - } -} -``` - -### React Hook (Subgraph) - -```typescript -// hooks/useStabilityPoolDeposits.ts -import { useState, useEffect } from "react"; -import { useAccount } from "wagmi"; - -const GRAPHQL_ENDPOINT = "http://localhost:8000/subgraphs/name/harbor-marks-local"; - -export interface StabilityPoolDeposit { - id: string; - poolAddress: string; - poolType: "collateral" | "sail"; - balance: string; // BigInt as string - balanceUSD: string; // BigDecimal as string - accumulatedMarks: string; // BigDecimal as string - marksPerDay: string; // BigDecimal as string - totalMarksEarned: string; // BigDecimal as string - firstDepositAt: string; // BigInt as string - lastUpdated: string; // BigInt as string - marketId: string | null; -} - -export function useStabilityPoolDeposits() { - const { address } = useAccount(); - const [deposits, setDeposits] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - async function fetchDeposits() { - if (!address) { - setLoading(false); - return; - } - - try { - setLoading(true); - const response = await fetch(GRAPHQL_ENDPOINT, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - query: ` - query GetStabilityPoolDeposits($userAddress: Bytes!) { - stabilityPoolDeposits(where: { user: $userAddress }) { - id - poolAddress - poolType - balance - balanceUSD - accumulatedMarks - marksPerDay - totalMarksEarned - firstDepositAt - lastUpdated - marketId - } - } - `, - variables: { - userAddress: address.toLowerCase(), - }, - }), - }); - - const data = await response.json(); - if (data.errors) { - throw new Error(data.errors[0].message); - } - - setDeposits(data.data?.stabilityPoolDeposits || []); - setError(null); - } catch (err) { - setError(err as Error); - } finally { - setLoading(false); - } - } - - fetchDeposits(); - // Poll for updates every 30-60 seconds - const interval = setInterval(fetchDeposits, 30000); - return () => clearInterval(interval); - }, [address]); - - return { deposits, loading, error }; -} -``` - -## Method 3: Hybrid Approach (Best of Both) - -Combine contract query for real-time balance with subgraph for marks: - -```typescript -// hooks/useStabilityPoolDepositsHybrid.ts -import { useState, useEffect } from "react"; -import { useAccount, usePublicClient } from "wagmi"; -import { useStabilityPoolDeposits } from "./useStabilityPoolDeposits"; - -export function useStabilityPoolDepositsHybrid() { - const { address } = useAccount(); - const publicClient = usePublicClient(); - const { deposits: subgraphDeposits, loading: subgraphLoading } = useStabilityPoolDeposits(); - const [contractBalances, setContractBalances] = useState>(new Map()); - const [loading, setLoading] = useState(true); - - // Fetch real-time balances from contracts - useEffect(() => { - async function fetchContractBalances() { - if (!address || !publicClient || subgraphDeposits.length === 0) { - setLoading(false); - return; - } - - try { - const balances = new Map(); - - for (const deposit of subgraphDeposits) { - const balance = await publicClient.readContract({ - address: deposit.poolAddress as `0x${string}`, - abi: STABILITY_POOL_ABI, - functionName: "assetBalanceOf", - args: [address as `0x${string}`], - }); - balances.set(deposit.poolAddress, balance); - } - - setContractBalances(balances); - } catch (err) { - console.error("Error fetching contract balances:", err); - } finally { - setLoading(false); - } - } - - fetchContractBalances(); - // Refresh every 10 seconds for real-time balance - const interval = setInterval(fetchContractBalances, 10000); - return () => clearInterval(interval); - }, [address, publicClient, subgraphDeposits]); - - // Merge subgraph data with contract balances - const deposits = subgraphDeposits.map((deposit) => ({ - ...deposit, - // Use contract balance for display (most up-to-date) - contractBalance: contractBalances.get(deposit.poolAddress) || BigInt(deposit.balance), - // Use subgraph balance for marks calculation - subgraphBalance: BigInt(deposit.balance), - })); - - return { - deposits, - loading: loading || subgraphLoading, - }; -} -``` - -## Filtering by Pool Type - -### Collateral Pool Only - -```graphql -query GetCollateralPoolDeposits($userAddress: Bytes!) { - stabilityPoolDeposits(where: { user: $userAddress, poolType: "collateral" }) { - id - poolAddress - balance - balanceUSD - accumulatedMarks - marksPerDay - } -} -``` - -### Leveraged Pool Only - -```graphql -query GetLeveragedPoolDeposits($userAddress: Bytes!) { - stabilityPoolDeposits(where: { user: $userAddress, poolType: "sail" }) { - id - poolAddress - balance - balanceUSD - accumulatedMarks - marksPerDay - } -} -``` - -### Specific Pool Address - -```graphql -query GetSpecificPoolDeposit($userAddress: Bytes!, $poolAddress: Bytes!) { - stabilityPoolDeposits(where: { user: $userAddress, poolAddress: $poolAddress }) { - id - balance - balanceUSD - accumulatedMarks - marksPerDay - } -} -``` - -## Complete React Component - -```typescript -// components/StabilityPoolDeposits.tsx -import { useStabilityPoolDeposits } from "../hooks/useStabilityPoolDeposits"; -import { formatEther } from "viem"; -import { calculateEstimatedMarks } from "../utils/marksCalculation"; - -export function StabilityPoolDeposits() { - const { deposits, loading, error } = useStabilityPoolDeposits(); - - if (loading) { - return
Loading deposits...
; - } - - if (error) { - return
Error: {error.message}
; - } - - if (deposits.length === 0) { - return
No deposits found
; - } - - return ( -
-

Stability Pool Deposits

- - {deposits.map((deposit) => { - const balance = parseFloat(formatEther(BigInt(deposit.balance))); - const balanceUSD = parseFloat(deposit.balanceUSD); - const accumulatedMarks = parseFloat(deposit.accumulatedMarks); - const marksPerDay = parseFloat(deposit.marksPerDay); - - // Calculate estimated marks (real-time) - const estimatedMarks = calculateEstimatedMarks({ - accumulatedMarks: deposit.accumulatedMarks, - marksPerDay: deposit.marksPerDay, - lastUpdated: deposit.lastUpdated, - }); - - return ( -
-
-
-

- {deposit.poolType === "collateral" ? "Collateral Pool" : "Leveraged Pool"} -

-

{deposit.poolAddress}

-
-
-

- {balance.toLocaleString(undefined, { maximumFractionDigits: 2 })} tokens -

-

- ${balanceUSD.toLocaleString(undefined, { maximumFractionDigits: 2 })} -

-
-
- -
-
- Accumulated Marks: - {estimatedMarks.toLocaleString(undefined, { maximumFractionDigits: 2 })} -
-
- Marks Per Day: - {marksPerDay.toLocaleString(undefined, { maximumFractionDigits: 2 })} -
-
-
- ); - })} -
- ); -} -``` - -## Real-Time Marks Estimation - -```typescript -// utils/marksCalculation.ts -export function calculateEstimatedStabilityPoolMarks( - deposit: StabilityPoolDeposit, - currentTime?: number, // Optional: chain time for consistency -): number { - const storedMarks = parseFloat(deposit.accumulatedMarks || "0"); - const marksPerDay = parseFloat(deposit.marksPerDay || "0"); - const lastUpdated = parseInt(deposit.lastUpdated || "0"); - - if (lastUpdated === 0 || marksPerDay === 0) { - return storedMarks; - } - - // Use chain time if provided, otherwise system time - const now = currentTime || Math.floor(Date.now() / 1000); - const secondsSinceUpdate = now - lastUpdated; - const daysSinceUpdate = secondsSinceUpdate / 86400; - - return storedMarks + marksPerDay * daysSinceUpdate; -} -``` - -## Aggregating Deposits - -### Total Deposits Across All Pools - -```typescript -function calculateTotalDeposits(deposits: StabilityPoolDeposit[]): { - totalBalance: number; - totalBalanceUSD: number; - totalMarks: number; - totalMarksPerDay: number; -} { - return deposits.reduce( - (acc, deposit) => { - const balance = parseFloat(formatEther(BigInt(deposit.balance))); - const balanceUSD = parseFloat(deposit.balanceUSD); - const marks = parseFloat(deposit.accumulatedMarks); - const marksPerDay = parseFloat(deposit.marksPerDay); - - return { - totalBalance: acc.totalBalance + balance, - totalBalanceUSD: acc.totalBalanceUSD + balanceUSD, - totalMarks: acc.totalMarks + marks, - totalMarksPerDay: acc.totalMarksPerDay + marksPerDay, - }; - }, - { totalBalance: 0, totalBalanceUSD: 0, totalMarks: 0, totalMarksPerDay: 0 }, - ); -} -``` - -### Group by Pool Type - -```typescript -function groupDepositsByType(deposits: StabilityPoolDeposit[]): { - collateral: StabilityPoolDeposit[]; - leveraged: StabilityPoolDeposit[]; -} { - return { - collateral: deposits.filter((d) => d.poolType === "collateral"), - leveraged: deposits.filter((d) => d.poolType === "sail"), - }; -} -``` - -## Withdrawal Request Status - -```typescript -// hooks/useWithdrawalRequest.ts -import { useAccount, usePublicClient } from "wagmi"; - -export function useWithdrawalRequest(poolAddress: string) { - const { address } = useAccount(); - const publicClient = usePublicClient(); - const [request, setRequest] = useState<{ start: bigint; end: bigint } | null>(null); - const [loading, setLoading] = useState(true); - - useEffect(() => { - async function fetchRequest() { - if (!address || !poolAddress || !publicClient) { - setLoading(false); - return; - } - - try { - const [start, end] = await publicClient.readContract({ - address: poolAddress as `0x${string}`, - abi: STABILITY_POOL_ABI, - functionName: "getWithdrawalRequest", - args: [address as `0x${string}`], - }); - - setRequest(start > 0 ? { start, end } : null); - } catch (err) { - console.error("Error fetching withdrawal request:", err); - } finally { - setLoading(false); - } - } - - fetchRequest(); - const interval = setInterval(fetchRequest, 30000); - return () => clearInterval(interval); - }, [address, poolAddress, publicClient]); - - return { request, loading }; -} -``` - -## Complete Example: All Deposits Display - -```typescript -// components/AllStabilityPoolDeposits.tsx -import { useStabilityPoolDeposits } from "../hooks/useStabilityPoolDeposits"; -import { useWithdrawalRequest } from "../hooks/useWithdrawalRequest"; -import { calculateEstimatedStabilityPoolMarks } from "../utils/marksCalculation"; -import { formatEther } from "viem"; - -const COLLATERAL_POOL = "0x3aAde2dCD2Df6a8cAc689EE797591b2913658659"; -const LEVERAGED_POOL = "0x525C7063E7C20997BaaE9bDa922159152D0e8417"; - -export function AllStabilityPoolDeposits() { - const { deposits, loading, error } = useStabilityPoolDeposits(); - const { request: collateralRequest } = useWithdrawalRequest(COLLATERAL_POOL); - const { request: leveragedRequest } = useWithdrawalRequest(LEVERAGED_POOL); - - // Group deposits - const collateralDeposits = deposits.filter(d => d.poolType === "collateral"); - const leveragedDeposits = deposits.filter(d => d.poolType === "sail"); - - // Calculate totals - const collateralTotal = collateralDeposits.reduce( - (sum, d) => sum + parseFloat(d.balanceUSD), - 0 - ); - const leveragedTotal = leveragedDeposits.reduce( - (sum, d) => sum + parseFloat(d.balanceUSD), - 0 - ); - - return ( -
- {/* Collateral Pool */} -
-

Collateral Pool Deposits

- {collateralDeposits.length === 0 ? ( -

No deposits

- ) : ( - collateralDeposits.map(deposit => ( - - )) - )} - {collateralTotal > 0 && ( -

Total: ${collateralTotal.toLocaleString()}

- )} -
- - {/* Leveraged Pool */} -
-

Leveraged Pool Deposits

- {leveragedDeposits.length === 0 ? ( -

No deposits

- ) : ( - leveragedDeposits.map(deposit => ( - - )) - )} - {leveragedTotal > 0 && ( -

Total: ${leveragedTotal.toLocaleString()}

- )} -
-
- ); -} - -function DepositCard({ deposit, withdrawalRequest }: { - deposit: StabilityPoolDeposit; - withdrawalRequest: { start: bigint; end: bigint } | null; -}) { - const balance = parseFloat(formatEther(BigInt(deposit.balance))); - const balanceUSD = parseFloat(deposit.balanceUSD); - const estimatedMarks = calculateEstimatedStabilityPoolMarks(deposit); - - return ( -
-
- {balance.toFixed(2)} tokens - ${balanceUSD.toFixed(2)} -
-
-
Marks: {estimatedMarks.toLocaleString(undefined, { maximumFractionDigits: 2 })}
-
Marks/Day: {parseFloat(deposit.marksPerDay).toLocaleString(undefined, { maximumFractionDigits: 2 })}
-
- {withdrawalRequest && ( -
- Withdrawal requested: Window {new Date(Number(withdrawalRequest.start) * 1000).toLocaleString()} -
- )} -
- ); -} -``` - -## Important Notes - -### 1. Address Format - -Always use **lowercase** addresses in GraphQL queries: - -```typescript -userAddress: address.toLowerCase(); -``` - -### 2. Balance Precision - -- `balance` from subgraph: BigInt string (18 decimals) -- Convert: `parseFloat(formatEther(BigInt(balance)))` -- `balanceUSD`: Already in human-readable format - -### 3. Real-Time Updates - -- **Contract balance**: Updates immediately on deposit/withdraw -- **Subgraph balance**: Updates when events are indexed (may lag) -- **Recommended**: Use contract for balance display, subgraph for marks - -### 4. Multiple Pools - -A user can have deposits in: - -- Multiple collateral pools (different markets) -- Multiple leveraged pools (different markets) -- Both types simultaneously - -### 5. Marks Calculation - -- Use `accumulatedMarks` from subgraph for stored marks -- Use `marksPerDay` and `lastUpdated` for real-time estimation -- Frontend calculates estimated marks (zero gas) - -## Summary - -**Recommended Approach:** - -1. **Query subgraph** for all deposits (includes marks, historical data) -2. **Query contract** for real-time balances (optional, for accuracy) -3. **Calculate estimated marks** on frontend using chain time -4. **Display** deposits grouped by pool type -5. **Show withdrawal request status** if applicable - -**Quick Implementation:** - -```typescript -const { deposits } = useStabilityPoolDeposits(); -// deposits contains all user's stability pool deposits with marks -``` - - diff --git a/doc/guides/FRONTEND-REDEEM-ERROR-TROUBLESHOOTING.md b/doc/guides/FRONTEND-REDEEM-ERROR-TROUBLESHOOTING.md deleted file mode 100644 index df7bdfdf..00000000 --- a/doc/guides/FRONTEND-REDEEM-ERROR-TROUBLESHOOTING.md +++ /dev/null @@ -1,269 +0,0 @@ -# Frontend Redeem Error Troubleshooting - -## Error: `0x3dbf8ab9` with Token Address - -If you're seeing a transaction revert with error selector `0x3dbf8ab9` and an address parameter (likely `0xe7f1725e7734ce288f8367e1bb143e90bb3f0512`), this indicates a token balance or allowance issue. - -## Common Causes - -### 1. **Zero Token Balance** (Most Likely) - -**Error**: `ZeroInputBalance(address token)` - -**Cause**: The user is trying to redeem pegged tokens but: -- They have zero balance of the pegged token (haPB), OR -- They passed `type(uint256).max` (redeem all) but their balance is zero - -**Solution**: -```typescript -// Check user's pegged token balance before redeeming -const peggedTokenAddress = "0x0165878A594ca255338adfa4d48449f69242Eb8F"; -const userBalance = await publicClient.readContract({ - address: peggedTokenAddress, - abi: erc20Abi, - functionName: "balanceOf", - args: [userAddress], -}); - -if (userBalance === 0n) { - // Show error: "You have no pegged tokens to redeem" - return; -} - -// If user selected "redeem all", use their actual balance -const redeemAmount = amount === "max" ? userBalance : parseEther(amount); -``` - -### 2. **Insufficient Token Allowance** - -**Error**: ERC20 transfer fails due to insufficient allowance - -**Cause**: The user hasn't approved the Minter contract to spend their pegged tokens. - -**Solution**: -```typescript -// Check allowance before redeeming -const minterAddress = "0x8A791620dd6260079BF849Dc5567aDC3F2FdC318"; -const peggedTokenAddress = "0x0165878A594ca255338adfa4d48449f69242Eb8F"; - -const allowance = await publicClient.readContract({ - address: peggedTokenAddress, - abi: erc20Abi, - functionName: "allowance", - args: [userAddress, minterAddress], -}); - -if (allowance < redeemAmount) { - // Request approval first - await writeContract({ - address: peggedTokenAddress, - abi: erc20Abi, - functionName: "approve", - args: [minterAddress, redeemAmount], - }); -} -``` - -### 3. **Insufficient Redeemable Tokens in Minter** - -**Error**: `InsufficientRedeemableTokens(address token, uint256 available, uint256 requested)` - -**Cause**: The Minter contract doesn't have enough pegged tokens in its balance to fulfill the redemption. - -**Solution**: -```typescript -// Check minter's pegged token balance -const minterPeggedBalance = await publicClient.readContract({ - address: minterAddress, - abi: minterAbi, - functionName: "peggedTokenBalance", -}); - -if (redeemAmount > minterPeggedBalance) { - // Show error: "Only X tokens available for redemption" - const maxRedeemable = minterPeggedBalance; - return; -} -``` - -### 4. **Zero Collateral Returned** - -**Error**: `ReturnZeroAmount(address token)` - -**Cause**: The redemption calculation results in zero collateral being returned (likely due to fees exceeding the redemption value or invalid price oracle data). - -**Solution**: -```typescript -// Always run a dry-run first to check the return amount -const dryRunResult = await publicClient.readContract({ - address: minterAddress, - abi: minterAbi, - functionName: "redeemPeggedTokenDryRun", - args: [redeemAmount], -}); - -if (dryRunResult.wrappedCollateralReturned === 0n) { - // Show error: "Redemption would return zero collateral" - return; -} -``` - -## Complete Pre-Redemption Check - -```typescript -import { parseEther, formatEther } from "viem"; - -async function validateRedeem( - userAddress: `0x${string}`, - redeemAmount: string | "max", - minterAddress: string, - peggedTokenAddress: string -) { - const errors: string[] = []; - - // 1. Check user balance - const userBalance = await publicClient.readContract({ - address: peggedTokenAddress, - abi: erc20Abi, - functionName: "balanceOf", - args: [userAddress], - }); - - if (userBalance === 0n) { - errors.push("You have no pegged tokens to redeem"); - return { valid: false, errors }; - } - - // 2. Calculate actual redeem amount - const actualAmount = redeemAmount === "max" ? userBalance : parseEther(redeemAmount); - - if (actualAmount > userBalance) { - errors.push(`Insufficient balance. You have ${formatEther(userBalance)} tokens`); - return { valid: false, errors }; - } - - // 3. Check allowance - const allowance = await publicClient.readContract({ - address: peggedTokenAddress, - abi: erc20Abi, - functionName: "allowance", - args: [userAddress, minterAddress], - }); - - if (allowance < actualAmount) { - errors.push("Insufficient allowance. Please approve the Minter contract first"); - return { valid: false, errors, needsApproval: true }; - } - - // 4. Check minter's redeemable balance - const minterBalance = await publicClient.readContract({ - address: minterAddress, - abi: minterAbi, - functionName: "peggedTokenBalance", - }); - - if (actualAmount > minterBalance) { - errors.push(`Only ${formatEther(minterBalance)} tokens available for redemption`); - return { valid: false, errors, maxRedeemable: minterBalance }; - } - - // 5. Run dry-run to check return amount - try { - const dryRun = await publicClient.readContract({ - address: minterAddress, - abi: minterAbi, - functionName: "redeemPeggedTokenDryRun", - args: [actualAmount], - }); - - if (dryRun.wrappedCollateralReturned === 0n) { - errors.push("Redemption would return zero collateral"); - return { valid: false, errors }; - } - - return { - valid: true, - dryRun, - actualAmount, - estimatedReturn: dryRun.wrappedCollateralReturned, - }; - } catch (error: any) { - errors.push(`Dry-run failed: ${error.message}`); - return { valid: false, errors }; - } -} -``` - -## Error Decoding - -To decode the exact error from a failed transaction: - -```typescript -import { decodeErrorResult } from "viem"; - -try { - // Your redeem transaction - await writeContract({...}); -} catch (error: any) { - if (error.data) { - try { - const decoded = decodeErrorResult({ - abi: minterAbi, - data: error.data, - }); - console.log("Decoded error:", decoded); - - if (decoded.errorName === "ZeroInputBalance") { - const tokenAddress = decoded.args[0]; - console.log("Zero balance for token:", tokenAddress); - // Check which token this is - if (tokenAddress.toLowerCase() === peggedTokenAddress.toLowerCase()) { - console.log("User has no pegged tokens"); - } else if (tokenAddress.toLowerCase() === wrappedCollateralAddress.toLowerCase()) { - console.log("Issue with wrapped collateral token"); - } - } - } catch (decodeError) { - console.log("Could not decode error:", error.data); - } - } -} -``` - -## Token Addresses (Chain ID 31337) - -``` -Minter: 0x8A791620dd6260079BF849Dc5567aDC3F2FdC318 -Pegged Token (haPB): 0x0165878A594ca255338adfa4d48449f69242Eb8F -Wrapped Collateral (wstETH): 0xe7f1725e7734ce288f8367e1bb143e90bb3f0512 -``` - -## Quick Fix Checklist - -Before allowing a user to redeem: - -- [ ] User has pegged token balance > 0 -- [ ] User has approved Minter to spend pegged tokens -- [ ] Minter has sufficient pegged token balance -- [ ] Dry-run returns non-zero collateral -- [ ] Amount is in wei (not human-readable) -- [ ] User is on the correct chain (31337) - -## Most Common Issue - -**90% of redeem failures are due to insufficient token allowance.** - -Always check and request approval before attempting to redeem: - -```typescript -// Check and request approval -const needsApproval = allowance < actualAmount; -if (needsApproval) { - // Show approval UI - await approveTokens(peggedTokenAddress, minterAddress, actualAmount); - // Wait for approval transaction to confirm - await waitForTransaction({ hash: approvalTxHash }); -} -// Then proceed with redeem -``` - diff --git a/doc/guides/FRONTEND-REDEEM-FEE-CALCULATION.md b/doc/guides/FRONTEND-REDEEM-FEE-CALCULATION.md deleted file mode 100644 index 52bfc2fe..00000000 --- a/doc/guides/FRONTEND-REDEEM-FEE-CALCULATION.md +++ /dev/null @@ -1,512 +0,0 @@ -# Frontend Guide: Calculating Redeem Fees Before Transaction - -This guide explains how to calculate and display redeem fees to users **before** they approve the transaction, using the Minter contract's dry-run functions. - -## Overview - -The Minter contract provides `dryRun` functions that simulate transactions without executing them. These functions return: - -- **Fee**: Amount deducted as a fee (if positive incentive ratio) -- **Discount**: Amount added as a bonus (if negative incentive ratio) -- **Collateral returned**: Net amount user will receive -- **Effective incentive ratio**: The fee/discount percentage - -## Key Functions - -### 1. Redeem Pegged Token (haPB) - Dry Run - -```solidity -function redeemPeggedTokenDryRun(uint256 peggedIn) - external - view - returns ( - int256 incentiveRatio, // Fee ratio (positive) or discount ratio (negative) - uint256 fee, // Fee amount in wrapped collateral - uint256 discount, // Discount/bonus amount in wrapped collateral - uint256 peggedRedeemed, // Amount of pegged tokens redeemed - uint256 wrappedCollateralReturned, // Total collateral returned (including discount) - uint256 price, // Price used in calculation - uint256 rate // Conversion rate (underlying → wrapped) - ) -``` - -### 2. Redeem Leveraged Token (hsPB) - Dry Run - -```solidity -function redeemLeveragedTokenDryRun(uint256 leveragedIn) - external - view - returns ( - int256 incentiveRatio, // Fee ratio (positive) or discount ratio (negative) - uint256 fee, // Fee amount in wrapped collateral - uint256 leveragedRedeemed, // Amount of leveraged tokens redeemed - uint256 collateralReturned, // Total collateral returned - uint256 price, // Price used in calculation - uint256 rate // Conversion rate (underlying → wrapped) - ) -``` - -## Understanding the Results - -### Incentive Ratio - -- **Positive value**: This is a **fee** (deducted from user) - - Example: `50000000000000000` (0.05e18) = **5% fee** -- **Negative value**: This is a **discount** (bonus to user) - - Example: `-100000000000000000` (-0.1e18) = **10% discount/bonus** -- **1e18 (1000000000000000000)**: Transaction is **disallowed** (100% fee) - -### Fee vs Discount - -- **Fee**: Deducted from the collateral returned -- **Discount**: Added to the collateral returned (paid from reserve pool) - -## Implementation - -### Minimal ABI Required - -```typescript -const MINTER_ABI = [ - "function redeemPeggedTokenDryRun(uint256) external view returns (int256, uint256, uint256, uint256, uint256, uint256, uint256)", - "function redeemLeveragedTokenDryRun(uint256) external view returns (int256, uint256, uint256, uint256, uint256, uint256)", -]; -``` - -### TypeScript Interface - -```typescript -interface RedeemFeeInfo { - // Input - tokenAmount: string; // Amount user wants to redeem (in wei) - tokenType: "pegged" | "leveraged"; - - // Results from dry-run - incentiveRatio: string; // in wei (1e18 = 100%) - fee: string; // Fee amount in wrapped collateral (wei) - discount: string; // Discount/bonus amount (wei) - only for pegged - collateralReturned: string; // Total collateral user will receive (wei) - price: string; // Price used in calculation - rate: string; // Conversion rate - - // Calculated for display - feePercentage: number; // Fee as percentage (e.g., 2.5 for 2.5%) - discountPercentage: number; // Discount as percentage (e.g., -5.0 for 5% bonus) - isDisallowed: boolean; // True if transaction would be blocked - netCollateralReturned: string; // Human-readable amount -} -``` - -### Using ethers.js - -```typescript -import { Contract, ethers } from "ethers"; - -/** - * Calculate redeem fee for pegged tokens (haPB) - */ -async function calculateRedeemPeggedFee( - minterAddress: string, - peggedAmount: string, // Amount in wei - provider: ethers.Provider, -): Promise { - const minter = new Contract(minterAddress, MINTER_ABI, provider); - - // Call dry-run function - const [incentiveRatio, fee, discount, peggedRedeemed, wrappedCollateralReturned, price, rate] = - await minter.redeemPeggedTokenDryRun(peggedAmount); - - // Convert from BigNumber to readable values - const incentiveRatioBN = BigInt(incentiveRatio.toString()); - const feeBN = BigInt(fee.toString()); - const discountBN = BigInt(discount.toString()); - - // Check if disallowed (incentiveRatio == 1e18) - const isDisallowed = incentiveRatioBN === BigInt("1000000000000000000"); - - // Calculate fee/discount percentage - let feePercentage = 0; - let discountPercentage = 0; - - if (incentiveRatioBN > 0n) { - // Positive = fee - feePercentage = Number(incentiveRatioBN) / 1e16; // Convert to percentage - } else if (incentiveRatioBN < 0n) { - // Negative = discount - discountPercentage = Number(-incentiveRatioBN) / 1e16; // Convert to percentage - } - - return { - tokenAmount: peggedAmount, - tokenType: "pegged", - incentiveRatio: incentiveRatio.toString(), - fee: fee.toString(), - discount: discount.toString(), - collateralReturned: wrappedCollateralReturned.toString(), - price: price.toString(), - rate: rate.toString(), - feePercentage, - discountPercentage, - isDisallowed, - netCollateralReturned: ethers.formatEther(wrappedCollateralReturned), - }; -} - -/** - * Calculate redeem fee for leveraged tokens (hsPB) - */ -async function calculateRedeemLeveragedFee( - minterAddress: string, - leveragedAmount: string, // Amount in wei - provider: ethers.Provider, -): Promise { - const minter = new Contract(minterAddress, MINTER_ABI, provider); - - // Call dry-run function - const [incentiveRatio, fee, leveragedRedeemed, collateralReturned, price, rate] = - await minter.redeemLeveragedTokenDryRun(leveragedAmount); - - // Convert from BigNumber to readable values - const incentiveRatioBN = BigInt(incentiveRatio.toString()); - const feeBN = BigInt(fee.toString()); - - // Check if disallowed (incentiveRatio == 1e18) - const isDisallowed = incentiveRatioBN === BigInt("1000000000000000000"); - - // Calculate fee percentage - let feePercentage = 0; - if (incentiveRatioBN > 0n) { - feePercentage = Number(incentiveRatioBN) / 1e16; // Convert to percentage - } - - return { - tokenAmount: leveragedAmount, - tokenType: "leveraged", - incentiveRatio: incentiveRatio.toString(), - fee: fee.toString(), - discount: "0", // Leveraged tokens don't have discounts - collateralReturned: collateralReturned.toString(), - price: price.toString(), - rate: rate.toString(), - feePercentage, - discountPercentage: 0, - isDisallowed, - netCollateralReturned: ethers.formatEther(collateralReturned), - }; -} -``` - -### Using wagmi/viem - -```typescript -import { useReadContract } from "wagmi"; -import { parseEther, formatEther } from "viem"; - -// Hook for pegged token redemption -function useRedeemPeggedFee(minterAddress: string, peggedAmount: string) { - const { data, isLoading, error } = useReadContract({ - address: minterAddress as `0x${string}`, - abi: MINTER_ABI, - functionName: "redeemPeggedTokenDryRun", - args: [BigInt(peggedAmount)], - }); - - if (!data) { - return { isLoading, error, feeInfo: null }; - } - - const [incentiveRatio, fee, discount, peggedRedeemed, wrappedCollateralReturned, price, rate] = data; - - const incentiveRatioBN = BigInt(incentiveRatio.toString()); - const isDisallowed = incentiveRatioBN === BigInt("1000000000000000000"); - - let feePercentage = 0; - let discountPercentage = 0; - - if (incentiveRatioBN > 0n) { - feePercentage = Number(incentiveRatioBN) / 1e16; - } else if (incentiveRatioBN < 0n) { - discountPercentage = Number(-incentiveRatioBN) / 1e16; - } - - return { - isLoading, - error, - feeInfo: { - tokenAmount: peggedAmount, - tokenType: "pegged" as const, - incentiveRatio: incentiveRatio.toString(), - fee: fee.toString(), - discount: discount.toString(), - collateralReturned: wrappedCollateralReturned.toString(), - price: price.toString(), - rate: rate.toString(), - feePercentage, - discountPercentage, - isDisallowed, - netCollateralReturned: formatEther(wrappedCollateralReturned), - }, - }; -} -``` - -## UI Display Examples - -### Example 1: Display Fee Before Approval - -```typescript -// In your component -const [redeemAmount, setRedeemAmount] = useState("0"); -const { feeInfo, isLoading } = useRedeemPeggedFee(minterAddress, redeemAmount); - -// In your JSX -{isLoading ? ( -
Calculating fee...
-) : feeInfo ? ( -
- {feeInfo.isDisallowed ? ( -
- ⚠️ Redemption is currently disallowed (system undercollateralized) -
- ) : ( - <> -
- You will receive: {feeInfo.netCollateralReturned} wstETH -
- {feeInfo.feePercentage > 0 && ( -
- Fee: {feeInfo.feePercentage.toFixed(2)}% ({formatEther(feeInfo.fee)} wstETH) -
- )} - {feeInfo.discountPercentage > 0 && ( -
- 🎉 Bonus: {feeInfo.discountPercentage.toFixed(2)}% ({formatEther(feeInfo.discount)} wstETH) -
- )} -
-
Redeeming: {formatEther(redeemAmount)} haPB
-
Fee deducted: {formatEther(feeInfo.fee)} wstETH
-
Bonus added: {formatEther(feeInfo.discount)} wstETH
-
Net received: {feeInfo.netCollateralReturned} wstETH
-
- - )} -
-) : null} -``` - -### Example 2: Real-time Fee Calculation on Input - -```typescript -function RedeemForm() { - const [amount, setAmount] = useState(""); - const minterAddress = "0x8A791620dd6260079BF849Dc5567aDC3F2FdC318"; // Your minter address - - // Convert user input to wei - const amountWei = amount ? parseEther(amount).toString() : "0"; - - // Get fee info - const { feeInfo, isLoading } = useRedeemPeggedFee(minterAddress, amountWei); - - return ( -
- setAmount(e.target.value)} - placeholder="Amount to redeem" - /> - - {amount && !isLoading && feeInfo && ( - - )} - - -
- ); -} -``` - -## Important Notes - -### 1. Fees are Dynamic - -Fees change based on the current **collateral ratio** of the system. Always call the dry-run function right before showing the user the transaction details. - -### 2. Reserve Pool Limits Discounts - -For pegged token redemptions, discounts are paid from the reserve pool. If the reserve pool is exhausted, the discount may be reduced. The dry-run function accounts for this. - -### 3. Transaction May Still Fail - -Even if the dry-run succeeds, the actual transaction might fail if: - -- The collateral ratio changes between dry-run and execution -- The reserve pool is depleted between calls -- The user's token balance is insufficient - -### 4. Price and Rate - -The `price` and `rate` values returned can be used to: - -- Display the current exchange rate -- Calculate expected amounts -- Show price impact - -## Fee Structure Reference - -### Redeem Pegged Tokens (haPB) - -| Collateral Ratio | Fee/Discount | Effect | -| ---------------- | ------------------- | ------------ | -| < 1.0x | **-10% (Discount)** | ✅ 10% bonus | -| 1.0x - 1.05x | **-5% (Discount)** | ✅ 5% bonus | -| 1.05x - 1.1x | **0% (FREE)** | ✅ No fee | -| 1.1x - 1.2x | **1%** | Small fee | -| 1.2x - 1.3x | **2%** | Small fee | -| 1.3x - 1.5x | **3%** | Moderate fee | -| 1.5x - 2.0x | **4%** | Higher fee | -| > 2.0x | **5%** | Standard fee | - -### Redeem Leveraged Tokens (hsPB) - -| Collateral Ratio | Fee | Effect | -| ---------------- | ------------------ | ---------------- | -| < 1.0x | **100% (BLOCKED)** | ❌ Cannot redeem | -| 1.0x - 1.05x | **30%** | Very high fee | -| 1.05x - 1.1x | **15%** | High fee | -| 1.1x - 1.2x | **8%** | Medium fee | -| 1.2x - 1.3x | **5%** | Low fee | -| 1.3x - 1.5x | **3%** | Very low fee | -| 1.5x - 2.0x | **2%** | Minimal fee | -| > 2.0x | **1.5%** | Minimal fee | - -## Complete Example Component - -```typescript -import { useState } from 'react'; -import { useReadContract } from 'wagmi'; -import { parseEther, formatEther } from 'viem'; - -const MINTER_ABI = [ - { - name: 'redeemPeggedTokenDryRun', - type: 'function', - stateMutability: 'view', - inputs: [{ name: 'peggedIn', type: 'uint256' }], - outputs: [ - { name: 'incentiveRatio', type: 'int256' }, - { name: 'fee', type: 'uint256' }, - { name: 'discount', type: 'uint256' }, - { name: 'peggedRedeemed', type: 'uint256' }, - { name: 'wrappedCollateralReturned', type: 'uint256' }, - { name: 'price', type: 'uint256' }, - { name: 'rate', type: 'uint256' }, - ], - }, -] as const; - -export function RedeemFeeCalculator({ minterAddress }: { minterAddress: string }) { - const [amount, setAmount] = useState(''); - - const amountWei = amount ? parseEther(amount).toString() : '0'; - - const { data, isLoading } = useReadContract({ - address: minterAddress as `0x${string}`, - abi: MINTER_ABI, - functionName: 'redeemPeggedTokenDryRun', - args: [BigInt(amountWei)], - query: { enabled: !!amount && amount !== '0' }, - }); - - if (!data) { - return ( -
- setAmount(e.target.value)} - placeholder="Amount to redeem (haPB)" - /> - {isLoading &&
Calculating...
} -
- ); - } - - const [ - incentiveRatio, - fee, - discount, - peggedRedeemed, - wrappedCollateralReturned, - price, - rate, - ] = data; - - const incentiveRatioBN = BigInt(incentiveRatio.toString()); - const isDisallowed = incentiveRatioBN === BigInt('1000000000000000000'); - const feePercentage = incentiveRatioBN > 0n ? Number(incentiveRatioBN) / 1e16 : 0; - const discountPercentage = incentiveRatioBN < 0n ? Number(-incentiveRatioBN) / 1e16 : 0; - - return ( -
- setAmount(e.target.value)} - placeholder="Amount to redeem (haPB)" - /> - - {isDisallowed ? ( -
- ⚠️ Redemption is currently disallowed -
- ) : ( -
-

Transaction Preview

-
Redeeming: {amount} haPB
-
You will receive: {formatEther(wrappedCollateralReturned)} wstETH
- - {feePercentage > 0 && ( -
- Fee: {feePercentage.toFixed(2)}% ({formatEther(fee)} wstETH) -
- )} - - {discountPercentage > 0 && ( -
- 🎉 Bonus: {discountPercentage.toFixed(2)}% ({formatEther(discount)} wstETH) -
- )} - -
-
Base collateral: {formatEther(BigInt(wrappedCollateralReturned.toString()) - BigInt(discount.toString()) + BigInt(fee.toString()))} wstETH
-
- Fee: {formatEther(fee)} wstETH
-
+ Bonus: {formatEther(discount)} wstETH
-
= Net: {formatEther(wrappedCollateralReturned)} wstETH
-
-
- )} -
- ); -} -``` - -## Summary - -1. **Call the dry-run function** with the user's desired redeem amount -2. **Check `isDisallowed`** - if true, block the transaction -3. **Calculate percentages** from `incentiveRatio`: - - Positive = fee percentage - - Negative = discount percentage -4. **Display to user**: - - Net collateral they'll receive - - Fee amount (if any) - - Discount/bonus (if any) -5. **Call dry-run again** right before transaction to ensure accuracy - -This ensures users always see accurate fee information before approving transactions! diff --git a/doc/guides/FRONTEND-REWARD-TOKENS-AND-RATES.md b/doc/guides/FRONTEND-REWARD-TOKENS-AND-RATES.md deleted file mode 100644 index ec1e413d..00000000 --- a/doc/guides/FRONTEND-REWARD-TOKENS-AND-RATES.md +++ /dev/null @@ -1,409 +0,0 @@ -# Frontend Guide: Finding All Reward Tokens and Their Rates - -This guide explains how to query stability pools to find all registered reward tokens and their reward rates. - -## Overview - -Stability pools can have multiple reward tokens registered. Each reward token has: - -- **Rate**: The amount of tokens distributed per second (in wei) -- **Period**: The vesting period in seconds (typically 7 days = 604,800 seconds) -- **Finish Time**: When the current reward period ends -- **Last Update Time**: When the reward data was last updated - -## Key Functions - -### 1. Get Active Reward Tokens - -```solidity -function activeRewardTokens() external view returns (address[] memory) -``` - -Returns an array of all reward token addresses currently registered and active. - -### 2. Get Reward Data for a Token - -```solidity -function rewardData(address token) external view returns ( - uint256 rate, // Reward rate (wei per second) - uint256 period, // Vesting period (seconds) - uint256 finishTime, // When current period ends - uint256 lastUpdateTime // Last update timestamp -) -``` - -Returns the reward configuration for a specific token. - -## Implementation - -### Minimal ABI Required - -```typescript -const STABILITY_POOL_ABI = [ - "function activeRewardTokens() external view returns (address[])", - "function rewardData(address) external view returns (uint256 rate, uint256 period, uint256 finishTime, uint256 lastUpdateTime)", -]; -``` - -### Using ethers.js - -```typescript -import { Contract, ethers } from "ethers"; - -interface RewardTokenInfo { - address: string; - symbol?: string; - name?: string; - rate: string; // wei per second - ratePerDay: string; // tokens per day - ratePerYear: string; // tokens per year - period: number; // vesting period in seconds - periodDays: number; // vesting period in days - finishTime: number; // timestamp when period ends - lastUpdateTime: number; // timestamp of last update - apr?: number; // APR percentage (if total supply available) -} - -async function getAllRewardTokens(poolAddress: string, provider: ethers.Provider): Promise { - const pool = new Contract(poolAddress, STABILITY_POOL_ABI, provider); - - // Get all active reward tokens - const tokenAddresses: string[] = await pool.activeRewardTokens(); - - // Get reward data for each token - const rewardTokens: RewardTokenInfo[] = await Promise.all( - tokenAddresses.map(async (tokenAddress) => { - const [rate, period, finishTime, lastUpdateTime] = await pool.rewardData(tokenAddress); - - // Convert rate from wei/second to tokens/day and tokens/year - const ratePerDay = ethers.formatEther(rate) * 86400; // seconds per day - const ratePerYear = ethers.formatEther(rate) * 31536000; // seconds per year - - return { - address: tokenAddress, - rate: rate.toString(), - ratePerDay: ratePerDay.toString(), - ratePerYear: ratePerYear.toString(), - period: Number(period), - periodDays: Number(period) / 86400, - finishTime: Number(finishTime), - lastUpdateTime: Number(lastUpdateTime), - }; - }), - ); - - return rewardTokens; -} -``` - -### Using wagmi - -```typescript -import { useContractRead } from "wagmi"; - -function useRewardTokens(poolAddress: string) { - // Get active reward tokens - const { data: tokenAddresses, ...tokenQuery } = useContractRead({ - address: poolAddress as `0x${string}`, - abi: [ - { - name: "activeRewardTokens", - type: "function", - stateMutability: "view", - inputs: [], - outputs: [{ type: "address[]" }], - }, - ], - functionName: "activeRewardTokens", - }); - - // Get reward data for each token - const rewardDataQueries = (tokenAddresses || []).map((tokenAddress: string) => - useContractRead({ - address: poolAddress as `0x${string}`, - abi: [ - { - name: "rewardData", - type: "function", - stateMutability: "view", - inputs: [{ type: "address", name: "token" }], - outputs: [ - { type: "uint256", name: "rate" }, - { type: "uint256", name: "period" }, - { type: "uint256", name: "finishTime" }, - { type: "uint256", name: "lastUpdateTime" }, - ], - }, - ], - functionName: "rewardData", - args: [tokenAddress as `0x${string}`], - enabled: !!tokenAddress, - }), - ); - - const isLoading = tokenQuery.isLoading || rewardDataQueries.some((q) => q.isLoading); - const isError = tokenQuery.isError || rewardDataQueries.some((q) => q.isError); - - return { - tokenAddresses: tokenAddresses || [], - rewardData: rewardDataQueries.map((q) => q.data), - isLoading, - isError, - }; -} -``` - -## Complete Example with Token Metadata - -```typescript -import { Contract, ethers } from "ethers"; - -// Standard ERC20 ABI for getting token metadata -const ERC20_ABI = [ - "function symbol() external view returns (string)", - "function name() external view returns (string)", - "function decimals() external view returns (uint8)", -]; - -interface RewardTokenInfo { - address: string; - symbol: string; - name: string; - decimals: number; - rate: bigint; // wei per second - ratePerDay: number; // tokens per day - ratePerYear: number; // tokens per year - period: number; // vesting period in seconds - periodDays: number; // vesting period in days - finishTime: number; // timestamp when period ends - lastUpdateTime: number; // timestamp of last update - isActive: boolean; // true if finishTime > current time - apr?: number; // APR percentage (if total supply available) -} - -async function getAllRewardTokensWithMetadata( - poolAddress: string, - provider: ethers.Provider, -): Promise { - const pool = new Contract(poolAddress, STABILITY_POOL_ABI, provider); - - // Get all active reward tokens - const tokenAddresses: string[] = await pool.activeRewardTokens(); - - // Get current block timestamp - const currentBlock = await provider.getBlock("latest"); - const currentTime = currentBlock?.timestamp || Math.floor(Date.now() / 1000); - - // Get reward data and token metadata for each token - const rewardTokens: RewardTokenInfo[] = await Promise.all( - tokenAddresses.map(async (tokenAddress) => { - // Get reward data - const [rate, period, finishTime, lastUpdateTime] = await pool.rewardData(tokenAddress); - - // Get token metadata - const tokenContract = new Contract(tokenAddress, ERC20_ABI, provider); - const [symbol, name, decimals] = await Promise.all([ - tokenContract.symbol(), - tokenContract.name(), - tokenContract.decimals(), - ]); - - // Calculate rates - const ratePerDay = Number(ethers.formatUnits(rate, decimals)) * 86400; - const ratePerYear = Number(ethers.formatUnits(rate, decimals)) * 31536000; - - return { - address: tokenAddress, - symbol, - name, - decimals: Number(decimals), - rate, - ratePerDay, - ratePerYear, - period: Number(period), - periodDays: Number(period) / 86400, - finishTime: Number(finishTime), - lastUpdateTime: Number(lastUpdateTime), - isActive: Number(finishTime) > currentTime, - }; - }), - ); - - return rewardTokens; -} -``` - -## Calculating APR - -To calculate APR, you need the total supply of the pool's asset token: - -```typescript -async function calculateAPR( - poolAddress: string, - rewardToken: RewardTokenInfo, - totalAssetSupply: bigint, // Total supply of the pool's asset token - rewardTokenPriceUSD: number, // Price of reward token in USD - assetTokenPriceUSD: number, // Price of asset token in USD -): Promise { - // Annual reward in tokens - const annualRewardTokens = rewardToken.ratePerYear; - - // Annual reward in USD - const annualRewardUSD = annualRewardTokens * rewardTokenPriceUSD; - - // Total deposit value in USD - const totalDepositUSD = Number(ethers.formatEther(totalAssetSupply)) * assetTokenPriceUSD; - - // APR = (annual reward USD / total deposit USD) * 100 - if (totalDepositUSD === 0) return 0; - - const apr = (annualRewardUSD / totalDepositUSD) * 100; - return apr; -} -``` - -## React Hook Example - -```typescript -import { useState, useEffect } from "react"; -import { Contract, ethers } from "ethers"; - -function useAllRewardTokens(poolAddress: string, provider: ethers.Provider) { - const [rewardTokens, setRewardTokens] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - if (!poolAddress || !provider) return; - - async function fetchRewardTokens() { - try { - setLoading(true); - setError(null); - - const tokens = await getAllRewardTokensWithMetadata(poolAddress, provider); - setRewardTokens(tokens); - } catch (err) { - setError(err instanceof Error ? err : new Error("Unknown error")); - } finally { - setLoading(false); - } - } - - fetchRewardTokens(); - - // Optionally refresh every 30 seconds - const interval = setInterval(fetchRewardTokens, 30000); - return () => clearInterval(interval); - }, [poolAddress, provider]); - - return { rewardTokens, loading, error }; -} -``` - -## Usage Example - -```typescript -// In your component -function StabilityPoolRewards({ poolAddress }: { poolAddress: string }) { - const provider = useProvider(); // or your provider hook - const { rewardTokens, loading, error } = useAllRewardTokens(poolAddress, provider); - - if (loading) return
Loading reward tokens...
; - if (error) return
Error: {error.message}
; - - return ( -
-

Reward Tokens

- {rewardTokens.map((token) => ( -
-

{token.symbol} ({token.name})

-

Rate: {token.ratePerDay.toFixed(6)} {token.symbol}/day

-

Rate: {token.ratePerYear.toFixed(2)} {token.symbol}/year

-

Vesting Period: {token.periodDays.toFixed(1)} days

-

Status: {token.isActive ? 'Active' : 'Inactive'}

- {token.finishTime > 0 && ( -

Period Ends: {new Date(token.finishTime * 1000).toLocaleString()}

- )} -
- ))} -
- ); -} -``` - -## Important Notes - -1. **Rate Units**: The `rate` returned by `rewardData()` is in wei per second. You need to convert it using the token's decimals. - -2. **Vesting Period**: Rewards vest linearly over the `period` (typically 7 days). The rate represents the distribution speed during this period. - -3. **Active Status**: A reward token is active if `finishTime > currentTime`. After `finishTime`, the rate becomes 0 unless new rewards are deposited. - -4. **Multiple Tokens**: Pools can have multiple reward tokens simultaneously. Always check `activeRewardTokens()` to get the complete list. - -5. **Rate Changes**: The reward rate can change when: - - New rewards are deposited (`depositReward()`) - - The vesting period ends and new rewards start - - Rewards are fully distributed - -6. **Performance**: If you have many reward tokens, consider batching the `rewardData()` calls or using multicall. - -## Error Handling - -```typescript -async function getRewardTokensSafely(poolAddress: string, provider: ethers.Provider): Promise { - try { - const pool = new Contract(poolAddress, STABILITY_POOL_ABI, provider); - const tokenAddresses: string[] = await pool.activeRewardTokens(); - - if (!tokenAddresses || tokenAddresses.length === 0) { - return []; - } - - // Get reward data with error handling for each token - const rewardTokens = await Promise.allSettled( - tokenAddresses.map(async (tokenAddress) => { - try { - const [rate, period, finishTime, lastUpdateTime] = await pool.rewardData(tokenAddress); - - // ... process data ... - - return { address: tokenAddress, rate, period, finishTime, lastUpdateTime }; - } catch (err) { - console.error(`Error fetching reward data for ${tokenAddress}:`, err); - return null; - } - }), - ); - - // Filter out failed requests - return rewardTokens - .filter( - (result): result is PromiseFulfilledResult => - result.status === "fulfilled" && result.value !== null, - ) - .map((result) => result.value); - } catch (error) { - console.error("Error fetching reward tokens:", error); - return []; - } -} -``` - -## Summary - -To find all reward tokens and their rates: - -1. Call `activeRewardTokens()` to get the list of token addresses -2. For each token, call `rewardData(tokenAddress)` to get: - - `rate`: Distribution rate in wei per second - - `period`: Vesting period in seconds - - `finishTime`: When the current period ends - - `lastUpdateTime`: Last update timestamp -3. Convert rates using token decimals for human-readable values -4. Calculate APR using total pool supply and token prices (if needed) - -This gives you complete information about all reward tokens and their distribution rates for display on the frontend. - - diff --git a/doc/guides/FRONTEND-SAIL-TOKEN-IMPLEMENTATION.md b/doc/guides/FRONTEND-SAIL-TOKEN-IMPLEMENTATION.md deleted file mode 100644 index b970d9a8..00000000 --- a/doc/guides/FRONTEND-SAIL-TOKEN-IMPLEMENTATION.md +++ /dev/null @@ -1,613 +0,0 @@ -# Frontend: Sail Token Marks Implementation Guide - -## Overview - -Sail tokens (leveraged tokens, `hs` tokens) earn marks at **5x the rate** of ha tokens (anchor tokens): -- **Ha Tokens**: 1 mark per dollar per day (1x multiplier) -- **Sail Tokens**: 5 marks per dollar per day (5x multiplier, default) - -This guide provides step-by-step instructions for integrating sail token marks tracking into the frontend. - -## Prerequisites - -Before implementing sail token marks, ensure: -1. ✅ Ha token marks are already implemented and working -2. ✅ The subgraph has been updated with sail token tracking (see `SAIL-TOKEN-IMPLEMENTATION.md`) -3. ✅ GraphQL endpoint is accessible -4. ✅ User wallet connection is working - -## Step 1: Update TypeScript Interfaces - -Add the `SailTokenBalance` interface to your types file: - -```typescript -// types/marks.ts or similar - -export interface SailTokenBalance { - id: string; - tokenAddress: string; - balance: string; // BigInt as string (18 decimals) - balanceUSD: string; // BigDecimal as string - accumulatedMarks: string; // BigDecimal as string - marksPerDay: string; // BigDecimal as string (already includes 5x multiplier) - lastUpdated: string; // BigInt as string (Unix timestamp) - firstSeenAt: string; // BigInt as string (Unix timestamp) - marketId: string | null; -} -``` - -## Step 2: Create GraphQL Query - -Add sail token query to your GraphQL queries file: - -```typescript -// queries/marks.ts or similar - -export const GET_SAIL_TOKEN_MARKS = ` - query GetSailTokenMarks($userAddress: Bytes!) { - sailTokenBalances(where: { user: $userAddress }) { - id - tokenAddress - balance - balanceUSD - accumulatedMarks - marksPerDay - lastUpdated - firstSeenAt - marketId - } - } -`; - -export const GET_ALL_MARKS_INCLUDING_SAIL = ` - query GetAllUserMarks($userAddress: Bytes!, $genesisId: ID!) { - # Ha Token Marks (1x multiplier) - haTokenBalances(where: { user: $userAddress }) { - id - tokenAddress - balance - balanceUSD - accumulatedMarks - marksPerDay - lastUpdated - } - - # Sail Token Marks (5x multiplier) - sailTokenBalances(where: { user: $userAddress }) { - id - tokenAddress - balance - balanceUSD - accumulatedMarks - marksPerDay - lastUpdated - } - - # Stability Pool Marks (1x multiplier) - stabilityPoolDeposits(where: { user: $userAddress }) { - id - poolAddress - poolType - balance - balanceUSD - accumulatedMarks - marksPerDay - lastUpdated - } - - # Genesis Marks - userHarborMarks(id: $genesisId) { - currentMarks - marksPerDay - totalMarksEarned - } - } -`; -``` - -## Step 3: Create Sail Token Fetching Function - -Create a function to fetch sail token marks from the subgraph: - -```typescript -// hooks/useSailTokenMarks.ts or similar - -import { useQuery } from "@apollo/client"; // or your GraphQL client -import { GET_SAIL_TOKEN_MARKS } from "../queries/marks"; -import { SailTokenBalance } from "../types/marks"; - -const GRAPHQL_ENDPOINT = process.env.NEXT_PUBLIC_GRAPHQL_ENDPOINT || - "http://localhost:8000/subgraphs/name/harbor-marks-local"; - -export async function fetchSailTokenMarks( - userAddress: string -): Promise { - const response = await fetch(GRAPHQL_ENDPOINT, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - query: GET_SAIL_TOKEN_MARKS, - variables: { - userAddress: userAddress.toLowerCase(), - }, - }), - }); - - const data = await response.json(); - - if (data.errors) { - console.error("GraphQL errors:", data.errors); - return []; - } - - return data.data?.sailTokenBalances || []; -} -``` - -## Step 4: Create Real-Time Estimation Function - -Create a function to calculate estimated marks (zero-gas approach): - -```typescript -// utils/marksCalculation.ts - -import { SailTokenBalance } from "../types/marks"; - -/** - * Calculate estimated marks from sail token balance - * Zero gas - pure frontend calculation - * - * Note: marksPerDay already includes the 5x multiplier! - */ -export function calculateEstimatedSailMarks( - balance: SailTokenBalance -): number { - const storedMarks = parseFloat(balance.accumulatedMarks || "0"); - const marksPerDay = parseFloat(balance.marksPerDay || "0"); // Already includes 5x multiplier! - const lastUpdated = parseInt(balance.lastUpdated || "0"); - - // If no data or no earning rate, return stored marks - if (lastUpdated === 0 || marksPerDay === 0) { - return storedMarks; - } - - // Calculate time elapsed since last update - const now = Math.floor(Date.now() / 1000); - const secondsSinceUpdate = now - lastUpdated; - const daysSinceUpdate = secondsSinceUpdate / 86400; - - // Estimated marks = stored + (rate × time) - // marksPerDay already accounts for 5x multiplier, so this is correct - return storedMarks + marksPerDay * daysSinceUpdate; -} - -/** - * Calculate total estimated marks from all sail token balances - */ -export function calculateTotalSailTokenMarks( - balances: SailTokenBalance[] -): number { - return balances.reduce((total, balance) => { - return total + calculateEstimatedSailMarks(balance); - }, 0); -} -``` - -## Step 5: Create React Hook for Sail Token Marks - -Create a custom hook for sail token marks with real-time updates: - -```typescript -// hooks/useSailTokenMarks.ts - -import { useState, useEffect, useMemo } from "react"; -import { fetchSailTokenMarks } from "./fetchSailTokenMarks"; -import { calculateEstimatedSailMarks, calculateTotalSailTokenMarks } from "../utils/marksCalculation"; -import { SailTokenBalance } from "../types/marks"; - -export function useSailTokenMarks(userAddress: string | null) { - const [balances, setBalances] = useState([]); - const [estimatedMarks, setEstimatedMarks] = useState(0); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - // Fetch from subgraph (poll every 60s for new events) - useEffect(() => { - if (!userAddress) { - setLoading(false); - return; - } - - const fetchData = async () => { - try { - const data = await fetchSailTokenMarks(userAddress); - setBalances(data); - setError(null); - } catch (err) { - setError(err as Error); - console.error("Failed to fetch sail token marks:", err); - } finally { - setLoading(false); - } - }; - - fetchData(); - // Poll for new events (infrequent - just to catch transfers) - const pollInterval = setInterval(fetchData, 60000); - return () => clearInterval(pollInterval); - }, [userAddress]); - - // Calculate estimated marks every second (zero gas!) - useEffect(() => { - if (balances.length === 0) { - setEstimatedMarks(0); - return; - } - - const calculateTotal = () => { - return calculateTotalSailTokenMarks(balances); - }; - - // Initial calculation - setEstimatedMarks(calculateTotal()); - - // Update every second for smooth live display - const interval = setInterval(() => { - setEstimatedMarks(calculateTotal()); - }, 1000); - - return () => clearInterval(interval); - }, [balances]); - - // Calculate marks per day - const marksPerDay = useMemo(() => { - return balances.reduce( - (sum, balance) => sum + parseFloat(balance.marksPerDay || "0"), - 0 - ); - }, [balances]); - - return { - balances, - estimatedMarks, // Live counter - updates every second - marksPerDay, // Current earning rate (already includes 5x multiplier) - loading, - error, - }; -} -``` - -## Step 6: Update Combined Marks Hook - -Update your existing combined marks hook to include sail tokens: - -```typescript -// hooks/useAllMarks.ts - -import { useHaTokenMarks } from "./useHaTokenMarks"; -import { useSailTokenMarks } from "./useSailTokenMarks"; -import { useStabilityPoolMarks } from "./useStabilityPoolMarks"; -import { useGenesisMarks } from "./useGenesisMarks"; - -export function useAllMarks(userAddress: string | null, genesisAddress: string) { - const { estimatedMarks: haMarks, marksPerDay: haMarksPerDay } = useHaTokenMarks(userAddress); - const { estimatedMarks: sailMarks, marksPerDay: sailMarksPerDay } = useSailTokenMarks(userAddress); - const { estimatedMarks: poolMarks, marksPerDay: poolMarksPerDay } = useStabilityPoolMarks(userAddress); - const { currentMarks: genesisMarks, marksPerDay: genesisMarksPerDay } = useGenesisMarks(userAddress, genesisAddress); - - const totalMarks = haMarks + sailMarks + poolMarks + genesisMarks; - const totalMarksPerDay = haMarksPerDay + sailMarksPerDay + poolMarksPerDay + genesisMarksPerDay; - - return { - // Individual sources - haTokenMarks: haMarks, - sailTokenMarks: sailMarks, - stabilityPoolMarks: poolMarks, - genesisMarks: genesisMarks, - - // Totals - totalMarks, - totalMarksPerDay, - - // Breakdown - breakdown: { - haTokens: haMarks, - sailTokens: sailMarks, - stabilityPools: poolMarks, - genesis: genesisMarks, - }, - }; -} -``` - -## Step 7: Create Sail Token Marks Display Component - -Create a component to display sail token marks: - -```typescript -// components/SailTokenMarksDisplay.tsx - -import React from "react"; -import { useSailTokenMarks } from "../hooks/useSailTokenMarks"; - -interface SailTokenMarksDisplayProps { - userAddress: string; -} - -export function SailTokenMarksDisplay({ userAddress }: SailTokenMarksDisplayProps) { - const { estimatedMarks, marksPerDay, balances, loading } = useSailTokenMarks(userAddress); - - if (loading) { - return
Loading sail token marks...
; - } - - if (balances.length === 0) { - return ( -
- No sail tokens held -
- ); - } - - return ( -
-
-

Sail Token Marks

-

- 5x multiplier (5 marks per dollar per day) -

-
- - {/* Live counter - ticks up every second */} -
- {estimatedMarks.toLocaleString(undefined, { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })} -
- -
- +{marksPerDay.toLocaleString()} marks/day -
- - {/* Individual token breakdown */} -
- {balances.map((balance) => ( -
-
- - {balance.tokenAddress.slice(0, 6)}...{balance.tokenAddress.slice(-4)} - - - {parseFloat(balance.balance) / 1e18} tokens - -
-
- Value: - ${parseFloat(balance.balanceUSD).toLocaleString()} -
-
- Marks/day: - - {parseFloat(balance.marksPerDay).toLocaleString()} - -
-
- ))} -
-
- ); -} -``` - -## Step 8: Update Total Marks Display - -Update your total marks display component to include sail tokens: - -```typescript -// components/TotalMarksDisplay.tsx - -import React from "react"; -import { useAllMarks } from "../hooks/useAllMarks"; - -interface TotalMarksDisplayProps { - userAddress: string; - genesisAddress: string; -} - -export function TotalMarksDisplay({ userAddress, genesisAddress }: TotalMarksDisplayProps) { - const { - totalMarks, - totalMarksPerDay, - breakdown, - } = useAllMarks(userAddress, genesisAddress); - - return ( -
- {/* Total Marks */} -
-

Total Marks

-
- {totalMarks.toLocaleString(undefined, { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })} -
-
- +{totalMarksPerDay.toLocaleString()} marks/day -
-
- - {/* Breakdown */} -
-
- Genesis Marks: - {breakdown.genesis.toLocaleString()} -
-
- Ha Token Marks (1x): - {breakdown.haTokens.toLocaleString()} -
-
- Sail Token Marks (5x): - {breakdown.sailTokens.toLocaleString()} -
-
- Stability Pool Marks: - {breakdown.stabilityPools.toLocaleString()} -
-
-
- ); -} -``` - -## Step 9: Update Leaderboard Query - -If you have a leaderboard, update it to include sail tokens: - -```typescript -// queries/leaderboard.ts - -export const GET_LEADERBOARD_WITH_SAIL = ` - query GetLeaderboard { - haTokenBalances(orderBy: accumulatedMarks, orderDirection: desc, first: 100) { - user - accumulatedMarks - marksPerDay - lastUpdated - } - sailTokenBalances(orderBy: accumulatedMarks, orderDirection: desc, first: 100) { - user - accumulatedMarks - marksPerDay - lastUpdated - } - } -`; - -// Then combine and calculate estimated marks for each user -export function calculateLeaderboardMarks( - haBalances: any[], - sailBalances: any[] -): LeaderboardEntry[] { - const userMap = new Map(); - - // Add ha token marks - haBalances.forEach((balance) => { - const user = balance.user.toLowerCase(); - const existing = userMap.get(user) || { user, totalMarks: 0 }; - existing.totalMarks += calculateEstimatedMarks(balance); - userMap.set(user, existing); - }); - - // Add sail token marks - sailBalances.forEach((balance) => { - const user = balance.user.toLowerCase(); - const existing = userMap.get(user) || { user, totalMarks: 0 }; - existing.totalMarks += calculateEstimatedSailMarks(balance); - userMap.set(user, existing); - }); - - return Array.from(userMap.values()) - .sort((a, b) => b.totalMarks - a.totalMarks) - .slice(0, 100); -} -``` - -## Step 10: Testing Checklist - -Test the implementation with these scenarios: - -### ✅ Basic Functionality -- [ ] Sail token balances are fetched correctly -- [ ] Estimated marks update in real-time (every second) -- [ ] Marks per day shows correct value (5x multiplier applied) -- [ ] No errors in console - -### ✅ Edge Cases -- [ ] User with no sail tokens shows 0 marks -- [ ] User with multiple sail tokens shows combined marks -- [ ] Marks continue updating after user disconnects wallet (if cached) -- [ ] GraphQL errors are handled gracefully - -### ✅ Integration -- [ ] Total marks includes sail token marks -- [ ] Breakdown shows sail token marks separately -- [ ] Leaderboard includes sail token marks -- [ ] All marks sources sum correctly - -### ✅ Performance -- [ ] No unnecessary re-renders -- [ ] Polling interval is reasonable (60s) -- [ ] Real-time updates don't cause lag - -## Example: Expected Values - -### User holds 100,000 sail tokens worth $100,000 - -**After 1 day:** -- Stored marks: 500,000 marks -- Marks per day: 500,000 marks/day -- Estimated marks (real-time): 500,000 + (500,000 × daysSinceUpdate) - -**After 2 days:** -- Stored marks: 1,000,000 marks -- Marks per day: 500,000 marks/day - -### User holds 50,000 ha tokens + 50,000 sail tokens (both worth $50k each) - -**Total marks per day:** -- Ha tokens: $50k × 1x = 50,000 marks/day -- Sail tokens: $50k × 5x = 250,000 marks/day -- **Total: 300,000 marks/day** - -## Important Notes - -1. **Multiplier Already Applied**: The `marksPerDay` field from the subgraph already includes the 5x multiplier. Don't multiply again! - -2. **Real-Time Updates**: Use the estimation function to show live marks ticking up, but remember that actual marks are only updated when Transfer events occur. - -3. **Address Format**: Always use lowercase addresses in GraphQL queries: - ```typescript - userAddress.toLowerCase() - ``` - -4. **Polling Frequency**: Poll every 60 seconds for new events. Real-time estimation happens every second on the frontend (zero gas). - -5. **Error Handling**: Always handle GraphQL errors gracefully and show appropriate fallbacks. - -## Troubleshooting - -### Issue: Sail token marks not showing -- **Check**: Is the subgraph deployed with sail token tracking? -- **Check**: Are there any Transfer events for sail tokens? -- **Check**: Is the user address correct and lowercase? - -### Issue: Marks per day seems wrong -- **Check**: Remember that `marksPerDay` already includes the 5x multiplier -- **Check**: Verify balanceUSD is correct -- **Check**: Expected: `balanceUSD × 5 = marksPerDay` - -### Issue: Estimated marks not updating -- **Check**: Is the `useEffect` interval running? -- **Check**: Are `lastUpdated` timestamps valid? -- **Check**: Is `Date.now()` working correctly? - -## Summary - -1. ✅ Add `SailTokenBalance` interface -2. ✅ Create GraphQL queries for sail tokens -3. ✅ Create fetching function -4. ✅ Create estimation function (zero-gas) -5. ✅ Create React hook with real-time updates -6. ✅ Update combined marks hook -7. ✅ Create display component -8. ✅ Update total marks display -9. ✅ Update leaderboard (if applicable) -10. ✅ Test thoroughly - -The implementation follows the same pattern as ha tokens but accounts for the 5x multiplier (which is already applied in `marksPerDay` from the subgraph). - - - diff --git a/doc/guides/FRONTEND-SAIL-TOKEN-QUICK-REFERENCE.md b/doc/guides/FRONTEND-SAIL-TOKEN-QUICK-REFERENCE.md deleted file mode 100644 index 4aac5917..00000000 --- a/doc/guides/FRONTEND-SAIL-TOKEN-QUICK-REFERENCE.md +++ /dev/null @@ -1,108 +0,0 @@ -# Frontend: Sail Token Marks - Quick Reference - -## 🎯 Key Points - -- **Sail tokens earn 5x marks** compared to ha tokens (5 marks per dollar per day vs 1 mark per dollar per day) -- **`marksPerDay` already includes the 5x multiplier** - don't multiply again! -- **Same zero-gas estimation approach** as ha tokens -- **Real-time updates** every second on frontend (zero gas) - -## 📋 Implementation Checklist - -### Step 1: Types -```typescript -interface SailTokenBalance { - id: string; - tokenAddress: string; - balance: string; - balanceUSD: string; - accumulatedMarks: string; - marksPerDay: string; // Already includes 5x multiplier! - lastUpdated: string; -} -``` - -### Step 2: GraphQL Query -```graphql -query GetSailTokenMarks($userAddress: Bytes!) { - sailTokenBalances(where: { user: $userAddress }) { - accumulatedMarks - marksPerDay - balanceUSD - lastUpdated - } -} -``` - -### Step 3: Estimation Function -```typescript -function calculateEstimatedSailMarks(balance: SailTokenBalance): number { - const storedMarks = parseFloat(balance.accumulatedMarks || "0"); - const marksPerDay = parseFloat(balance.marksPerDay || "0"); // Already 5x! - const lastUpdated = parseInt(balance.lastUpdated || "0"); - - if (lastUpdated === 0 || marksPerDay === 0) return storedMarks; - - const now = Math.floor(Date.now() / 1000); - const daysSinceUpdate = (now - lastUpdated) / 86400; - - return storedMarks + (marksPerDay * daysSinceUpdate); -} -``` - -### Step 4: React Hook -```typescript -function useSailTokenMarks(userAddress: string | null) { - const [balances, setBalances] = useState([]); - const [estimatedMarks, setEstimatedMarks] = useState(0); - - // Fetch every 60s, estimate every 1s - // ... (see full implementation guide) - - return { balances, estimatedMarks, marksPerDay, loading, error }; -} -``` - -### Step 5: Update Combined Marks -```typescript -const totalMarks = haMarks + sailMarks + poolMarks + genesisMarks; -``` - -## 🔢 Expected Values - -**User holds 100,000 sail tokens worth $100,000:** -- Marks per day: **500,000 marks/day** (5x multiplier) -- After 1 day: **500,000 marks** -- After 2 days: **1,000,000 marks** - -## ⚠️ Common Mistakes - -1. ❌ **Don't multiply `marksPerDay` by 5** - it's already included! -2. ❌ **Don't forget to use lowercase addresses** in GraphQL queries -3. ❌ **Don't poll too frequently** - 60s is enough for events -4. ✅ **Do use real-time estimation** - update every 1s for smooth UX - -## 📊 Example Calculation - -``` -Balance: $100,000 sail tokens -Multiplier: 5x (default) -Rate: 5 marks per dollar per day -Marks per day: $100,000 × 5 = 500,000 marks/day -``` - -## 🔍 Verification - -After implementation, verify: -- [ ] Sail token balances fetch correctly -- [ ] Estimated marks update every second -- [ ] Marks per day = balanceUSD × 5 (approximately) -- [ ] Total marks includes sail token marks -- [ ] No console errors - -## 📚 Full Documentation - -See `FRONTEND-SAIL-TOKEN-IMPLEMENTATION.md` for complete step-by-step guide with code examples. - - - diff --git a/doc/guides/FRONTEND-SAIL-TOKEN-TVL.md b/doc/guides/FRONTEND-SAIL-TOKEN-TVL.md deleted file mode 100644 index 49fcfd80..00000000 --- a/doc/guides/FRONTEND-SAIL-TOKEN-TVL.md +++ /dev/null @@ -1,342 +0,0 @@ -# Frontend: Sail Token TVL Display Guide - -## Overview - -TVL (Total Value Locked) for sail tokens represents the total USD value of all sail tokens held by all users. There are two approaches: - -1. **Subgraph Aggregation** (recommended for accuracy) - Sum all `balanceUSD` from the subgraph -2. **Contract Query** (faster, simpler) - Query token `totalSupply()` and multiply by price - -## Approach 1: Subgraph Aggregation (Recommended) - -### GraphQL Query - -```graphql -query GetSailTokenTVL($tokenAddress: Bytes!) { - sailTokenBalances( - where: { - tokenAddress: $tokenAddress - balance_gt: "0" # Only non-zero balances - } - first: 1000 # Adjust based on expected users - ) { - balanceUSD - } -} -``` - -### TypeScript Implementation - -```typescript -// hooks/useSailTokenTVL.ts -import { useQuery } from "@apollo/client"; -import { gql } from "@apollo/client"; - -const GET_SAIL_TOKEN_TVL = gql` - query GetSailTokenTVL($tokenAddress: Bytes!) { - sailTokenBalances( - where: { - tokenAddress: $tokenAddress - balance_gt: "0" - } - first: 1000 - ) { - balanceUSD - } - } -`; - -export function useSailTokenTVL(tokenAddress: string) { - const { data, loading, error } = useQuery(GET_SAIL_TOKEN_TVL, { - variables: { - tokenAddress: tokenAddress.toLowerCase(), - }, - pollInterval: 60000, // Refresh every 60 seconds - }); - - const tvl = useMemo(() => { - if (!data?.sailTokenBalances) return 0; - - return data.sailTokenBalances.reduce((sum: number, balance: any) => { - return sum + parseFloat(balance.balanceUSD || "0"); - }, 0); - }, [data]); - - return { tvl, loading, error }; -} -``` - -### React Component - -```typescript -// components/SailTokenTVL.tsx -import { useSailTokenTVL } from "../hooks/useSailTokenTVL"; - -interface Props { - tokenAddress: string; -} - -export function SailTokenTVL({ tokenAddress }: Props) { - const { tvl, loading, error } = useSailTokenTVL(tokenAddress); - - if (loading) return
Loading TVL...
; - if (error) return
Error loading TVL
; - - return ( -
-

Total Value Locked

-

- ${tvl.toLocaleString(undefined, { - minimumFractionDigits: 2, - maximumFractionDigits: 2 - })} -

-
- ); -} -``` - -## Approach 2: Contract Query (Faster, Simpler) - -This approach queries the token contract directly for `totalSupply()` and multiplies by the token price. - -### Contract Query - -```typescript -// utils/sailTokenTVL.ts -import { Contract } from "ethers"; -import { ERC20_ABI } from "../abis/ERC20"; - -/** - * Get sail token TVL by querying totalSupply and price - */ -export async function getSailTokenTVL( - tokenAddress: string, - tokenPriceUSD: number, // Price per token in USD - provider: any -): Promise { - const tokenContract = new Contract(tokenAddress, ERC20_ABI, provider); - - // Get total supply (in wei, 18 decimals) - const totalSupply = await tokenContract.totalSupply(); - const totalSupplyTokens = parseFloat(totalSupply.toString()) / 1e18; - - // Calculate TVL - const tvl = totalSupplyTokens * tokenPriceUSD; - - return tvl; -} -``` - -### React Hook - -```typescript -// hooks/useSailTokenTVLContract.ts -import { useState, useEffect } from "react"; -import { useProvider } from "wagmi"; -import { getSailTokenTVL } from "../utils/sailTokenTVL"; - -export function useSailTokenTVLContract( - tokenAddress: string, - tokenPriceUSD: number -) { - const provider = useProvider(); - const [tvl, setTvl] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - async function fetchTVL() { - try { - setLoading(true); - const tvlValue = await getSailTokenTVL( - tokenAddress, - tokenPriceUSD, - provider - ); - setTvl(tvlValue); - setError(null); - } catch (err) { - setError(err as Error); - } finally { - setLoading(false); - } - } - - if (tokenAddress && tokenPriceUSD > 0) { - fetchTVL(); - // Refresh every 30 seconds - const interval = setInterval(fetchTVL, 30000); - return () => clearInterval(interval); - } - }, [tokenAddress, tokenPriceUSD, provider]); - - return { tvl, loading, error }; -} -``` - -## Approach 3: Hybrid (Best of Both Worlds) - -Use contract query for speed, subgraph for accuracy verification: - -```typescript -// hooks/useSailTokenTVLHybrid.ts -import { useState, useEffect } from "react"; -import { useProvider } from "wagmi"; -import { useSailTokenTVL } from "./useSailTokenTVL"; -import { getSailTokenTVL } from "../utils/sailTokenTVL"; - -export function useSailTokenTVLHybrid( - tokenAddress: string, - tokenPriceUSD: number -) { - const provider = useProvider(); - const { tvl: subgraphTVL, loading: subgraphLoading } = useSailTokenTVL(tokenAddress); - const [contractTVL, setContractTVL] = useState(null); - const [loading, setLoading] = useState(true); - - useEffect(() => { - async function fetchContractTVL() { - try { - const tvl = await getSailTokenTVL(tokenAddress, tokenPriceUSD, provider); - setContractTVL(tvl); - } catch (err) { - console.error("Error fetching contract TVL:", err); - } finally { - setLoading(false); - } - } - - if (tokenAddress && tokenPriceUSD > 0) { - fetchContractTVL(); - const interval = setInterval(fetchContractTVL, 30000); - return () => clearInterval(interval); - } - }, [tokenAddress, tokenPriceUSD, provider]); - - // Use contract TVL for display (faster), subgraph for verification - const displayTVL = contractTVL ?? subgraphTVL; - const isLoading = loading || subgraphLoading; - - return { - tvl: displayTVL, - subgraphTVL, - contractTVL, - loading: isLoading - }; -} -``` - -## Multiple Sail Tokens (Multiple Markets) - -If you have multiple sail tokens (different markets), sum their TVLs: - -```typescript -// hooks/useAllSailTokenTVL.ts -import { useSailTokenTVL } from "./useSailTokenTVL"; - -const SAIL_TOKEN_ADDRESSES = [ - "0x367761085bf3c12e5da2df99ac6e1a824612b8fb", // hsPB - // Add other sail token addresses as they launch -]; - -export function useAllSailTokenTVL() { - const tvls = SAIL_TOKEN_ADDRESSES.map(address => - useSailTokenTVL(address) - ); - - const totalTVL = useMemo(() => { - return tvls.reduce((sum, { tvl }) => sum + (tvl || 0), 0); - }, [tvls]); - - const loading = tvls.some(({ loading }) => loading); - const error = tvls.find(({ error }) => error)?.error; - - return { totalTVL, loading, error }; -} -``` - -## Display Formatting - -```typescript -// utils/formatTVL.ts -export function formatTVL(tvl: number): string { - if (tvl >= 1_000_000_000) { - return `$${(tvl / 1_000_000_000).toFixed(2)}B`; - } else if (tvl >= 1_000_000) { - return `$${(tvl / 1_000_000).toFixed(2)}M`; - } else if (tvl >= 1_000) { - return `$${(tvl / 1_000).toFixed(2)}K`; - } else { - return `$${tvl.toFixed(2)}`; - } -} - -// Usage -const formatted = formatTVL(1234567); // "$1.23M" -``` - -## Recommendations - -1. **For Production**: Use **Approach 2 (Contract Query)** for speed and simplicity - - Faster (single contract call vs. multiple subgraph queries) - - More accurate (direct from source) - - Less load on subgraph - -2. **For Development/Testing**: Use **Approach 1 (Subgraph)** to verify data consistency - -3. **For Best UX**: Use **Approach 3 (Hybrid)** - show contract TVL immediately, verify with subgraph - -## Important Notes - -- **Token Price**: You'll need to fetch the sail token price separately (from price oracle or DEX) -- **Decimals**: Sail tokens use 18 decimals (standard ERC20) -- **Multiple Markets**: If you launch multiple sail tokens, sum their TVLs -- **Real-time Updates**: TVL changes when tokens are minted/burned, so refresh periodically - -## Example: Complete Component - -```typescript -// components/SailTokenStats.tsx -import { useSailTokenTVLContract } from "../hooks/useSailTokenTVLContract"; -import { formatTVL } from "../utils/formatTVL"; - -interface Props { - tokenAddress: string; - tokenPriceUSD: number; -} - -export function SailTokenStats({ tokenAddress, tokenPriceUSD }: Props) { - const { tvl, loading, error } = useSailTokenTVLContract( - tokenAddress, - tokenPriceUSD - ); - - if (loading) { - return ( -
-
-
- ); - } - - if (error) { - return
Error loading TVL
; - } - - return ( -
-
Total Value Locked
-
- {tvl !== null ? formatTVL(tvl) : "$0.00"} -
-
- Updated every 30 seconds -
-
- ); -} -``` - - - diff --git a/doc/guides/FRONTEND-STABILITY-POOL-CONTRACT-QUERY.md b/doc/guides/FRONTEND-STABILITY-POOL-CONTRACT-QUERY.md deleted file mode 100644 index 45418f79..00000000 --- a/doc/guides/FRONTEND-STABILITY-POOL-CONTRACT-QUERY.md +++ /dev/null @@ -1,622 +0,0 @@ -# Frontend: Query Stability Pool Positions from Contracts - -## Overview - -This guide shows how to query stability pool positions **directly from contracts** without using the subgraph. This is useful when the subgraph is stopped or you need real-time data. - -## Contract Functions - -### Core Functions - -```solidity -// Get user's deposit balance (in asset tokens, 18 decimals) -function assetBalanceOf(address account) external view returns (uint256); - -// Get total pool supply -function totalAssetSupply() external view returns (uint256); - -// Get asset token address (ha tokens for collateral pool, hs tokens for leveraged pool) -function ASSET_TOKEN() external view returns (address); - -// Get withdrawal request window -function getWithdrawalRequest(address account) external view returns (uint64 start, uint64 end); - -// Get early withdrawal fee -function getEarlyWithdrawalFee() external view returns (uint256); - -// Get fee address -function getFeeAddress() external view returns (address); - -// Get withdrawal window configuration -function getWithdrawalWindow() external view returns (uint64 startDelay, uint64 endWindow); -``` - -## Basic Implementation - -### Step 1: Query User Balance - -```typescript -// utils/stabilityPoolContract.ts -import { Contract } from "ethers"; -import { STABILITY_POOL_ABI } from "../abis/StabilityPool"; - -export async function getUserStabilityPoolBalance( - poolAddress: string, - userAddress: string, - provider: any, -): Promise { - const pool = new Contract(poolAddress, STABILITY_POOL_ABI, provider); - const balance = await pool.assetBalanceOf(userAddress); - return balance; -} -``` - -### Step 2: Get Pool Information - -```typescript -export async function getStabilityPoolInfo( - poolAddress: string, - provider: any, -): Promise<{ - totalSupply: bigint; - assetToken: string; - earlyWithdrawalFee: bigint; - feeAddress: string; - withdrawalWindow: { startDelay: bigint; endWindow: bigint }; -}> { - const pool = new Contract(poolAddress, STABILITY_POOL_ABI, provider); - - const [totalSupply, assetToken, earlyWithdrawalFee, feeAddress, withdrawalWindow] = await Promise.all([ - pool.totalAssetSupply(), - pool.ASSET_TOKEN(), - pool.getEarlyWithdrawalFee(), - pool.getFeeAddress(), - pool.getWithdrawalWindow(), - ]); - - return { - totalSupply, - assetToken, - earlyWithdrawalFee, - feeAddress, - withdrawalWindow: { - startDelay: withdrawalWindow[0], - endWindow: withdrawalWindow[1], - }, - }; -} -``` - -### Step 3: Get Withdrawal Request Status - -```typescript -export async function getWithdrawalRequestStatus( - poolAddress: string, - userAddress: string, - provider: any, - currentTimestamp?: bigint, -): Promise<{ - hasRequest: boolean; - start: bigint | null; - end: bigint | null; - status: "none" | "waiting" | "active" | "expired"; - canWithdrawFeeFree: boolean; - timeUntilStart: number | null; - timeUntilEnd: number | null; -}> { - const pool = new Contract(poolAddress, STABILITY_POOL_ABI, provider); - - const [start, end] = await pool.getWithdrawalRequest(userAddress); - const now = currentTimestamp || BigInt(Math.floor(Date.now() / 1000)); - - const hasRequest = start > 0 && end > start; - let status: "none" | "waiting" | "active" | "expired" = "none"; - let canWithdrawFeeFree = false; - let timeUntilStart: number | null = null; - let timeUntilEnd: number | null = null; - - if (hasRequest) { - if (now < start) { - status = "waiting"; - timeUntilStart = Number(start - now); - } else if (now >= start && now <= end) { - status = "active"; - canWithdrawFeeFree = true; - timeUntilEnd = Number(end - now); - } else { - status = "expired"; - } - } - - return { - hasRequest, - start: hasRequest ? start : null, - end: hasRequest ? end : null, - status, - canWithdrawFeeFree, - timeUntilStart, - timeUntilEnd, - }; -} -``` - -## Complete React Hook - -```typescript -// hooks/useStabilityPoolPosition.ts -import { useState, useEffect } from "react"; -import { useAccount, usePublicClient } from "wagmi"; -import { formatEther } from "viem"; -import { - getUserStabilityPoolBalance, - getStabilityPoolInfo, - getWithdrawalRequestStatus, -} from "../utils/stabilityPoolContract"; - -export interface StabilityPoolPosition { - poolAddress: string; - poolType: "collateral" | "leveraged"; - balance: bigint; - balanceFormatted: string; - balanceUSD: number; // Assuming $1 per token for ha/hs tokens - totalSupply: bigint; - userShare: number; // Percentage of pool - assetToken: string; - earlyWithdrawalFee: bigint; - withdrawalRequest: { - hasRequest: boolean; - start: bigint | null; - end: bigint | null; - status: "none" | "waiting" | "active" | "expired"; - canWithdrawFeeFree: boolean; - timeUntilStart: number | null; - timeUntilEnd: number | null; - }; -} - -export function useStabilityPoolPosition(poolAddress: string, poolType: "collateral" | "leveraged") { - const { address } = useAccount(); - const publicClient = usePublicClient(); - const [position, setPosition] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - async function fetchPosition() { - if (!address || !poolAddress || !publicClient) { - setLoading(false); - return; - } - - try { - setLoading(true); - - // Get current block for timestamp - const block = await publicClient.getBlock({ blockTag: "latest" }); - const currentTimestamp = BigInt(block.timestamp); - - // Fetch all data in parallel - const [balance, poolInfo, withdrawalRequest] = await Promise.all([ - getUserStabilityPoolBalance(poolAddress, address, publicClient), - getStabilityPoolInfo(poolAddress, publicClient), - getWithdrawalRequestStatus(poolAddress, address, publicClient, currentTimestamp), - ]); - - const balanceFormatted = formatEther(balance); - const balanceUSD = parseFloat(balanceFormatted); // Assuming $1 per token - const userShare = poolInfo.totalSupply > 0 ? (Number(balance) / Number(poolInfo.totalSupply)) * 100 : 0; - - setPosition({ - poolAddress, - poolType, - balance, - balanceFormatted, - balanceUSD, - totalSupply: poolInfo.totalSupply, - userShare, - assetToken: poolInfo.assetToken, - earlyWithdrawalFee: poolInfo.earlyWithdrawalFee, - withdrawalRequest, - }); - - setError(null); - } catch (err) { - setError(err as Error); - } finally { - setLoading(false); - } - } - - fetchPosition(); - // Refresh every 10 seconds for real-time updates - const interval = setInterval(fetchPosition, 10000); - return () => clearInterval(interval); - }, [address, poolAddress, poolType, publicClient]); - - return { position, loading, error }; -} -``` - -## Hook for Multiple Pools - -```typescript -// hooks/useAllStabilityPoolPositions.ts -import { useStabilityPoolPosition } from "./useStabilityPoolPosition"; - -const COLLATERAL_POOL = "0x3aAde2dCD2Df6a8cAc689EE797591b2913658659"; -const LEVERAGED_POOL = "0x525C7063E7C20997BaaE9bDa922159152D0e8417"; - -export function useAllStabilityPoolPositions() { - const collateral = useStabilityPoolPosition(COLLATERAL_POOL, "collateral"); - const leveraged = useStabilityPoolPosition(LEVERAGED_POOL, "leveraged"); - - const totalBalance = (collateral.position?.balance || BigInt(0)) + (leveraged.position?.balance || BigInt(0)); - const totalBalanceUSD = (collateral.position?.balanceUSD || 0) + (leveraged.position?.balanceUSD || 0); - - return { - collateral: collateral.position, - leveraged: leveraged.position, - totalBalance, - totalBalanceUSD, - loading: collateral.loading || leveraged.loading, - error: collateral.error || leveraged.error, - }; -} -``` - -## Complete React Component - -```typescript -// components/StabilityPoolPositions.tsx -import { useAllStabilityPoolPositions } from "../hooks/useAllStabilityPoolPositions"; -import { formatEther } from "viem"; -import { formatTimeRemaining } from "../utils/timeFormat"; - -export function StabilityPoolPositions() { - const { collateral, leveraged, totalBalanceUSD, loading, error } = useAllStabilityPoolPositions(); - - if (loading) { - return
Loading positions...
; - } - - if (error) { - return
Error: {error.message}
; - } - - return ( -
-

Stability Pool Positions

- - {/* Collateral Pool */} - {collateral && ( - - )} - - {/* Leveraged Pool */} - {leveraged && ( - - )} - - {/* Total */} -
-
- Total Deposits - - ${totalBalanceUSD.toLocaleString(undefined, { maximumFractionDigits: 2 })} - -
-
-
- ); -} - -function PoolPositionCard({ position, title }: { - position: StabilityPoolPosition; - title: string; -}) { - const feePercentage = Number(position.earlyWithdrawalFee) / 1e18 * 100; - - return ( -
-

{title}

- - {/* Balance */} -
-
- Deposit Balance: - {position.balanceFormatted} tokens -
-
- USD Value: - ${position.balanceUSD.toLocaleString(undefined, { maximumFractionDigits: 2 })} -
-
- Pool Share: - {position.userShare.toFixed(4)}% -
-
- - {/* Withdrawal Request Status */} - {position.withdrawalRequest.hasRequest && ( -
-
-
- - {position.withdrawalRequest.status === "active" && "✅ Fee-Free Withdrawal Available"} - {position.withdrawalRequest.status === "waiting" && "⏳ Waiting for Fee-Free Window"} - {position.withdrawalRequest.status === "expired" && "⚠️ Withdrawal Window Expired"} - - {position.withdrawalRequest.timeUntilStart && ( -

- Starts in: {formatTimeRemaining(position.withdrawalRequest.timeUntilStart)} -

- )} - {position.withdrawalRequest.timeUntilEnd && ( -

- Closes in: {formatTimeRemaining(position.withdrawalRequest.timeUntilEnd)} -

- )} -
- {position.withdrawalRequest.canWithdrawFeeFree && ( - No Fee - )} -
-
- )} - - {/* Fee Info */} -
-

Early Withdrawal Fee: {feePercentage.toFixed(2)}%

- {!position.withdrawalRequest.hasRequest && ( -

- 💡 Create a withdrawal request to avoid fees -

- )} -
-
- ); -} -``` - -## Using wagmi Hooks (Alternative) - -```typesity -// hooks/useStabilityPoolPositionWagmi.ts -import { useReadContract, useReadContracts } from "wagmi"; -import { STABILITY_POOL_ABI } from "../abis/StabilityPool"; -import { formatEther } from "viem"; - -export function useStabilityPoolPositionWagmi( - poolAddress: string, - userAddress: string -) { - // Read multiple values in parallel - const { data, isLoading, error } = useReadContracts({ - contracts: [ - { - address: poolAddress as `0x${string}`, - abi: STABILITY_POOL_ABI, - functionName: "assetBalanceOf", - args: [userAddress as `0x${string}`], - }, - { - address: poolAddress as `0x${string}`, - abi: STABILITY_POOL_ABI, - functionName: "totalAssetSupply", - }, - { - address: poolAddress as `0x${string}`, - abi: STABILITY_POOL_ABI, - functionName: "ASSET_TOKEN", - }, - { - address: poolAddress as `0x${string}`, - abi: STABILITY_POOL_ABI, - functionName: "getWithdrawalRequest", - args: [userAddress as `0x${string}`], - }, - { - address: poolAddress as `0x${string}`, - abi: STABILITY_POOL_ABI, - functionName: "getEarlyWithdrawalFee", - }, - ], - }); - - if (isLoading || !data) { - return { loading: true, position: null, error: null }; - } - - const [balance, totalSupply, assetToken, withdrawalRequest, earlyWithdrawalFee] = data; - - // Check for errors - const hasError = data.some((result) => result.status === "failure"); - if (hasError) { - return { - loading: false, - position: null, - error: new Error("Failed to fetch pool data"), - }; - } - - const balanceValue = balance.result as bigint; - const totalSupplyValue = totalSupply.result as bigint; - const assetTokenValue = assetToken.result as string; - const [start, end] = withdrawalRequest.result as [bigint, bigint]; - const feeValue = earlyWithdrawalFee.result as bigint; - - const balanceFormatted = formatEther(balanceValue); - const balanceUSD = parseFloat(balanceFormatted); - const userShare = totalSupplyValue > 0 - ? (Number(balanceValue) / Number(totalSupplyValue)) * 100 - : 0; - - // Calculate withdrawal request status - const now = BigInt(Math.floor(Date.now() / 1000)); - const hasRequest = start > 0 && end > start; - let status: "none" | "waiting" | "active" | "expired" = "none"; - let canWithdrawFeeFree = false; - - if (hasRequest) { - if (now < start) { - status = "waiting"; - } else if (now >= start && now <= end) { - status = "active"; - canWithdrawFeeFree = true; - } else { - status = "expired"; - } - } - - return { - loading: false, - position: { - balance: balanceValue, - balanceFormatted, - balanceUSD, - totalSupply: totalSupplyValue, - userShare, - assetToken: assetTokenValue, - earlyWithdrawalFee: feeValue, - withdrawalRequest: { - hasRequest, - start: hasRequest ? start : null, - end: hasRequest ? end : null, - status, - canWithdrawFeeFree, - timeUntilStart: hasRequest && now < start ? Number(start - now) : null, - timeUntilEnd: hasRequest && now >= start && now <= end ? Number(end - now) : null, - }, - }, - error: null, - }; -} -``` - -## Calculate USD Value - -For ha tokens (pegged tokens), you can assume $1.00, or query the Minter: - -```typescript -// utils/tokenPrice.ts -import { Contract } from "ethers"; -import { MINTER_ABI } from "../abis/Minter"; - -export async function getPeggedTokenPrice(minterAddress: string, provider: any): Promise { - const minter = new Contract(minterAddress, MINTER_ABI, provider); - - // Get price in underlying collateral (stETH) - const priceInCollateral = await minter.peggedTokenPrice(); - - // Get collateral price in USD (from oracle or assume $2000 for stETH) - const collateralPriceUSD = 2000; // Or query from oracle - - // Convert to USD - const priceUSD = (Number(priceInCollateral) / 1e18) * collateralPriceUSD; - - return priceUSD; -} -``` - -## Error Handling - -```typescript -export async function getUserStabilityPoolBalanceSafe( - poolAddress: string, - userAddress: string, - provider: any, -): Promise<{ balance: bigint | null; error: Error | null }> { - try { - const pool = new Contract(poolAddress, STABILITY_POOL_ABI, provider); - const balance = await pool.assetBalanceOf(userAddress); - return { balance, error: null }; - } catch (err: any) { - // Handle specific errors - if (err.message?.includes("revert")) { - return { balance: null, error: new Error("Contract call reverted") }; - } - if (err.message?.includes("network")) { - return { balance: null, error: new Error("Network error") }; - } - return { balance: null, error: err as Error }; - } -} -``` - -## Performance Optimization - -### Batch Queries - -```typescript -// Query multiple pools at once -export async function getMultiplePoolPositions( - pools: Array<{ address: string; type: "collateral" | "leveraged" }>, - userAddress: string, - provider: any, -): Promise { - const queries = pools.map((pool) => - Promise.all([ - getUserStabilityPoolBalance(pool.address, userAddress, provider), - getStabilityPoolInfo(pool.address, provider), - getWithdrawalRequestStatus(pool.address, userAddress, provider), - ]).then(([balance, info, withdrawalRequest]) => ({ - poolAddress: pool.address, - poolType: pool.type, - balance, - balanceFormatted: formatEther(balance), - balanceUSD: parseFloat(formatEther(balance)), - totalSupply: info.totalSupply, - userShare: info.totalSupply > 0 ? (Number(balance) / Number(info.totalSupply)) * 100 : 0, - assetToken: info.assetToken, - earlyWithdrawalFee: info.earlyWithdrawalFee, - withdrawalRequest, - })), - ); - - return Promise.all(queries); -} -``` - -## Summary - -### Key Functions - -- `assetBalanceOf(address)` - Get user's deposit balance -- `totalAssetSupply()` - Get total pool supply -- `ASSET_TOKEN()` - Get asset token address -- `getWithdrawalRequest(address)` - Get withdrawal window status -- `getEarlyWithdrawalFee()` - Get fee percentage - -### Quick Implementation - -```typescript -const { position } = useStabilityPoolPosition(poolAddress, "collateral"); -// position contains: balance, balanceUSD, withdrawalRequest, etc. -``` - -### Advantages Over Subgraph - -- ✅ Real-time data (no indexing delay) -- ✅ Always available (doesn't depend on subgraph) -- ✅ Direct contract calls -- ✅ No subgraph deployment needed - -### Disadvantages - -- ❌ No marks tracking (need subgraph for that) -- ❌ More contract calls (higher gas for writes) -- ❌ No historical data - -This approach is perfect for displaying current positions and withdrawal status! - - diff --git a/doc/guides/FRONTEND-STABILITY-POOL-DEPOSIT.md b/doc/guides/FRONTEND-STABILITY-POOL-DEPOSIT.md deleted file mode 100644 index 3f5b2a97..00000000 --- a/doc/guides/FRONTEND-STABILITY-POOL-DEPOSIT.md +++ /dev/null @@ -1,495 +0,0 @@ -# Frontend: Stability Pool Deposit Guide - -## Overview - -This guide covers how the frontend should handle deposits into stability pools (both collateral and leveraged pools). - -## Deposit Function - -```solidity -function deposit( - uint256 assetAmount, // Amount to deposit (use uint256(-1) for all balance) - address receiver, // Address to receive the deposit shares - uint256 minAmount // Minimum amount to deposit (slippage protection) -) external returns (uint256 sharesMinted); -``` - -## Step-by-Step Frontend Flow - -### Step 1: Check Prerequisites - -```typescript -// utils/stabilityPool.ts -import { Contract } from "ethers"; -import { STABILITY_POOL_ABI } from "../abis/StabilityPool"; -import { ERC20_ABI } from "../abis/ERC20"; - -export async function checkDepositPrerequisites( - poolAddress: string, - userAddress: string, - amount: bigint, - provider: any, -): Promise<{ - canDeposit: boolean; - errors: string[]; - minDeposit: bigint; - userBalance: bigint; - allowance: bigint; -}> { - const pool = new Contract(poolAddress, STABILITY_POOL_ABI, provider); - const errors: string[] = []; - - // Get asset token address - const assetTokenAddress = await pool.ASSET_TOKEN(); - const assetToken = new Contract(assetTokenAddress, ERC20_ABI, provider); - - // Check minimum deposit - const minDeposit = await pool.MIN_DEPOSIT(); - - // Check user balance - const userBalance = await assetToken.balanceOf(userAddress); - - // Check allowance - const allowance = await assetToken.allowance(userAddress, poolAddress); - - // Validate - if (amount > userBalance) { - errors.push("Insufficient balance"); - } - - if (amount < minDeposit && amount !== BigInt("0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff")) { - errors.push(`Amount below minimum deposit: ${minDeposit.toString()}`); - } - - if (allowance < amount) { - errors.push("Insufficient allowance. Please approve first."); - } - - return { - canDeposit: errors.length === 0, - errors, - minDeposit, - userBalance, - allowance, - }; -} -``` - -### Step 2: Approve Token (If Needed) - -```typescript -// hooks/useStabilityPoolDeposit.ts -import { useContractWrite, useWaitForTransaction } from "wagmi"; -import { useAccount } from "wagmi"; - -export function useApproveStabilityPool(poolAddress: string, assetTokenAddress: string) { - const { address } = useAccount(); - - const { - write: approve, - data: approveData, - isLoading: isApproving, - } = useContractWrite({ - address: assetTokenAddress as `0x${string}`, - abi: ERC20_ABI, - functionName: "approve", - args: [ - poolAddress as `0x${string}`, - BigInt("0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"), // Max approval - ], - }); - - const { isLoading: isWaiting } = useWaitForTransaction({ - hash: approveData?.hash, - }); - - return { - approve, - isApproving: isApproving || isWaiting, - approveData, - }; -} -``` - -### Step 3: Execute Deposit - -```typescript -// hooks/useStabilityPoolDeposit.ts -export function useStabilityPoolDeposit( - poolAddress: string, - assetAmount: bigint, - receiver: string, - minAmount: bigint = BigInt(0), // Default to 0 (no slippage protection) -) { - const { - write: deposit, - data: depositData, - isLoading: isDepositing, - error, - } = useContractWrite({ - address: poolAddress as `0x${string}`, - abi: STABILITY_POOL_ABI, - functionName: "deposit", - args: [assetAmount, receiver as `0x${string}`, minAmount], - }); - - const { isLoading: isWaiting, isSuccess } = useWaitForTransaction({ - hash: depositData?.hash, - }); - - return { - deposit, - isDepositing: isDepositing || isWaiting, - depositData, - isSuccess, - error, - }; -} -``` - -## Complete React Component - -```typescript -// components/StabilityPoolDeposit.tsx -import { useState, useEffect } from "react"; -import { useAccount } from "wagmi"; -import { parseEther, formatEther } from "viem"; -import { useApproveStabilityPool } from "../hooks/useStabilityPoolDeposit"; -import { useStabilityPoolDeposit } from "../hooks/useStabilityPoolDeposit"; -import { checkDepositPrerequisites } from "../utils/stabilityPool"; - -interface Props { - poolAddress: string; - assetTokenAddress: string; - poolType: "collateral" | "leveraged"; -} - -export function StabilityPoolDeposit({ poolAddress, assetTokenAddress, poolType }: Props) { - const { address } = useAccount(); - const [amount, setAmount] = useState(""); - const [receiver, setReceiver] = useState(address || ""); - const [prerequisites, setPrerequisites] = useState(null); - const [loading, setLoading] = useState(false); - - const { approve, isApproving } = useApproveStabilityPool(poolAddress, assetTokenAddress); - const { deposit, isDepositing, isSuccess } = useStabilityPoolDeposit( - poolAddress, - amount ? parseEther(amount) : BigInt(0), - receiver, - BigInt(0) // minAmount - can add slippage protection - ); - - // Check prerequisites when amount changes - useEffect(() => { - async function check() { - if (!amount || !address) return; - - setLoading(true); - const result = await checkDepositPrerequisites( - poolAddress, - address, - parseEther(amount), - provider // You'll need to get provider from wagmi - ); - setPrerequisites(result); - setLoading(false); - } - check(); - }, [amount, address, poolAddress]); - - const needsApproval = prerequisites?.allowance < parseEther(amount || "0"); - const canDeposit = prerequisites?.canDeposit && !needsApproval; - - const handleDeposit = () => { - if (needsApproval) { - approve?.(); - } else { - deposit?.(); - } - }; - - return ( -
-

Deposit to {poolType === "collateral" ? "Collateral" : "Leveraged"} Pool

- -
- - setAmount(e.target.value)} - placeholder="0.0" - /> - {prerequisites?.minDeposit && ( -

- Minimum: {formatEther(prerequisites.minDeposit)} -

- )} -
- -
- - setReceiver(e.target.value)} - placeholder={address} - /> -

- Address to receive deposit shares (defaults to your address) -

-
- - {prerequisites?.errors && prerequisites.errors.length > 0 && ( -
- {prerequisites.errors.map((error, i) => ( -

{error}

- ))} -
- )} - - - - {isSuccess && ( -
Deposit successful!
- )} -
- ); -} -``` - -## Important Considerations - -### 1. Token Approval - -**Always check allowance first:** - -```typescript -const allowance = await assetToken.allowance(userAddress, poolAddress); -if (allowance < amount) { - // Request approval - await assetToken.approve(poolAddress, amount); - // Or approve max: await assetToken.approve(poolAddress, uint256(-1)); -} -``` - -### 2. Minimum Deposit - -**Check minimum deposit:** - -```typescript -const minDeposit = await pool.MIN_DEPOSIT(); -if (amount < minDeposit) { - throw new Error(`Amount must be at least ${minDeposit}`); -} -``` - -### 3. Deposit All Balance - -**To deposit entire balance:** - -```typescript -const maxUint256 = BigInt("0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); -await pool.deposit(maxUint256, receiver, BigInt(0)); -``` - -### 4. Withdrawal Request Cancellation - -**Important:** If the user has an active withdrawal request, depositing will **cancel** it: - -- The withdrawal request window will be cleared -- User will need to create a new withdrawal request if they want to withdraw later - -### 5. Receiver Address - -**The `receiver` parameter:** - -- Can be different from `msg.sender` (the depositor) -- Receives the deposit shares -- Must not be zero address -- Typically set to user's own address, but can be used for depositing on behalf of others - -## Error Handling - -```typescript -// Common errors to handle -const ERROR_MESSAGES: Record = { - DepositZeroAmount: "Cannot deposit zero amount", - DepositAmountLessThanMinimum: "Amount below minimum deposit", - InvalidReceiver: "Invalid receiver address", - "ERC20: insufficient allowance": "Please approve token first", - "ERC20: transfer amount exceeds allowance": "Insufficient allowance", - "ERC20: transfer amount exceeds balance": "Insufficient balance", -}; - -try { - await deposit(); -} catch (error: any) { - const errorMessage = error.message || error.reason || "Unknown error"; - const userMessage = ERROR_MESSAGES[errorMessage] || errorMessage; - // Show error to user -} -``` - -## Dry Run / Preview - -```typescript -// Preview deposit (read-only, no transaction) -export async function previewDeposit( - poolAddress: string, - assetAmount: bigint, - provider: any, -): Promise<{ - sharesMinted: bigint; - currentBalance: bigint; - newBalance: bigint; -}> { - const pool = new Contract(poolAddress, STABILITY_POOL_ABI, provider); - - // Get current balance - const currentBalance = await pool.assetBalanceOf(userAddress); - - // Estimate shares (this is approximate - actual shares depend on pool state) - // Note: Stability pools don't have a preview function, so this is an estimate - const totalSupply = await pool.totalAssetSupply(); - const sharesMinted = - totalSupply === BigInt(0) - ? assetAmount // First deposit: 1:1 - : (assetAmount * totalSupply) / (totalSupply + assetAmount); // Approximate - - return { - sharesMinted, - currentBalance, - newBalance: currentBalance + assetAmount, - }; -} -``` - -## Complete Hook with All Features - -```typescript -// hooks/useStabilityPoolDepositComplete.ts -import { useState, useEffect } from "react"; -import { useAccount, usePublicClient } from "wagmi"; -import { parseEther } from "viem"; -import { useContractWrite, useWaitForTransaction } from "wagmi"; - -export function useStabilityPoolDepositComplete(poolAddress: string) { - const { address } = useAccount(); - const publicClient = usePublicClient(); - const [assetTokenAddress, setAssetTokenAddress] = useState(null); - const [minDeposit, setMinDeposit] = useState(null); - - // Fetch pool info - useEffect(() => { - async function fetchPoolInfo() { - if (!poolAddress || !publicClient) return; - - const assetToken = await publicClient.readContract({ - address: poolAddress as `0x${string}`, - abi: STABILITY_POOL_ABI, - functionName: "ASSET_TOKEN", - }); - - const minDep = await publicClient.readContract({ - address: poolAddress as `0x${string}`, - abi: STABILITY_POOL_ABI, - functionName: "MIN_DEPOSIT", - }); - - setAssetTokenAddress(assetToken); - setMinDeposit(minDep); - } - fetchPoolInfo(); - }, [poolAddress, publicClient]); - - // Approval - const { write: approve, data: approveData } = useContractWrite({ - address: assetTokenAddress as `0x${string}`, - abi: ERC20_ABI, - functionName: "approve", - args: [poolAddress as `0x${string}`, BigInt("0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff")], - enabled: !!assetTokenAddress, - }); - - const { isLoading: isApproving } = useWaitForTransaction({ - hash: approveData?.hash, - }); - - // Deposit - const deposit = (amount: bigint, receiver: string = address || "") => { - return useContractWrite({ - address: poolAddress as `0x${string}`, - abi: STABILITY_POOL_ABI, - functionName: "deposit", - args: [amount, receiver as `0x${string}`, BigInt(0)], - }); - }; - - return { - assetTokenAddress, - minDeposit, - approve, - isApproving, - deposit, - }; -} -``` - -## UI/UX Recommendations - -### 1. Show Current Balance - -```typescript -const currentBalance = await pool.assetBalanceOf(userAddress); -const totalSupply = await pool.totalAssetSupply(); -const userShare = totalSupply > 0 ? (currentBalance * 100n) / totalSupply : 0n; -``` - -### 2. Show Estimated APR - -Use the APR calculation guide to show projected returns. - -### 3. Show Withdrawal Status - -```typescript -const withdrawalRequest = await pool.getWithdrawalRequest(userAddress); -if (withdrawalRequest.start > 0) { - // Show withdrawal window status - // Warn that deposit will cancel withdrawal request -} -``` - -### 4. Transaction Flow - -1. **Check prerequisites** → Show errors if any -2. **Check approval** → Show "Approve" button if needed -3. **Show deposit button** → Enable when ready -4. **Show transaction status** → Loading, success, error -5. **Refresh data** → Update balances after success - -## Summary Checklist - -- [ ] Check user has sufficient balance -- [ ] Check amount meets minimum deposit -- [ ] Check/request token approval -- [ ] Handle withdrawal request cancellation (warn user) -- [ ] Execute deposit transaction -- [ ] Show transaction status -- [ ] Refresh balances after success -- [ ] Handle errors gracefully -- [ ] Update marks tracking (subgraph will handle automatically) - - diff --git a/doc/guides/FRONTEND-STABILITY-POOL-REWARDS-DISPLAY.md b/doc/guides/FRONTEND-STABILITY-POOL-REWARDS-DISPLAY.md deleted file mode 100644 index bdd151c5..00000000 --- a/doc/guides/FRONTEND-STABILITY-POOL-REWARDS-DISPLAY.md +++ /dev/null @@ -1,462 +0,0 @@ -# Frontend Guide: Displaying Stability Pool Rewards - -This guide explains how to query and display reward tokens, claimable values, and APR for stability pool deposits. - -## Overview - -Stability pools use the `IMultipleRewardDistributor` and `IMultipleRewardAccumulator` interfaces to manage rewards. Each pool can have multiple reward tokens registered. - -## 1. Finding Registered Reward Tokens - -### Get Active Reward Tokens - -```typescript -import { Contract } from "ethers"; - -async function getActiveRewardTokens(stabilityPool: Contract): Promise { - // Returns array of reward token addresses - const tokens = await stabilityPool.activeRewardTokens(); - return tokens; -} -``` - -**Example:** - -```typescript -const collateralPool = new Contract(COLLATERAL_POOL_ADDRESS, STABILITY_POOL_ABI, provider); - -const rewardTokens = await getActiveRewardTokens(collateralPool); -// Returns: ['0x0165878A594ca255338adfa4d48449f69242Eb8F', ...] (wstETH, ha tokens, etc.) -``` - -### Check if Token is Active - -```typescript -async function isRewardTokenActive(stabilityPool: Contract, tokenAddress: string): Promise { - return await stabilityPool.isActiveRewardToken(tokenAddress); -} -``` - -## 2. Getting Claimable Rewards - -### Get Claimable Amount for User - -```typescript -import { formatEther } from "ethers"; - -async function getClaimableRewards( - stabilityPool: Contract, - userAddress: string, - rewardTokenAddress: string, -): Promise { - // Returns claimable amount in wei (18 decimals) - const claimable = await stabilityPool.claimable(userAddress, rewardTokenAddress); - return claimable; -} -``` - -**Complete Example - Get All Claimable Rewards:** - -```typescript -interface ClaimableReward { - token: string; - amount: bigint; - amountFormatted: string; - symbol: string; - usdValue: number; -} - -async function getAllClaimableRewards( - stabilityPool: Contract, - userAddress: string, - tokenPriceMap: Map, // token address -> USD price -): Promise { - const rewardTokens = await stabilityPool.activeRewardTokens(); - const claimableRewards: ClaimableReward[] = []; - - for (const token of rewardTokens) { - const claimable = await stabilityPool.claimable(userAddress, token); - - if (claimable > 0n) { - // Get token symbol (you'll need ERC20 ABI) - const tokenContract = new Contract(token, ERC20_ABI, provider); - const symbol = await tokenContract.symbol(); - - // Calculate USD value - const price = tokenPriceMap.get(token.toLowerCase()) || 0; - const amountFormatted = formatEther(claimable); - const usdValue = parseFloat(amountFormatted) * price; - - claimableRewards.push({ - token, - amount: claimable, - amountFormatted, - symbol, - usdValue, - }); - } - } - - return claimableRewards; -} -``` - -### Calculate Total Claimable Value (USD) - -```typescript -async function getTotalClaimableValue( - stabilityPool: Contract, - userAddress: string, - tokenPriceMap: Map, -): Promise { - const rewards = await getAllClaimableRewards(stabilityPool, userAddress, tokenPriceMap); - - return rewards.reduce((total, reward) => total + reward.usdValue, 0); -} -``` - -## 3. Calculating Current APR - -APR calculation depends on the current reward rate and user's deposit. Here's how to calculate it: - -### Get Reward Data - -```typescript -interface RewardData { - lastUpdate: bigint; - finishAt: bigint; - rate: bigint; // rewards per second - queued: bigint; // queued rewards for next period -} - -async function getRewardData(stabilityPool: Contract, rewardTokenAddress: string): Promise { - const [lastUpdate, finishAt, rate, queued] = await stabilityPool.rewardData(rewardTokenAddress); - - return { - lastUpdate, - finishAt, - rate, - queued, - }; -} -``` - -### Calculate APR for a Specific Reward Token - -```typescript -async function calculateAPR( - stabilityPool: Contract, - rewardTokenAddress: string, - userAddress: string, - rewardTokenPrice: number, // USD price of reward token - depositTokenPrice: number, // USD price of deposit token (ha token) -): Promise { - // 1. Get reward rate - const rewardData = await getRewardData(stabilityPool, rewardTokenAddress); - const ratePerSecond = rewardData.rate; // rewards per second - - if (ratePerSecond === 0n) { - return 0; // No rewards currently - } - - // 2. Get total pool supply - const totalSupply = await stabilityPool.totalAssetSupply(); - - if (totalSupply === 0n) { - return 0; // No deposits - } - - // 3. Calculate rate per token per second - const ratePerTokenPerSecond = Number(ratePerSecond) / Number(totalSupply); - - // 4. Get user balance - const userBalance = await stabilityPool.assetBalanceOf(userAddress); - - if (userBalance === 0n) { - return 0; // User has no deposit - } - - // 5. Calculate annual rewards for user - const SECONDS_PER_YEAR = 365 * 24 * 60 * 60; - const annualRewards = ratePerTokenPerSecond * Number(userBalance) * SECONDS_PER_YEAR; - - // 6. Calculate USD values - const userDepositValueUSD = (Number(userBalance) * depositTokenPrice) / 1e18; - const annualRewardsValueUSD = (annualRewards * rewardTokenPrice) / 1e18; - - // 7. Calculate APR - if (userDepositValueUSD === 0) { - return 0; - } - - const apr = (annualRewardsValueUSD / userDepositValueUSD) * 100; - return apr; -} -``` - -### Calculate Combined APR (All Reward Tokens) - -```typescript -async function calculateCombinedAPR( - stabilityPool: Contract, - userAddress: string, - tokenPriceMap: Map, - depositTokenPrice: number, -): Promise { - const rewardTokens = await stabilityPool.activeRewardTokens(); - let totalAPR = 0; - - for (const token of rewardTokens) { - const rewardPrice = tokenPriceMap.get(token.toLowerCase()) || 0; - const apr = await calculateAPR(stabilityPool, token, userAddress, rewardPrice, depositTokenPrice); - totalAPR += apr; - } - - return totalAPR; -} -``` - -## 4. Complete React Hook Example - -```typescript -import { useState, useEffect } from "react"; -import { Contract } from "ethers"; -import { formatEther } from "ethers"; - -interface StabilityPoolRewards { - claimableValue: number; - apr: number; - rewardTokens: Array<{ - address: string; - symbol: string; - claimable: string; - claimableUSD: number; - apr: number; - }>; - loading: boolean; -} - -export function useStabilityPoolRewards( - stabilityPool: Contract | null, - userAddress: string | null, - tokenPriceMap: Map, - depositTokenPrice: number, -): StabilityPoolRewards { - const [rewards, setRewards] = useState({ - claimableValue: 0, - apr: 0, - rewardTokens: [], - loading: true, - }); - - useEffect(() => { - if (!stabilityPool || !userAddress) { - setRewards((prev) => ({ ...prev, loading: false })); - return; - } - - async function fetchRewards() { - try { - // Get active reward tokens - const rewardTokens = await stabilityPool.activeRewardTokens(); - - const rewardData = await Promise.all( - rewardTokens.map(async (token: string) => { - // Get claimable amount - const claimable = await stabilityPool.claimable(userAddress, token); - - // Get token symbol - const tokenContract = new Contract(token, ERC20_ABI, provider); - const symbol = await tokenContract.symbol(); - - // Calculate USD value - const price = tokenPriceMap.get(token.toLowerCase()) || 0; - const claimableFormatted = formatEther(claimable); - const claimableUSD = parseFloat(claimableFormatted) * price; - - // Calculate APR for this token - const apr = await calculateAPR(stabilityPool, token, userAddress, price, depositTokenPrice); - - return { - address: token, - symbol, - claimable: claimableFormatted, - claimableUSD, - apr, - }; - }), - ); - - // Calculate totals - const claimableValue = rewardData.reduce((sum, r) => sum + r.claimableUSD, 0); - const apr = rewardData.reduce((sum, r) => sum + r.apr, 0); - - setRewards({ - claimableValue, - apr, - rewardTokens: rewardData, - loading: false, - }); - } catch (error) { - console.error("Error fetching rewards:", error); - setRewards((prev) => ({ ...prev, loading: false })); - } - } - - fetchRewards(); - - // Refresh every 30 seconds - const interval = setInterval(fetchRewards, 30000); - return () => clearInterval(interval); - }, [stabilityPool, userAddress, tokenPriceMap, depositTokenPrice]); - - return rewards; -} -``` - -## 5. Displaying in UI - -### Example Component - -```typescript -function StabilityPoolRewardsDisplay({ pool, userAddress }) { - const { claimableValue, apr, rewardTokens, loading } = useStabilityPoolRewards( - pool, - userAddress, - tokenPriceMap, - depositTokenPrice - ); - - if (loading) { - return
Loading rewards...
; - } - - return ( -
- {/* Total Claimable Value */} -
-

Claimable Value

-

${claimableValue.toFixed(2)}

-
- - {/* APR */} -
-

APR

-

{apr.toFixed(2)}%

-
- - {/* Reward Tokens */} -
-

Reward Assets

- {rewardTokens.map((reward) => ( -
- {reward.symbol} - {reward.claimable} - ${reward.claimableUSD.toFixed(2)} - {reward.apr.toFixed(2)}% APR -
- ))} -
- - {/* Claim Button */} - -
- ); -} -``` - -## 6. Important Notes - -### Reward Period Length - -Rewards vest over a period (typically 7 days). The `rate` represents rewards per second during the active period. - -```typescript -// Get reward period length -const REWARD_PERIOD_LENGTH = await stabilityPool.REWARD_PERIOD_LENGTH(); -// Typically: 604800 (7 days in seconds) -``` - -### Pending vs Claimable - -- **Pending**: Rewards that are being distributed but not yet fully claimable -- **Claimable**: Rewards that can be claimed right now - -The `claimable()` function returns only what can be claimed immediately. - -### Multiple Reward Tokens - -A pool can have multiple reward tokens (e.g., wstETH, ha tokens). You need to: - -1. Query all active tokens -2. Calculate claimable for each -3. Calculate APR for each -4. Sum them for totals - -### Price Feeds - -You'll need USD prices for: - -- Reward tokens (to show USD value) -- Deposit token (to calculate APR) - -Use your existing price feed system (Chainlink, CoinGecko, etc.). - -## 7. Contract ABI Requirements - -You'll need these interfaces: - -```typescript -const STABILITY_POOL_ABI = [ - "function activeRewardTokens() view returns (address[])", - "function isActiveRewardToken(address) view returns (bool)", - "function claimable(address, address) view returns (uint256)", - "function rewardData(address) view returns (uint256, uint256, uint256, uint256)", - "function assetBalanceOf(address) view returns (uint256)", - "function totalAssetSupply() view returns (uint256)", - "function REWARD_PERIOD_LENGTH() view returns (uint40)", - "function ASSET_TOKEN() view returns (address)", -]; - -const ERC20_ABI = ["function symbol() view returns (string)", "function decimals() view returns (uint8)"]; -``` - -## 8. Performance Optimization - -### Batch Queries - -```typescript -// Use multicall to batch queries -import { Multicall } from "@makerdao/multicall"; - -const multicall = new Multicall({ - multicallAddress: MULTICALL_ADDRESS, - provider, -}); - -const calls = rewardTokens.map((token) => ({ - target: stabilityPool.address, - call: ["claimable(address,address)(uint256)", userAddress, token], - returns: [["claimable", (val) => val]], -})); - -const results = await multicall.aggregate(calls); -``` - -### Caching - -- Cache reward token list (changes infrequently) -- Cache token symbols (rarely changes) -- Refresh claimable amounts every 30-60 seconds -- Refresh APR every 5-10 minutes (changes less frequently) - -## Summary - -1. **Get reward tokens**: `activeRewardTokens()` -2. **Get claimable**: `claimable(userAddress, tokenAddress)` -3. **Calculate APR**: Use `rewardData()` to get rate, then calculate based on user balance -4. **Display**: Show total claimable value, APR, and breakdown by token - -This gives users a complete view of their rewards and expected returns! - - diff --git a/doc/guides/FRONTEND-STABILITY-POOL-WITHDRAWAL-REQUEST.md b/doc/guides/FRONTEND-STABILITY-POOL-WITHDRAWAL-REQUEST.md deleted file mode 100644 index d4f604f4..00000000 --- a/doc/guides/FRONTEND-STABILITY-POOL-WITHDRAWAL-REQUEST.md +++ /dev/null @@ -1,580 +0,0 @@ -# Frontend: Stability Pool Withdrawal Request Guide - -## Overview - -Users can **bypass the early withdrawal fee** by creating a withdrawal request and waiting for the fee-free window. This guide covers implementing the withdrawal request flow in the frontend. - -## How It Works - -### Withdrawal Request Window - -1. **Request Withdrawal**: User calls `requestWithdrawal()` to create a request -2. **Wait Period**: Must wait for `WITHDRAWAL_START_DELAY` seconds -3. **Fee-Free Window**: During `[start, end]`, withdrawals are **fee-free** -4. **Window Duration**: `WITHDRAWAL_END_WINDOW` seconds (e.g., 1 day = 86400 seconds) - -### Fee Rules - -- **Before window starts**: Early withdrawal fee applies -- **During window [start, end]**: **NO FEE** ✅ -- **After window ends**: Early withdrawal fee applies again - -### Important Notes - -- **Depositing cancels the request**: If user deposits during an active window, the request is cancelled -- **Withdrawal clears the request**: After withdrawing, the request window is cleared -- **No request needed**: Users can withdraw without a request, but will pay the fee - -## Contract Functions - -### Read Functions - -```solidity -// Get user's withdrawal request window -function getWithdrawalRequest(address account) external view returns (uint64 start, uint64 end); - -// Get global withdrawal window configuration -function getWithdrawalWindow() external view returns (uint64 startDelay, uint64 endWindow); - -// Get early withdrawal fee ratio (scaled by 1e18, e.g., 0.025e18 = 2.5%) -function getEarlyWithdrawalFee() external view returns (uint256); - -// Get fee receiver address -function getFeeAddress() external view returns (address); -``` - -### Write Functions - -```solidity -// Create or update withdrawal request -function requestWithdrawal() external; - -// Withdraw (works with or without request) -function withdraw(uint256 assetAmount, address receiver, uint256 minAmount) external returns (uint256); -``` - -## Frontend Implementation - -### Step 1: Check Withdrawal Request Status - -```typescript -// hooks/useWithdrawalRequest.ts -import { useState, useEffect } from "react"; -import { useAccount, usePublicClient } from "wagmi"; -import { STABILITY_POOL_ABI } from "../abis/StabilityPool"; - -export interface WithdrawalRequestStatus { - hasRequest: boolean; - start: bigint | null; - end: bigint | null; - startDelay: bigint; - endWindow: bigint; - earlyWithdrawalFee: bigint; - feeAddress: string; - currentTime: bigint; - status: "none" | "waiting" | "active" | "expired"; - timeUntilStart: number | null; // seconds - timeUntilEnd: number | null; // seconds - canWithdrawFeeFree: boolean; -} - -export function useWithdrawalRequest(poolAddress: string) { - const { address } = useAccount(); - const publicClient = usePublicClient(); - const [status, setStatus] = useState(null); - const [loading, setLoading] = useState(true); - - useEffect(() => { - async function fetchStatus() { - if (!address || !poolAddress || !publicClient) { - setLoading(false); - return; - } - - try { - // Get user's withdrawal request - const [start, end] = await publicClient.readContract({ - address: poolAddress as `0x${string}`, - abi: STABILITY_POOL_ABI, - functionName: "getWithdrawalRequest", - args: [address as `0x${string}`], - }); - - // Get global configuration - const [startDelay, endWindow] = await publicClient.readContract({ - address: poolAddress as `0x${string}`, - abi: STABILITY_POOL_ABI, - functionName: "getWithdrawalWindow", - }); - - // Get fee info - const earlyWithdrawalFee = await publicClient.readContract({ - address: poolAddress as `0x${string}`, - abi: STABILITY_POOL_ABI, - functionName: "getEarlyWithdrawalFee", - }); - - const feeAddress = await publicClient.readContract({ - address: poolAddress as `0x${string}`, - abi: STABILITY_POOL_ABI, - functionName: "getFeeAddress", - }); - - // Get current block timestamp - const block = await publicClient.getBlock({ blockTag: "latest" }); - const currentTime = BigInt(block.timestamp); - - // Determine status - let statusType: "none" | "waiting" | "active" | "expired" = "none"; - let canWithdrawFeeFree = false; - let timeUntilStart: number | null = null; - let timeUntilEnd: number | null = null; - - if (start > 0 && end > start) { - if (currentTime < start) { - statusType = "waiting"; - timeUntilStart = Number(start - currentTime); - } else if (currentTime >= start && currentTime <= end) { - statusType = "active"; - canWithdrawFeeFree = true; - timeUntilEnd = Number(end - currentTime); - } else { - statusType = "expired"; - } - } - - setStatus({ - hasRequest: start > 0 && end > start, - start: start > 0 ? start : null, - end: end > start ? end : null, - startDelay, - endWindow, - earlyWithdrawalFee, - feeAddress, - currentTime, - status: statusType, - timeUntilStart, - timeUntilEnd, - canWithdrawFeeFree, - }); - } catch (err) { - console.error("Error fetching withdrawal request:", err); - } finally { - setLoading(false); - } - } - - fetchStatus(); - // Refresh every 10 seconds to update countdown - const interval = setInterval(fetchStatus, 10000); - return () => clearInterval(interval); - }, [address, poolAddress, publicClient]); - - return { status, loading }; -} -``` - -### Step 2: Create Withdrawal Request - -```typescript -// hooks/useRequestWithdrawal.ts -import { useContractWrite, useWaitForTransaction } from "wagmi"; -import { STABILITY_POOL_ABI } from "../abis/StabilityPool"; - -export function useRequestWithdrawal(poolAddress: string) { - const { write: requestWithdrawal, data: requestData, isLoading: isRequesting, error } = useContractWrite({ - address: poolAddress as `0x${string}`, - abi: STABILITY_POOL_ABI, - functionName: "requestWithdrawal", - }); - - const { isLoading: isWaiting, isSuccess } = useWaitForTransaction({ - hash: requestData?.hash, - }); - - return { - requestWithdrawal, - isRequesting: isRequesting || isWaiting, - isSuccess, - error, - }; -} -``` - -### Step 3: Calculate Fee - -```typescript -// utils/withdrawalFee.ts -export function calculateWithdrawalFee( - amount: bigint, - earlyWithdrawalFee: bigint, // scaled by 1e18 - canWithdrawFeeFree: boolean -): { - feeAmount: bigint; - netAmount: bigint; - feePercentage: number; -} { - if (canWithdrawFeeFree) { - return { - feeAmount: BigInt(0), - netAmount: amount, - feePercentage: 0, - }; - } - - // Fee is scaled by 1e18, so divide by 1e18 to get percentage - const feeAmount = (amount * earlyWithdrawalFee) / BigInt("1000000000000000000"); - const netAmount = amount - feeAmount; - const feePercentage = Number(earlyWithdrawalFee) / 1e18 * 100; - - return { - feeAmount, - netAmount, - feePercentage, - }; -} -``` - -### Step 4: Format Time Remaining - -```typescript -// utils/timeFormat.ts -export function formatTimeRemaining(seconds: number): string { - if (seconds <= 0) return "Now"; - - const days = Math.floor(seconds / 86400); - const hours = Math.floor((seconds % 86400) / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - const secs = seconds % 60; - - const parts: string[] = []; - if (days > 0) parts.push(`${days}d`); - if (hours > 0) parts.push(`${hours}h`); - if (minutes > 0) parts.push(`${minutes}m`); - if (secs > 0 && days === 0) parts.push(`${secs}s`); - - return parts.join(" ") || "Now"; -} - -export function formatDate(timestamp: bigint): string { - return new Date(Number(timestamp) * 1000).toLocaleString(); -} -``` - -## Complete React Component - -```typescript -// components/StabilityPoolWithdrawal.tsx -import { useState } from "react"; -import { useAccount } from "wagmi"; -import { parseEther, formatEther } from "viem"; -import { useWithdrawalRequest } from "../hooks/useWithdrawalRequest"; -import { useRequestWithdrawal } from "../hooks/useRequestWithdrawal"; -import { useStabilityPoolWithdraw } from "../hooks/useStabilityPoolWithdraw"; -import { calculateWithdrawalFee } from "../utils/withdrawalFee"; -import { formatTimeRemaining, formatDate } from "../utils/timeFormat"; - -interface Props { - poolAddress: string; - userBalance: bigint; -} - -export function StabilityPoolWithdrawal({ poolAddress, userBalance }: Props) { - const { address } = useAccount(); - const [amount, setAmount] = useState(""); - const [showRequestFlow, setShowRequestFlow] = useState(false); - - const { status, loading: statusLoading } = useWithdrawalRequest(poolAddress); - const { requestWithdrawal, isRequesting, isSuccess: requestSuccess } = useRequestWithdrawal(poolAddress); - const { withdraw, isWithdrawing } = useStabilityPoolWithdraw(poolAddress); - - const amountBigInt = amount ? parseEther(amount) : BigInt(0); - const feeInfo = status - ? calculateWithdrawalFee(amountBigInt, status.earlyWithdrawalFee, status.canWithdrawFeeFree) - : null; - - const handleRequestWithdrawal = () => { - requestWithdrawal?.(); - }; - - const handleWithdraw = () => { - if (!amount || !feeInfo) return; - withdraw?.(amountBigInt, address || "", feeInfo.netAmount); - }; - - if (statusLoading) { - return
Loading withdrawal status...
; - } - - return ( -
-

Withdraw from Stability Pool

- - {/* Withdrawal Request Status */} - {status && status.hasRequest && ( -
-
-
-

- {status.status === "active" && "✅ Fee-Free Withdrawal Available"} - {status.status === "waiting" && "⏳ Waiting for Fee-Free Window"} - {status.status === "expired" && "⚠️ Withdrawal Window Expired"} -

- {status.status === "waiting" && status.timeUntilStart && ( -

- Fee-free window starts in: {formatTimeRemaining(status.timeUntilStart)} -

- )} - {status.status === "active" && status.timeUntilEnd && ( -

- Window closes in: {formatTimeRemaining(status.timeUntilEnd)} -

- )} - {status.start && status.end && ( -

- Window: {formatDate(status.start)} - {formatDate(status.end)} -

- )} -
- {status.status === "active" && ( -
- No Fee -
- )} -
-
- )} - - {/* No Request - Show Option to Create */} - {status && !status.hasRequest && ( -
-

Avoid Early Withdrawal Fee

-

- Create a withdrawal request to access a fee-free withdrawal window. You'll need to wait{" "} - {formatTimeRemaining(Number(status.startDelay))} after requesting. -

- - {requestSuccess && ( -

✅ Withdrawal request created!

- )} -
- )} - - {/* Warning: Deposit Cancels Request */} - {status && status.hasRequest && ( -
-

- ⚠️ Note: Depositing during an active withdrawal window will cancel your request. -

-
- )} - - {/* Withdrawal Form */} -
- - - {/* Fee Calculation */} - {feeInfo && amount && ( -
-
- Withdrawal Amount: - {formatEther(amountBigInt)} tokens -
- {feeInfo.feeAmount > 0 ? ( - <> -
- Early Withdrawal Fee ({feeInfo.feePercentage.toFixed(2)}%): - -{formatEther(feeInfo.feeAmount)} tokens -
-
- You'll Receive: - {formatEther(feeInfo.netAmount)} tokens -
- {!status.canWithdrawFeeFree && ( -

- 💡 Create a withdrawal request to avoid this fee -

- )} - - ) : ( -
- You'll Receive (No Fee): - {formatEther(feeInfo.netAmount)} tokens -
- )} -
- )} - - {/* Withdraw Button */} - -
-
- ); -} -``` - -## Withdrawal Hook - -```typescript -// hooks/useStabilityPoolWithdraw.ts -import { useContractWrite, useWaitForTransaction } from "wagmi"; -import { STABILITY_POOL_ABI } from "../abis/StabilityPool"; - -export function useStabilityPoolWithdraw( - poolAddress: string, - assetAmount: bigint, - receiver: string, - minAmount: bigint = BigInt(0) -) { - const { write: withdraw, data: withdrawData, isLoading: isWithdrawing, error } = useContractWrite({ - address: poolAddress as `0x${string}`, - abi: STABILITY_POOL_ABI, - functionName: "withdraw", - args: [assetAmount, receiver as `0x${string}`, minAmount], - }); - - const { isLoading: isWaiting, isSuccess } = useWaitForTransaction({ - hash: withdrawData?.hash, - }); - - return { - withdraw, - isWithdrawing: isWithdrawing || isWaiting, - isSuccess, - error, - }; -} -``` - -## UI/UX Recommendations - -### 1. Status Indicators - -```typescript -// Visual status indicators -const statusConfig = { - none: { - color: "gray", - icon: "ℹ️", - message: "No withdrawal request. Early withdrawal fee applies.", - }, - waiting: { - color: "yellow", - icon: "⏳", - message: `Fee-free window starts in ${formatTimeRemaining(timeUntilStart)}`, - }, - active: { - color: "green", - icon: "✅", - message: "Fee-free withdrawal available now!", - }, - expired: { - color: "orange", - icon: "⚠️", - message: "Withdrawal window expired. Fee applies.", - }, -}; -``` - -### 2. Countdown Timer - -```typescript -// components/CountdownTimer.tsx -export function CountdownTimer({ targetTimestamp }: { targetTimestamp: bigint }) { - const [timeRemaining, setTimeRemaining] = useState(null); - - useEffect(() => { - const updateTimer = () => { - const now = Math.floor(Date.now() / 1000); - const remaining = Number(targetTimestamp) - now; - setTimeRemaining(remaining > 0 ? remaining : 0); - }; - - updateTimer(); - const interval = setInterval(updateTimer, 1000); - return () => clearInterval(interval); - }, [targetTimestamp]); - - if (timeRemaining === null) return null; - if (timeRemaining <= 0) return Now; - - return {formatTimeRemaining(timeRemaining)}; -} -``` - -### 3. Deposit Warning - -```typescript -// Show warning when user tries to deposit during active withdrawal window -{status?.hasRequest && status.status === "active" && ( -
-

- ⚠️ Warning: Depositing now will cancel your fee-free withdrawal window. - Consider withdrawing first. -

-
-)} -``` - -## Summary - -### Key Points - -1. **Request First**: Call `requestWithdrawal()` to create a fee-free window -2. **Wait Period**: Must wait `WITHDRAWAL_START_DELAY` seconds -3. **Fee-Free Window**: Withdraw during `[start, end]` to avoid fees -4. **Deposit Cancels**: Depositing during active window cancels the request -5. **Withdrawal Clears**: Withdrawing clears the request window - -### User Flow - -1. User wants to withdraw → Check if they have a request -2. If no request → Show option to create one (with wait time) -3. If request exists → Show countdown to fee-free window -4. When window is active → Show "No Fee" indicator -5. User withdraws → Fee-free if in window, fee applies otherwise - -### Example Timeline - -- **Day 0, 00:00**: User creates withdrawal request -- **Day 0, 00:00 - Day 7, 00:00**: Waiting period (7 days, example) -- **Day 7, 00:00 - Day 8, 00:00**: Fee-free window (1 day, example) -- **After Day 8, 00:00**: Window expired, fee applies again - -This allows users to plan withdrawals and avoid fees by waiting for the fee-free window! - diff --git a/doc/guides/FRONTEND-TROUBLESHOOTING.md b/doc/guides/FRONTEND-TROUBLESHOOTING.md deleted file mode 100644 index 03c0dab1..00000000 --- a/doc/guides/FRONTEND-TROUBLESHOOTING.md +++ /dev/null @@ -1,340 +0,0 @@ -# Frontend Troubleshooting - wstETH Balance Issue - -## ✅ Contract Verification - -The wstETH contract is **deployed and working correctly**: - -- **Address**: `0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512` -- **Contract Code**: ✅ Deployed -- **Symbol**: `wstETH` ✅ -- **Balance of Dev Address**: 1000 tokens ✅ -- **balanceOf() function**: ✅ Working - -## 🔍 Common Issues & Solutions - -### 1. Wrong RPC URL - -**Problem**: Frontend connecting to wrong network - -**Solution**: Ensure your frontend uses: - -```typescript -const RPC_URL = "http://localhost:8545"; -const CHAIN_ID = 31337; -``` - -### 2. Network Not Added to Wallet - -**Problem**: MetaMask/wallet doesn't recognize the network - -**Solution**: Add custom network: - -```typescript -const anvilNetwork = { - chainId: 31337, - chainName: "Anvil Local", - nativeCurrency: { - name: "Ether", - symbol: "ETH", - decimals: 18, - }, - rpcUrls: ["http://localhost:8545"], - blockExplorerUrls: [], -}; - -// Add to wallet -await window.ethereum.request({ - method: "wallet_addEthereumChain", - params: [anvilNetwork], -}); -``` - -### 3. Wrong Contract ABI - -**Problem**: Frontend using incorrect ABI - -**Solution**: Use standard ERC20 ABI. The contract implements: - -- `balanceOf(address) returns (uint256)` -- `symbol() returns (string)` -- `decimals() returns (uint8)` -- `totalSupply() returns (uint256)` - -### 4. Contract Address Typo - -**Problem**: Wrong address in frontend config - -**Solution**: Double-check the address: - -``` -0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 -``` - -### 5. Provider Not Connected - -**Problem**: Web3 provider not connected to Anvil - -**Solution**: Verify connection: - -```typescript -// Check if provider is connected -const provider = new ethers.providers.JsonRpcProvider("http://localhost:8545"); -const network = await provider.getNetwork(); -console.log("Chain ID:", network.chainId); // Should be 31337 -``` - -## 🧪 Test Script - -Run this to verify everything works: - -```bash -# Check contract -cast call 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 \ - "balanceOf(address)(uint256)" \ - 0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e \ - --rpc-url http://localhost:8545 - -# Should return: 1000000000000000000000 (1000 tokens) -``` - -## 📋 Quick Checklist - -- [ ] Anvil is running on `http://localhost:8545` -- [ ] Frontend RPC URL is `http://localhost:8545` -- [ ] Chain ID is `31337` -- [ ] Contract address is `0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512` -- [ ] Using standard ERC20 ABI -- [ ] Wallet/provider is connected to Anvil network -- [ ] Network is added to wallet (if using MetaMask) - -## 🔧 Example Frontend Code - -```typescript -import { ethers } from "ethers"; - -const WSTETH_ADDRESS = "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512"; -const DEV_ADDRESS = "0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e"; -const RPC_URL = "http://localhost:8545"; - -// Standard ERC20 ABI (minimal) -const ERC20_ABI = [ - "function balanceOf(address owner) view returns (uint256)", - "function symbol() view returns (string)", - "function decimals() view returns (uint8)", -]; - -async function getBalance() { - const provider = new ethers.providers.JsonRpcProvider(RPC_URL); - const contract = new ethers.Contract(WSTETH_ADDRESS, ERC20_ABI, provider); - - try { - const balance = await contract.balanceOf(DEV_ADDRESS); - const symbol = await contract.symbol(); - const decimals = await contract.decimals(); - - const formatted = ethers.utils.formatUnits(balance, decimals); - console.log(`Balance: ${formatted} ${symbol}`); - return formatted; - } catch (error) { - console.error("Error fetching balance:", error); - throw error; - } -} -``` - -## ✅ Verification Commands - -```bash -# 1. Check contract exists -cast code 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 --rpc-url local - -# 2. Check balance -cast call 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 \ - "balanceOf(address)(uint256)" \ - 0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e \ - --rpc-url local - -# 3. Check symbol -cast call 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 \ - "symbol()(string)" \ - --rpc-url local - -# 4. Check chain ID -cast chain-id --rpc-url local -``` - ---- - -**All checks pass on the backend** - the issue is likely in the frontend configuration or connection. - - -## ✅ Contract Verification - -The wstETH contract is **deployed and working correctly**: - -- **Address**: `0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512` -- **Contract Code**: ✅ Deployed -- **Symbol**: `wstETH` ✅ -- **Balance of Dev Address**: 1000 tokens ✅ -- **balanceOf() function**: ✅ Working - -## 🔍 Common Issues & Solutions - -### 1. Wrong RPC URL - -**Problem**: Frontend connecting to wrong network - -**Solution**: Ensure your frontend uses: - -```typescript -const RPC_URL = "http://localhost:8545"; -const CHAIN_ID = 31337; -``` - -### 2. Network Not Added to Wallet - -**Problem**: MetaMask/wallet doesn't recognize the network - -**Solution**: Add custom network: - -```typescript -const anvilNetwork = { - chainId: 31337, - chainName: "Anvil Local", - nativeCurrency: { - name: "Ether", - symbol: "ETH", - decimals: 18, - }, - rpcUrls: ["http://localhost:8545"], - blockExplorerUrls: [], -}; - -// Add to wallet -await window.ethereum.request({ - method: "wallet_addEthereumChain", - params: [anvilNetwork], -}); -``` - -### 3. Wrong Contract ABI - -**Problem**: Frontend using incorrect ABI - -**Solution**: Use standard ERC20 ABI. The contract implements: - -- `balanceOf(address) returns (uint256)` -- `symbol() returns (string)` -- `decimals() returns (uint8)` -- `totalSupply() returns (uint256)` - -### 4. Contract Address Typo - -**Problem**: Wrong address in frontend config - -**Solution**: Double-check the address: - -``` -0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 -``` - -### 5. Provider Not Connected - -**Problem**: Web3 provider not connected to Anvil - -**Solution**: Verify connection: - -```typescript -// Check if provider is connected -const provider = new ethers.providers.JsonRpcProvider("http://localhost:8545"); -const network = await provider.getNetwork(); -console.log("Chain ID:", network.chainId); // Should be 31337 -``` - -## 🧪 Test Script - -Run this to verify everything works: - -```bash -# Check contract -cast call 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 \ - "balanceOf(address)(uint256)" \ - 0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e \ - --rpc-url http://localhost:8545 - -# Should return: 1000000000000000000000 (1000 tokens) -``` - -## 📋 Quick Checklist - -- [ ] Anvil is running on `http://localhost:8545` -- [ ] Frontend RPC URL is `http://localhost:8545` -- [ ] Chain ID is `31337` -- [ ] Contract address is `0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512` -- [ ] Using standard ERC20 ABI -- [ ] Wallet/provider is connected to Anvil network -- [ ] Network is added to wallet (if using MetaMask) - -## 🔧 Example Frontend Code - -```typescript -import { ethers } from "ethers"; - -const WSTETH_ADDRESS = "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512"; -const DEV_ADDRESS = "0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e"; -const RPC_URL = "http://localhost:8545"; - -// Standard ERC20 ABI (minimal) -const ERC20_ABI = [ - "function balanceOf(address owner) view returns (uint256)", - "function symbol() view returns (string)", - "function decimals() view returns (uint8)", -]; - -async function getBalance() { - const provider = new ethers.providers.JsonRpcProvider(RPC_URL); - const contract = new ethers.Contract(WSTETH_ADDRESS, ERC20_ABI, provider); - - try { - const balance = await contract.balanceOf(DEV_ADDRESS); - const symbol = await contract.symbol(); - const decimals = await contract.decimals(); - - const formatted = ethers.utils.formatUnits(balance, decimals); - console.log(`Balance: ${formatted} ${symbol}`); - return formatted; - } catch (error) { - console.error("Error fetching balance:", error); - throw error; - } -} -``` - -## ✅ Verification Commands - -```bash -# 1. Check contract exists -cast code 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 --rpc-url local - -# 2. Check balance -cast call 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 \ - "balanceOf(address)(uint256)" \ - 0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e \ - --rpc-url local - -# 3. Check symbol -cast call 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 \ - "symbol()(string)" \ - --rpc-url local - -# 4. Check chain ID -cast chain-id --rpc-url local -``` - ---- - -**All checks pass on the backend** - the issue is likely in the frontend configuration or connection. - - - - diff --git a/doc/guides/FRONTEND-UPDATE-NEW-GENESIS.txt b/doc/guides/FRONTEND-UPDATE-NEW-GENESIS.txt deleted file mode 100644 index 7f5a72f0..00000000 --- a/doc/guides/FRONTEND-UPDATE-NEW-GENESIS.txt +++ /dev/null @@ -1,80 +0,0 @@ -================================================================================ -UPDATED FRONTEND CONFIGURATION - NEW GENESIS CONTRACT -================================================================================ - -NEW GENESIS ADDRESS -------------------- -Genesis: 0xAD523115cd35a8d4E60B3C0953E0E0ac10418309 - -(Previous address: 0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82 - OLD, DO NOT USE) - -GENESIS OWNER -------------- -Owner: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 (Anvil deployer) - -To use admin functions, import this account into your wallet: -- Address: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 -- Private Key: 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 - -SUBGRAPH UPDATE ---------------- -The subgraph.yaml has been updated with the new Genesis address. -You may need to redeploy the subgraph: - -cd /Users/andrewyoung/Harbor-App/harbor-app/subgraph -graph build -graph deploy --node http://localhost:8020/ --ipfs http://localhost:5001 harbor-marks-local - -Or use the quick script: -./QUICK-UPDATE-SUBGRAPH.sh - -OTHER CONTRACTS (unchanged) ---------------------------- -Minter: 0x8A791620dd6260079BF849Dc5567aDC3F2FdC318 -wstETH: 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 -wstETH/USD Feed: 0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9 - -================================================================================ - - -UPDATED FRONTEND CONFIGURATION - NEW GENESIS CONTRACT -================================================================================ - -NEW GENESIS ADDRESS -------------------- -Genesis: 0xAD523115cd35a8d4E60B3C0953E0E0ac10418309 - -(Previous address: 0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82 - OLD, DO NOT USE) - -GENESIS OWNER -------------- -Owner: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 (Anvil deployer) - -To use admin functions, import this account into your wallet: -- Address: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 -- Private Key: 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 - -SUBGRAPH UPDATE ---------------- -The subgraph.yaml has been updated with the new Genesis address. -You may need to redeploy the subgraph: - -cd /Users/andrewyoung/Harbor-App/harbor-app/subgraph -graph build -graph deploy --node http://localhost:8020/ --ipfs http://localhost:5001 harbor-marks-local - -Or use the quick script: -./QUICK-UPDATE-SUBGRAPH.sh - -OTHER CONTRACTS (unchanged) ---------------------------- -Minter: 0x8A791620dd6260079BF849Dc5567aDC3F2FdC318 -wstETH: 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 -wstETH/USD Feed: 0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9 - -================================================================================ - - - - - diff --git a/doc/guides/FRONTEND-WALLET-ERROR-FIX.md b/doc/guides/FRONTEND-WALLET-ERROR-FIX.md deleted file mode 100644 index f1780370..00000000 --- a/doc/guides/FRONTEND-WALLET-ERROR-FIX.md +++ /dev/null @@ -1,233 +0,0 @@ -# Fix: "THE METHOD ETH_SENDRAWTRANSACTION DOES NOT EXIST" Error - -## Problem -When trying to approve wstETH for deposit, the wallet shows: -``` -THE METHOD ETH_SENDRAWTRANSACTION DOES NOT EXIST/IS NOT AVAILABLE -``` - -## Root Cause -The wallet (MetaMask/other) is trying to use `eth_sendRawTransaction` which Anvil may not support in the same way as mainnet, OR the frontend is configured incorrectly. - -## Solutions - -### Solution 1: Ensure Correct RPC Configuration - -Make sure your frontend is using the Anvil RPC URL, not mainnet: - -```typescript -// ✅ CORRECT - Use localhost:8545 -const provider = new ethers.providers.JsonRpcProvider("http://localhost:8545"); - -// ❌ WRONG - Don't use mainnet RPC -// const provider = new ethers.providers.JsonRpcProvider("https://eth-mainnet.g.alchemy.com/..."); -``` - -### Solution 2: Use Wallet Provider, Not Raw Transactions - -When using a wallet like MetaMask, use the wallet's provider, not raw transaction methods: - -```typescript -// ✅ CORRECT - Use wallet provider -import { ethers } from "ethers"; - -// Get provider from wallet -const provider = new ethers.providers.Web3Provider(window.ethereum); -const signer = provider.getSigner(); - -// Use the signer to send transactions -const wstETH = new ethers.Contract( - "0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0", // wstETH address - ERC20_ABI, - signer -); - -// This will use the wallet's signing mechanism, not raw transactions -const tx = await wstETH.approve( - "0x8806fc80A0274Eda6a45E2944f6bB6E6Bb635831", // Genesis contract - ethers.constants.MaxUint256 -); -await tx.wait(); - -// ❌ WRONG - Don't use raw transactions -// const rawTx = await signer.signTransaction(...); -// await provider.sendTransaction(rawTx); -``` - -### Solution 3: Ensure Wallet is Connected to Anvil Network - -Add the Anvil network to MetaMask: - -```typescript -// Add Anvil network to wallet -const anvilNetwork = { - chainId: "0x7A69", // 31337 in hex - chainName: "Anvil Local", - nativeCurrency: { - name: "Ether", - symbol: "ETH", - decimals: 18, - }, - rpcUrls: ["http://localhost:8545"], - blockExplorerUrls: [], -}; - -try { - await window.ethereum.request({ - method: "wallet_addEthereumChain", - params: [anvilNetwork], - }); -} catch (error) { - console.error("Error adding network:", error); -} - -// Switch to Anvil network -await window.ethereum.request({ - method: "wallet_switchEthereumChain", - params: [{ chainId: "0x7A69" }], -}); -``` - -### Solution 4: Check Wallet Provider Configuration - -If using wagmi or similar, ensure the RPC URL is correct: - -```typescript -// wagmi configuration -import { configureChains, createConfig } from "wagmi"; -import { jsonRpcProvider } from "wagmi/providers/jsonRpc"; - -const { chains, publicClient } = configureChains( - [ - { - id: 31337, - name: "Anvil Local", - network: "anvil", - nativeCurrency: { - decimals: 18, - name: "Ether", - symbol: "ETH", - }, - rpcUrls: { - default: { - http: ["http://localhost:8545"], - }, - }, - }, - ], - [ - jsonRpcProvider({ - rpc: (chain) => ({ - http: "http://localhost:8545", - }), - }), - ] -); -``` - -### Solution 5: Use ethers.js Correctly with Wallets - -```typescript -// ✅ CORRECT - Full example -import { ethers } from "ethers"; - -async function approveWstETH() { - // 1. Get provider from wallet - if (!window.ethereum) { - throw new Error("No wallet found"); - } - - const provider = new ethers.providers.Web3Provider(window.ethereum); - - // 2. Request account access - await provider.send("eth_requestAccounts", []); - - // 3. Get signer - const signer = provider.getSigner(); - - // 4. Create contract instance with signer - const wstETH = new ethers.Contract( - "0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0", - [ - "function approve(address spender, uint256 amount) external returns (bool)", - "function allowance(address owner, address spender) external view returns (uint256)", - ], - signer - ); - - // 5. Check current allowance - const currentAllowance = await wstETH.allowance( - await signer.getAddress(), - "0x8806fc80A0274Eda6a45E2944f6bB6E6Bb635831" - ); - - // 6. Approve if needed - if (currentAllowance.lt(ethers.utils.parseEther("1000"))) { - const tx = await wstETH.approve( - "0x8806fc80A0274Eda6a45E2944f6bB6E6Bb635831", - ethers.constants.MaxUint256 - ); - console.log("Transaction sent:", tx.hash); - await tx.wait(); - console.log("Approval confirmed!"); - } -} -``` - -## Quick Debug Checklist - -1. ✅ Is Anvil running on `http://localhost:8545`? - ```bash - curl http://localhost:8545 -X POST -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","method":"eth_chainId","params":[],"id":1}' - # Should return: {"result":"0x7a69"} (31337 in hex) - ``` - -2. ✅ Is the wallet connected to chain ID 31337? - - Check MetaMask network dropdown - - Should show "Anvil Local" or chain ID 31337 - -3. ✅ Is the frontend using `http://localhost:8545` as RPC URL? - - Check browser console for network requests - - Should see requests to `localhost:8545`, not mainnet RPCs - -4. ✅ Is the code using wallet provider, not raw transactions? - - Look for `eth_sendRawTransaction` in your code - - Should use `signer.sendTransaction()` or `contract.method()` instead - -## Common Mistakes - -❌ **Using mainnet RPC URL:** -```typescript -const provider = new ethers.providers.JsonRpcProvider("https://eth-mainnet.g.alchemy.com/..."); -``` - -❌ **Trying to send raw transactions manually:** -```typescript -const rawTx = await signer.signTransaction(tx); -await provider.send("eth_sendRawTransaction", [rawTx]); -``` - -❌ **Not connecting wallet to Anvil network:** -- Wallet is on mainnet but trying to interact with Anvil contracts - -## Verification - -After applying fixes, test the approval: - -```typescript -// Test approval -const wstETH = new ethers.Contract(wstETHAddress, ERC20_ABI, signer); -const tx = await wstETH.approve(genesisAddress, ethers.constants.MaxUint256); -console.log("Tx hash:", tx.hash); -const receipt = await tx.wait(); -console.log("Confirmed in block:", receipt.blockNumber); -``` - -## Current Contract Addresses (from latest deployment) - -- **wstETH**: `0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0` -- **Genesis**: `0x8806fc80A0274Eda6a45E2944f6bB6E6Bb635831` -- **Chain ID**: `31337` -- **RPC URL**: `http://localhost:8545` - - diff --git a/doc/guides/GENESIS-END-REQUIRED.md b/doc/guides/GENESIS-END-REQUIRED.md deleted file mode 100644 index f20e037e..00000000 --- a/doc/guides/GENESIS-END-REQUIRED.md +++ /dev/null @@ -1,114 +0,0 @@ -# Genesis End Required for Fees to Work - -## Current Situation - -✅ **Genesis has deposits**: 500 wstETH (~$1M at $2000/wstETH) -❌ **Genesis has NOT ended**: `genesisIsEnded() = false` -❌ **No pegged tokens minted**: System is empty from Minter's perspective -❌ **Fees show 0%**: Because collateral ratio is infinite (empty system) - -## Why Fees Show 0% - -When Genesis hasn't ended: -- No pegged tokens exist in the Minter -- Collateral ratio = infinity (1e36) -- System lands in highest fee band (> 2.0x) = 0.5% fee -- 0.5% on small amounts might round to 0% in UI - -## What Happens When Genesis Ends - -When `endGenesis()` is called: -1. Genesis transfers half the collateral (~250 wstETH) to Minter -2. Calls `freeMintPeggedToken()` to mint pegged tokens to Genesis -3. Minter updates its state: - - `underlyingCollateral` = ~250 wstETH - - `peggedTokenBalance` = minted pegged tokens -4. Collateral ratio becomes calculable: ~2.0x (200%) -5. Fees will show correctly based on actual ratio - -## Expected Fees After Genesis Ends - -With ~$500k collateral and ~$500k pegged tokens: -- **Collateral ratio**: ~2.0x (200%) -- **Fee band**: 1.5x - 2.0x = **1% fee** (if exactly 2.0x) -- **OR**: > 2.0x = **0.5% fee** (if slightly above 2.0x) - -## To End Genesis - -```bash -cast send 0x6732128F9cc0c4344b2d4DC6285BCd516b7E59E6 \ - "endGenesis()" \ - --rpc-url http://localhost:8545 \ - --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 -``` - -**Requirements:** -- Caller must be Genesis owner: `0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266` -- Genesis must have `ZERO_FEE_ROLE` on Minter (to call `freeMintPeggedToken`) - -## After Genesis Ends - -1. Users can call `claim()` to get their pegged and leveraged tokens -2. Minter will have proper state (collateral + pegged tokens) -3. Collateral ratio will be calculable -4. Fees will display correctly based on the actual ratio - - - -## Current Situation - -✅ **Genesis has deposits**: 500 wstETH (~$1M at $2000/wstETH) -❌ **Genesis has NOT ended**: `genesisIsEnded() = false` -❌ **No pegged tokens minted**: System is empty from Minter's perspective -❌ **Fees show 0%**: Because collateral ratio is infinite (empty system) - -## Why Fees Show 0% - -When Genesis hasn't ended: -- No pegged tokens exist in the Minter -- Collateral ratio = infinity (1e36) -- System lands in highest fee band (> 2.0x) = 0.5% fee -- 0.5% on small amounts might round to 0% in UI - -## What Happens When Genesis Ends - -When `endGenesis()` is called: -1. Genesis transfers half the collateral (~250 wstETH) to Minter -2. Calls `freeMintPeggedToken()` to mint pegged tokens to Genesis -3. Minter updates its state: - - `underlyingCollateral` = ~250 wstETH - - `peggedTokenBalance` = minted pegged tokens -4. Collateral ratio becomes calculable: ~2.0x (200%) -5. Fees will show correctly based on actual ratio - -## Expected Fees After Genesis Ends - -With ~$500k collateral and ~$500k pegged tokens: -- **Collateral ratio**: ~2.0x (200%) -- **Fee band**: 1.5x - 2.0x = **1% fee** (if exactly 2.0x) -- **OR**: > 2.0x = **0.5% fee** (if slightly above 2.0x) - -## To End Genesis - -```bash -cast send 0x6732128F9cc0c4344b2d4DC6285BCd516b7E59E6 \ - "endGenesis()" \ - --rpc-url http://localhost:8545 \ - --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 -``` - -**Requirements:** -- Caller must be Genesis owner: `0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266` -- Genesis must have `ZERO_FEE_ROLE` on Minter (to call `freeMintPeggedToken`) - -## After Genesis Ends - -1. Users can call `claim()` to get their pegged and leveraged tokens -2. Minter will have proper state (collateral + pegged tokens) -3. Collateral ratio will be calculable -4. Fees will display correctly based on the actual ratio - - - - - diff --git a/doc/guides/GENESIS-INVESTIGATION.md b/doc/guides/GENESIS-INVESTIGATION.md deleted file mode 100644 index 3fb1385e..00000000 --- a/doc/guides/GENESIS-INVESTIGATION.md +++ /dev/null @@ -1,92 +0,0 @@ -# Genesis Investigation - What Happened? - -## Current Situation - -- ✅ **Genesis Ended**: `genesisIsEnded() = true` (block 157) -- ✅ **Tokens Claimed**: Dev address has ~200,000 haPB and 200,000 hsPB tokens -- ❌ **Minter Has No Collateral**: The Minter we've been checking (`0x34B40BA116d5Dec75548a9e9A8f15411461E8c70`) has 0 collateral -- ❌ **Genesis Has No Collateral**: Genesis contract has 0 wstETH -- ❌ **Genesis Has No Tokens**: All tokens were claimed - -## Transaction Analysis - -**endGenesis Transaction**: `0x7404705a9b9d6607d27970db0db679078d8e9a8680bfceab45260a9477936779` -- **Block**: 157 -- **Status**: Success ✅ -- **Events Found**: - 1. `GenesisEnds()` event emitted - 2. wstETH `Approval` from Genesis to `0x7a9ec1d04904907de0ed7b6839ccdd59c3716ac9` - 3. `MintPeggedToken` event - haPB minted to Genesis - 4. `MintLeveragedToken` event - hsPB minted to Genesis - 5. wstETH `Transfer` from Genesis to `0x7a9ec1d04904907de0ed7b6839ccdd59c3716ac9` - -## Key Finding - -**The collateral was transferred to a DIFFERENT Minter address!** - -- **Minter in transaction**: `0x7a9ec1d04904907de0ed7b6839ccdd59c3716ac9` -- **Minter we've been checking**: `0x34B40BA116d5Dec75548a9e9A8f15411461E8c70` - -These are **different addresses**! - -## What This Means - -1. Genesis is configured to use a different Minter contract -2. The collateral was transferred to the correct Minter (the one Genesis uses) -3. We've been checking the wrong Minter address -4. The correct Minter should have the collateral - -## Next Steps - -1. Check what Minter address Genesis is configured to use -2. Check the collateral balance in the CORRECT Minter -3. Update frontend configuration to use the correct Minter address - - - -## Current Situation - -- ✅ **Genesis Ended**: `genesisIsEnded() = true` (block 157) -- ✅ **Tokens Claimed**: Dev address has ~200,000 haPB and 200,000 hsPB tokens -- ❌ **Minter Has No Collateral**: The Minter we've been checking (`0x34B40BA116d5Dec75548a9e9A8f15411461E8c70`) has 0 collateral -- ❌ **Genesis Has No Collateral**: Genesis contract has 0 wstETH -- ❌ **Genesis Has No Tokens**: All tokens were claimed - -## Transaction Analysis - -**endGenesis Transaction**: `0x7404705a9b9d6607d27970db0db679078d8e9a8680bfceab45260a9477936779` -- **Block**: 157 -- **Status**: Success ✅ -- **Events Found**: - 1. `GenesisEnds()` event emitted - 2. wstETH `Approval` from Genesis to `0x7a9ec1d04904907de0ed7b6839ccdd59c3716ac9` - 3. `MintPeggedToken` event - haPB minted to Genesis - 4. `MintLeveragedToken` event - hsPB minted to Genesis - 5. wstETH `Transfer` from Genesis to `0x7a9ec1d04904907de0ed7b6839ccdd59c3716ac9` - -## Key Finding - -**The collateral was transferred to a DIFFERENT Minter address!** - -- **Minter in transaction**: `0x7a9ec1d04904907de0ed7b6839ccdd59c3716ac9` -- **Minter we've been checking**: `0x34B40BA116d5Dec75548a9e9A8f15411461E8c70` - -These are **different addresses**! - -## What This Means - -1. Genesis is configured to use a different Minter contract -2. The collateral was transferred to the correct Minter (the one Genesis uses) -3. We've been checking the wrong Minter address -4. The correct Minter should have the collateral - -## Next Steps - -1. Check what Minter address Genesis is configured to use -2. Check the collateral balance in the CORRECT Minter -3. Update frontend configuration to use the correct Minter address - - - - - diff --git a/doc/guides/HA-TOKEN-DEBUG-FIX.md b/doc/guides/HA-TOKEN-DEBUG-FIX.md deleted file mode 100644 index ff7ed385..00000000 --- a/doc/guides/HA-TOKEN-DEBUG-FIX.md +++ /dev/null @@ -1,114 +0,0 @@ -# Ha Token Tracking - Debug Fix - -## Problem Identified - -The AssemblyScript compiler was crashing when compiling `haToken.ts` due to complex price feed query logic and type mismatches. - -## Root Causes - -1. **Complex Chainlink Aggregator Query**: The original `getOrCreatePriceFeed` function had complex conditional logic with Chainlink aggregator bindings that caused compilation issues. - -2. **Type Mismatch**: The `updateHaTokenBalance` function was calling `accumulateMarks` with a `BigInt` timestamp instead of `ethereum.Block`. - -3. **Historical Multiplier Tracking**: Complex historical multiplier tracking logic with nested conditionals. - -## Solution - -Created a simplified version that: - -1. **Simplified Price Feed**: Removed complex Chainlink aggregator queries, using a simple default price of $1 for ha tokens (can be enhanced later). - -2. **Removed Problematic Function**: Commented out `updateHaTokenBalance` which had type mismatches. - -3. **Simplified Multiplier Logic**: Removed complex historical tracking, using simple multiplier storage. - -## Changes Made - -### Simplified `getOrCreatePriceFeed` -- Removed Chainlink aggregator binding and query logic -- Uses default $1 price for ha tokens -- Can be enhanced later with proper price feed integration - -### Simplified `getHaTokenMultiplier` -- Removed historical multiplier tracking complexity -- Simple load/create pattern - -### Removed `updateHaTokenBalance` -- Had type mismatch (timestamp vs block) -- Not needed for basic transfer tracking - -## Build Status - -✅ **Build Successful**: The simplified version compiles without errors. - -## Next Steps - -1. Deploy the subgraph -2. Test ha token transfer tracking -3. Verify marks accumulation -4. Enhance price feed integration later if needed - -## Files Modified - -- `/Users/andrewyoung/Harbor-App/harbor-app/subgraph/src/haToken.ts` - Simplified version -- `/Users/andrewyoung/Harbor-App/harbor-app/subgraph/subgraph.yaml` - Added static data source for haPB token - - - -## Problem Identified - -The AssemblyScript compiler was crashing when compiling `haToken.ts` due to complex price feed query logic and type mismatches. - -## Root Causes - -1. **Complex Chainlink Aggregator Query**: The original `getOrCreatePriceFeed` function had complex conditional logic with Chainlink aggregator bindings that caused compilation issues. - -2. **Type Mismatch**: The `updateHaTokenBalance` function was calling `accumulateMarks` with a `BigInt` timestamp instead of `ethereum.Block`. - -3. **Historical Multiplier Tracking**: Complex historical multiplier tracking logic with nested conditionals. - -## Solution - -Created a simplified version that: - -1. **Simplified Price Feed**: Removed complex Chainlink aggregator queries, using a simple default price of $1 for ha tokens (can be enhanced later). - -2. **Removed Problematic Function**: Commented out `updateHaTokenBalance` which had type mismatches. - -3. **Simplified Multiplier Logic**: Removed complex historical tracking, using simple multiplier storage. - -## Changes Made - -### Simplified `getOrCreatePriceFeed` -- Removed Chainlink aggregator binding and query logic -- Uses default $1 price for ha tokens -- Can be enhanced later with proper price feed integration - -### Simplified `getHaTokenMultiplier` -- Removed historical multiplier tracking complexity -- Simple load/create pattern - -### Removed `updateHaTokenBalance` -- Had type mismatch (timestamp vs block) -- Not needed for basic transfer tracking - -## Build Status - -✅ **Build Successful**: The simplified version compiles without errors. - -## Next Steps - -1. Deploy the subgraph -2. Test ha token transfer tracking -3. Verify marks accumulation -4. Enhance price feed integration later if needed - -## Files Modified - -- `/Users/andrewyoung/Harbor-App/harbor-app/subgraph/src/haToken.ts` - Simplified version -- `/Users/andrewyoung/Harbor-App/harbor-app/subgraph/subgraph.yaml` - Added static data source for haPB token - - - - - diff --git a/doc/guides/HA-TOKEN-TRACKING-ENABLED.md b/doc/guides/HA-TOKEN-TRACKING-ENABLED.md deleted file mode 100644 index 171ddd79..00000000 --- a/doc/guides/HA-TOKEN-TRACKING-ENABLED.md +++ /dev/null @@ -1,160 +0,0 @@ -# Ha Token Tracking - Enabled and Deployed - -## Status: ✅ FIXED AND DEPLOYED - -The ha token tracking has been successfully debugged, fixed, and deployed to the local Graph Node. - -## What Was Fixed - -1. **AssemblyScript Compilation Issue**: Simplified the `haToken.ts` handler by removing complex Chainlink aggregator queries that were causing compiler crashes. - -2. **Type Mismatches**: Fixed type issues in function calls. - -3. **Simplified Price Feed**: Using default $1 price for ha tokens (can be enhanced later). - -## Deployment Details - -- **Subgraph Version**: v1.0.1 -- **Deployment Status**: ✅ Deployed successfully -- **GraphQL Endpoint**: http://localhost:8000/subgraphs/name/harbor-marks-local -- **Ha Token Address**: `0x1c85638e118b37167e9298c2268758e058DdfDA0` (haPB) - -## Configuration - -The ha token is configured as a **static data source** (not a template) in `subgraph.yaml`: - -```yaml - - kind: ethereum - name: HaToken_haPB - network: anvil - source: - address: "0x1c85638e118b37167e9298c2268758e058DdfDA0" - abi: ERC20 - startBlock: 93 -``` - -## Current Status - -- **Subgraph Block**: Catching up (currently at block 92, needs to reach block 157+) -- **Transfer Event Found**: Block 157 -- **Indexing**: In progress - -## Verification Query - -Once the subgraph catches up, you can query ha token balances: - -```graphql -{ - haTokenBalances(where: {user: "0xae7dbb17bc40d53a6363409c6b1ed88d3cfdc31e"}) { - id - tokenAddress - balance - balanceUSD - accumulatedMarks - marksPerDay - } - - userTotalMarks(id: "0xae7dbb17bc40d53a6363409c6b1ed88d3cfdc31e") { - haTokenMarks - totalMarks - } -} -``` - -## Expected Results - -For wallet `0xae7dbb17bc40d53a6363409c6b1ed88d3cfdc31e`: -- **Balance**: 200,000 haPB tokens -- **Balance USD**: ~$200,000 (assuming $1 per haPB) -- **Marks Per Day**: ~200,000 marks/day (1 mark per dollar per day) -- **Accumulated Marks**: Will accumulate over time based on holding duration - -## Next Steps - -1. Wait for subgraph to sync to block 157+ -2. Verify ha token balances are indexed -3. Test marks accumulation over time -4. Enhance price feed integration if needed (currently using $1 default) - - - -## Status: ✅ FIXED AND DEPLOYED - -The ha token tracking has been successfully debugged, fixed, and deployed to the local Graph Node. - -## What Was Fixed - -1. **AssemblyScript Compilation Issue**: Simplified the `haToken.ts` handler by removing complex Chainlink aggregator queries that were causing compiler crashes. - -2. **Type Mismatches**: Fixed type issues in function calls. - -3. **Simplified Price Feed**: Using default $1 price for ha tokens (can be enhanced later). - -## Deployment Details - -- **Subgraph Version**: v1.0.1 -- **Deployment Status**: ✅ Deployed successfully -- **GraphQL Endpoint**: http://localhost:8000/subgraphs/name/harbor-marks-local -- **Ha Token Address**: `0x1c85638e118b37167e9298c2268758e058DdfDA0` (haPB) - -## Configuration - -The ha token is configured as a **static data source** (not a template) in `subgraph.yaml`: - -```yaml - - kind: ethereum - name: HaToken_haPB - network: anvil - source: - address: "0x1c85638e118b37167e9298c2268758e058DdfDA0" - abi: ERC20 - startBlock: 93 -``` - -## Current Status - -- **Subgraph Block**: Catching up (currently at block 92, needs to reach block 157+) -- **Transfer Event Found**: Block 157 -- **Indexing**: In progress - -## Verification Query - -Once the subgraph catches up, you can query ha token balances: - -```graphql -{ - haTokenBalances(where: {user: "0xae7dbb17bc40d53a6363409c6b1ed88d3cfdc31e"}) { - id - tokenAddress - balance - balanceUSD - accumulatedMarks - marksPerDay - } - - userTotalMarks(id: "0xae7dbb17bc40d53a6363409c6b1ed88d3cfdc31e") { - haTokenMarks - totalMarks - } -} -``` - -## Expected Results - -For wallet `0xae7dbb17bc40d53a6363409c6b1ed88d3cfdc31e`: -- **Balance**: 200,000 haPB tokens -- **Balance USD**: ~$200,000 (assuming $1 per haPB) -- **Marks Per Day**: ~200,000 marks/day (1 mark per dollar per day) -- **Accumulated Marks**: Will accumulate over time based on holding duration - -## Next Steps - -1. Wait for subgraph to sync to block 157+ -2. Verify ha token balances are indexed -3. Test marks accumulation over time -4. Enhance price feed integration if needed (currently using $1 default) - - - - - diff --git a/doc/guides/HA-TOKEN-TRACKING-STATUS.md b/doc/guides/HA-TOKEN-TRACKING-STATUS.md deleted file mode 100644 index 07edf794..00000000 --- a/doc/guides/HA-TOKEN-TRACKING-STATUS.md +++ /dev/null @@ -1,214 +0,0 @@ -# Ha Token Tracking Status - -## Current Situation - -**Wallet:** `0xae7dbb17bc40d53a6363409c6b1ed88d3cfdc31e` -**haPB Token Balance:** 200,000 tokens (200000000000000000000000 wei) -**Token Address:** `0x1c85638e118b37167e9298c2268758e058DdfDA0` - -## Problem - -❌ **Ha token marks are NOT being tracked** by the subgraph. - -**Root Cause:** -- The ha token templates in `subgraph.yaml` are commented out (lines 40-64) -- The `haToken.ts` handler exists but isn't being used -- No data sources are created to track ha token transfers - -## Attempted Fix - -✅ Added ha token as a **static data source** (not a template) in `subgraph.yaml`: -```yaml - - kind: ethereum - name: HaToken_haPB - network: anvil - source: - address: "0x1c85638e118b37167e9298c2268758e058DdfDA0" - abi: ERC20 - startBlock: 93 - mapping: - ... - eventHandlers: - - event: Transfer(indexed address,indexed address,uint256) - handler: handleHaTokenTransfer - file: ./src/haToken.ts -``` - -✅ Updated imports in `haToken.ts` from template imports to static data source imports: -```typescript -// Changed from: -import { Transfer as TransferEvent } from "../generated/templates/HaToken/ERC20"; -import { ERC20 } from "../generated/templates/HaToken/ERC20"; - -// To: -import { Transfer as TransferEvent } from "../generated/HaToken_haPB/ERC20"; -import { ERC20 } from "../generated/HaToken_haPB/ERC20"; -``` - -✅ `graph codegen` completed successfully - generated types for `HaToken_haPB` - -## Current Issue - -❌ **AssemblyScript compiler crash** when building the subgraph: -``` -Failed to compile data source mapping: The AssemblyScript compiler crashed when compiling this file: 'src/haToken.ts' -``` - -This is the same compilation issue that caused the templates to be commented out initially. - -## Next Steps - -1. **Debug the compilation issue** in `haToken.ts`: - - Comment out sections of the file to isolate the problematic code - - Check for type mismatches, unsupported operations, or circular dependencies - - The issue may be in: - - Contract calls (`queryTokenBalance`, `calculateBalanceUSD`) - - Price feed interactions - - Marks accumulation logic - -2. **Alternative approach** (if compilation can't be fixed): - - Create a simpler handler that only tracks transfers without contract calls - - Use periodic updates or manual balance queries instead of real-time contract calls - - Consider using a different approach for USD value calculation - -3. **Temporary workaround**: - - Track ha token balances manually or via a separate service - - Calculate marks off-chain and inject into the subgraph via a different mechanism - -## Verification - -Once fixed and deployed, verify ha token tracking: - -```graphql -{ - haTokenBalances(where: {user: "0xae7dbb17bc40d53a6363409c6b1ed88d3cfdc31e"}) { - id - tokenAddress - balance - balanceUSD - accumulatedMarks - marksPerDay - } - - userTotalMarks(id: "0xae7dbb17bc40d53a6363409c6b1ed88d3cfdc31e") { - haTokenMarks - totalMarks - } -} -``` - -Expected result: -- `haTokenBalances` should show 200,000 tokens -- `balanceUSD` should show ~$200,000 (if haPB is pegged to $1) -- `accumulatedMarks` should show marks earned from holding ha tokens -- `userTotalMarks.haTokenMarks` should include ha token marks in total - - - -## Current Situation - -**Wallet:** `0xae7dbb17bc40d53a6363409c6b1ed88d3cfdc31e` -**haPB Token Balance:** 200,000 tokens (200000000000000000000000 wei) -**Token Address:** `0x1c85638e118b37167e9298c2268758e058DdfDA0` - -## Problem - -❌ **Ha token marks are NOT being tracked** by the subgraph. - -**Root Cause:** -- The ha token templates in `subgraph.yaml` are commented out (lines 40-64) -- The `haToken.ts` handler exists but isn't being used -- No data sources are created to track ha token transfers - -## Attempted Fix - -✅ Added ha token as a **static data source** (not a template) in `subgraph.yaml`: -```yaml - - kind: ethereum - name: HaToken_haPB - network: anvil - source: - address: "0x1c85638e118b37167e9298c2268758e058DdfDA0" - abi: ERC20 - startBlock: 93 - mapping: - ... - eventHandlers: - - event: Transfer(indexed address,indexed address,uint256) - handler: handleHaTokenTransfer - file: ./src/haToken.ts -``` - -✅ Updated imports in `haToken.ts` from template imports to static data source imports: -```typescript -// Changed from: -import { Transfer as TransferEvent } from "../generated/templates/HaToken/ERC20"; -import { ERC20 } from "../generated/templates/HaToken/ERC20"; - -// To: -import { Transfer as TransferEvent } from "../generated/HaToken_haPB/ERC20"; -import { ERC20 } from "../generated/HaToken_haPB/ERC20"; -``` - -✅ `graph codegen` completed successfully - generated types for `HaToken_haPB` - -## Current Issue - -❌ **AssemblyScript compiler crash** when building the subgraph: -``` -Failed to compile data source mapping: The AssemblyScript compiler crashed when compiling this file: 'src/haToken.ts' -``` - -This is the same compilation issue that caused the templates to be commented out initially. - -## Next Steps - -1. **Debug the compilation issue** in `haToken.ts`: - - Comment out sections of the file to isolate the problematic code - - Check for type mismatches, unsupported operations, or circular dependencies - - The issue may be in: - - Contract calls (`queryTokenBalance`, `calculateBalanceUSD`) - - Price feed interactions - - Marks accumulation logic - -2. **Alternative approach** (if compilation can't be fixed): - - Create a simpler handler that only tracks transfers without contract calls - - Use periodic updates or manual balance queries instead of real-time contract calls - - Consider using a different approach for USD value calculation - -3. **Temporary workaround**: - - Track ha token balances manually or via a separate service - - Calculate marks off-chain and inject into the subgraph via a different mechanism - -## Verification - -Once fixed and deployed, verify ha token tracking: - -```graphql -{ - haTokenBalances(where: {user: "0xae7dbb17bc40d53a6363409c6b1ed88d3cfdc31e"}) { - id - tokenAddress - balance - balanceUSD - accumulatedMarks - marksPerDay - } - - userTotalMarks(id: "0xae7dbb17bc40d53a6363409c6b1ed88d3cfdc31e") { - haTokenMarks - totalMarks - } -} -``` - -Expected result: -- `haTokenBalances` should show 200,000 tokens -- `balanceUSD` should show ~$200,000 (if haPB is pegged to $1) -- `accumulatedMarks` should show marks earned from holding ha tokens -- `userTotalMarks.haTokenMarks` should include ha token marks in total - - - - - diff --git a/doc/guides/HARVEST-FLOW-CLARIFIED.md b/doc/guides/HARVEST-FLOW-CLARIFIED.md deleted file mode 100644 index 6812b7c9..00000000 --- a/doc/guides/HARVEST-FLOW-CLARIFIED.md +++ /dev/null @@ -1,218 +0,0 @@ -# Harvest Flow - Clarified - -## Quick Answer - -**Harvested yields are automatically deposited to stability pools during harvest.** Only a small "cut" portion goes to the fee receiver. The majority goes directly to pools. - -## The Harvest Flow (Step by Step) - -### Step 1: Sweep from Minter -```solidity -ITokenHolder(MINTER).sweep(WRAPPED_COLLATERAL_TOKEN, harvestableAmount, address(this)); -``` -- All harvestable tokens are swept from Minter to `StabilityPoolManager` - -### Step 2: Calculate Deductions -```solidity -uint256 bountyAmount = (harvestableAmount * $.harvestBountyRatio) / 1 ether; -uint256 cutAmount = (harvestableAmount * $.harvestCutRatio) / 1 ether; -uint256 harvestableRemaining = harvestableAmount - bountyAmount - cutAmount; -``` - -### Step 3: Distribute Deductions -```solidity -// Bounty goes to harvester (whoever called harvest()) -IERC20(WRAPPED_COLLATERAL_TOKEN).safeTransfer(bountyReceiver, bountyAmount); - -// Cut goes to fee receiver (or treasury) -address cutReceiver = $.feeReceiver == address(0) ? TREASURY : $.feeReceiver; -IERC20(WRAPPED_COLLATERAL_TOKEN).safeTransfer(cutReceiver, cutAmount); -``` - -### Step 4: Automatically Deposit Remainder to Pools -```solidity -// Automatically deposited to stability pools -_harvestToPool(harvestedToCollateral, _STABILITY_POOL_COLLATERAL); -_harvestToPool(harvestableRemaining - harvestedToCollateral, _STABILITY_POOL_LEVERAGED); -``` - -Where `_harvestToPool()` calls: -```solidity -IMultipleRewardDistributor(pool).depositReward(WRAPPED_COLLATERAL_TOKEN, amount); -``` - -## The Split - -**Example with 100 wstETH harvestable:** - -| Portion | Amount | Where It Goes | -|---------|--------|---------------| -| **Bounty** | ~1-5% (e.g., 2 wstETH) | → `bountyReceiver` (harvester/keeper) | -| **Cut** | ~1-5% (e.g., 3 wstETH) | → `feeReceiver` (or treasury) | -| **Remainder** | ~90-98% (e.g., 95 wstETH) | → **Automatically deposited to stability pools** ✅ | - -## Key Points - -### ✅ Automatically Deposited -- The **remainder** (after bounty and cut) is **automatically deposited** to stability pools -- No manual action needed -- Happens in the same transaction as harvest - -### ❌ NOT All Goes to Fee Receiver -- Only the **cut** portion goes to fee receiver -- The **majority** goes directly to pools -- Fee receiver does NOT need to deposit anything - -### 🔄 Two Different Things - -**Harvest Cut (goes to fee receiver):** -- Small percentage of harvestable amount -- Goes to `feeReceiver` address -- Protocol revenue -- Does NOT need to be deposited to pools - -**Harvest Remainder (automatically deposited):** -- Majority of harvestable amount -- Automatically deposited to pools via `depositReward()` -- Becomes rewards for stability pool depositors -- Vests over 7 days - -## Comparison - -| Aspect | Harvest Cut | Harvest Remainder | -|--------|-------------|-------------------| -| **Amount** | Small (~1-5%) | Large (~90-98%) | -| **Destination** | Fee receiver | Stability pools | -| **Automatic?** | Yes (transferred) | Yes (deposited) | -| **Needs deposit?** | No | No (already deposited) | -| **Purpose** | Protocol revenue | User rewards | - -## Summary - -**Question:** Do harvested yields go to fee receiver which then has to deposit them? - -**Answer:** -- ❌ **No** - Only a small "cut" goes to fee receiver -- ✅ **Yes** - The majority is **automatically deposited** to stability pools during harvest -- ✅ **No manual action needed** - It all happens in one transaction - -**The flow:** -1. Harvest triggered -2. Tokens swept from Minter -3. Bounty → harvester -4. Cut → fee receiver -5. **Remainder → automatically deposited to pools** ✅ - -Fee receiver gets the cut and can do whatever it wants with it (it's protocol revenue). The remainder is already in the pools as rewards for users. - - - -## Quick Answer - -**Harvested yields are automatically deposited to stability pools during harvest.** Only a small "cut" portion goes to the fee receiver. The majority goes directly to pools. - -## The Harvest Flow (Step by Step) - -### Step 1: Sweep from Minter -```solidity -ITokenHolder(MINTER).sweep(WRAPPED_COLLATERAL_TOKEN, harvestableAmount, address(this)); -``` -- All harvestable tokens are swept from Minter to `StabilityPoolManager` - -### Step 2: Calculate Deductions -```solidity -uint256 bountyAmount = (harvestableAmount * $.harvestBountyRatio) / 1 ether; -uint256 cutAmount = (harvestableAmount * $.harvestCutRatio) / 1 ether; -uint256 harvestableRemaining = harvestableAmount - bountyAmount - cutAmount; -``` - -### Step 3: Distribute Deductions -```solidity -// Bounty goes to harvester (whoever called harvest()) -IERC20(WRAPPED_COLLATERAL_TOKEN).safeTransfer(bountyReceiver, bountyAmount); - -// Cut goes to fee receiver (or treasury) -address cutReceiver = $.feeReceiver == address(0) ? TREASURY : $.feeReceiver; -IERC20(WRAPPED_COLLATERAL_TOKEN).safeTransfer(cutReceiver, cutAmount); -``` - -### Step 4: Automatically Deposit Remainder to Pools -```solidity -// Automatically deposited to stability pools -_harvestToPool(harvestedToCollateral, _STABILITY_POOL_COLLATERAL); -_harvestToPool(harvestableRemaining - harvestedToCollateral, _STABILITY_POOL_LEVERAGED); -``` - -Where `_harvestToPool()` calls: -```solidity -IMultipleRewardDistributor(pool).depositReward(WRAPPED_COLLATERAL_TOKEN, amount); -``` - -## The Split - -**Example with 100 wstETH harvestable:** - -| Portion | Amount | Where It Goes | -|---------|--------|---------------| -| **Bounty** | ~1-5% (e.g., 2 wstETH) | → `bountyReceiver` (harvester/keeper) | -| **Cut** | ~1-5% (e.g., 3 wstETH) | → `feeReceiver` (or treasury) | -| **Remainder** | ~90-98% (e.g., 95 wstETH) | → **Automatically deposited to stability pools** ✅ | - -## Key Points - -### ✅ Automatically Deposited -- The **remainder** (after bounty and cut) is **automatically deposited** to stability pools -- No manual action needed -- Happens in the same transaction as harvest - -### ❌ NOT All Goes to Fee Receiver -- Only the **cut** portion goes to fee receiver -- The **majority** goes directly to pools -- Fee receiver does NOT need to deposit anything - -### 🔄 Two Different Things - -**Harvest Cut (goes to fee receiver):** -- Small percentage of harvestable amount -- Goes to `feeReceiver` address -- Protocol revenue -- Does NOT need to be deposited to pools - -**Harvest Remainder (automatically deposited):** -- Majority of harvestable amount -- Automatically deposited to pools via `depositReward()` -- Becomes rewards for stability pool depositors -- Vests over 7 days - -## Comparison - -| Aspect | Harvest Cut | Harvest Remainder | -|--------|-------------|-------------------| -| **Amount** | Small (~1-5%) | Large (~90-98%) | -| **Destination** | Fee receiver | Stability pools | -| **Automatic?** | Yes (transferred) | Yes (deposited) | -| **Needs deposit?** | No | No (already deposited) | -| **Purpose** | Protocol revenue | User rewards | - -## Summary - -**Question:** Do harvested yields go to fee receiver which then has to deposit them? - -**Answer:** -- ❌ **No** - Only a small "cut" goes to fee receiver -- ✅ **Yes** - The majority is **automatically deposited** to stability pools during harvest -- ✅ **No manual action needed** - It all happens in one transaction - -**The flow:** -1. Harvest triggered -2. Tokens swept from Minter -3. Bounty → harvester -4. Cut → fee receiver -5. **Remainder → automatically deposited to pools** ✅ - -Fee receiver gets the cut and can do whatever it wants with it (it's protocol revenue). The remainder is already in the pools as rewards for users. - - - - - diff --git a/doc/guides/HARVEST-REWARDS-DISTRIBUTION.md b/doc/guides/HARVEST-REWARDS-DISTRIBUTION.md deleted file mode 100644 index 99769c5c..00000000 --- a/doc/guides/HARVEST-REWARDS-DISTRIBUTION.md +++ /dev/null @@ -1,282 +0,0 @@ -# Harvest Rewards Distribution - How It Works - -## Quick Answer - -**No, rewards are NOT distributed straight away.** They are deposited into a **linear vesting schedule** and become claimable over time (typically 7 days). - -## The Harvest Flow - -### Step 1: Harvest is Triggered -```solidity -StabilityPoolManager.harvest(bountyReceiver, minBounty) -``` - -### Step 2: Tokens Are Swept from Minter -- Calls `IMinter.harvestable()` to get the harvestable amount -- Sweeps tokens from Minter to StabilityPoolManager -- Takes out: - - **Bounty** (goes to harvester/keeper) - - **Cut** (goes to fee receiver/treasury) - - **Remainder** (goes to stability pools) - -### Step 3: Rewards Are Deposited to Pools -```solidity -_harvestToPool(amount, pool) - → IMultipleRewardDistributor(pool).depositReward(token, amount) -``` - -### Step 4: Linear Distribution Schedule -When `depositReward()` is called: - -1. **Transfers tokens** to the pool contract -2. **Distributes any pending rewards** from previous deposits -3. **Adds new rewards to linear schedule**: - - If `REWARD_PERIOD_LENGTH == 0`: Immediate distribution (rare) - - If `REWARD_PERIOD_LENGTH > 0`: Linear vesting over time (typical) - -## How Linear Vesting Works - -### Example: 7-Day Vesting Period - -**Day 0 (Harvest):** -- 100 wstETH deposited as rewards -- Users can claim: **0 wstETH** (0%) - -**Day 3.5:** -- Users can claim: **~25 wstETH** (25%) -- Rewards vest linearly over time - -**Day 7:** -- Users can claim: **~50 wstETH** (50%) -- Halfway through the period - -**Day 14:** -- Users can claim: **100 wstETH** (100%) -- Full amount is claimable - -### The Math - -``` -claimable = (timeElapsed / REWARD_PERIOD_LENGTH) × totalRewards -``` - -- **Time elapsed**: How long since reward was deposited -- **Reward period**: Typically 7 days (configurable) -- **Total rewards**: Amount deposited in harvest - -## Why Linear Vesting? - -1. **Prevents Instant Withdrawal** - - Users can't immediately withdraw rewards after harvest - - Encourages longer-term participation - -2. **Smooth Distribution** - - Rewards become available gradually - - Reduces sudden liquidity changes - -3. **Fair Distribution** - - All users get proportional share - - No advantage for early claimers - -## When Can Users Claim? - -### Immediately After Harvest -- ❌ **Cannot claim** - rewards are in vesting schedule -- ✅ **Can see pending rewards** via `claimable(user, token)` - -### Over Time -- ✅ **Gradually becomes claimable** as time passes -- ✅ **Proportional to deposit size** - bigger deposits = bigger rewards - -### After Vesting Period -- ✅ **Fully claimable** - all rewards available -- ✅ **Can claim anytime** via `claim()` function - -## Key Points - -| Aspect | Details | -|--------|---------| -| **Distribution Timing** | Not immediate - linear vesting | -| **Vesting Period** | Typically 7 days (configurable) | -| **Claimable Immediately** | No - must wait for vesting | -| **Proportional** | Yes - based on deposit size | -| **Automatic** | Yes - no need to claim to earn | -| **Claim Anytime** | Yes - once vested, can claim anytime | - -## Code Flow Summary - -``` -harvest() - ↓ -sweep tokens from Minter - ↓ -take bounty + cut - ↓ -depositReward() to pools - ↓ -_add to linear vesting schedule_ - ↓ -Users can claim over time (7 days) -``` - -## Comparison: Harvest vs Liquidation Rewards - -| Type | Distribution | Timing | -|------|-------------|--------| -| **Liquidation Rewards** | Immediate | Right away (during rebalance) | -| **Harvest Rewards** | Linear vesting | Over 7 days (typically) | - -## Summary - -**When harvest is triggered:** -1. ✅ Tokens are **deposited** to stability pools -2. ✅ Rewards are **added to vesting schedule** -3. ❌ Rewards are **NOT immediately claimable** -4. ✅ Rewards become **gradually claimable** over time (7 days) -5. ✅ Users can **claim anytime** once vested - -The rewards are "distributed" in the sense that they're allocated to the pool and tracked, but they're **not immediately claimable** - they vest linearly over the reward period. - - - -## Quick Answer - -**No, rewards are NOT distributed straight away.** They are deposited into a **linear vesting schedule** and become claimable over time (typically 7 days). - -## The Harvest Flow - -### Step 1: Harvest is Triggered -```solidity -StabilityPoolManager.harvest(bountyReceiver, minBounty) -``` - -### Step 2: Tokens Are Swept from Minter -- Calls `IMinter.harvestable()` to get the harvestable amount -- Sweeps tokens from Minter to StabilityPoolManager -- Takes out: - - **Bounty** (goes to harvester/keeper) - - **Cut** (goes to fee receiver/treasury) - - **Remainder** (goes to stability pools) - -### Step 3: Rewards Are Deposited to Pools -```solidity -_harvestToPool(amount, pool) - → IMultipleRewardDistributor(pool).depositReward(token, amount) -``` - -### Step 4: Linear Distribution Schedule -When `depositReward()` is called: - -1. **Transfers tokens** to the pool contract -2. **Distributes any pending rewards** from previous deposits -3. **Adds new rewards to linear schedule**: - - If `REWARD_PERIOD_LENGTH == 0`: Immediate distribution (rare) - - If `REWARD_PERIOD_LENGTH > 0`: Linear vesting over time (typical) - -## How Linear Vesting Works - -### Example: 7-Day Vesting Period - -**Day 0 (Harvest):** -- 100 wstETH deposited as rewards -- Users can claim: **0 wstETH** (0%) - -**Day 3.5:** -- Users can claim: **~25 wstETH** (25%) -- Rewards vest linearly over time - -**Day 7:** -- Users can claim: **~50 wstETH** (50%) -- Halfway through the period - -**Day 14:** -- Users can claim: **100 wstETH** (100%) -- Full amount is claimable - -### The Math - -``` -claimable = (timeElapsed / REWARD_PERIOD_LENGTH) × totalRewards -``` - -- **Time elapsed**: How long since reward was deposited -- **Reward period**: Typically 7 days (configurable) -- **Total rewards**: Amount deposited in harvest - -## Why Linear Vesting? - -1. **Prevents Instant Withdrawal** - - Users can't immediately withdraw rewards after harvest - - Encourages longer-term participation - -2. **Smooth Distribution** - - Rewards become available gradually - - Reduces sudden liquidity changes - -3. **Fair Distribution** - - All users get proportional share - - No advantage for early claimers - -## When Can Users Claim? - -### Immediately After Harvest -- ❌ **Cannot claim** - rewards are in vesting schedule -- ✅ **Can see pending rewards** via `claimable(user, token)` - -### Over Time -- ✅ **Gradually becomes claimable** as time passes -- ✅ **Proportional to deposit size** - bigger deposits = bigger rewards - -### After Vesting Period -- ✅ **Fully claimable** - all rewards available -- ✅ **Can claim anytime** via `claim()` function - -## Key Points - -| Aspect | Details | -|--------|---------| -| **Distribution Timing** | Not immediate - linear vesting | -| **Vesting Period** | Typically 7 days (configurable) | -| **Claimable Immediately** | No - must wait for vesting | -| **Proportional** | Yes - based on deposit size | -| **Automatic** | Yes - no need to claim to earn | -| **Claim Anytime** | Yes - once vested, can claim anytime | - -## Code Flow Summary - -``` -harvest() - ↓ -sweep tokens from Minter - ↓ -take bounty + cut - ↓ -depositReward() to pools - ↓ -_add to linear vesting schedule_ - ↓ -Users can claim over time (7 days) -``` - -## Comparison: Harvest vs Liquidation Rewards - -| Type | Distribution | Timing | -|------|-------------|--------| -| **Liquidation Rewards** | Immediate | Right away (during rebalance) | -| **Harvest Rewards** | Linear vesting | Over 7 days (typically) | - -## Summary - -**When harvest is triggered:** -1. ✅ Tokens are **deposited** to stability pools -2. ✅ Rewards are **added to vesting schedule** -3. ❌ Rewards are **NOT immediately claimable** -4. ✅ Rewards become **gradually claimable** over time (7 days) -5. ✅ Users can **claim anytime** once vested - -The rewards are "distributed" in the sense that they're allocated to the pool and tracked, but they're **not immediately claimable** - they vest linearly over the reward period. - - - - - diff --git a/doc/guides/HOW-HARVEST-CUTS-ARE-DECIDED.md b/doc/guides/HOW-HARVEST-CUTS-ARE-DECIDED.md deleted file mode 100644 index ebadbaea..00000000 --- a/doc/guides/HOW-HARVEST-CUTS-ARE-DECIDED.md +++ /dev/null @@ -1,274 +0,0 @@ -# How Harvest Cuts Are Decided - -## Quick Answer - -**The harvest bounty and cut ratios are configurable parameters set by the contract owner.** They start at 0 and must be explicitly set after deployment. - -## Initial State - -### Default Values -- **`harvestBountyRatio`**: `0` (starts at zero) -- **`harvestCutRatio`**: `0` (starts at zero) - -Both ratios are stored in the contract's storage and initialized to 0 when the contract is deployed. - -## Who Decides? - -### The Owner -Only the **contract owner** can set these ratios via: -- `updateHarvestBountyRatio(uint256 harvestRatio_)` - Sets bounty ratio -- `updateHarvestCutRatio(uint256 harvestCutRatio_)` - Sets cut ratio - -### Constraints -- Both ratios must be **≤ 1 ether** (100%) -- Can be set to any value from `0` to `1 ether` -- Can be updated at any time by the owner - -## How They Work - -### Calculation -```solidity -uint256 bountyAmount = (harvestableAmount * harvestBountyRatio) / 1 ether; -uint256 cutAmount = (harvestableAmount * harvestCutRatio) / 1 ether; -uint256 remainder = harvestableAmount - bountyAmount - cutAmount; -``` - -### Distribution -- **Bounty** → Goes to `bountyReceiver` (whoever calls `harvest()`) -- **Cut** → Goes to `feeReceiver` (or treasury if not set) -- **Remainder** → Automatically deposited to stability pools - -## Typical Values (From Tests) - -### Bounty Ratio Examples -- `0.01 ether` = 1% (common for small bounties) -- `0.05 ether` = 5% (moderate bounty) -- `0.10 ether` = 10% (higher bounty) - -### Cut Ratio Examples -- `0.1 ether` = 10% (typical protocol revenue) -- `0.2 ether` = 20% (higher protocol revenue) - -### Example Split -With `harvestBountyRatio = 0.05 ether` (5%) and `harvestCutRatio = 0.1 ether` (10%): - -**100 wstETH harvestable:** -- Bounty: 5 wstETH (5%) → harvester -- Cut: 10 wstETH (10%) → fee receiver -- Remainder: 85 wstETH (85%) → stability pools - -## Decision Process - -### 1. **Initial Setup** (After Deployment) -Owner must call: -```solidity -updateHarvestBountyRatio(0.05 ether); // Set to 5% -updateHarvestCutRatio(0.1 ether); // Set to 10% -``` - -### 2. **Ongoing Management** -Owner can adjust ratios at any time: -- Increase bounty to incentivize more frequent harvesting -- Decrease cut to give more to users -- Adjust based on protocol needs - -### 3. **Considerations** -When setting ratios, owner should consider: -- **Bounty**: High enough to incentivize keepers, but not so high it reduces user rewards -- **Cut**: Protocol revenue needs vs. user rewards -- **Remainder**: Should be the majority (80-95%) to reward stability pool depositors - -## Code Reference - -### Setting Ratios -```solidity -// Owner sets bounty ratio (e.g., 5%) -function updateHarvestBountyRatio(uint256 harvestRatio_) external onlyOwner { - if (harvestRatio_ > 1 ether) { - revert InvalidHarvestBountyRatio(harvestRatio_); - } - $.harvestBountyRatio = harvestRatio_; - emit HarvestBountyUpdated(harvestRatio_); -} - -// Owner sets cut ratio (e.g., 10%) -function updateHarvestCutRatio(uint256 harvestCutRatio_) external onlyOwner { - if (harvestCutRatio_ > 1 ether) { - revert InvalidHarvestBountyRatio(harvestCutRatio_); - } - $.harvestCutRatio = harvestCutRatio_; - emit HarvestCutUpdated(harvestCutRatio_); -} -``` - -### Using Ratios -```solidity -// During harvest -uint256 bountyAmount = (harvestableAmount * $.harvestBountyRatio) / 1 ether; -uint256 cutAmount = (harvestableAmount * $.harvestCutRatio) / 1 ether; -``` - -## Summary - -| Aspect | Details | -|--------|---------| -| **Who decides?** | Contract owner | -| **Initial value?** | 0 (zero) | -| **Can be changed?** | Yes, by owner anytime | -| **Maximum value?** | 1 ether (100%) | -| **Typical bounty?** | 1-10% (0.01-0.10 ether) | -| **Typical cut?** | 5-20% (0.05-0.20 ether) | -| **How set?** | Via `updateHarvestBountyRatio()` and `updateHarvestCutRatio()` | - -## Key Points - -1. ✅ **Configurable** - Owner sets the values -2. ✅ **Start at zero** - Must be explicitly set after deployment -3. ✅ **Can be updated** - Owner can adjust anytime -4. ✅ **Bounded** - Cannot exceed 100% (1 ether) -5. ✅ **Flexible** - Can be set to any value within bounds - -The ratios are **governance decisions** made by the protocol owner, balancing: -- Keeper incentives (bounty) -- Protocol revenue (cut) -- User rewards (remainder) - - - -## Quick Answer - -**The harvest bounty and cut ratios are configurable parameters set by the contract owner.** They start at 0 and must be explicitly set after deployment. - -## Initial State - -### Default Values -- **`harvestBountyRatio`**: `0` (starts at zero) -- **`harvestCutRatio`**: `0` (starts at zero) - -Both ratios are stored in the contract's storage and initialized to 0 when the contract is deployed. - -## Who Decides? - -### The Owner -Only the **contract owner** can set these ratios via: -- `updateHarvestBountyRatio(uint256 harvestRatio_)` - Sets bounty ratio -- `updateHarvestCutRatio(uint256 harvestCutRatio_)` - Sets cut ratio - -### Constraints -- Both ratios must be **≤ 1 ether** (100%) -- Can be set to any value from `0` to `1 ether` -- Can be updated at any time by the owner - -## How They Work - -### Calculation -```solidity -uint256 bountyAmount = (harvestableAmount * harvestBountyRatio) / 1 ether; -uint256 cutAmount = (harvestableAmount * harvestCutRatio) / 1 ether; -uint256 remainder = harvestableAmount - bountyAmount - cutAmount; -``` - -### Distribution -- **Bounty** → Goes to `bountyReceiver` (whoever calls `harvest()`) -- **Cut** → Goes to `feeReceiver` (or treasury if not set) -- **Remainder** → Automatically deposited to stability pools - -## Typical Values (From Tests) - -### Bounty Ratio Examples -- `0.01 ether` = 1% (common for small bounties) -- `0.05 ether` = 5% (moderate bounty) -- `0.10 ether` = 10% (higher bounty) - -### Cut Ratio Examples -- `0.1 ether` = 10% (typical protocol revenue) -- `0.2 ether` = 20% (higher protocol revenue) - -### Example Split -With `harvestBountyRatio = 0.05 ether` (5%) and `harvestCutRatio = 0.1 ether` (10%): - -**100 wstETH harvestable:** -- Bounty: 5 wstETH (5%) → harvester -- Cut: 10 wstETH (10%) → fee receiver -- Remainder: 85 wstETH (85%) → stability pools - -## Decision Process - -### 1. **Initial Setup** (After Deployment) -Owner must call: -```solidity -updateHarvestBountyRatio(0.05 ether); // Set to 5% -updateHarvestCutRatio(0.1 ether); // Set to 10% -``` - -### 2. **Ongoing Management** -Owner can adjust ratios at any time: -- Increase bounty to incentivize more frequent harvesting -- Decrease cut to give more to users -- Adjust based on protocol needs - -### 3. **Considerations** -When setting ratios, owner should consider: -- **Bounty**: High enough to incentivize keepers, but not so high it reduces user rewards -- **Cut**: Protocol revenue needs vs. user rewards -- **Remainder**: Should be the majority (80-95%) to reward stability pool depositors - -## Code Reference - -### Setting Ratios -```solidity -// Owner sets bounty ratio (e.g., 5%) -function updateHarvestBountyRatio(uint256 harvestRatio_) external onlyOwner { - if (harvestRatio_ > 1 ether) { - revert InvalidHarvestBountyRatio(harvestRatio_); - } - $.harvestBountyRatio = harvestRatio_; - emit HarvestBountyUpdated(harvestRatio_); -} - -// Owner sets cut ratio (e.g., 10%) -function updateHarvestCutRatio(uint256 harvestCutRatio_) external onlyOwner { - if (harvestCutRatio_ > 1 ether) { - revert InvalidHarvestBountyRatio(harvestCutRatio_); - } - $.harvestCutRatio = harvestCutRatio_; - emit HarvestCutUpdated(harvestCutRatio_); -} -``` - -### Using Ratios -```solidity -// During harvest -uint256 bountyAmount = (harvestableAmount * $.harvestBountyRatio) / 1 ether; -uint256 cutAmount = (harvestableAmount * $.harvestCutRatio) / 1 ether; -``` - -## Summary - -| Aspect | Details | -|--------|---------| -| **Who decides?** | Contract owner | -| **Initial value?** | 0 (zero) | -| **Can be changed?** | Yes, by owner anytime | -| **Maximum value?** | 1 ether (100%) | -| **Typical bounty?** | 1-10% (0.01-0.10 ether) | -| **Typical cut?** | 5-20% (0.05-0.20 ether) | -| **How set?** | Via `updateHarvestBountyRatio()` and `updateHarvestCutRatio()` | - -## Key Points - -1. ✅ **Configurable** - Owner sets the values -2. ✅ **Start at zero** - Must be explicitly set after deployment -3. ✅ **Can be updated** - Owner can adjust anytime -4. ✅ **Bounded** - Cannot exceed 100% (1 ether) -5. ✅ **Flexible** - Can be set to any value within bounds - -The ratios are **governance decisions** made by the protocol owner, balancing: -- Keeper incentives (bounty) -- Protocol revenue (cut) -- User rewards (remainder) - - - - - diff --git a/doc/guides/HOW-TO-FETCH-COLLATERAL-RATIO.txt b/doc/guides/HOW-TO-FETCH-COLLATERAL-RATIO.txt deleted file mode 100644 index b28b04f6..00000000 --- a/doc/guides/HOW-TO-FETCH-COLLATERAL-RATIO.txt +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/doc/guides/LATEST-EVENTS-SUMMARY.md b/doc/guides/LATEST-EVENTS-SUMMARY.md deleted file mode 100644 index be78c13f..00000000 --- a/doc/guides/LATEST-EVENTS-SUMMARY.md +++ /dev/null @@ -1,156 +0,0 @@ -# Latest Events Summary - -## Current Chain Status - -- **Current Block**: 272 -- **Current Timestamp**: 1764895365 (Fri, Dec 5, 2025 00:42:45 GMT) - -## Latest Events from Subgraph - -### Genesis Events - -- **Genesis End**: Block 157, Timestamp 1764267659 - - Transaction: `0x7404705a9b9d6607d27970db0db679078d8e9a8680bfceab45260a9477936779` - - Genesis contract: `0xA4899D35897033b927acFCf422bc745916139776` - -### Genesis Deposits - -- **1 Deposit** found: - - User: `0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e` - - Amount: 200,000 tokens (200,000,000,000,000,000,000 wei) - - Amount USD: $400,000 - - Timestamp: 1764265277 - - Transaction: `0x7d64058e348381e4b2516edb6d4ce30e2e22fbe588e718999141b90f676347ae` - -### Stability Pool Deposits - -- **1 Active Deposit** in Collateral Pool: - - Pool: `0x3aAde2dCD2Df6a8cAc689EE797591b2913658659` (Collateral Pool) - - User: `0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e` - - Balance: 150,000 tokens (150,000,000,000,000,000,000 wei) - - Balance USD: $150,000 - - Last Updated: 1764895365 (Block 272) - -### Ha Token (Anchor Token) Balances - -- **5 Active Balances** tracked: - 1. **User**: `0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266` - - Balance: 1,250 tokens - - Balance USD: $1,250 - - Last Updated: 1764895365 - - 2. **User**: `0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e` (Dev Account) - - Balance: 442,007.73 tokens - - Balance USD: $442,007.73 - - Last Updated: 1764895365 - - 3. **Pool**: `0x3aAde2dCD2Df6a8cAc689EE797591b2913658659` (Collateral Pool) - - Balance: 150,000 tokens - - Balance USD: $150,000 - - Last Updated: 1764895365 - - 4. **User**: `0x1111111111111111111111111111111111111111` - - Balance: 2 wei (minimal) - - Balance USD: $0.000000000000000002 - - Last Updated: 1764551007 - - 5. **Genesis**: `0xA4899D35897033b927acFCf422bc745916139776` - - Balance: 0 (Genesis ended, tokens transferred) - - Last Updated: 1764268474 - -### Sail Token (Leveraged Token) Balances - -- **2 Active Balances** tracked: - 1. **User**: `0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e` (Dev Account) - - Balance: 401,651.48 tokens - - Balance USD: $401,651.48 - - Last Updated: 1764882233 - - 2. **Genesis**: `0xA4899D35897033b927acFCf422bc745916139776` - - Balance: 0 - - Last Updated: 1764268474 - -## Latest Blockchain Events (Block 272) - -### Stability Pool Withdrawal (Most Recent - Corrected) - -- **Function Called**: `deposit()` (but user confirms this was actually a withdrawal) -- **Block**: 272 -- **Transaction**: `0xcaf2670f7abe7ee3142aa7cdc461233fe07211b0ce0a5acbc34ac59db5bf335a` -- **From**: `0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e` (Dev Account) -- **Pool**: `0x3aAde2dCD2Df6a8cAc689EE797591b2913658659` (Collateral Pool) -- **Amount Attempted**: 50,000 tokens (from transaction input) -- **Note**: User confirms this was a withdrawal, not a deposit - -### Events Emitted in Transaction - -1. **UserDepositChange Event**: - - New Balance: 150,000 tokens (150,000,000,000,000,000,000,000 wei) - - Previous Balance: 200,000 tokens (before withdrawal) - - **Net Withdrawal**: 50,000 tokens - -2. **WithdrawalRequestUpdated Event**: - - User: `0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e` - - Indicates withdrawal request was processed/updated - -3. **Ha Token Transfer (To Dev Account)**: - - **From**: `0x3aAde2dCD2Df6a8cAc689EE797591b2913658659` (Collateral Pool) - - **To**: `0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e` (Dev Account) - - **Amount**: 1,250 tokens - - **Purpose**: Withdrawn tokens returned to user - -4. **Ha Token Transfer (To Owner/Fee Receiver)**: - - **From**: `0x3aAde2dCD2Df6a8cAc689EE797591b2913658659` (Collateral Pool) - - **To**: `0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266` (Owner/Fee Receiver) - - **Amount**: 1,250 tokens - - **Purpose**: Early withdrawal fee (2.5% of 50,000 = 1,250 tokens) - -5. **Deposit Event** (1,250 tokens): - - This appears to be an internal event or related to fee processing - -### Current Pool State - -- **User Balance**: 150,000 tokens (confirmed via contract call) -- **Total Pool Supply**: 150,000 tokens -- **Owner/Fee Receiver**: `0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266` (Anvil account #0) - -### Sail Token Transfer Event - -- **Event**: `Transfer` (ERC20) -- **Block**: Earlier (likely block 271 or earlier) -- **Transaction**: `0xbc36dde057998430c666725bb46079587f079b9af66774124a927f4b7d9e272c` -- **From**: `0x0000000000000000000000000000000000000000` (Mint) -- **To**: `0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e` -- **Amount**: 401,651.48 tokens (0x2a4720da02ef4c809932 wei) - -## Summary - -### Most Recent Activity - -1. **Block 272** (Latest): - - **Stability Pool Withdrawal**: 50,000 tokens withdrawn from Collateral Pool by dev account - - **Early Withdrawal Fee**: 1,250 tokens (2.5%) sent to owner/fee receiver (`0xf39...`) - - **Net Withdrawal**: 48,750 tokens returned to dev account - - **Current Balance**: 150,000 tokens remaining in pool - -2. **Block 271 or earlier**: - - Sail Token Mint: 401,651.48 tokens minted to dev account - -3. **Block 157**: - - Genesis Ended: Genesis phase completed, tokens distributed - -### Current State - -- **Genesis**: Ended (Block 157) -- **Total Genesis Deposit**: 200,000 tokens ($400,000 USD) -- **Active Stability Pool Deposits**: 150,000 tokens in Collateral Pool (subgraph shows this, but latest deposit was 50,000 tokens - may need subgraph sync) -- **Ha Token Holdings**: ~593,258 tokens across all users -- **Sail Token Holdings**: ~401,651 tokens (mostly dev account) - -### Subgraph Status - -- ✅ Genesis events indexed -- ✅ Ha token transfers indexed -- ✅ Sail token transfers indexed -- ✅ Stability pool deposits indexed -- ✅ All balances tracked and up-to-date diff --git a/doc/guides/LIQUIDATION-REWARDS-EXPLAINED.md b/doc/guides/LIQUIDATION-REWARDS-EXPLAINED.md deleted file mode 100644 index 009a8a48..00000000 --- a/doc/guides/LIQUIDATION-REWARDS-EXPLAINED.md +++ /dev/null @@ -1,222 +0,0 @@ -# Why Liquidation Rewards Usually Give You More Back - -## The Key Insight - -You don't always get "more" - it depends on the system's health. But here's why it often works out favorably: - -## How Liquidation Works - -### Step 1: System Needs Rebalancing -- Collateral ratio drops below threshold (e.g., 1.3x) -- System is **unhealthy** and needs more collateral - -### Step 2: Your Tokens Get Liquidated -- Some of your deposited haPB tokens are taken from the stability pool -- These are redeemed via `freeRedeemPeggedToken()` - -### Step 3: Redemption Calculation -The amount you get back is calculated as: -``` -collateralOut = (peggedTokens × peggedTokenPrice) / collateralPrice -``` - -**Pegged Token Price:** -- If system is healthy (CR > 1.0): pegged token = **1.0 collateral** (1:1 ratio) -- If system is depegged (CR < 1.0): pegged token = **less than 1.0 collateral** - -### Step 4: Oracle Price Used -- Liquidation uses `_fetchMax()` oracle - the **maximum** price -- This is more favorable than the mid price used for normal redemptions -- Gives you the best possible rate - -### Step 5: Bounty Extraction -- A small bounty is taken out (goes to whoever triggered rebalance) -- The **remainder** goes back to the stability pool (your reward) - -## Why You Often Get More - -### Scenario 1: System is Healthy (CR > 1.0) -- Pegged tokens are worth 1.0 collateral each -- You get back: `(peggedTokens × 1.0) - bounty` -- **Result:** Roughly 1:1, minus small bounty (~0.5-2%) -- You get back **slightly less** in this case - -### Scenario 2: System is Unhealthy (CR < 1.0) - But Rebalancing Helps -- Pegged tokens might be worth < 1.0 collateral (depegged) -- BUT: Rebalancing **improves** the system health -- The liquidation happens at a moment when the system is recovering -- You might get back **more** than the depegged value - -### Scenario 3: The Real "More" - System Health Improvement -- When system rebalances, it **increases** the collateral ratio -- This makes remaining pegged tokens **more valuable** -- Your remaining deposit in the pool becomes worth more -- Plus you got collateral back from the liquidation - -## The Math - -**Example:** -- You deposit: 100,000 haPB -- System collateral ratio: 1.25x (unhealthy, needs rebalancing) -- Pegged token price: ~0.96 collateral (slightly depegged) -- 20,000 haPB gets liquidated -- You get back: `(20,000 × 0.96) - bounty = ~19,200 wstETH - small bounty` -- **But:** The rebalancing improves system to 1.35x -- Your remaining 80,000 haPB is now worth more (system is healthier) -- **Net result:** You got collateral back + your remaining deposit is more valuable - -## Why It's Not Always "More" - -### If System is Very Healthy -- Pegged tokens = 1.0 collateral -- You get back: `(peggedTokens × 1.0) - bounty` -- **Result:** Slightly less than 1:1 (due to bounty) -- But the system health improvement benefits your remaining deposit - -### If System is Severely Depegged -- Pegged tokens might be worth 0.8 collateral -- You get back: `(peggedTokens × 0.8) - bounty` -- **Result:** Less than you put in -- This is the risk of providing liquidity - -## The Real Benefit - -The "more" you get isn't always in the immediate liquidation return. It's: - -1. **Immediate:** You get collateral/leveraged tokens back (at favorable max price) -2. **System Health:** Rebalancing improves the system, making your remaining deposit more valuable -3. **Proportional:** You get your fair share based on your deposit size -4. **No Fees:** Liquidation uses `freeRedeemPeggedToken()` (no fees, unlike normal redemption) - -## Summary - -**You don't always get "more" in absolute terms**, but: - -✅ **You get back collateral/leveraged tokens** (different asset, might appreciate) -✅ **Liquidation uses max price** (most favorable rate) -✅ **System health improves** (your remaining deposit becomes more valuable) -✅ **No fees** (unlike normal redemption which has fees) -✅ **Proportional rewards** (fair distribution based on your share) - -The "more" is often in the **combination** of: -- Getting collateral back (which might appreciate) -- System health improvement (making remaining deposit more valuable) -- Favorable pricing (max oracle price, no fees) - -## Risk Note - -⚠️ **If system is severely depegged**, you might get back less than you put in. This is the risk of providing liquidity to stability pools. The rewards compensate for this risk. - - - -## The Key Insight - -You don't always get "more" - it depends on the system's health. But here's why it often works out favorably: - -## How Liquidation Works - -### Step 1: System Needs Rebalancing -- Collateral ratio drops below threshold (e.g., 1.3x) -- System is **unhealthy** and needs more collateral - -### Step 2: Your Tokens Get Liquidated -- Some of your deposited haPB tokens are taken from the stability pool -- These are redeemed via `freeRedeemPeggedToken()` - -### Step 3: Redemption Calculation -The amount you get back is calculated as: -``` -collateralOut = (peggedTokens × peggedTokenPrice) / collateralPrice -``` - -**Pegged Token Price:** -- If system is healthy (CR > 1.0): pegged token = **1.0 collateral** (1:1 ratio) -- If system is depegged (CR < 1.0): pegged token = **less than 1.0 collateral** - -### Step 4: Oracle Price Used -- Liquidation uses `_fetchMax()` oracle - the **maximum** price -- This is more favorable than the mid price used for normal redemptions -- Gives you the best possible rate - -### Step 5: Bounty Extraction -- A small bounty is taken out (goes to whoever triggered rebalance) -- The **remainder** goes back to the stability pool (your reward) - -## Why You Often Get More - -### Scenario 1: System is Healthy (CR > 1.0) -- Pegged tokens are worth 1.0 collateral each -- You get back: `(peggedTokens × 1.0) - bounty` -- **Result:** Roughly 1:1, minus small bounty (~0.5-2%) -- You get back **slightly less** in this case - -### Scenario 2: System is Unhealthy (CR < 1.0) - But Rebalancing Helps -- Pegged tokens might be worth < 1.0 collateral (depegged) -- BUT: Rebalancing **improves** the system health -- The liquidation happens at a moment when the system is recovering -- You might get back **more** than the depegged value - -### Scenario 3: The Real "More" - System Health Improvement -- When system rebalances, it **increases** the collateral ratio -- This makes remaining pegged tokens **more valuable** -- Your remaining deposit in the pool becomes worth more -- Plus you got collateral back from the liquidation - -## The Math - -**Example:** -- You deposit: 100,000 haPB -- System collateral ratio: 1.25x (unhealthy, needs rebalancing) -- Pegged token price: ~0.96 collateral (slightly depegged) -- 20,000 haPB gets liquidated -- You get back: `(20,000 × 0.96) - bounty = ~19,200 wstETH - small bounty` -- **But:** The rebalancing improves system to 1.35x -- Your remaining 80,000 haPB is now worth more (system is healthier) -- **Net result:** You got collateral back + your remaining deposit is more valuable - -## Why It's Not Always "More" - -### If System is Very Healthy -- Pegged tokens = 1.0 collateral -- You get back: `(peggedTokens × 1.0) - bounty` -- **Result:** Slightly less than 1:1 (due to bounty) -- But the system health improvement benefits your remaining deposit - -### If System is Severely Depegged -- Pegged tokens might be worth 0.8 collateral -- You get back: `(peggedTokens × 0.8) - bounty` -- **Result:** Less than you put in -- This is the risk of providing liquidity - -## The Real Benefit - -The "more" you get isn't always in the immediate liquidation return. It's: - -1. **Immediate:** You get collateral/leveraged tokens back (at favorable max price) -2. **System Health:** Rebalancing improves the system, making your remaining deposit more valuable -3. **Proportional:** You get your fair share based on your deposit size -4. **No Fees:** Liquidation uses `freeRedeemPeggedToken()` (no fees, unlike normal redemption) - -## Summary - -**You don't always get "more" in absolute terms**, but: - -✅ **You get back collateral/leveraged tokens** (different asset, might appreciate) -✅ **Liquidation uses max price** (most favorable rate) -✅ **System health improves** (your remaining deposit becomes more valuable) -✅ **No fees** (unlike normal redemption which has fees) -✅ **Proportional rewards** (fair distribution based on your share) - -The "more" is often in the **combination** of: -- Getting collateral back (which might appreciate) -- System health improvement (making remaining deposit more valuable) -- Favorable pricing (max oracle price, no fees) - -## Risk Note - -⚠️ **If system is severely depegged**, you might get back less than you put in. This is the risk of providing liquidity to stability pools. The rewards compensate for this risk. - - - - - diff --git a/doc/guides/LP-RISK-PARAMETER-RESPONSE-DRAFT.md b/doc/guides/LP-RISK-PARAMETER-RESPONSE-DRAFT.md deleted file mode 100644 index 43818abe..00000000 --- a/doc/guides/LP-RISK-PARAMETER-RESPONSE-DRAFT.md +++ /dev/null @@ -1,153 +0,0 @@ -# Response: Risk Parameter Configuration (Draft) - -Good question - Well configured markets are key & being cautious is important to us. - -## Data-Driven Configuration - -Our market configs are data-driven - we set the minimum collateral ratios based on the largest historical price movements in a single day seen between the collateral and pegged assets, with multiple layers of testing and review. - -**Specific Example:** -- Historical analysis shows BTC can drop ~20% in a single day (May 2022: $36,950 → $29,737) -- ETH can see 40-50% drops in extreme events (March 2020 COVID crash) -- Our rebalance threshold of 1.3x-1.4x provides a 30-40% buffer above the 1.0x minimum -- This means even a 30% price drop wouldn't immediately threaten undercollateralization - -**Validation Process:** -- Stress tested against historical crashes (March 2020, May 2021, May 2022) -- Monte Carlo simulations with various market scenarios -- Independent review by security auditors -- Testnet validation before mainnet deployment - -## Collateral Risk Assessment - -Collaterals are risk assessed - We use highly liquid assets that can fully unwind to base assets like ETH or USDC. This ensures: - -- **Liquidity**: Assets can be liquidated even during market stress -- **Price Discovery**: Reliable pricing even in volatile conditions -- **Unwinding Path**: Clear path to base assets (ETH/USDC) if needed -- **Market Depth**: Sufficient liquidity to handle large redemptions - -**Initial Markets:** -- wstETH (wrapped staked ETH) - highly liquid, established market -- Future markets will follow the same rigorous assessment criteria - -## Oracle Reliability & Transparency - -Price feeds are Chainlink, at least for initial markets. If we expand into more exotic markets, we will work with Dia to create reliable price feeds that don't yet exist. In either case: - -**Oracle Transparency:** -- The oracles used are clearly displayed on the front end, so liquidity providers are aware of the oracle risks they are exposed to -- All oracle addresses are publicly verifiable on-chain -- Oracle constraints (staleness limits, deviation thresholds) are documented and visible - -**Oracle Safeguards:** -- We have checks for stale prices (1 hour maximum age) -- Deviation limits (20% relative, $1000 absolute) prevent accepting flash crash prices -- Trend reversal detection catches manipulation attempts -- Real-time monitoring of oracle health and error rates - -**Future Markets:** -- For exotic markets, we'll work with Dia to create custom price feeds -- Same transparency and safeguards will apply -- Community review before adding new oracles - -## Enhanced Fee Structure - -We have also improved the fee structure used by f(x): 7 fee lines can be configured, allowing us to set fees that greatly incentivise keeping markets well-balanced. - -**Key Improvements:** -- **7 Configurable Bands**: More granular control than f(x)'s structure -- **Health-Based**: Fees automatically adjust based on collateral ratio -- **Dynamic Incentives**: Fees increase when system is unhealthy, decrease when healthy - -**Negative Fees (Discounts):** -We will even offer negative fees when a market becomes at risk of undercollateralization. This means: - -- **Redeem Pegged Tokens**: Up to -10% discount when ratio < 1.0x (you get 10% bonus) -- **Mint Leveraged Tokens**: Up to -15% discount when ratio < 1.0x (you get 15% bonus) -- **Strong Incentives**: Encourages actions that improve system health - -**Fee Structure Examples:** -- **Mint Pegged (when unhealthy)**: 50% fee at 1.0x-1.05x, 100% blocked below 1.0x -- **Redeem Pegged (when unhealthy)**: -10% discount below 1.0x, -5% at 1.0x-1.05x -- **Mint Leveraged (when unhealthy)**: -15% discount below 1.0x, -10% at 1.0x-1.05x -- **Redeem Leveraged (when unhealthy)**: 30% fee at 1.0x-1.05x, 100% blocked below 1.0x - -## Volatility Risk Transparency - -In addition, we display "volatility risk" on the front end, showing users the level of price movement needed to drain all stability pools and bring the collateral ratio below 100%. - -**What This Shows:** -- **Required Price Drop**: "X% price drop would drain all stability pools" -- **Current Buffer**: "System can absorb Y% price drop before reaching 100% collateralization" -- **Real-Time Updates**: Updates as pool sizes and collateral ratios change -- **Historical Context**: Compares to historical single-day movements - -**Example Display:** -- "Current stability pools can absorb a 45% price drop before reaching 100% collateralization" -- "Historical maximum single-day drop: 20% (May 2022)" -- "Safety margin: 2.25x historical maximum" - -## Stability Pool Economics - -Since yield will be very good for stability pool depositors, we expect to be able to absorb far greater price changes than are ever likely to happen. - -**Yield Sources:** -- **Harvest Rewards**: Majority of protocol yield (90-98% after bounty/cut) -- **Liquidation Rewards**: Premium when tokens are liquidated during rebalancing -- **Fee Revenue**: Can be directed to pools during stress periods - -**Expected Pool Sizes:** -- High yields attract significant deposits -- Larger pools = greater ability to absorb price movements -- Pool sizes are publicly visible and monitored - -**Economic Incentives:** -- Higher yields during stress periods (more frequent rebalancing = more rewards) -- Early withdrawal fees discourage panic exits -- Fee-free withdrawal window after waiting period - -## Additional Safeguards - -**Multi-Signature Governance:** -- All parameter changes require multisig approval (3-of-5 or 4-of-7) -- No single point of failure -- Timelock for major changes (48-72 hours) - -**Continuous Monitoring:** -- Real-time alerts when collateral ratio approaches thresholds -- Stability pool size monitoring -- Oracle health tracking -- Automated notifications for parameter changes - -**Gradual Adjustments:** -- Parameters adjusted incrementally based on real-world data -- Never make sudden, large changes -- Test changes on testnet first -- Monitor impact before next adjustment - -**Transparency:** -- All parameters publicly readable on-chain -- Parameter change history documented -- Regular review reports published -- Community can query and verify all values - -## Conclusion - -We take configuration risk seriously and have built multiple layers of protection: - -1. **Data-driven**: Parameters based on historical analysis -2. **Conservative**: Start safe, adjust based on data -3. **Transparent**: All oracles and parameters visible -4. **Incentivized**: Fee structure encourages healthy behavior -5. **Monitored**: Continuous oversight and alerting -6. **Governed**: Multisig and timelock protections - -We're happy to discuss any specific concerns or provide more detail on any aspect of our risk management approach. - ---- - -*This response addresses the liquidity provider's concern about configuration risk while demonstrating Harbor's comprehensive approach to parameter safety.* - - - diff --git a/doc/guides/LP-RISK-PARAMETER-RESPONSE-FINAL.md b/doc/guides/LP-RISK-PARAMETER-RESPONSE-FINAL.md deleted file mode 100644 index 6d420920..00000000 --- a/doc/guides/LP-RISK-PARAMETER-RESPONSE-FINAL.md +++ /dev/null @@ -1,76 +0,0 @@ -# Response: Risk Parameter Configuration (Final) - -Good question - Well configured markets are key & being cautious is important to us. - -## Data-Driven Configuration - -Our market configs are data-driven - we set the minimum collateral ratios based on the largest historical price movements in a single day seen between the collateral and pegged assets, with multiple layers of testing and review. - -**For example:** - -- Historical analysis shows BTC can drop ~20% in a single day (May 2022: $36,950 → $29,737) -- ETH can see 40-50% drops in extreme events (March 2020 COVID crash) -- Our rebalance threshold of 1.3x-1.4x provides a 30-40% buffer above the 1.0x minimum -- This means even a 30% price drop wouldn't immediately threaten undercollateralization - -We stress test against historical crashes, run Monte Carlo simulations, and have independent security review before deployment. - -## Collateral Risk Assessment - -Collaterals are risk assessed - We use highly liquid assets that can fully unwind to base assets like ETH or USDC. This ensures reliable pricing and liquidation even during market stress. Initial markets will use wstETH (wrapped staked ETH), with future markets following the same rigorous assessment criteria. - -## Oracle Reliability & Transparency - -Price feeds are Chainlink, at least for initial markets. If we expand into more exotic markets, we will work with Dia to create reliable price feeds that don't yet exist. In either case: - -- **The oracles used are clearly displayed on the front end**, so liquidity providers are aware of the oracle risks they are exposed to -- **We have checks for stale prices** (1 hour maximum age) -- **Deviation limits** (20% relative, $1000 absolute) prevent accepting flash crash prices -- **All oracle addresses are publicly verifiable on-chain** - -## Enhanced Fee Structure - -We have also improved the fee structure used by f(x): **7 fee lines can be configured**, allowing us to set fees that greatly incentivise keeping markets well-balanced. - -**We will even offer negative fees when a market becomes at risk of undercollateralization:** - -- Redeem pegged tokens: Up to **-10% discount** when ratio < 1.0x (you get 10% bonus) -- Mint leveraged tokens: Up to **-15% discount** when ratio < 1.0x (you get 15% bonus) - -This strongly incentivizes actions that improve system health. Conversely, dangerous operations (like minting pegged tokens when unhealthy) face fees up to 50% or are completely blocked below 1.0x. - -## Volatility Risk Transparency - -In addition, we display **"volatility risk"** on the front end, showing users the level of price movement needed to drain all stability pools and bring the collateral ratio below 100%. - -This shows: - -- The required price drop to drain pools (e.g., "45% price drop would drain all stability pools") -- Current safety margin vs. historical maximums (e.g., "2.25x historical maximum single-day drop") -- Real-time updates as pool sizes and ratios change - -## Stability Pool Economics - -Since yield will be very good for stability pool depositors, we expect to be able to absorb far greater price changes than are ever likely to happen. - -**Yield sources include:** - -- Harvest rewards (90-98% of protocol yield after bounty/cut) -- Liquidation rewards (premium when tokens are liquidated during rebalancing) -- Fee revenue (can be directed to pools during stress periods) - -Higher yields attract significant deposits, creating larger pools that can absorb greater price movements. Pool sizes are publicly visible and continuously monitored. - -## Additional Safeguards - -**Multi-signature governance:** All parameter changes require multisig approval (3-of-5 or 4-of-7), with timelock for major changes (48-72 hours). - -**Continuous monitoring:** Real-time alerts when collateral ratio approaches thresholds, stability pool size monitoring, and oracle health tracking. - -**Transparency:** All parameters publicly readable on-chain, parameter change history documented, and regular review reports published. - ---- - -We take configuration risk seriously and have built multiple layers of protection. We're happy to discuss any specific concerns or provide more detail on any aspect of our risk management approach. - - diff --git a/doc/guides/LP-RISK-PARAMETER-RESPONSE.md b/doc/guides/LP-RISK-PARAMETER-RESPONSE.md deleted file mode 100644 index f10f0d25..00000000 --- a/doc/guides/LP-RISK-PARAMETER-RESPONSE.md +++ /dev/null @@ -1,360 +0,0 @@ -# Response: Risk Parameter Selection and Configuration Safety - -## Acknowledgment - -We appreciate your thorough due diligence. You're absolutely right to be concerned about parameter configuration - it's one of the most critical aspects of protocol safety, and misconfiguration can indeed lead to the risks we've documented. We take this seriously and have built multiple layers of protection. - ---- - -## Our Parameter Selection Methodology - -### 1. **Data-Driven, Conservative Approach** - -Our parameters are not arbitrary - they're based on: - -**Historical Market Analysis:** -- **BTC/USD**: Largest single-day drops of ~20% (May 2022: $36,950 → $29,737) -- **ETH/USD**: Can see 40-50% drops in extreme events (March 2020 COVID crash) -- **ETH/BTC**: Can move 10-15% in a single day - -**Stress Scenario Modeling:** -- We model worst-case scenarios: 50% collateral price drops, flash crashes, oracle failures -- Our rebalance threshold (1.3x-1.4x) provides a 30-40% buffer above the 1.0x minimum -- This means even a 30% price drop wouldn't immediately threaten undercollateralization - -**Industry Benchmarks:** -- We've studied similar protocols (MakerDAO, Liquity, etc.) and their parameter choices -- Our thresholds are more conservative than many existing protocols -- We err on the side of safety, especially at launch - -### 2. **Multi-Layer Validation** - -Every parameter goes through: - -**Pre-Deployment:** -- ✅ Mathematical validation (stress testing with historical data) -- ✅ Simulation testing (Monte Carlo scenarios) -- ✅ Testnet deployment and validation -- ✅ Independent review by security auditors -- ✅ Community review period before mainnet - -**Post-Deployment:** -- ✅ Continuous monitoring of all parameters -- ✅ Real-world performance tracking -- ✅ Monthly parameter reviews -- ✅ Quarterly stress testing -- ✅ Annual comprehensive audits - -### 3. **Conservative Initial Settings** - -**Our Philosophy: "Start Conservative, Relax Over Time"** - -- **Rebalance Threshold**: We start at 1.35x-1.4x (more conservative than our 1.3x default) -- **Oracle Constraints**: Stricter initially (15-20% deviation limits) -- **Fee Structures**: Higher fees initially to discourage risky behavior -- **Stability Pool Minimums**: Sized for worst-case scenarios (5-10% of total supply) - -We can always relax parameters as we gather real-world data, but we can't easily make them more conservative after launch. - ---- - -## Safeguards Against Misconfiguration - -### 1. **Multi-Signature Governance** - -**Critical Parameters Require Multiple Approvals:** -- All admin functions require multisig (3-of-5 or 4-of-7) -- No single point of failure -- Changes require consensus from multiple trusted parties - -**Current Setup:** -- Owner/admin roles: Multisig wallet -- Fee receiver: Multisig wallet -- All parameter updates: Require multisig approval - -### 2. **Timelock for Major Changes** - -**Proposed Implementation:** -- Major parameter changes: 48-72 hour timelock -- Allows community review before execution -- Provides opportunity to detect and prevent bad changes -- Emergency changes still possible but with higher thresholds - -### 3. **Parameter Validation Checks** - -**Built-in Constraints:** -- Rebalance threshold: Must be ≥ 1.0x (cannot be set below minimum) -- Fee structures: Must block operations below 1.0x (validated in code) -- Oracle constraints: Must be within reasonable bounds -- Stability pool minimums: Cannot be set to zero - -**Code-Level Protections:** -```solidity -// Example: Rebalance threshold validation -function updateRebalanceThreshold(uint256 newThreshold) external onlyOwner { - require(newThreshold >= 1.0e18, "Threshold must be >= 1.0x"); - require(newThreshold <= 2.0e18, "Threshold must be <= 2.0x"); - // Additional validation logic... -} -``` - -### 4. **Transparency and Monitoring** - -**Public Monitoring:** -- All parameters are publicly readable on-chain -- Real-time dashboards showing current values -- Alert systems for parameter changes -- Public documentation of all parameter decisions - -**Regular Reporting:** -- Monthly parameter review reports -- Quarterly stress test results -- Annual comprehensive audit reports -- All changes documented and explained - -### 5. **Gradual Adjustment Process** - -**We Never Make Sudden Changes:** -- Changes are incremental (e.g., 0.05x adjustments, not 0.5x) -- Test changes on testnet first -- Monitor impact of each change before next adjustment -- Rollback plan for every change - -**Example Process:** -1. Propose change (with justification) -2. Community review period (7 days) -3. Testnet deployment and validation -4. Multisig approval -5. Timelock execution (48 hours) -6. Post-deployment monitoring -7. Impact assessment before next change - ---- - -## What Happens If Configuration Is Wrong? - -### 1. **Early Detection Systems** - -**Automated Monitoring:** -- Collateral ratio alerts when approaching thresholds -- Stability pool size monitoring -- Oracle error rate tracking -- Fee structure effectiveness analysis - -**Alert Thresholds:** -- Collateral ratio < 1.15x → Immediate alert -- Stability pool < 2x minimum → Warning -- Oracle errors > 5% → Investigation -- Parameter change detected → Notification - -### 2. **Emergency Response Procedures** - -**If Parameters Are Too Aggressive:** -- Immediate: Increase rebalance threshold -- Immediate: Adjust fee structure to be more conservative -- Short-term: Pause risky operations if needed -- Recovery: Direct protocol fees to stability pools - -**If Parameters Are Too Conservative:** -- Gradual relaxation based on data -- Monitor impact of each adjustment -- Never make multiple changes at once - -### 3. **Graceful Degradation** - -**Even If Configuration Fails:** -- System continues to function (no hard shutdowns) -- Graceful degradation mode (as documented in risks) -- Fair distribution of remaining collateral -- Recovery mechanisms remain available - -**This is by Design:** -- We've built the system to handle failures gracefully -- Even in worst-case scenarios, users get proportional value -- System remains composable and recoverable - ---- - -## Our Commitment to Safety - -### 1. **Ongoing Parameter Review** - -**Regular Schedule:** -- **Weekly**: Monitor key metrics -- **Monthly**: Review all parameters -- **Quarterly**: Comprehensive stress testing -- **Annually**: Full audit and parameter review - -**Review Criteria:** -- Are parameters achieving desired behavior? -- Have market conditions changed? -- Are there new risks to consider? -- Should we adjust based on real-world data? - -### 2. **Community Governance (Future)** - -**Planned Transition:** -- Initial: Team-controlled multisig (for safety) -- Phase 2: Community voting on parameter changes -- Phase 3: Full decentralized governance -- All transitions: Gradual, with safeguards - -**Current Transparency:** -- All parameter decisions are public -- Community can propose changes -- Team provides detailed justifications -- Open discussion before any changes - -### 3. **Independent Validation** - -**Third-Party Reviews:** -- Security audits before launch -- Ongoing security reviews -- Parameter validation by external experts -- Community review and feedback - ---- - -## Specific Parameter Examples - -### Rebalance Threshold: 1.3x-1.4x - -**Why This Value?** -- Historical data: BTC/ETH can drop 20-50% in extreme events -- 1.3x provides 30% buffer above 1.0x minimum -- Triggers rebalancing before system becomes critically undercollateralized -- Balances safety with efficiency - -**Validation:** -- Tested against historical crashes (March 2020, May 2021, May 2022) -- Simulated 50% price drops: System remains above 1.0x -- Stress tested with various pool sizes - -### Fee Structure: Health-Based - -**Why This Design?** -- Automatically discourages risky behavior when system is unhealthy -- Encourages helpful behavior (redemptions, leveraged minting) -- Blocks dangerous operations below 1.0x (hardcoded) -- Adapts to market conditions automatically - -**Validation:** -- Mathematical proof: Fees always improve or maintain health -- Simulation: System recovers faster with this structure -- Historical comparison: More conservative than similar protocols - -### Oracle Constraints: 20% Deviation, 1 Hour Staleness - -**Why These Values?** -- Historical data: BTC/ETH rarely moves >20% in legitimate single-day moves -- 1 hour: Chainlink updates typically every hour, provides buffer -- Prevents accepting flash crash prices -- Prevents accepting stale/manipulated prices - -**Validation:** -- Tested against historical price movements -- Simulated oracle failures and manipulation attempts -- Validated against Chainlink's actual update frequency - ---- - -## Comparison to Other Protocols - -**We're More Conservative Than:** -- Many protocols use 1.1x-1.2x thresholds → We use 1.3x-1.4x -- Some protocols have minimal fees → We have health-based fees -- Some protocols have lenient oracle constraints → We're stricter - -**We Match or Exceed:** -- Industry best practices for multisig governance -- Standard practices for timelock delays -- Best-in-class monitoring and alerting - ---- - -## What You Can Do - -### 1. **Monitor Parameters Yourself** - -**On-Chain Queries:** -```solidity -// Check rebalance threshold -uint256 threshold = stabilityPoolManager.rebalanceThreshold(); - -// Check current collateral ratio -uint256 ratio = minter.collateralRatio(); - -// Check if rebalancing is needed -bool canRebalance = stabilityPoolManager.rebalanceable(); -``` - -**Public Dashboards:** -- Real-time parameter values -- Historical parameter changes -- System health metrics -- Alert notifications - -### 2. **Review Our Documentation** - -**Available Resources:** -- Risk Mitigation Configuration Guide (detailed parameter explanations) -- Parameter selection methodology (this document) -- Historical parameter changes (transparent log) -- Stress test results (quarterly reports) - -### 3. **Participate in Governance** - -**Ways to Engage:** -- Review and comment on parameter proposals -- Participate in community discussions -- Propose parameter adjustments (with justification) -- Vote on parameter changes (when governance is live) - ---- - -## Conclusion - -**We Understand Your Concern:** -Configuration risk is real, and we've built multiple layers of protection against it. We're not just relying on "getting it right" - we've built systems to detect, prevent, and recover from misconfiguration. - -**Our Approach:** -1. **Data-driven**: Parameters based on historical analysis and stress testing -2. **Conservative**: Start safe, adjust based on real-world data -3. **Validated**: Multiple layers of review and testing -4. **Governed**: Multisig, timelock, and community oversight -5. **Monitored**: Continuous oversight and alerting -6. **Transparent**: All decisions and changes are public - -**We're Committed To:** -- Regular parameter reviews and adjustments -- Transparent decision-making -- Community involvement in governance -- Ongoing safety improvements -- Learning from real-world data - -**We Welcome:** -- Your questions and feedback -- Your participation in parameter discussions -- Your independent validation of our approach -- Your suggestions for improvements - -We believe this multi-layered approach provides strong protection against configuration risks while maintaining the flexibility to adapt as we learn. We're happy to discuss any specific concerns you have about our parameter selection or governance processes. - ---- - -## Additional Resources - -- **Risk Mitigation Configuration Guide**: Detailed parameter explanations -- **Parameter Selection Methodology**: This document -- **Historical Parameter Log**: All changes documented -- **Stress Test Reports**: Quarterly results -- **Security Audit Reports**: Independent validation -- **Community Governance Forum**: Discussion and proposals - ---- - -*Last Updated: [Current Date]* -*Next Review: [Monthly Review Date]* - - - diff --git a/doc/guides/MIN-COLLATERAL-RATIO-INFO.txt b/doc/guides/MIN-COLLATERAL-RATIO-INFO.txt deleted file mode 100644 index b28b04f6..00000000 --- a/doc/guides/MIN-COLLATERAL-RATIO-INFO.txt +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/doc/guides/MINTER-FEE-STRUCTURE-SUMMARY.md b/doc/guides/MINTER-FEE-STRUCTURE-SUMMARY.md deleted file mode 100644 index f3438058..00000000 --- a/doc/guides/MINTER-FEE-STRUCTURE-SUMMARY.md +++ /dev/null @@ -1,151 +0,0 @@ -# Minter Fee Structure Summary - -## Overview - -The Minter contract uses a **health-based fee structure** that dynamically adjusts fees based on the current collateral ratio. This incentivizes actions that improve system health and discourages actions that worsen it. - -## Token Types - -- **ha tokens** = Anchor (Pegged) Tokens -- **hs tokens** = Sail (Leveraged) Tokens - -## Fee Structure by Collateral Ratio - -### 1. Mint Anchor (ha) Tokens - -| Collateral Ratio | Fee | Effect | -|-----------------|-----|--------| -| < 1.0x | **100% (BLOCKED)** | ❌ Cannot mint - system undercollateralized | -| 1.0x - 1.05x | **50%** | Very expensive - system at risk | -| 1.05x - 1.1x | **20%** | High fee - system stressed | -| 1.1x - 1.2x | **10%** | Medium fee - system recovering | -| 1.2x - 1.3x | **5%** | Low fee - system healthy | -| 1.3x - 1.5x | **2%** | Very low fee - system very healthy | -| 1.5x - 2.0x | **1%** | Minimal fee - system extremely healthy | -| > 2.0x | **0.5%** | Minimal fee - system overcollateralized | - -**Rationale**: Discourages minting when system is unhealthy to prevent further stress. - ---- - -### 2. Redeem Anchor (ha) Tokens - -| Collateral Ratio | Fee/Discount | Effect | -|-----------------|--------------|--------| -| < 1.0x | **-10% (Discount)** | ✅ You get 10% bonus - strongly encouraged | -| 1.0x - 1.05x | **-5% (Discount)** | ✅ You get 5% bonus - encouraged | -| 1.05x - 1.1x | **0% (FREE)** | ✅ No fee - system needs help | -| 1.1x - 1.2x | **1%** | Low fee - system recovering | -| 1.2x - 1.3x | **2%** | Small fee - system healthy | -| 1.3x - 1.5x | **3%** | Moderate fee - system very healthy | -| 1.5x - 2.0x | **4%** | Higher fee - system extremely healthy | -| > 2.0x | **5%** | Standard fee - system overcollateralized | - -**Rationale**: Encourages redemption when system is unhealthy (improves collateral ratio). - ---- - -### 3. Mint Sail (hs) Tokens - -| Collateral Ratio | Fee/Discount | Effect | -|-----------------|--------------|--------| -| < 1.0x | **-15% (Discount)** | ✅ You get 15% bonus - strongly encouraged | -| 1.0x - 1.05x | **-10% (Discount)** | ✅ You get 10% bonus - encouraged | -| 1.05x - 1.1x | **-5% (Discount)** | ✅ You get 5% bonus - small incentive | -| 1.1x - 1.2x | **-2% (Discount)** | ✅ You get 2% bonus - minimal incentive | -| 1.2x - 1.3x | **0% (FREE)** | ✅ No fee - system healthy | -| 1.3x - 1.5x | **1%** | Small fee - system very healthy | -| 1.5x - 2.0x | **2%** | Moderate fee - system extremely healthy | -| > 2.0x | **3%** | Standard fee - system overcollateralized | - -**Rationale**: Encourages minting when system is unhealthy (increases leverage, improves collateral ratio). - ---- - -### 4. Redeem Sail (hs) Tokens - -| Collateral Ratio | Fee | Effect | -|-----------------|-----|--------| -| < 1.0x | **100% (BLOCKED)** | ❌ Cannot redeem - would worsen system health | -| 1.0x - 1.05x | **30%** | Very expensive - system at risk | -| 1.05x - 1.1x | **15%** | High fee - system stressed | -| 1.1x - 1.2x | **8%** | Medium-high fee - system recovering | -| 1.2x - 1.3x | **5%** | Medium fee - system healthy | -| 1.3x - 1.5x | **3%** | Low fee - system very healthy | -| 1.5x - 2.0x | **2%** | Very low fee - system extremely healthy | -| > 2.0x | **1.5%** | Minimal fee - system overcollateralized | - -**Rationale**: Discourages redemption when system is unhealthy (reduces leverage, worsens collateral ratio). - ---- - -## Example Scenarios - -### Scenario 1: System at 1.05x (Stressed) -- **Mint ha**: 20% fee (expensive) -- **Redeem ha**: -5% discount (you get 5% bonus) -- **Mint hs**: -10% discount (you get 10% bonus) -- **Redeem hs**: 30% fee (very expensive) - -### Scenario 2: System at 1.25x (Healthy) -- **Mint ha**: 5% fee (reasonable) -- **Redeem ha**: 2% fee (normal) -- **Mint hs**: -2% discount (you get 2% bonus) -- **Redeem hs**: 5% fee (normal) - -### Scenario 3: System at 0.98x (Undercollateralized) -- **Mint ha**: BLOCKED ❌ -- **Redeem ha**: -10% discount (you get 10% bonus) -- **Mint hs**: -15% discount (you get 15% bonus) -- **Redeem hs**: BLOCKED ❌ - -### Scenario 4: System at 2.0x (Overcollateralized) -- **Mint ha**: 0.5% fee (minimal) -- **Redeem ha**: 5% fee (standard) -- **Mint hs**: 3% fee (standard) -- **Redeem hs**: 1.5% fee (minimal) - ---- - -## How Fees Work - -### Positive Values = Fees -- `0.05e18` = 5% fee -- `1.0e18` = 100% fee = BLOCKED - -### Negative Values = Discounts -- `-0.1e18` = -10% discount (you get 10% bonus) -- `-0.15e18` = -15% discount (you get 15% bonus) - -### Zero = Free -- `0` = No fee, no discount - ---- - -## Key Principles - -1. **Minting ha tokens**: Discouraged when unhealthy (expensive fees) -2. **Redeeming ha tokens**: Encouraged when unhealthy (discounts/free) -3. **Minting hs tokens**: Encouraged when unhealthy (discounts) -4. **Redeeming hs tokens**: Discouraged when unhealthy (expensive fees/blocked) - ---- - -## Configuration Files - -- **Config JSON**: `script/minter-fee-config-health-based.json` -- **Forge Script**: `script/UpdateMinterFees.s.sol` -- **Helper Script**: `script/apply-fee-config.sh` -- **Full Documentation**: `FEE-STRUCTURE-DESIGN.md` - ---- - -## Notes - -- Fees are calculated dynamically based on the current collateral ratio -- The system uses bands to determine which fee applies -- Only the contract owner can update the config -- Fees are applied at the time of transaction execution - - - diff --git a/doc/guides/MINTER-FUNCTIONS-FRONTEND.txt b/doc/guides/MINTER-FUNCTIONS-FRONTEND.txt deleted file mode 100644 index b28b04f6..00000000 --- a/doc/guides/MINTER-FUNCTIONS-FRONTEND.txt +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/doc/guides/ORACLE-PRICE-EXPLAINED.md b/doc/guides/ORACLE-PRICE-EXPLAINED.md deleted file mode 100644 index 33b921dd..00000000 --- a/doc/guides/ORACLE-PRICE-EXPLAINED.md +++ /dev/null @@ -1,250 +0,0 @@ -# Oracle Price Types: Min, Mid, and Max - Explained - -## The Three Price Types - -The price oracle returns **two prices** (min and max), and the Minter uses them in different ways: - -### 1. **Min Price** (`_fetchMin`) -- Returns: `minPrice` and `minRate` -- **Most conservative** (lowest price) -- Used when: System needs to be **pessimistic** (protect against overvaluing) - -### 2. **Mid Price** (`_fetchMid`) -- Returns: `(minPrice + maxPrice) / 2` and `(minRate + maxRate) / 2` -- **Average** of min and max -- Used when: System needs a **balanced/fair** price (most common) - -### 3. **Max Price** (`_fetchMax`) -- Returns: `maxPrice` and `maxRate` -- **Most optimistic** (highest price) -- Used when: System needs to be **generous** (favor the user) - -## Why Min and Max Exist - -The price oracle is designed to handle **price uncertainty**: - -1. **Price Spreads**: Real markets have bid/ask spreads - - Min = bid price (what you can sell for) - - Max = ask price (what you can buy for) - -2. **Price Volatility**: Prices fluctuate - - Min = conservative estimate (lower bound) - - Max = optimistic estimate (upper bound) - -3. **Safety Margins**: Different operations need different risk levels - - Min = protect the system (don't overvalue) - - Max = favor users (don't undervalue) - -## Current Implementation - -**Note:** In the current `StakedETHWrappedPriceOracle` implementation: -```solidity -minUnderlyingPrice = maxUnderlyingPrice = PriceOracle_v1.latestAnswer(...) -``` - -**They're the same!** This means: -- Min = Max = Current Chainlink price -- Mid = (Min + Max) / 2 = Same price - -So currently, **all three return the same value**. But the system is designed to support different min/max prices in the future. - -## When Each Is Used - -### **Mid Price** (Most Common) -Used for: -- ✅ Normal minting (`mintPeggedToken`) -- ✅ Normal redemption (`redeemPeggedToken`) -- ✅ Collateral ratio calculations -- ✅ Token price queries -- ✅ Most view functions - -**Why:** Fair, balanced price for normal operations - -### **Max Price** (Favorable to Users) -Used for: -- ✅ **Liquidation rewards** (`freeRedeemPeggedToken` during rebalancing) -- ✅ **Leveraged token redemption** (`freeRedeemLeveragedToken`) -- ✅ Some view functions that favor users - -**Why:** Give users the best possible rate when they're helping the system - -### **Min Price** (Conservative) -Used for: -- ✅ **Leveraged token minting** (`freeMintLeveragedToken`) -- ✅ Some operations that need to protect the system - -**Why:** Don't overvalue assets when minting leveraged tokens - -## Example - -**Hypothetical scenario** (if min/max were different): -- Chainlink reports: $2000 per stETH -- Price spread: ±0.5% -- **Min Price**: $1990 (conservative, -0.5%) -- **Max Price**: $2010 (optimistic, +0.5%) -- **Mid Price**: $2000 (average) - -**When redeeming during liquidation:** -- Uses **Max Price** ($2010) -- You get **more** collateral back (favorable rate) - -**When normal redemption:** -- Uses **Mid Price** ($2000) -- You get **fair** amount back - -**When minting leveraged:** -- Uses **Min Price** ($1990) -- System is **conservative** (protects against overvaluation) - -## Real-World Analogy - -Think of it like a **currency exchange**: - -- **Min Price** = Exchange rate when **selling** (worse rate for you) -- **Max Price** = Exchange rate when **buying** (better rate for you) -- **Mid Price** = Average rate (fair for both) - -The system uses: -- **Max** when you're helping (liquidation rewards) = better rate -- **Mid** for normal operations = fair rate -- **Min** when system needs protection = conservative rate - -## Summary - -| Price Type | Value | Used For | Effect | -|------------|-------|----------|--------| -| **Min** | Lowest price | Protecting system | Conservative | -| **Mid** | Average price | Normal operations | Fair | -| **Max** | Highest price | User rewards | Generous | - -**Current State:** All three are the same (min = max = Chainlink price) - -**Future:** Could support bid/ask spreads or price ranges for better accuracy - - - -## The Three Price Types - -The price oracle returns **two prices** (min and max), and the Minter uses them in different ways: - -### 1. **Min Price** (`_fetchMin`) -- Returns: `minPrice` and `minRate` -- **Most conservative** (lowest price) -- Used when: System needs to be **pessimistic** (protect against overvaluing) - -### 2. **Mid Price** (`_fetchMid`) -- Returns: `(minPrice + maxPrice) / 2` and `(minRate + maxRate) / 2` -- **Average** of min and max -- Used when: System needs a **balanced/fair** price (most common) - -### 3. **Max Price** (`_fetchMax`) -- Returns: `maxPrice` and `maxRate` -- **Most optimistic** (highest price) -- Used when: System needs to be **generous** (favor the user) - -## Why Min and Max Exist - -The price oracle is designed to handle **price uncertainty**: - -1. **Price Spreads**: Real markets have bid/ask spreads - - Min = bid price (what you can sell for) - - Max = ask price (what you can buy for) - -2. **Price Volatility**: Prices fluctuate - - Min = conservative estimate (lower bound) - - Max = optimistic estimate (upper bound) - -3. **Safety Margins**: Different operations need different risk levels - - Min = protect the system (don't overvalue) - - Max = favor users (don't undervalue) - -## Current Implementation - -**Note:** In the current `StakedETHWrappedPriceOracle` implementation: -```solidity -minUnderlyingPrice = maxUnderlyingPrice = PriceOracle_v1.latestAnswer(...) -``` - -**They're the same!** This means: -- Min = Max = Current Chainlink price -- Mid = (Min + Max) / 2 = Same price - -So currently, **all three return the same value**. But the system is designed to support different min/max prices in the future. - -## When Each Is Used - -### **Mid Price** (Most Common) -Used for: -- ✅ Normal minting (`mintPeggedToken`) -- ✅ Normal redemption (`redeemPeggedToken`) -- ✅ Collateral ratio calculations -- ✅ Token price queries -- ✅ Most view functions - -**Why:** Fair, balanced price for normal operations - -### **Max Price** (Favorable to Users) -Used for: -- ✅ **Liquidation rewards** (`freeRedeemPeggedToken` during rebalancing) -- ✅ **Leveraged token redemption** (`freeRedeemLeveragedToken`) -- ✅ Some view functions that favor users - -**Why:** Give users the best possible rate when they're helping the system - -### **Min Price** (Conservative) -Used for: -- ✅ **Leveraged token minting** (`freeMintLeveragedToken`) -- ✅ Some operations that need to protect the system - -**Why:** Don't overvalue assets when minting leveraged tokens - -## Example - -**Hypothetical scenario** (if min/max were different): -- Chainlink reports: $2000 per stETH -- Price spread: ±0.5% -- **Min Price**: $1990 (conservative, -0.5%) -- **Max Price**: $2010 (optimistic, +0.5%) -- **Mid Price**: $2000 (average) - -**When redeeming during liquidation:** -- Uses **Max Price** ($2010) -- You get **more** collateral back (favorable rate) - -**When normal redemption:** -- Uses **Mid Price** ($2000) -- You get **fair** amount back - -**When minting leveraged:** -- Uses **Min Price** ($1990) -- System is **conservative** (protects against overvaluation) - -## Real-World Analogy - -Think of it like a **currency exchange**: - -- **Min Price** = Exchange rate when **selling** (worse rate for you) -- **Max Price** = Exchange rate when **buying** (better rate for you) -- **Mid Price** = Average rate (fair for both) - -The system uses: -- **Max** when you're helping (liquidation rewards) = better rate -- **Mid** for normal operations = fair rate -- **Min** when system needs protection = conservative rate - -## Summary - -| Price Type | Value | Used For | Effect | -|------------|-------|----------|--------| -| **Min** | Lowest price | Protecting system | Conservative | -| **Mid** | Average price | Normal operations | Fair | -| **Max** | Highest price | User rewards | Generous | - -**Current State:** All three are the same (min = max = Chainlink price) - -**Future:** Could support bid/ask spreads or price ranges for better accuracy - - - - - diff --git a/doc/guides/ORACLE-PRICES-SUMMARY.md b/doc/guides/ORACLE-PRICES-SUMMARY.md deleted file mode 100644 index f39a83eb..00000000 --- a/doc/guides/ORACLE-PRICES-SUMMARY.md +++ /dev/null @@ -1,220 +0,0 @@ -# Oracle Prices Summary - -## Current Oracle Prices (Local Anvil) - -### Chainlink Price Feeds - -| Feed | Address | Raw Value (8 decimals) | Price | -|------|---------|----------------------|-------| -| **stETH/USD** | `0xa513E6E4b8f2a923D98304ec87F64353C4D5C853` | 200000000000 | **$2,000.00** | -| **stETH/ETH** | `0x2279B7A0a67DB372996a5FaB50D91eAA73d2eBe6` | 100000000 | **1.00000000** | -| **wstETH/USD** | `0x8A791620dd6260079BF849Dc5567aDC3F2FdC318` | 200000000000 | **$2,000.00** | - -### Pegged Token (haPB) Oracle Price - -**Price: $1.00 USD** (Fixed Peg) - -- **Token**: Harbor Anchored PB (haPB) -- **Address**: `0x1c85638e118b37167e9298c2268758e058DdfDA0` -- **Peg Value**: Always $1.00 USD -- **How it works**: - - Pegged tokens are designed to maintain a stable $1 value - - The price is not determined by an oracle - it's a fixed peg - - When minting: 1 haPB = $1 worth of collateral - - When redeeming: 1 haPB = $1 worth of collateral - - The actual redemption amount depends on the collateral price at the time - -### Leveraged Token (hsPB) Oracle Price - -**Price: Variable** (Derived from Collateral Ratio) - -- **Token**: Harbor Sail hsPBxstETH (hsPB) -- **Address**: `0x367761085BF3C12e5DA2Df99AC6E1a824612b8fb` -- **Price Calculation**: - - Derived from collateral ratio and collateral price - - Formula: `price = (collateralValue / leveragedTokenSupply)` - - Varies based on system health and collateral ratio - - Higher collateral ratio = higher leveraged token price - -### Minter Price Oracle - -**Status: Not Configured** ⚠️ - -- **Minter Address**: `0x34B40BA116d5Dec75548a9e9A8f15411461E8c70` -- **Price Oracle Address**: `0x0000000000000000000000000000000000000000` (zero address) -- **Issue**: Price oracle is not set on the Minter contract -- **Impact**: Minter functions that require price oracle will fail - -**Note**: The Minter needs a price oracle contract (like `StakedETHWrappedPriceOracle_v1`) to: -- Calculate collateral ratios -- Determine mint/redeem amounts -- Perform rebalancing operations - -## Price Oracle Types - -The Minter uses three types of prices from the oracle: - -1. **Min Price** (`_fetchMin`): Most conservative (lowest) - - Used for: Leveraged token minting (protect system) - -2. **Mid Price** (`_fetchMid`): Average of min and max - - Used for: Normal minting/redeeming, collateral ratio calculations - -3. **Max Price** (`_fetchMax`): Most optimistic (highest) - - Used for: Liquidation rewards, leveraged token redemption (favor users) - -**Current State**: In `StakedETHWrappedPriceOracle_v1`, min = max = Chainlink price, so all three return the same value. - -## How to Query Prices - -### Chainlink Feeds (Direct) -```bash -# stETH/USD -cast call 0xa513E6E4b8f2a923D98304ec87F64353C4D5C853 "latestAnswer()(int256)" --rpc-url http://localhost:8545 - -# stETH/ETH -cast call 0x2279B7A0a67DB372996a5FaB50D91eAA73d2eBe6 "latestAnswer()(int256)" --rpc-url http://localhost:8545 - -# wstETH/USD -cast call 0x8A791620dd6260079BF849Dc5567aDC3F2FdC318 "latestAnswer()(int256)" --rpc-url http://localhost:8545 -``` - -### Minter Price Oracle (When Configured) -```bash -# Get price oracle address -cast call $MINTER "priceOracle()(address)" --rpc-url http://localhost:8545 - -# Get latest answer (returns: minPrice, maxPrice, minRate, maxRate) -cast call $PRICE_ORACLE "latestAnswer()(uint256,uint256,uint256,uint256)" --rpc-url http://localhost:8545 -``` - -### Pegged Token Price -- **Always $1.00** - no oracle needed -- The redemption amount varies based on collateral price, but the peg value is fixed - -### Leveraged Token Price -- Query from Minter: `peggedTokenPrice()` or `leveragedTokenPrice()` -- Or calculate: `collateralValue / leveragedTokenSupply` - -## Summary Table - -| Asset | Price | Source | Notes | -|-------|-------|--------|-------| -| stETH | $2,000.00 | Chainlink (stETH/USD) | 8 decimals | -| stETH | 1.0 ETH | Chainlink (stETH/ETH) | 8 decimals | -| wstETH | $2,000.00 | Chainlink (wstETH/USD) | 8 decimals | -| haPB | $1.00 | Fixed Peg | Always $1 | -| hsPB | Variable | Derived | Based on CR | - - - -## Current Oracle Prices (Local Anvil) - -### Chainlink Price Feeds - -| Feed | Address | Raw Value (8 decimals) | Price | -|------|---------|----------------------|-------| -| **stETH/USD** | `0xa513E6E4b8f2a923D98304ec87F64353C4D5C853` | 200000000000 | **$2,000.00** | -| **stETH/ETH** | `0x2279B7A0a67DB372996a5FaB50D91eAA73d2eBe6` | 100000000 | **1.00000000** | -| **wstETH/USD** | `0x8A791620dd6260079BF849Dc5567aDC3F2FdC318` | 200000000000 | **$2,000.00** | - -### Pegged Token (haPB) Oracle Price - -**Price: $1.00 USD** (Fixed Peg) - -- **Token**: Harbor Anchored PB (haPB) -- **Address**: `0x1c85638e118b37167e9298c2268758e058DdfDA0` -- **Peg Value**: Always $1.00 USD -- **How it works**: - - Pegged tokens are designed to maintain a stable $1 value - - The price is not determined by an oracle - it's a fixed peg - - When minting: 1 haPB = $1 worth of collateral - - When redeeming: 1 haPB = $1 worth of collateral - - The actual redemption amount depends on the collateral price at the time - -### Leveraged Token (hsPB) Oracle Price - -**Price: Variable** (Derived from Collateral Ratio) - -- **Token**: Harbor Sail hsPBxstETH (hsPB) -- **Address**: `0x367761085BF3C12e5DA2Df99AC6E1a824612b8fb` -- **Price Calculation**: - - Derived from collateral ratio and collateral price - - Formula: `price = (collateralValue / leveragedTokenSupply)` - - Varies based on system health and collateral ratio - - Higher collateral ratio = higher leveraged token price - -### Minter Price Oracle - -**Status: Not Configured** ⚠️ - -- **Minter Address**: `0x34B40BA116d5Dec75548a9e9A8f15411461E8c70` -- **Price Oracle Address**: `0x0000000000000000000000000000000000000000` (zero address) -- **Issue**: Price oracle is not set on the Minter contract -- **Impact**: Minter functions that require price oracle will fail - -**Note**: The Minter needs a price oracle contract (like `StakedETHWrappedPriceOracle_v1`) to: -- Calculate collateral ratios -- Determine mint/redeem amounts -- Perform rebalancing operations - -## Price Oracle Types - -The Minter uses three types of prices from the oracle: - -1. **Min Price** (`_fetchMin`): Most conservative (lowest) - - Used for: Leveraged token minting (protect system) - -2. **Mid Price** (`_fetchMid`): Average of min and max - - Used for: Normal minting/redeeming, collateral ratio calculations - -3. **Max Price** (`_fetchMax`): Most optimistic (highest) - - Used for: Liquidation rewards, leveraged token redemption (favor users) - -**Current State**: In `StakedETHWrappedPriceOracle_v1`, min = max = Chainlink price, so all three return the same value. - -## How to Query Prices - -### Chainlink Feeds (Direct) -```bash -# stETH/USD -cast call 0xa513E6E4b8f2a923D98304ec87F64353C4D5C853 "latestAnswer()(int256)" --rpc-url http://localhost:8545 - -# stETH/ETH -cast call 0x2279B7A0a67DB372996a5FaB50D91eAA73d2eBe6 "latestAnswer()(int256)" --rpc-url http://localhost:8545 - -# wstETH/USD -cast call 0x8A791620dd6260079BF849Dc5567aDC3F2FdC318 "latestAnswer()(int256)" --rpc-url http://localhost:8545 -``` - -### Minter Price Oracle (When Configured) -```bash -# Get price oracle address -cast call $MINTER "priceOracle()(address)" --rpc-url http://localhost:8545 - -# Get latest answer (returns: minPrice, maxPrice, minRate, maxRate) -cast call $PRICE_ORACLE "latestAnswer()(uint256,uint256,uint256,uint256)" --rpc-url http://localhost:8545 -``` - -### Pegged Token Price -- **Always $1.00** - no oracle needed -- The redemption amount varies based on collateral price, but the peg value is fixed - -### Leveraged Token Price -- Query from Minter: `peggedTokenPrice()` or `leveragedTokenPrice()` -- Or calculate: `collateralValue / leveragedTokenSupply` - -## Summary Table - -| Asset | Price | Source | Notes | -|-------|-------|--------|-------| -| stETH | $2,000.00 | Chainlink (stETH/USD) | 8 decimals | -| stETH | 1.0 ETH | Chainlink (stETH/ETH) | 8 decimals | -| wstETH | $2,000.00 | Chainlink (wstETH/USD) | 8 decimals | -| haPB | $1.00 | Fixed Peg | Always $1 | -| hsPB | Variable | Derived | Based on CR | - - - - - diff --git a/doc/guides/PRICE-FEED-FIX-COMPLETE.md b/doc/guides/PRICE-FEED-FIX-COMPLETE.md deleted file mode 100644 index 7bfa0791..00000000 --- a/doc/guides/PRICE-FEED-FIX-COMPLETE.md +++ /dev/null @@ -1,52 +0,0 @@ -# Price Feed "Round not found" Fix - Complete - -## Root Cause Identified -The "Round not found" error was happening because: -1. **PriceOracle uses stETH/USD feed** (not wstETH/USD) -2. The stETH/USD feed in `bcinfo.local.json` was pointing to **OLD address** without the fix -3. PriceOracle calls `getRoundData(prevRoundId)` which fails on old feeds - -## Fix Applied -✅ Updated `bcinfo.local.json` with **ALL** new fixed price feed addresses: -- **stETH/USD**: `0xb007167714e2940013ec3bb551584130b7497e22` (NEW, has fix) -- **stETH/ETH**: `0x6b39b761b1b64c8c095bf0e3bb0c6a74705b4788` (NEW, has fix) -- **wstETH/USD**: `0xec827421505972a2ae9c320302d3573b42363c26` (NEW, has fix) - -## Next Step -**Redeploy all contracts** so that: -1. PriceOracle is deployed with the NEW stETH/USD feed address -2. All contracts use the fixed price feeds - -After redeployment, `endGenesis()` should work without "Round not found" error. - -## Verification -All new price feeds have the fix where `getRoundData()` accepts any round ID, not just the exact current round. - - - -## Root Cause Identified -The "Round not found" error was happening because: -1. **PriceOracle uses stETH/USD feed** (not wstETH/USD) -2. The stETH/USD feed in `bcinfo.local.json` was pointing to **OLD address** without the fix -3. PriceOracle calls `getRoundData(prevRoundId)` which fails on old feeds - -## Fix Applied -✅ Updated `bcinfo.local.json` with **ALL** new fixed price feed addresses: -- **stETH/USD**: `0xb007167714e2940013ec3bb551584130b7497e22` (NEW, has fix) -- **stETH/ETH**: `0x6b39b761b1b64c8c095bf0e3bb0c6a74705b4788` (NEW, has fix) -- **wstETH/USD**: `0xec827421505972a2ae9c320302d3573b42363c26` (NEW, has fix) - -## Next Step -**Redeploy all contracts** so that: -1. PriceOracle is deployed with the NEW stETH/USD feed address -2. All contracts use the fixed price feeds - -After redeployment, `endGenesis()` should work without "Round not found" error. - -## Verification -All new price feeds have the fix where `getRoundData()` accepts any round ID, not just the exact current round. - - - - - diff --git a/doc/guides/PRICE-FEED-FIX.md b/doc/guides/PRICE-FEED-FIX.md deleted file mode 100644 index c9ce789b..00000000 --- a/doc/guides/PRICE-FEED-FIX.md +++ /dev/null @@ -1,70 +0,0 @@ -# Price Feed "Round not found" Fix - -## Problem -`endGenesis()` fails with "Round not found" error because the mock Chainlink price feed's `getRoundData()` function is too strict - it only allows querying the exact current round ID. - -## Root Cause -The PriceOracle (or something in the call chain) calls `getRoundData()` with a specific round ID, but the mock price feed requires an exact match with `_latestRoundId`. If the round ID doesn't match, it reverts with "Round not found". - -## Fix Applied -Updated `MockChainlinkAggregator.sol` to be more lenient: -- `getRoundData()` now returns latest data for ANY round query (not just exact match) -- `getAnswer()` and `getTimestamp()` also return latest data for any round query - -This makes the mock more flexible for testing while still implementing the Chainlink interface. - -## New Price Feed Addresses -After redeploying with the fix: -- wstETH: `0x2e8880cAdC08E9B438c6052F5ce3869FBd6cE513` -- wstETH/USD Feed: `0xeC827421505972a2AE9C320302d3573B42363C26` - -## Next Steps -The deployed contracts (Genesis, Minter, etc.) are still using the OLD price feed addresses. To fully fix the issue: - -1. **Option A (Recommended)**: Redeploy all contracts so they use the new price feed addresses -2. **Option B**: Check if PriceOracle allows updating feed addresses (if it has an admin function) - -## Current Status -✅ Mock price feed code fixed -✅ New price feeds deployed with fix -✅ bcinfo.local.json updated with new addresses -⚠️ Existing contracts still reference old price feed addresses -❌ Need to redeploy contracts OR update PriceOracle feed addresses - - - -## Problem -`endGenesis()` fails with "Round not found" error because the mock Chainlink price feed's `getRoundData()` function is too strict - it only allows querying the exact current round ID. - -## Root Cause -The PriceOracle (or something in the call chain) calls `getRoundData()` with a specific round ID, but the mock price feed requires an exact match with `_latestRoundId`. If the round ID doesn't match, it reverts with "Round not found". - -## Fix Applied -Updated `MockChainlinkAggregator.sol` to be more lenient: -- `getRoundData()` now returns latest data for ANY round query (not just exact match) -- `getAnswer()` and `getTimestamp()` also return latest data for any round query - -This makes the mock more flexible for testing while still implementing the Chainlink interface. - -## New Price Feed Addresses -After redeploying with the fix: -- wstETH: `0x2e8880cAdC08E9B438c6052F5ce3869FBd6cE513` -- wstETH/USD Feed: `0xeC827421505972a2AE9C320302d3573B42363C26` - -## Next Steps -The deployed contracts (Genesis, Minter, etc.) are still using the OLD price feed addresses. To fully fix the issue: - -1. **Option A (Recommended)**: Redeploy all contracts so they use the new price feed addresses -2. **Option B**: Check if PriceOracle allows updating feed addresses (if it has an admin function) - -## Current Status -✅ Mock price feed code fixed -✅ New price feeds deployed with fix -✅ bcinfo.local.json updated with new addresses -⚠️ Existing contracts still reference old price feed addresses -❌ Need to redeploy contracts OR update PriceOracle feed addresses - - - - - diff --git a/doc/guides/PRICE-FEED-UPDATE-SUMMARY.md b/doc/guides/PRICE-FEED-UPDATE-SUMMARY.md deleted file mode 100644 index 110b84db..00000000 --- a/doc/guides/PRICE-FEED-UPDATE-SUMMARY.md +++ /dev/null @@ -1,74 +0,0 @@ -# Price Feed Update Summary - -## Issue -The `mintPeggedTokenDryRun()` and `collateralRatio()` calls were reverting with `StaleUnderlyingPrice` error because the underlying Chainlink price feeds had stale timestamps. - -## Root Cause -The `StakedETHWrappedPriceOracle_v1` used by the Minter depends on: -1. **stETH/USD Chainlink aggregator** (at `0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0`) -2. This aggregator's timestamp was too old (exceeded `maxPriceAge` of 3600 seconds) - -## Solution Applied -✅ Updated all price feeds with fresh timestamps: -- **stETH/USD**: `0xb007167714e2940013EC3bb551584130B7497E22` -- **stETH/ETH**: `0x6b39b761b1b64C8C095BF0e3Bb0c6a74705b4788` -- **wstETH/USD**: `0xeC827421505972a2AE9C320302d3573B42363C26` -- **stETH feed used by oracle**: `0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0` - -## Remaining Issue -⚠️ **"Round not found" error**: The deployed MockChainlinkAggregator at `0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0` is an older version that doesn't have the lenient `getRoundData()` implementation. The PriceOracle library tries to fetch the previous round for deviation checks, but the old contract reverts. - -## Next Steps -1. **Option 1 (Recommended)**: Redeploy the MockChainlinkAggregator with the fixed `getRoundData()` implementation and update the price oracle to use it -2. **Option 2**: Modify the PriceOracle constraints to skip historical deviation checks for testing (not recommended for production) - -## Files Created -- `script/UpdateAllPriceFeeds.s.sol` - Script to update all price feeds -- `script/update-all-price-feeds.sh` - Helper script to run the update - -## Usage -```bash -./script/update-all-price-feeds.sh -``` - -This will update all price feeds with fresh timestamps matching the current block time. - - - -## Issue -The `mintPeggedTokenDryRun()` and `collateralRatio()` calls were reverting with `StaleUnderlyingPrice` error because the underlying Chainlink price feeds had stale timestamps. - -## Root Cause -The `StakedETHWrappedPriceOracle_v1` used by the Minter depends on: -1. **stETH/USD Chainlink aggregator** (at `0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0`) -2. This aggregator's timestamp was too old (exceeded `maxPriceAge` of 3600 seconds) - -## Solution Applied -✅ Updated all price feeds with fresh timestamps: -- **stETH/USD**: `0xb007167714e2940013EC3bb551584130B7497E22` -- **stETH/ETH**: `0x6b39b761b1b64C8C095BF0e3Bb0c6a74705b4788` -- **wstETH/USD**: `0xeC827421505972a2AE9C320302d3573B42363C26` -- **stETH feed used by oracle**: `0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0` - -## Remaining Issue -⚠️ **"Round not found" error**: The deployed MockChainlinkAggregator at `0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0` is an older version that doesn't have the lenient `getRoundData()` implementation. The PriceOracle library tries to fetch the previous round for deviation checks, but the old contract reverts. - -## Next Steps -1. **Option 1 (Recommended)**: Redeploy the MockChainlinkAggregator with the fixed `getRoundData()` implementation and update the price oracle to use it -2. **Option 2**: Modify the PriceOracle constraints to skip historical deviation checks for testing (not recommended for production) - -## Files Created -- `script/UpdateAllPriceFeeds.s.sol` - Script to update all price feeds -- `script/update-all-price-feeds.sh` - Helper script to run the update - -## Usage -```bash -./script/update-all-price-feeds.sh -``` - -This will update all price feeds with fresh timestamps matching the current block time. - - - - - diff --git a/doc/guides/REBALANCE-THRESHOLD-INFO.txt b/doc/guides/REBALANCE-THRESHOLD-INFO.txt deleted file mode 100644 index 5a69719e..00000000 --- a/doc/guides/REBALANCE-THRESHOLD-INFO.txt +++ /dev/null @@ -1,168 +0,0 @@ -=== How to Find Rebalance Collateral Ratio === - -The rebalance threshold is stored in the StabilityPoolManager contract, NOT the Minter. - -═══════════════════════════════════════════════════════════════════════════════ -HOW TO GET THE REBALANCE THRESHOLD -═══════════════════════════════════════════════════════════════════════════════ - -Contract: StabilityPoolManager -Function: rebalanceThreshold() → uint256 -Returns: The collateral ratio threshold below which rebalancing can occur (18 decimals) - -Example: -```javascript -const stabilityPoolManager = "0x26291175Fa0Ea3C8583fEdEB56805eA68289b105"; -const threshold = await stabilityPoolManager.rebalanceThreshold(); -// Returns value in 18 decimals (e.g., 1300000000000000000 = 1.3x) -``` - -═══════════════════════════════════════════════════════════════════════════════ -HOW REBALANCING WORKS -═══════════════════════════════════════════════════════════════════════════════ - -Rebalancing is enabled when: - collateralRatio < rebalanceThreshold - -Where: -- collateralRatio: Current system collateral ratio (from Minter.collateralRatio()) -- rebalanceThreshold: Threshold set in StabilityPoolManager - -When rebalancing occurs: -- Pegged tokens in stability pools are liquidated -- This increases the collateral ratio back above the threshold -- Anyone can call rebalance() and receive a bounty - -═══════════════════════════════════════════════════════════════════════════════ -RELATED FUNCTIONS -═══════════════════════════════════════════════════════════════════════════════ - -StabilityPoolManager: -- rebalanceThreshold() → uint256 - Returns the rebalance threshold collateral ratio - -- rebalanceable() → bool - Returns true if rebalancing is currently available - (checks if collateralRatio < rebalanceThreshold) - -- rebalance(address bountyReceiver, uint256 minPeggedLiquidated) - Executes rebalancing (anyone can call when rebalanceable() is true) - -Minter: -- collateralRatio() → uint256 - Returns the current collateral ratio (18 decimals) - This is what's compared against the rebalance threshold - -═══════════════════════════════════════════════════════════════════════════════ -USAGE FOR FRONTEND -═══════════════════════════════════════════════════════════════════════════════ - -1. Get current collateral ratio: - const currentRatio = await minter.collateralRatio(); - -2. Get rebalance threshold: - const threshold = await stabilityPoolManager.rebalanceThreshold(); - -3. Check if rebalancing is available: - const canRebalance = await stabilityPoolManager.rebalanceable(); - -4. Display to user: - - Show current ratio vs threshold - - Show if rebalancing is available - - Show how close/far from threshold - -═══════════════════════════════════════════════════════════════════════════════ -NOTES -═══════════════════════════════════════════════════════════════════════════════ - -- The rebalance threshold is set by the owner of StabilityPoolManager -- It can be updated via updateRebalanceThreshold(uint256 newRatio) -- The threshold is typically set to a value like 1.3x (1300000000000000000) -- Rebalancing helps maintain system stability by increasing collateral ratio - - - -The rebalance threshold is stored in the StabilityPoolManager contract, NOT the Minter. - -═══════════════════════════════════════════════════════════════════════════════ -HOW TO GET THE REBALANCE THRESHOLD -═══════════════════════════════════════════════════════════════════════════════ - -Contract: StabilityPoolManager -Function: rebalanceThreshold() → uint256 -Returns: The collateral ratio threshold below which rebalancing can occur (18 decimals) - -Example: -```javascript -const stabilityPoolManager = "0x26291175Fa0Ea3C8583fEdEB56805eA68289b105"; -const threshold = await stabilityPoolManager.rebalanceThreshold(); -// Returns value in 18 decimals (e.g., 1300000000000000000 = 1.3x) -``` - -═══════════════════════════════════════════════════════════════════════════════ -HOW REBALANCING WORKS -═══════════════════════════════════════════════════════════════════════════════ - -Rebalancing is enabled when: - collateralRatio < rebalanceThreshold - -Where: -- collateralRatio: Current system collateral ratio (from Minter.collateralRatio()) -- rebalanceThreshold: Threshold set in StabilityPoolManager - -When rebalancing occurs: -- Pegged tokens in stability pools are liquidated -- This increases the collateral ratio back above the threshold -- Anyone can call rebalance() and receive a bounty - -═══════════════════════════════════════════════════════════════════════════════ -RELATED FUNCTIONS -═══════════════════════════════════════════════════════════════════════════════ - -StabilityPoolManager: -- rebalanceThreshold() → uint256 - Returns the rebalance threshold collateral ratio - -- rebalanceable() → bool - Returns true if rebalancing is currently available - (checks if collateralRatio < rebalanceThreshold) - -- rebalance(address bountyReceiver, uint256 minPeggedLiquidated) - Executes rebalancing (anyone can call when rebalanceable() is true) - -Minter: -- collateralRatio() → uint256 - Returns the current collateral ratio (18 decimals) - This is what's compared against the rebalance threshold - -═══════════════════════════════════════════════════════════════════════════════ -USAGE FOR FRONTEND -═══════════════════════════════════════════════════════════════════════════════ - -1. Get current collateral ratio: - const currentRatio = await minter.collateralRatio(); - -2. Get rebalance threshold: - const threshold = await stabilityPoolManager.rebalanceThreshold(); - -3. Check if rebalancing is available: - const canRebalance = await stabilityPoolManager.rebalanceable(); - -4. Display to user: - - Show current ratio vs threshold - - Show if rebalancing is available - - Show how close/far from threshold - -═══════════════════════════════════════════════════════════════════════════════ -NOTES -═══════════════════════════════════════════════════════════════════════════════ - -- The rebalance threshold is set by the owner of StabilityPoolManager -- It can be updated via updateRebalanceThreshold(uint256 newRatio) -- The threshold is typically set to a value like 1.3x (1300000000000000000) -- Rebalancing helps maintain system stability by increasing collateral ratio - - - - - diff --git a/doc/guides/REBALANCE-TRIGGER-AND-TARGET.md b/doc/guides/REBALANCE-TRIGGER-AND-TARGET.md deleted file mode 100644 index 096470ca..00000000 --- a/doc/guides/REBALANCE-TRIGGER-AND-TARGET.md +++ /dev/null @@ -1,310 +0,0 @@ -# Rebalance Trigger and Target Collateral Ratio - -## Quick Answer - -**Trigger Threshold**: Rebalancing is triggered when `collateralRatio < rebalanceThreshold` - -**Target Collateral Ratio**: The rebalance aims to bring the collateral ratio back up to **exactly the `rebalanceThreshold`** value. - -**Current Values**: -- Trigger: When collateral ratio drops below **1.3x** (1300000000000000000) -- Target: **1.3x** (1300000000000000000) - same as the threshold - ---- - -## How It Works - -### 1. Trigger Condition - -Rebalancing is **enabled** when: -```solidity -collateralRatio < rebalanceThreshold -``` - -**Code Location**: `StabilityPoolManager_v1.sol`, line 301-303 -```solidity -if (!_rebalanceable(IMinter(MINTER).collateralRatio(), rebalanceThreshold_)) { - revert CollateralRatioNotBelowRebalanceThreshold(...); -} -``` - -**Example**: -- If `rebalanceThreshold = 1.3x` (1300000000000000000) -- Rebalancing is available when `collateralRatio < 1.3x` -- If `collateralRatio = 1.25x`, rebalancing can be triggered -- If `collateralRatio = 1.35x`, rebalancing is NOT available - -### 2. Target Collateral Ratio - -When rebalancing executes, it liquidates pegged tokens to bring the collateral ratio back up to the threshold: - -**Code Location**: `StabilityPoolManager_v1.sol`, line 314-317 -```solidity -// Get the amount of pegged tokens needed to be liquidated to reach target collateral ratio -(uint256 peggedForCollateral, uint256 peggedForLeveraged) = IMinter(MINTER).redeemPeggedForCollateralRatio( - rebalanceThreshold_ // <-- Target is the threshold itself! -); -``` - -**Key Point**: The `rebalanceThreshold` serves **dual purpose**: -1. **Trigger**: Rebalancing is enabled when ratio < threshold -2. **Target**: Rebalancing aims to bring ratio back up to the threshold - -### 3. Calculation Logic - -The `redeemPeggedForCollateralRatio()` function calculates how many pegged tokens need to be liquidated to reach the target ratio: - -**Code Location**: `Minter_v1.sol`, line 363-382 -```solidity -function redeemPeggedForCollateralRatio( - uint256 targetCollateralRatio -) external view returns (uint256 peggedForCollateral, uint256 peggedForLeveraged) { - // ... calculates pegged tokens to liquidate to reach targetCollateralRatio -} -``` - -**Formula**: -- Liquidates pegged tokens until: `(collateral × price) / (remaining_pegged) = targetCollateralRatio` -- This increases the collateral ratio back up to the threshold - ---- - -## Example Scenario - -**Initial State**: -- Collateral: 1000 wstETH × $2000 = $2,000,000 -- Pegged tokens: 2000 haUSD -- **Current Collateral Ratio**: $2,000,000 / 2000 = **1.0x** (100%) - -**Rebalance Threshold**: 1.3x (1300000000000000000) - -**Trigger**: ✅ Rebalancing is available (1.0x < 1.3x) - -**Rebalance Execution**: -1. Calculates: Need to liquidate ~461 haUSD to reach 1.3x -2. Liquidates pegged tokens from stability pools -3. Removes 461 haUSD from circulation -4. **New Collateral Ratio**: $2,000,000 / 1539 = **1.3x** (130%) ✅ - -**Result**: Collateral ratio is now at the threshold (1.3x) - ---- - -## Important Notes - -### 1. Threshold = Target -The `rebalanceThreshold` is both: -- The **trigger point** (when to rebalance) -- The **target** (what ratio to achieve) - -### 2. Multiple Rebalances -If the collateral ratio drops below the threshold again after a rebalance, another rebalance can be triggered: -- Rebalance #1: Brings ratio from 1.0x → 1.3x -- If ratio drops to 1.1x again → Rebalance #2 can be triggered -- Rebalance #2: Brings ratio from 1.1x → 1.3x again - -### 3. Not Above Threshold -The rebalance does **NOT** aim to go above the threshold. It brings the ratio to exactly the threshold level (or as close as possible given available pegged tokens in stability pools). - -### 4. Pool Distribution -The rebalance distributes liquidation between: -- **Collateral Pool**: Redeems pegged tokens for collateral -- **Leveraged Pool**: Redeems pegged tokens for leveraged tokens - -Both reduce pegged token supply, increasing the collateral ratio. - ---- - -## How to Query - -### Get Rebalance Threshold (Trigger/Target) -```javascript -const stabilityPoolManager = "0x26291175Fa0Ea3C8583fEdEB56805eA68289b105"; -const threshold = await stabilityPoolManager.rebalanceThreshold(); -// Returns: 1300000000000000000 (1.3x) -``` - -### Check if Rebalancing is Available -```javascript -const canRebalance = await stabilityPoolManager.rebalanceable(); -// Returns: true if collateralRatio < threshold -``` - -### Get Current Collateral Ratio -```javascript -const minter = "0x6484EB0792c646A4827638Fc1B6F20461418eB00"; -const currentRatio = await minter.collateralRatio(); -// Compare with threshold to see if rebalancing is needed -``` - ---- - -## Summary - -| Aspect | Value | -|--------|-------| -| **Trigger Condition** | `collateralRatio < rebalanceThreshold` | -| **Trigger Threshold** | 1.3x (1300000000000000000) - configurable | -| **Target Collateral Ratio** | 1.3x (same as threshold) | -| **Purpose** | Bring collateral ratio back up to threshold | -| **Result** | Collateral ratio = threshold (or as close as possible) | - -**Key Insight**: The rebalance threshold serves as both the trigger point and the target. When the ratio drops below it, rebalancing brings it back up to that same level. - - - -## Quick Answer - -**Trigger Threshold**: Rebalancing is triggered when `collateralRatio < rebalanceThreshold` - -**Target Collateral Ratio**: The rebalance aims to bring the collateral ratio back up to **exactly the `rebalanceThreshold`** value. - -**Current Values**: -- Trigger: When collateral ratio drops below **1.3x** (1300000000000000000) -- Target: **1.3x** (1300000000000000000) - same as the threshold - ---- - -## How It Works - -### 1. Trigger Condition - -Rebalancing is **enabled** when: -```solidity -collateralRatio < rebalanceThreshold -``` - -**Code Location**: `StabilityPoolManager_v1.sol`, line 301-303 -```solidity -if (!_rebalanceable(IMinter(MINTER).collateralRatio(), rebalanceThreshold_)) { - revert CollateralRatioNotBelowRebalanceThreshold(...); -} -``` - -**Example**: -- If `rebalanceThreshold = 1.3x` (1300000000000000000) -- Rebalancing is available when `collateralRatio < 1.3x` -- If `collateralRatio = 1.25x`, rebalancing can be triggered -- If `collateralRatio = 1.35x`, rebalancing is NOT available - -### 2. Target Collateral Ratio - -When rebalancing executes, it liquidates pegged tokens to bring the collateral ratio back up to the threshold: - -**Code Location**: `StabilityPoolManager_v1.sol`, line 314-317 -```solidity -// Get the amount of pegged tokens needed to be liquidated to reach target collateral ratio -(uint256 peggedForCollateral, uint256 peggedForLeveraged) = IMinter(MINTER).redeemPeggedForCollateralRatio( - rebalanceThreshold_ // <-- Target is the threshold itself! -); -``` - -**Key Point**: The `rebalanceThreshold` serves **dual purpose**: -1. **Trigger**: Rebalancing is enabled when ratio < threshold -2. **Target**: Rebalancing aims to bring ratio back up to the threshold - -### 3. Calculation Logic - -The `redeemPeggedForCollateralRatio()` function calculates how many pegged tokens need to be liquidated to reach the target ratio: - -**Code Location**: `Minter_v1.sol`, line 363-382 -```solidity -function redeemPeggedForCollateralRatio( - uint256 targetCollateralRatio -) external view returns (uint256 peggedForCollateral, uint256 peggedForLeveraged) { - // ... calculates pegged tokens to liquidate to reach targetCollateralRatio -} -``` - -**Formula**: -- Liquidates pegged tokens until: `(collateral × price) / (remaining_pegged) = targetCollateralRatio` -- This increases the collateral ratio back up to the threshold - ---- - -## Example Scenario - -**Initial State**: -- Collateral: 1000 wstETH × $2000 = $2,000,000 -- Pegged tokens: 2000 haUSD -- **Current Collateral Ratio**: $2,000,000 / 2000 = **1.0x** (100%) - -**Rebalance Threshold**: 1.3x (1300000000000000000) - -**Trigger**: ✅ Rebalancing is available (1.0x < 1.3x) - -**Rebalance Execution**: -1. Calculates: Need to liquidate ~461 haUSD to reach 1.3x -2. Liquidates pegged tokens from stability pools -3. Removes 461 haUSD from circulation -4. **New Collateral Ratio**: $2,000,000 / 1539 = **1.3x** (130%) ✅ - -**Result**: Collateral ratio is now at the threshold (1.3x) - ---- - -## Important Notes - -### 1. Threshold = Target -The `rebalanceThreshold` is both: -- The **trigger point** (when to rebalance) -- The **target** (what ratio to achieve) - -### 2. Multiple Rebalances -If the collateral ratio drops below the threshold again after a rebalance, another rebalance can be triggered: -- Rebalance #1: Brings ratio from 1.0x → 1.3x -- If ratio drops to 1.1x again → Rebalance #2 can be triggered -- Rebalance #2: Brings ratio from 1.1x → 1.3x again - -### 3. Not Above Threshold -The rebalance does **NOT** aim to go above the threshold. It brings the ratio to exactly the threshold level (or as close as possible given available pegged tokens in stability pools). - -### 4. Pool Distribution -The rebalance distributes liquidation between: -- **Collateral Pool**: Redeems pegged tokens for collateral -- **Leveraged Pool**: Redeems pegged tokens for leveraged tokens - -Both reduce pegged token supply, increasing the collateral ratio. - ---- - -## How to Query - -### Get Rebalance Threshold (Trigger/Target) -```javascript -const stabilityPoolManager = "0x26291175Fa0Ea3C8583fEdEB56805eA68289b105"; -const threshold = await stabilityPoolManager.rebalanceThreshold(); -// Returns: 1300000000000000000 (1.3x) -``` - -### Check if Rebalancing is Available -```javascript -const canRebalance = await stabilityPoolManager.rebalanceable(); -// Returns: true if collateralRatio < threshold -``` - -### Get Current Collateral Ratio -```javascript -const minter = "0x6484EB0792c646A4827638Fc1B6F20461418eB00"; -const currentRatio = await minter.collateralRatio(); -// Compare with threshold to see if rebalancing is needed -``` - ---- - -## Summary - -| Aspect | Value | -|--------|-------| -| **Trigger Condition** | `collateralRatio < rebalanceThreshold` | -| **Trigger Threshold** | 1.3x (1300000000000000000) - configurable | -| **Target Collateral Ratio** | 1.3x (same as threshold) | -| **Purpose** | Bring collateral ratio back up to threshold | -| **Result** | Collateral ratio = threshold (or as close as possible) | - -**Key Insight**: The rebalance threshold serves as both the trigger point and the target. When the ratio drops below it, rebalancing brings it back up to that same level. - - - - - diff --git a/doc/guides/REWARDS-TESTING-STATUS.md b/doc/guides/REWARDS-TESTING-STATUS.md deleted file mode 100644 index 539d2780..00000000 --- a/doc/guides/REWARDS-TESTING-STATUS.md +++ /dev/null @@ -1,164 +0,0 @@ -# Rewards Testing Status - -## Current State - -### Rewards Deposited -- **Collateral Pool**: 625 ha tokens deposited as rewards -- **Leveraged Pool**: 625 ha tokens deposited as rewards -- **Total**: 1,250 ha tokens - -### Reward Configuration -- **Reward Token**: haPB (0x1c85638e118b37167e9298c2268758e058DdfDA0) -- **Reward Period**: 604,800 seconds (7 days) -- **Deposit Time**: Block 280 (timestamp: 1764945836) -- **Finish Time**: Timestamp 1765550636 (7 days later) - -### Current Status -- **Current Time**: 1764945837 (1 second after deposit) -- **Time Elapsed**: 1 second (0.0002% of period) -- **Reward Rate**: ~1.033e15 wei/second (~0.001033 tokens/second) -- **Claimable After 1 Second**: ~0.00000103 tokens (essentially 0) - -## Answer: Do We Need to Advance Blocks? - -### ✅ YES - Time Must Pass for Claimable Rewards - -**Why:** -- Rewards vest **linearly over 7 days** -- `claimable()` calculates based on `block.timestamp` -- Only 1 second has passed since deposit -- Claimable amount = `(timeElapsed / periodLength) × totalRewards` - -**Current Claimable:** -- After 1 second: ~0.000001 tokens (too small to display) -- After 1 hour: ~0.037 tokens -- After 1 day: ~0.89 tokens -- After 7 days: ~312.5 tokens (50% of deposit) - -### Frontend Can Still Display - -Even without advancing time, the frontend can show: - -1. **Registered Reward Tokens**: ✅ Available now - ```typescript - const tokens = await pool.activeRewardTokens(); - // Returns: ['0x1c85638e118b37167e9298c2268758e058DdfDA0'] - ``` - -2. **Reward Rate**: ✅ Available now - ```typescript - const { rate } = await pool.rewardData(token); - // Returns: 1033399470899470 (rewards per second) - ``` - -3. **Projected APR**: ✅ Can calculate now - - Use current rate and user balance - - Project forward 7 days - - Calculate annualized APR - -4. **Pending Rewards**: ✅ Can show "pending" status - - Show that rewards are vesting - - Display time until next claimable amount - - Show progress bar (0.0002% complete) - -5. **Claimable Amount**: ⚠️ Will be 0 until time passes - ```typescript - const claimable = await pool.claimable(userAddress, token); - // Currently: 0 (needs time to pass) - ``` - -## How to Test Claimable Rewards - -### Option 1: Advance Time (Recommended for Testing) - -```bash -# Advance 1 hour (3,600 seconds) -cast rpc anvil_increaseTime 3600 --rpc-url http://localhost:8545 -cast rpc anvil_mine --rpc-url http://localhost:8545 - -# Advance 1 day (86,400 seconds) -cast rpc anvil_increaseTime 86400 --rpc-url http://localhost:8545 -cast rpc anvil_mine --rpc-url http://localhost:8545 - -# Advance 7 days (604,800 seconds) - full period -cast rpc anvil_increaseTime 604800 --rpc-url http://localhost:8545 -cast rpc anvil_mine --rpc-url http://localhost:8545 -``` - -**After advancing:** -- Check claimable again -- Should see increasing amounts -- After 7 days: ~50% of rewards claimable - -### Option 2: Check with User Who Has Deposit - -The owner (0xf39...) has 0 balance, so claimable is 0. Check with dev account: - -```bash -# Check dev account balance -cast call POOL "assetBalanceOf(address)(uint256)" 0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e - -# Check dev account claimable -cast call POOL "claimable(address,address)(uint256)" \ - 0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e \ - 0x1c85638e118b37167e9298c2268758e058DdfDA0 -``` - -## Frontend Implementation Notes - -### What to Display Now (Without Advancing Time) - -```typescript -// 1. Show reward tokens are registered -const rewardTokens = await pool.activeRewardTokens(); -// Display: "Reward Assets: haPB" - -// 2. Show reward rate and projected APR -const { rate } = await pool.rewardData(token); -const totalSupply = await pool.totalAssetSupply(); -const userBalance = await pool.assetBalanceOf(userAddress); -const ratePerToken = rate / totalSupply; -const annualRewards = ratePerToken * userBalance * SECONDS_PER_YEAR; -// Display: "APR: X%" (projected) - -// 3. Show pending status -const { finishAt } = await pool.rewardData(token); -const currentTime = await provider.getBlock('latest').then(b => b.timestamp); -const timeRemaining = finishAt - currentTime; -// Display: "Rewards vesting... X days remaining" - -// 4. Show claimable (will be 0 initially) -const claimable = await pool.claimable(userAddress, token); -// Display: "$0.00" or "0.00 tokens" (with note: "Vesting...") -``` - -### What Happens When Time Passes - -- **After 1 hour**: Claimable ~0.037 tokens -- **After 1 day**: Claimable ~0.89 tokens -- **After 3.5 days**: Claimable ~312.5 tokens (50%) -- **After 7 days**: Claimable ~312.5 tokens (50% - full period complete) -- **After 14 days**: Claimable ~625 tokens (100% - all rewards claimable) - -## Summary - -**Are there rewards pending?** -- ✅ **Yes** - 625 ha tokens per pool are in the vesting schedule -- ✅ **Rate is active** - ~0.001 tokens/second being distributed -- ⚠️ **Not claimable yet** - Only 1 second has passed (0.0002% of period) - -**Do we need to advance blocks?** -- ✅ **Yes, for testing claimable amounts** - Time must pass for rewards to vest -- ✅ **No, for displaying reward info** - Frontend can show: - - Registered tokens - - Reward rate - - Projected APR - - Pending status - - Time remaining - -**Recommendation:** -- For frontend testing: Advance time by 1-24 hours to see claimable amounts -- For production: Frontend should display pending status and projected APR even when claimable is 0 - - - diff --git a/doc/guides/RISK-MITIGATION-CONFIGURATION.md b/doc/guides/RISK-MITIGATION-CONFIGURATION.md deleted file mode 100644 index 5a92e02e..00000000 --- a/doc/guides/RISK-MITIGATION-CONFIGURATION.md +++ /dev/null @@ -1,515 +0,0 @@ -# Risk Mitigation Configuration Guide - -This document outlines critical configuration parameters that must be carefully set to minimize the risks outlined in Harbor's risk documentation. - -## 1. Stability Pool Drain Risk Mitigation - -### Rebalance Threshold Configuration - -**Parameter**: `rebalanceThreshold` (StabilityPoolManager) - -**Current Default**: 1.3x (1300000000000000000) - -**Risk Consideration**: -- **Too Low**: System may not rebalance until it's too late, allowing collateral ratio to drop dangerously close to 1.0x -- **Too High**: Excessive rebalancing, draining stability pools unnecessarily, reducing user confidence - -**Recommended Configuration**: -- **Conservative (High Security)**: 1.35x - 1.4x - - Provides larger buffer before reaching critical levels - - Triggers rebalancing earlier, preventing rapid drains - - Better for volatile collateral assets - -- **Balanced (Default)**: 1.3x - - Good balance between safety and efficiency - - Allows some market movement before intervention - -- **Aggressive (Lower Security)**: 1.25x - 1.3x - - Only recommended for very stable collateral - - Higher risk of pool exhaustion during rapid drops - -**Configuration Method**: -```solidity -// Set via StabilityPoolManager.updateRebalanceThreshold() -// Requires owner/admin role -stabilityPoolManager.updateRebalanceThreshold(1.35e18); // 1.35x -``` - -**Monitoring**: Continuously monitor `collateralRatio()` vs `rebalanceThreshold()` to ensure adequate buffer. - ---- - -### Stability Pool Minimum Sizes - -**Parameters**: -- `MIN_DEPOSIT` (StabilityPool) -- `MIN_TOTAL_ASSET_SUPPLY` (StabilityPool) - -**Risk Consideration**: -- **Too Low**: Allows pool to be drained too quickly during stress -- **Too High**: Discourages participation, reduces liquidity - -**Recommended Configuration**: -- **MIN_DEPOSIT**: Set to prevent dust attacks while allowing small users - - Typical: 100-1000 tokens (in asset token decimals) - - Prevents spam deposits that could complicate liquidation math - -- **MIN_TOTAL_ASSET_SUPPLY**: Critical for preventing complete drain - - Should be sized based on expected stress scenarios - - Consider: "What's the maximum expected liquidation in a single rebalance?" - - Typical: 5-10% of total pegged token supply - - Example: If 1M ha tokens exist, MIN_TOTAL_ASSET_SUPPLY should be 50k-100k - -**Configuration Method**: -```solidity -// Set in constructor (immutable) -// Cannot be changed after deployment -// Must be set carefully at deployment time -``` - ---- - -### Early Withdrawal Fees - -**Parameters**: -- `WITHDRAWAL_START_DELAY` (StabilityPool) -- `WITHDRAWAL_END_WINDOW` (StabilityPool) -- `MAX_EARLY_WITHDRAWAL_FEE` (StabilityPool) - -**Risk Consideration**: -- **Too Low Fees**: Users can exit quickly during stress, accelerating pool drain -- **Too High Fees**: Unfair to users, reduces participation - -**Recommended Configuration**: -- **WITHDRAWAL_START_DELAY**: 1-7 days - - Prevents panic withdrawals during short-term volatility - - Gives system time to rebalance before mass exits - -- **WITHDRAWAL_END_WINDOW**: 24-48 hours - - Provides reasonable window for fee-free withdrawals - - Prevents indefinite lockup - -- **MAX_EARLY_WITHDRAWAL_FEE**: 5-10% (0.05e18 - 0.10e18) - - High enough to discourage panic exits - - Low enough to be fair for genuine needs - -**Configuration Method**: -```solidity -// Set in constructor (immutable) -// Cannot be changed after deployment -// Must be set carefully at deployment time -``` - ---- - -## 2. Undercollateralization Prevention - -### Fee Structure Configuration - -**Parameter**: `incentiveConfig` (Minter) - -**Risk Consideration**: -- Fee structure must strongly discourage actions that worsen health -- Must encourage actions that improve health -- Must block dangerous operations when system is undercollateralized - -**Critical Configuration Points**: - -#### 1. Mint Pegged Token (ha) Fees -**Must Block Below 1.0x**: -```json -{ - "collateralRatioBandUpperBounds": [1.0e18, ...], - "incentiveRatios": [1.0e18, ...] // 100% fee = BLOCKED -} -``` - -**Recommended Bands**: -- < 1.0x: **100% (BLOCKED)** - Critical -- 1.0x - 1.05x: **50%** - Very expensive -- 1.05x - 1.1x: **20%** - High fee -- 1.1x - 1.2x: **10%** - Medium fee -- 1.2x - 1.3x: **5%** - Low fee -- > 1.3x: **0.5-2%** - Minimal fee - -#### 2. Redeem Pegged Token (ha) Discounts -**Must Encourage Below 1.1x**: -```json -{ - "collateralRatioBandUpperBounds": [1.0e18, 1.1e18, ...], - "incentiveRatios": [-0.10e18, -0.05e18, ...] // Negative = discount -} -``` - -**Recommended Bands**: -- < 1.0x: **-10% discount** - Strong incentive -- 1.0x - 1.05x: **-5% discount** - Good incentive -- 1.05x - 1.1x: **0%** - Free redemption -- > 1.1x: **1-5% fee** - Normal operation - -#### 3. Mint Leveraged Token (hs) Discounts -**Must Encourage Below 1.2x**: -- < 1.0x: **-15% discount** - Strong incentive -- 1.0x - 1.05x: **-10% discount** - Good incentive -- 1.05x - 1.1x: **-5% discount** - Small incentive -- 1.1x - 1.2x: **-2% discount** - Minimal incentive -- > 1.2x: **0-3% fee** - Normal operation - -#### 4. Redeem Leveraged Token (hs) Fees -**Must Block Below 1.0x**: -- < 1.0x: **100% (BLOCKED)** - Critical -- 1.0x - 1.05x: **30%** - Very expensive -- 1.05x - 1.1x: **15%** - High fee -- > 1.1x: **1.5-8%** - Normal operation - -**Configuration Method**: -```solidity -// Update via Minter.updateConfig() -// Requires owner/admin role -// Can be updated dynamically based on market conditions -``` - -**Validation Checklist**: -- ✅ Mint ha blocked below 1.0x -- ✅ Redeem ha has discounts below 1.1x -- ✅ Mint hs has discounts below 1.2x -- ✅ Redeem hs blocked below 1.0x -- ✅ Fees increase smoothly (no sudden jumps) -- ✅ Bands cover all possible collateral ratios - ---- - -## 3. Oracle Reliability Configuration - -### Price Oracle Constraints - -**Parameters** (StakedETHWrappedPriceOracle): -- `maxAnswerAge` (max staleness) -- `maxRelativeDeviation` (percentage change limit) -- `maxAbsoluteDeviation` (absolute change limit) -- `maxTrendReversalDeviation` (reversal detection) - -**Risk Consideration**: -- **Too Lenient**: Accepts stale/manipulated prices -- **Too Strict**: Rejects valid price movements, blocks operations - -**Recommended Configuration**: - -#### 1. Max Answer Age (Staleness) -```solidity -// Typical: 3600 seconds (1 hour) -// For volatile markets: 1800 seconds (30 minutes) -// For stable markets: 7200 seconds (2 hours) -maxAnswerAge = 3600; // 1 hour -``` - -**Rationale**: -- Chainlink updates typically every 1 hour -- 1 hour provides buffer for network delays -- Too short: Rejects valid prices during network issues -- Too long: Accepts stale prices during rapid market moves - -#### 2. Max Relative Deviation (Percentage) -```solidity -// Typical: 20% (0.20e18) -// For volatile markets: 30% (0.30e18) -// For stable markets: 15% (0.15e18) -maxRelativeDeviation = 0.20e18; // 20% -``` - -**Rationale**: -- Prevents accepting flash crash prices -- Allows normal volatility -- 20% covers most legitimate 24-hour moves -- Too low: Rejects valid large moves -- Too high: Accepts manipulation attempts - -#### 3. Max Absolute Deviation -```solidity -// Typical: $1000 (1000e18) -// Adjust based on asset price -// For $2000 asset: 1000e18 = 50% move -maxAbsoluteDeviation = 1000e18; // $1000 -``` - -**Rationale**: -- Prevents accepting prices that are clearly wrong -- Complements percentage check -- Should be sized relative to asset price - -#### 4. Max Trend Reversal Deviation -```solidity -// Typical: 10% (0.10e18) -// Detects sudden reversals (potential manipulation) -maxTrendReversalDeviation = 0.10e18; // 10% -``` - -**Rationale**: -- Detects suspicious price reversals -- Prevents accepting manipulated prices -- 10% catches most manipulation attempts - -**Configuration Method**: -```solidity -// Set in constructor (immutable) -// Cannot be changed after deployment -// Must be set carefully at deployment time -``` - -**Monitoring**: -- Track oracle revert rates -- Monitor for frequent `StaleUnderlyingPrice` errors -- Monitor for frequent `UnderlyingPriceDeviation` errors -- Adjust if too strict or too lenient - ---- - -### Price Oracle Address Configuration - -**Parameter**: `priceOracle` (Minter) - -**Risk Consideration**: -- Must point to valid, reliable oracle -- Must be updated if oracle is upgraded -- Must not be set to zero address - -**Configuration Method**: -```solidity -// Update via Minter.updatePriceOracle() -// Requires owner/admin role -minter.updatePriceOracle(newOracleAddress); -``` - -**Validation Checklist**: -- ✅ Oracle address is not zero -- ✅ Oracle implements required interface -- ✅ Oracle has fresh price data -- ✅ Oracle constraints are appropriate -- ✅ Oracle is tested and audited - ---- - -## 4. Market Risk Mitigation - -### Reserve Pool Configuration - -**Parameter**: `reservePool` (Minter) - -**Risk Consideration**: -- Provides buffer during stress -- Absorbs redemption discounts -- Must be adequately funded - -**Recommended Configuration**: -- **Initial Funding**: 5-10% of expected pegged token supply -- **Maintenance**: Keep funded from protocol fees -- **Minimum**: Enough to cover expected redemption discounts - -**Configuration Method**: -```solidity -// Set in constructor/initializer -// Can be updated via governance -minter.setReservePool(reservePoolAddress); -``` - ---- - -### Harvest Configuration - -**Parameters** (StabilityPoolManager): -- `harvestBountyRatio` -- `harvestCutRatio` -- `feeReceiver` - -**Risk Consideration**: -- Bounty incentivizes keepers to harvest -- Cut provides protocol revenue -- Must balance incentives vs. protocol sustainability - -**Recommended Configuration**: -- **harvestBountyRatio**: 1-5% (0.01e18 - 0.05e18) - - High enough to incentivize keepers - - Low enough to preserve rewards for users - -- **harvestCutRatio**: 1-5% (0.01e18 - 0.05e18) - - Provides protocol revenue - - Low enough to maximize user rewards - -- **feeReceiver**: Trusted address (multisig recommended) - - Receives harvest cut - - Can be used to fund reserve pool or stability pools - -**Configuration Method**: -```solidity -// Update via StabilityPoolManager -stabilityPoolManager.updateHarvestBountyRatio(0.02e18); // 2% -stabilityPoolManager.updateHarvestCutRatio(0.03e18); // 3% -stabilityPoolManager.updateFeeReceiver(newFeeReceiver); -``` - ---- - -## 5. Configuration Best Practices - -### 1. Conservative Initial Settings - -**Principle**: Start conservative, relax over time - -- Set rebalance threshold higher initially (1.35x-1.4x) -- Set oracle constraints stricter initially -- Set fees higher initially -- Monitor and adjust based on real-world data - -### 2. Gradual Adjustments - -**Principle**: Make changes incrementally - -- Don't change multiple parameters at once -- Test changes on testnet first -- Monitor impact of each change -- Have rollback plan - -### 3. Multi-Signature Governance - -**Principle**: Critical parameters require multiple approvals - -- Use multisig for owner/admin roles -- Require 3-of-5 or 4-of-7 signatures -- Implement timelock for major changes -- Publicize changes before execution - -### 4. Monitoring and Alerts - -**Principle**: Continuous monitoring of key metrics - -**Key Metrics to Monitor**: -- Collateral ratio vs. rebalance threshold -- Stability pool sizes -- Oracle staleness/error rates -- Fee structure effectiveness -- User behavior patterns - -**Alert Thresholds**: -- Collateral ratio < 1.15x (approaching rebalance) -- Stability pool < 2x MIN_TOTAL_ASSET_SUPPLY -- Oracle errors > 5% of calls -- Fee structure not achieving desired behavior - -### 5. Stress Testing - -**Principle**: Test configurations under stress scenarios - -**Test Scenarios**: -- Rapid collateral price drop (50% in 1 hour) -- Oracle failure/staleness -- Mass redemption event -- Stability pool exhaustion -- Flash crash recovery - -**Validation**: -- System recovers without undercollateralization -- Stability pools don't drain completely -- Fees incentivize correct behavior -- Oracle constraints catch manipulation - ---- - -## 6. Configuration Checklist - -### Pre-Deployment - -- [ ] Rebalance threshold set (recommended: 1.3x-1.4x) -- [ ] Stability pool minimums set appropriately -- [ ] Early withdrawal fees configured -- [ ] Fee structure configured and validated -- [ ] Oracle constraints set (staleness, deviations) -- [ ] Price oracle address configured -- [ ] Reserve pool funded -- [ ] Harvest parameters configured -- [ ] Fee receiver set (multisig recommended) -- [ ] All parameters tested on testnet - -### Post-Deployment Monitoring - -- [ ] Monitor collateral ratio daily -- [ ] Track stability pool sizes -- [ ] Monitor oracle error rates -- [ ] Analyze fee structure effectiveness -- [ ] Review user behavior patterns -- [ ] Check for parameter adjustment needs -- [ ] Document any changes made - -### Regular Reviews - -- [ ] Monthly review of all parameters -- [ ] Quarterly stress testing -- [ ] Annual comprehensive audit -- [ ] Update documentation as needed -- [ ] Community governance for major changes - ---- - -## 7. Emergency Procedures - -### If Collateral Ratio Drops Rapidly - -1. **Immediate Actions**: - - Verify oracle is functioning correctly - - Check for manipulation attempts - - Monitor stability pool sizes - - Prepare for potential rebalance - -2. **Parameter Adjustments** (if needed): - - Increase rebalance threshold (if too low) - - Adjust fee structure (if not working) - - Pause operations (if critical) - -3. **Recovery Actions**: - - Encourage redemptions (via discounts) - - Encourage leveraged token minting - - Direct protocol fees to stability pools - - Community governance intervention - -### If Oracle Fails - -1. **Immediate Actions**: - - Pause operations requiring oracle - - Switch to backup oracle (if available) - - Notify community - -2. **Recovery Actions**: - - Fix or replace oracle - - Update oracle address - - Resume operations gradually - - Monitor closely - -### If Stability Pool Drains - -1. **Immediate Actions**: - - Analyze cause (price drop, manipulation, etc.) - - Verify rebalance threshold is appropriate - - Check fee structure effectiveness - -2. **Recovery Actions**: - - Increase rebalance threshold (if too low) - - Adjust fees to encourage deposits - - Direct protocol revenue to pools - - Community governance for recapitalization - ---- - -## Summary - -**Critical Configuration Priorities**: - -1. **Rebalance Threshold**: Set conservatively (1.3x-1.4x) -2. **Fee Structure**: Must block dangerous operations, encourage helpful ones -3. **Oracle Constraints**: Balance between security and usability -4. **Stability Pool Minimums**: Size appropriately for expected stress -5. **Early Withdrawal Fees**: Discourage panic exits -6. **Monitoring**: Continuous oversight of all parameters - -**Remember**: Configuration is not set-and-forget. Regular monitoring, testing, and adjustment based on real-world data is essential for maintaining system health and minimizing risks. - - - diff --git a/doc/guides/SAIL-TOKEN-FINAL-SETUP.md b/doc/guides/SAIL-TOKEN-FINAL-SETUP.md deleted file mode 100644 index 6fbbcf03..00000000 --- a/doc/guides/SAIL-TOKEN-FINAL-SETUP.md +++ /dev/null @@ -1,117 +0,0 @@ -# Sail Token Subgraph - Final Setup Instructions - -## ✅ Completed - -1. ✅ Handler file: `/Users/andrewyoung/Harbor-App/harbor-app/subgraph/src/sailToken.ts` -2. ✅ Schema: `SailTokenBalance` entity added to `schema.graphql` - -## ⚠️ Action Required: Fix subgraph.yaml - -The `subgraph.yaml` file has structural issues. Here's the **simplest way to fix it**: - -### Option 1: Manual Edit (Recommended) - -1. Open `/Users/andrewyoung/Harbor-App/harbor-app/subgraph/subgraph.yaml` -2. Find the `HaToken_haPB` data source (around line 40-60) -3. After the line `file: ./src/haToken.ts`, add a blank line -4. Insert this exact block (with proper 2-space indentation): - -```yaml - # Static data source for hsPB token (sail token) - - kind: ethereum - name: SailToken_hsPB - network: anvil - source: - address: "0x367761085BF3C12e5DA2Df99AC6E1a824612b8fb" - abi: ERC20 - startBlock: 93 - mapping: - kind: ethereum/events - apiVersion: 0.0.7 - language: wasm/assemblyscript - entities: - - SailTokenBalance - - MarksMultiplier - - UserTotalMarks - - PriceFeed - abis: - - name: ERC20 - file: ./abis/ERC20.json - - name: ChainlinkAggregator - file: ./abis/ChainlinkAggregator.json - eventHandlers: - - event: Transfer(indexed address,indexed address,uint256) - handler: handleSailTokenTransfer - file: ./src/sailToken.ts -``` - -5. **Remove any duplicate** `SailToken_hsPB` entries elsewhere in the file -6. Save the file - -### Option 2: Use Backup and Re-add - -If the file is too corrupted: - -```bash -cd /Users/andrewyoung/Harbor-App/harbor-app/subgraph -# Make a backup -cp subgraph.yaml subgraph.yaml.backup - -# Remove all sail token references -grep -v "SailToken_hsPB" subgraph.yaml > subgraph.yaml.tmp -mv subgraph.yaml.tmp subgraph.yaml - -# Then manually add the sail token block after HaToken_haPB (see Option 1) -``` - -## After Fixing YAML - -### Step 1: Run Codegen - -```bash -cd /Users/andrewyoung/Harbor-App/harbor-app/subgraph -yarn codegen -``` - -**Expected**: Should generate types without errors - -### Step 2: Build - -```bash -yarn build -``` - -**Expected**: Should compile successfully - -### Step 3: Deploy - -```bash -graph deploy --node http://localhost:8020/ --ipfs http://localhost:5001 harbor-marks-local --version-label v1.1.0 -``` - -## Verification - -After deployment, test: - -```graphql -{ - sailTokenBalances(where: {user: "0xae7dbb17bc40d53a6363409c6b1ed88d3cfdc31e"}) { - id - balance - balanceUSD - accumulatedMarks - marksPerDay - } -} -``` - -## Summary - -- ✅ Handler: `src/sailToken.ts` - **DONE** -- ✅ Schema: `SailTokenBalance` entity - **DONE** -- ⚠️ YAML: Needs manual fix - **ACTION REQUIRED** - -The YAML file needs careful manual editing to ensure proper structure. Once fixed, codegen → build → deploy should work. - - - diff --git a/doc/guides/SAIL-TOKEN-IMPLEMENTATION.md b/doc/guides/SAIL-TOKEN-IMPLEMENTATION.md deleted file mode 100644 index f3eb8e2c..00000000 --- a/doc/guides/SAIL-TOKEN-IMPLEMENTATION.md +++ /dev/null @@ -1,175 +0,0 @@ -# Sail Token Marks Tracking Implementation - -## Overview - -Sail tokens (leveraged tokens, `hs` tokens) now earn marks at **5x the rate** of ha tokens (anchor tokens). - -- **Ha Tokens**: 1 mark per dollar per day (1x multiplier) -- **Sail Tokens**: 5 marks per dollar per day (5x multiplier, default) - -Each sail token can have its own multiplier, but the default is **5x** for all sail tokens. - -## Implementation Steps - -### 1. Add SailTokenBalance Entity to Schema - -Add to `/Users/andrewyoung/Harbor-App/harbor-app/subgraph/schema.graphql`: - -```graphql -type SailTokenBalance @entity(immutable: false) { - id: ID! # {tokenAddress}-{userAddress} - tokenAddress: Bytes! # Sail token contract address - user: Bytes! # User address - balance: BigInt! # Current token balance - balanceUSD: BigDecimal! # Current balance in USD - marksPerDay: BigDecimal! # Current marks per day rate (includes multiplier) - accumulatedMarks: BigDecimal! # Marks accumulated from this balance - totalMarksEarned: BigDecimal! # Total marks ever earned from this token - firstSeenAt: BigInt! # First time user had balance > 0 - lastUpdated: BigInt! # Last block timestamp when updated - marketId: String # Market identifier (optional, for grouping) -} -``` - -### 2. Create Sail Token Handler - -Create `/Users/andrewyoung/Harbor-App/harbor-app/subgraph/src/sailToken.ts`: - -Copy the contents from `subgraph-sail-token-handler.ts` in this directory. - -**Key differences from haToken.ts:** -- Uses `SailTokenBalance` entity instead of `HaTokenBalance` -- Default multiplier is **5.0x** instead of 1.0x -- Source type is `"sailToken"` instead of `"haToken"` -- Imports from `../generated/SailToken_hsPB/ERC20` instead of `HaToken_haPB` - -### 3. Add Data Source to subgraph.yaml - -Add to `/Users/andrewyoung/Harbor-App/harbor-app/subgraph/subgraph.yaml` in the `dataSources` section: - -```yaml - - kind: ethereum - name: SailToken_hsPB - network: anvil - source: - address: "0x367761085BF3C12e5DA2Df99AC6E1a824612b8fb" # hsPB token address - abi: ERC20 - startBlock: 93 # Start from Genesis deployment block - mapping: - kind: ethereum/events - apiVersion: 0.0.7 - language: wasm/assemblyscript - entities: - - SailTokenBalance - - MarksMultiplier - - UserTotalMarks - - PriceFeed - abis: - - name: ERC20 - file: ./abis/ERC20.json - - name: ChainlinkAggregator - file: ./abis/ChainlinkAggregator.json - eventHandlers: - - event: Transfer(indexed address,indexed address,uint256) - handler: handleSailTokenTransfer - file: ./src/sailToken.ts -``` - -### 4. Generate Types and Build - -```bash -cd /Users/andrewyoung/Harbor-App/harbor-app/subgraph -yarn codegen -yarn build -``` - -### 5. Deploy Subgraph - -```bash -graph deploy --node http://localhost:8020/ --ipfs http://localhost:5001 harbor-marks-local --version-label v1.1.0 -``` - -## Multiplier Configuration - -### Default Multiplier - -- **Sail Tokens**: 5.0x (5 marks per dollar per day) -- **Ha Tokens**: 1.0x (1 mark per dollar per day) - -### Per-Token Multipliers - -Each sail token can have its own multiplier configured via the `MarksMultiplier` entity: - -- `sourceType`: `"sailToken"` -- `sourceAddress`: The sail token contract address -- `multiplier`: The multiplier value (default 5.0) - -### Example: Different Multipliers - -``` -Sail Token A (hsPB): 5.0x multiplier → 5 marks/dollar/day -Sail Token B (hsETH): 10.0x multiplier → 10 marks/dollar/day -Sail Token C (hsBTC): 3.0x multiplier → 3 marks/dollar/day -``` - -## GraphQL Query - -```graphql -query GetSailTokenMarks($userAddress: Bytes!) { - sailTokenBalances(where: { user: $userAddress }) { - id - tokenAddress - balance - balanceUSD - accumulatedMarks - marksPerDay - lastUpdated - } -} -``` - -## Example Calculation - -User holds: -- **100,000 sail tokens** (hsPB) worth $100,000 -- **Multiplier**: 5.0x -- **Marks per day**: $100,000 × 5.0 = **500,000 marks/day** - -After 2 days: -- **Accumulated marks**: 500,000 × 2 = **1,000,000 marks** - -## Frontend Integration - -The frontend should query `sailTokenBalances` similar to `haTokenBalances`: - -```typescript -const { sailTokenBalances } = await getSailTokenMarks(userAddress); -const totalSailMarks = sailTokenBalances.reduce( - (sum, balance) => sum + parseFloat(balance.accumulatedMarks || "0"), - 0 -); -``` - -The `marksPerDay` field already includes the multiplier, so the frontend estimation works the same way: - -```typescript -const estimatedMarks = accumulatedMarks + (marksPerDay * daysSinceLastUpdate); -``` - -## Files Created - -1. `subgraph-sail-token-handler.ts` - Handler implementation (copy to subgraph) -2. `subgraph-schema-update.graphql` - Schema addition (add to schema.graphql) -3. `subgraph-yaml-update.yaml` - Data source config (add to subgraph.yaml) -4. `SAIL-TOKEN-IMPLEMENTATION.md` - This file - -## Next Steps - -1. Copy files to subgraph directory -2. Run `yarn codegen` and `yarn build` -3. Deploy subgraph -4. Update frontend documentation to include sail tokens -5. Test with actual sail token transfers - - - diff --git a/doc/guides/SAIL-TOKEN-SETUP-SUMMARY.md b/doc/guides/SAIL-TOKEN-SETUP-SUMMARY.md deleted file mode 100644 index c848bc71..00000000 --- a/doc/guides/SAIL-TOKEN-SETUP-SUMMARY.md +++ /dev/null @@ -1,152 +0,0 @@ -# Sail Token Marks Tracking - Setup Summary - -## ✅ Files Created - -1. **`subgraph-sail-token-handler.ts`** - Handler implementation for sail tokens -2. **`subgraph-schema-update.graphql`** - Schema addition for `SailTokenBalance` entity -3. **`subgraph-yaml-update.yaml`** - Data source configuration for sail tokens -4. **`SAIL-TOKEN-IMPLEMENTATION.md`** - Detailed implementation guide -5. **`FRONTEND-HA-TOKEN-MARKS.md`** - Updated with sail token documentation - -## 🎯 Key Features - -- **5x Default Multiplier**: Sail tokens earn 5 marks per dollar per day (vs 1x for ha tokens) -- **Per-Token Multipliers**: Each sail token can have its own multiplier -- **Zero-Gas Estimation**: Same frontend estimation approach as ha tokens -- **Same Structure**: Mirrors ha token tracking for consistency - -## 📋 Implementation Steps - -### 1. Copy Files to Subgraph Directory - -```bash -# Copy handler -cp subgraph-sail-token-handler.ts /Users/andrewyoung/Harbor-App/harbor-app/subgraph/src/sailToken.ts - -# Add to schema.graphql (manually add the SailTokenBalance entity) -# Add to subgraph.yaml (manually add the SailToken_hsPB data source) -``` - -### 2. Update Schema - -Add to `/Users/andrewyoung/Harbor-App/harbor-app/subgraph/schema.graphql`: - -```graphql -type SailTokenBalance @entity(immutable: false) { - id: ID! # {tokenAddress}-{userAddress} - tokenAddress: Bytes! # Sail token contract address - user: Bytes! # User address - balance: BigInt! # Current token balance - balanceUSD: BigDecimal! # Current balance in USD - marksPerDay: BigDecimal! # Current marks per day rate (includes multiplier) - accumulatedMarks: BigDecimal! # Marks accumulated from this balance - totalMarksEarned: BigDecimal! # Total marks ever earned from this token - firstSeenAt: BigInt! # First time user had balance > 0 - lastUpdated: BigInt! # Last block timestamp when updated - marketId: String # Market identifier (optional, for grouping) -} -``` - -### 3. Update subgraph.yaml - -Add to the `dataSources` section: - -```yaml -- kind: ethereum - name: SailToken_hsPB - network: anvil - source: - address: "0x367761085BF3C12e5DA2Df99AC6E1a824612b8fb" # hsPB token address - abi: ERC20 - startBlock: 93 - mapping: - kind: ethereum/events - apiVersion: 0.0.7 - language: wasm/assemblyscript - entities: - - SailTokenBalance - - MarksMultiplier - - UserTotalMarks - - PriceFeed - abis: - - name: ERC20 - file: ./abis/ERC20.json - - name: ChainlinkAggregator - file: ./abis/ChainlinkAggregator.json - eventHandlers: - - event: Transfer(indexed address,indexed address,uint256) - handler: handleSailTokenTransfer - file: ./src/sailToken.ts -``` - -### 4. Generate Types and Build - -```bash -cd /Users/andrewyoung/Harbor-App/harbor-app/subgraph -yarn codegen -yarn build -``` - -### 5. Deploy Subgraph - -```bash -graph deploy --node http://localhost:8020/ --ipfs http://localhost:5001 harbor-marks-local --version-label v1.1.0 -``` - -## 🔍 Verification - -After deployment, test with: - -```graphql -query GetSailTokenMarks($userAddress: Bytes!) { - sailTokenBalances(where: { user: $userAddress }) { - id - tokenAddress - balance - balanceUSD - accumulatedMarks - marksPerDay - lastUpdated - } -} -``` - -Expected results: - -- `marksPerDay` should be 5x the USD value (e.g., $100k = 500k marks/day) -- `accumulatedMarks` should increase over time -- Multiplier should default to 5.0x - -## 📊 Multiplier Configuration - -### Default Multipliers - -- **Ha Tokens**: 1.0x (1 mark per dollar per day) -- **Sail Tokens**: 5.0x (5 marks per dollar per day) -- **Stability Pools**: 1.0x (1 mark per dollar per day) - -### Per-Token Multipliers - -Each sail token can have its own multiplier via `MarksMultiplier` entity: - -- `sourceType`: `"sailToken"` -- `sourceAddress`: The sail token contract address -- `multiplier`: The multiplier value (default 5.0) - -## 🎨 Frontend Integration - -The frontend documentation has been updated in `FRONTEND-HA-TOKEN-MARKS.md` with: - -- Sail token GraphQL queries -- Frontend implementation examples -- Real-time estimation approach -- Complete query examples including sail tokens - -## 📝 Notes - -- Sail tokens use the same daily snapshot approach as ha tokens -- Multipliers are automatically applied when calculating `marksPerDay` -- Frontend doesn't need to apply multipliers manually -- Each sail token can have its own multiplier (default 5.0x) - - diff --git a/doc/guides/SAIL-TOKEN-SUBGRAPH-SETUP.md b/doc/guides/SAIL-TOKEN-SUBGRAPH-SETUP.md deleted file mode 100644 index 5a4a37b1..00000000 --- a/doc/guides/SAIL-TOKEN-SUBGRAPH-SETUP.md +++ /dev/null @@ -1,198 +0,0 @@ -# Sail Token Subgraph Setup - Manual Instructions - -## Current Status - -✅ Handler file created: `/Users/andrewyoung/Harbor-App/harbor-app/subgraph/src/sailToken.ts` -✅ Schema updated: `SailTokenBalance` entity added to `schema.graphql` -⚠️ **YAML needs manual fix**: `subgraph.yaml` has structural issues that need to be fixed manually - -## Manual Fix Required - -The `subgraph.yaml` file needs the sail token data source added in the correct location. Here's what to do: - -### Step 1: Locate HaToken_haPB Data Source - -Find this section in `subgraph.yaml` (around line 40-60): - -```yaml - # Static data source for haPB token (ha token) - - kind: ethereum - name: HaToken_haPB - network: anvil - source: - address: "0x1c85638e118b37167e9298c2268758e058DdfDA0" # haPB token address - abi: ERC20 - startBlock: 93 # Start from Genesis deployment block - mapping: - kind: ethereum/events - apiVersion: 0.0.7 - language: wasm/assemblyscript - entities: - - HaTokenBalance - - MarksMultiplier - - UserTotalMarks - - PriceFeed - abis: - - name: ERC20 - file: ./abis/ERC20.json - - name: ChainlinkAggregator - file: ./abis/ChainlinkAggregator.json - eventHandlers: - - event: Transfer(indexed address,indexed address,uint256) - handler: handleHaTokenTransfer - file: ./src/haToken.ts -``` - -### Step 2: Add Sail Token Data Source Right After - -Insert this **immediately after** the `file: ./src/haToken.ts` line: - -```yaml - # Static data source for hsPB token (sail token) - - kind: ethereum - name: SailToken_hsPB - network: anvil - source: - address: "0x367761085BF3C12e5DA2Df99AC6E1a824612b8fb" # hsPB token address - abi: ERC20 - startBlock: 93 # Start from Genesis deployment block - mapping: - kind: ethereum/events - apiVersion: 0.0.7 - language: wasm/assemblyscript - entities: - - SailTokenBalance - - MarksMultiplier - - UserTotalMarks - - PriceFeed - abis: - - name: ERC20 - file: ./abis/ERC20.json - - name: ChainlinkAggregator - file: ./abis/ChainlinkAggregator.json - eventHandlers: - - event: Transfer(indexed address,indexed address,uint256) - handler: handleSailTokenTransfer - file: ./src/sailToken.ts -``` - -### Step 3: Remove Any Duplicates - -Search for `SailToken_hsPB` in the file and remove any duplicate entries. There should be only **one** sail token data source. - -### Step 4: Verify YAML Structure - -Make sure: -- ✅ Indentation is correct (2 spaces) -- ✅ No duplicate `SailToken_hsPB` entries -- ✅ The sail token entry is in the `dataSources:` section -- ✅ It comes after `HaToken_haPB` and before `StabilityPoolCollateral` - -### Step 5: Run Codegen - -```bash -cd /Users/andrewyoung/Harbor-App/harbor-app/subgraph -yarn codegen -``` - -Expected output: Should generate types for `SailToken_hsPB` without errors. - -### Step 6: Build - -```bash -yarn build -``` - -Expected output: Should compile successfully. - -### Step 7: Deploy - -```bash -graph deploy --node http://localhost:8020/ --ipfs http://localhost:5001 harbor-marks-local --version-label v1.1.0 -``` - -## Verification - -After deployment, test with: - -```graphql -query GetSailTokenMarks($userAddress: Bytes!) { - sailTokenBalances(where: { user: $userAddress }) { - id - tokenAddress - balance - balanceUSD - accumulatedMarks - marksPerDay - lastUpdated - } -} -``` - -## Files Status - -- ✅ `/Users/andrewyoung/Harbor-App/harbor-app/subgraph/src/sailToken.ts` - Handler file created -- ✅ `/Users/andrewyoung/Harbor-App/harbor-app/subgraph/schema.graphql` - `SailTokenBalance` entity added -- ⚠️ `/Users/andrewyoung/Harbor-App/harbor-app/subgraph/subgraph.yaml` - Needs manual fix (see above) - -## Quick Fix Command - -If you want to try an automated fix, you can use this Python script: - -```python -import re - -with open('subgraph.yaml', 'r') as f: - content = f.read() - -# Remove all existing sail token entries -content = re.sub(r' # Static data source for hsPB token.*?file: \.\/src\/sailToken\.ts\n', '', content, flags=re.DOTALL) - -# Insert sail token after haToken -sail_config = ''' # Static data source for hsPB token (sail token) - - kind: ethereum - name: SailToken_hsPB - network: anvil - source: - address: "0x367761085BF3C12e5DA2Df99AC6E1a824612b8fb" # hsPB token address - abi: ERC20 - startBlock: 93 # Start from Genesis deployment block - mapping: - kind: ethereum/events - apiVersion: 0.0.7 - language: wasm/assemblyscript - entities: - - SailTokenBalance - - MarksMultiplier - - UserTotalMarks - - PriceFeed - abis: - - name: ERC20 - file: ./abis/ERC20.json - - name: ChainlinkAggregator - file: ./abis/ChainlinkAggregator.json - eventHandlers: - - event: Transfer(indexed address,indexed address,uint256) - handler: handleSailTokenTransfer - file: ./src/sailToken.ts -''' - -content = re.sub( - r'(file: \.\/src\/haToken\.ts)\n', - r'\1\n' + sail_config + '\n', - content, - count=1 -) - -with open('subgraph.yaml', 'w') as f: - f.write(content) -``` - -Save this as `fix-yaml.py` and run: -```bash -cd /Users/andrewyoung/Harbor-App/harbor-app/subgraph -python3 fix-yaml.py -``` - - - diff --git a/doc/guides/SET-WITHDRAWAL-WINDOW-FOR-TESTING.md b/doc/guides/SET-WITHDRAWAL-WINDOW-FOR-TESTING.md deleted file mode 100644 index 3f83f585..00000000 --- a/doc/guides/SET-WITHDRAWAL-WINDOW-FOR-TESTING.md +++ /dev/null @@ -1,158 +0,0 @@ -# Setting Withdrawal Window for Testing - -## Current Configuration - -The stability pools are currently configured with: -- **Start Delay**: 3600 seconds (1 hour) -- **End Window**: 90000 seconds (25 hours) -- **Early Withdrawal Fee**: 2.5% (0.025 ether) - -These values are **immutable** (set in constructor), so they cannot be changed on existing contracts. - -## Option 1: Fast-Forward Time (Quick Testing) - -For quick testing, you can use Anvil's time manipulation to fast-forward the chain: - -```bash -# Fast-forward 5 minutes (300 seconds) -cast rpc anvil_increaseTime 300 --rpc-url http://localhost:8545 - -# Or fast-forward 1 hour to skip the delay -cast rpc anvil_increaseTime 3600 --rpc-url http://localhost:8545 - -# Mine a block to apply the time change -cast rpc anvil_mine 1 --rpc-url http://localhost:8545 -``` - -**Example workflow:** -```bash -# 1. Create withdrawal request -cast send 0x3aAde2dCD2Df6a8cAc689EE797591b2913658659 "requestWithdrawal()" --rpc-url http://localhost:8545 --private-key $PRIVATE_KEY - -# 2. Fast-forward 5 minutes -cast rpc anvil_increaseTime 300 --rpc-url http://localhost:8545 -cast rpc anvil_mine 1 --rpc-url http://localhost:8545 - -# 3. Now you can withdraw fee-free (if within the window) -``` - -## Option 2: Redeploy with 5-Minute Delay (Permanent) - -To permanently set a 5-minute delay, you need to redeploy the stability pools. Here's how: - -### Step 1: Update Deployment Script - -The pools are deployed in `script/deploy-minter` at line 274. You would need to change: - -```bash -# Current (line 274): -deploy_contract StabilityPool_v1${liquidation} "src/minter/StabilityPool_v1.sol:StabilityPool_v1" \ - --constructor-args minter ${TOKEN_KEY[$liquidation]} 25000000000000000 treasury 3600 90000 1e18 - -# Change to (5 minutes = 300 seconds, 1 hour window = 3600 seconds): -deploy_contract StabilityPool_v1${liquidation} "src/minter/StabilityPool_v1.sol:StabilityPool_v1" \ - --constructor-args minter ${TOKEN_KEY[$liquidation]} 25000000000000000 treasury 300 3600 1e18 -``` - -**Constructor Parameters:** -- `minter`: Minter contract address -- `${TOKEN_KEY[$liquidation]}`: Liquidation token (wrappedCollateralToken or leveragedToken) -- `25000000000000000`: Early withdrawal fee (0.025 ether = 2.5%) -- `treasury`: Fee receiver address -- `300`: **WITHDRAWAL_START_DELAY** (5 minutes in seconds) -- `3600`: **WITHDRAWAL_END_WINDOW** (1 hour window duration) -- `1e18`: MIN_TOTAL_ASSET_SUPPLY (1 token minimum) - -### Step 2: Redeploy Stability Pools - -```bash -# Redeploy with new withdrawal window -cd /Users/andrewyoung/Documents/Harbor/Harbor-minter/harbor -yarn deploy:anvil -``` - -**Note:** This will redeploy ALL contracts. If you only want to redeploy the stability pools, you'll need to modify the deployment script to skip other contracts or create a separate script. - -### Step 3: Update Subgraph - -After redeploying, update the subgraph with the new pool addresses: - -1. Update `subgraph.yaml` with new pool addresses -2. Update `startBlock` to the deployment block -3. Run `graph codegen` and `graph build` -4. Redeploy subgraph - -## Option 3: Create Test-Specific Deployment Script - -Create a script that deploys pools with testing parameters: - -```bash -# script/deploy-test-pools.sh -#!/bin/bash - -# Deploy test stability pools with 5-minute delay -deploy_contract StabilityPool_v1Collateral_Test "src/minter/StabilityPool_v1.sol:StabilityPool_v1" \ - --constructor-args $MINTER $WRAPPED_COLLATERAL_TOKEN 25000000000000000 $TREASURY 300 3600 1e18 - -deploy_contract StabilityPool_v1Leveraged_Test "src/minter/StabilityPool_v1.sol:StabilityPool_v1" \ - --constructor-args $MINTER $LEVERAGED_TOKEN 25000000000000000 $TREASURY 300 3600 1e18 - -# Upgrade existing proxies (if you want to keep same addresses) -upgrade_proxy stabilityPoolCollateral StabilityPool_v1Collateral_Test "initialize(address,uint256,address)" \ - $OWNER 25000000000000000 $TREASURY - -upgrade_proxy stabilityPoolLeveraged StabilityPool_v1Leveraged_Test "initialize(address,uint256,address)" \ - $OWNER 25000000000000000 $TREASURY -``` - -**Note:** Upgrading won't change immutable values. You must deploy new implementations. - -## Recommended Approach for Testing - -**For quick testing, use Option 1 (time manipulation):** - -```bash -# Helper script: test-withdrawal-request.sh -#!/bin/bash - -POOL_ADDRESS="0x3aAde2dCD2Df6a8cAc689EE797591b2913658659" -PRIVATE_KEY="your-private-key" -RPC_URL="http://localhost:8545" - -echo "1. Creating withdrawal request..." -cast send $POOL_ADDRESS "requestWithdrawal()" --rpc-url $RPC_URL --private-key $PRIVATE_KEY - -echo "2. Fast-forwarding 5 minutes..." -cast rpc anvil_increaseTime 300 --rpc-url $RPC_URL -cast rpc anvil_mine 1 --rpc-url $RPC_URL - -echo "3. Checking withdrawal request status..." -cast call $POOL_ADDRESS "getWithdrawalRequest(address)(uint64,uint64)" 0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e --rpc-url $RPC_URL - -echo "4. You can now withdraw fee-free!" -``` - -## Verification - -After setting up, verify the configuration: - -```bash -# Check withdrawal window -cast call 0x3aAde2dCD2Df6a8cAc689EE797591b2913658659 "getWithdrawalWindow()(uint64,uint64)" --rpc-url http://localhost:8545 - -# Should return: -# 300 (5 minutes start delay) -# 3600 (1 hour window duration) -``` - -## Summary - -- **Current**: 1 hour delay, 25 hour window -- **For Testing**: Use `anvil_increaseTime` to fast-forward (Option 1) -- **For Permanent**: Redeploy pools with 300 seconds delay (Option 2) -- **Immutable Values**: Cannot be changed on existing contracts - -**Recommendation**: Use Option 1 for testing, as it's faster and doesn't require redeployment. - - - diff --git a/doc/guides/SETUP-COMPLETE-SUMMARY.md b/doc/guides/SETUP-COMPLETE-SUMMARY.md deleted file mode 100644 index daac891f..00000000 --- a/doc/guides/SETUP-COMPLETE-SUMMARY.md +++ /dev/null @@ -1,322 +0,0 @@ -# ✅ Harbor Deployment Setup Complete - -**Date**: November 19, 2025 -**Status**: Contracts deployed, ready for Graph Node and subgraph deployment - ---- - -## ✅ Completed Steps - -1. **Anvil Started** - Clean chain (no fork) running on port 8545 -2. **Mock Tokens Deployed**: - - stETH: `0x5FbDB2315678afecb367f032d93F642f64180aa3` - - wstETH: `0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512` - - Chainlink price feeds deployed -3. **Harbor Contracts Deployed**: - - Genesis: `0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82` - - Minter: `0x8A791620dd6260079BF849Dc5567aDC3F2FdC318` - - All other contracts deployed successfully -4. **Developer Permissions Set**: - - Developer (`0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e`) is owner of Genesis - - Developer has ZERO_FEE_ROLE on Minter -5. **Tokens Minted**: 1000 stETH and 1000 wstETH to developer address -6. **Frontend Configuration Created**: `FRONTEND-CONFIG-NEW-DEPLOYMENT.md` - ---- - -## ⏳ Remaining Steps - -### 1. Start Docker Desktop -```bash -# Open Docker Desktop application -# Wait for it to fully start (whale icon in menu bar) -``` - -### 2. Start Graph Node -```bash -cd graph-node-local -docker compose up -d -``` - -Wait for services to start (about 30 seconds), then verify: -```bash -curl http://localhost:8000 > /dev/null && echo "✅ Graph Node is running" -``` - -### 3. Update Subgraph Configuration - -Navigate to your subgraph directory and update `subgraph.yaml`: - -```yaml -specVersion: 0.0.5 -schema: - file: ./schema.graphql -dataSources: - - kind: ethereum - name: Genesis - network: anvil # ← Must be "anvil" - source: - address: "0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82" # ← New Genesis address - abi: Genesis - startBlock: 55 # ← Deployment block - mapping: - kind: ethereum/events - apiVersion: 0.0.7 - language: wasm/assemblyscript - entities: - - Deposit - - Withdrawal - - GenesisEnd - - UserHarborMarks - abis: - - name: Genesis - file: ./abis/Genesis.json - eventHandlers: - - event: Deposit(indexed address,indexed address,uint256) - handler: handleDeposit - - event: Withdraw(indexed address,indexed address,uint256) - handler: handleWithdraw - - event: GenesisEnds() - handler: handleGenesisEnd - file: ./src/genesis.ts -``` - -### 4. Deploy Subgraph - -From your subgraph directory: - -```bash -# Create subgraph on local node -graph create --node http://localhost:8020/ harbor-marks-local - -# Build the subgraph -graph build - -# Deploy to local node -graph deploy --node http://localhost:8020/ \ - --ipfs http://localhost:5001 \ - harbor-marks-local -``` - -### 5. Verify Deployment - -```bash -# Check indexing status -curl -X POST http://localhost:8030/graphql \ - -H "Content-Type: application/json" \ - -d '{"query":"{ indexingStatuses { subgraph health synced chains { latestBlock { number } chainHeadBlock { number } } } }"}' - -# Test GraphQL query -curl -X POST http://localhost:8000/subgraphs/name/harbor-marks-local \ - -H "Content-Type: application/json" \ - -d '{"query":"{ userHarborMarks { id totalDeposited totalWithdrawn } }"}' -``` - ---- - -## 📋 Contract Addresses Summary - -| Contract | Address | -|----------|---------| -| Genesis | `0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82` | -| Minter | `0x8A791620dd6260079BF849Dc5567aDC3F2FdC318` | -| Pegged Token (haPB) | `0x0165878A594ca255338adfa4d48449f69242Eb8F` | -| Leveraged Token | `0xa513E6E4b8f2a923D98304ec87F64353C4D5C853` | -| Reserve Pool | `0x610178dA211FEF7D417bC0e6FeD39F05609AD788` | -| Stability Pool Manager | `0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0` | -| Fee Receiver | `0xB7f8BC63BbcaD18155201308C8f3540b07f84F5e` | -| Mock stETH | `0x5FbDB2315678afecb367f032d93F642f64180aa3` | -| Mock wstETH | `0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512` | - ---- - -## 🔗 Endpoints (After Graph Node Starts) - -- **GraphQL**: `http://localhost:8000/subgraphs/name/harbor-marks-local` -- **JSON-RPC**: `http://localhost:8020/` -- **IPFS**: `http://localhost:5001/` -- **Index Status**: `http://localhost:8030/graphql` - ---- - -## 📄 Documentation Files - -- **Frontend Config**: `FRONTEND-CONFIG-NEW-DEPLOYMENT.md` - Complete frontend integration guide -- **This Summary**: `SETUP-COMPLETE-SUMMARY.md` - Current document - ---- - -## 🎯 Quick Reference - -**Network**: anvil (Chain ID: 31337) -**RPC**: http://localhost:8545 -**Genesis Block**: 55 -**Developer**: `0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e` (has tokens and permissions) - ---- - -**Next Action**: Start Docker Desktop, then run `cd graph-node-local && docker compose up -d` - - - -**Date**: November 19, 2025 -**Status**: Contracts deployed, ready for Graph Node and subgraph deployment - ---- - -## ✅ Completed Steps - -1. **Anvil Started** - Clean chain (no fork) running on port 8545 -2. **Mock Tokens Deployed**: - - stETH: `0x5FbDB2315678afecb367f032d93F642f64180aa3` - - wstETH: `0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512` - - Chainlink price feeds deployed -3. **Harbor Contracts Deployed**: - - Genesis: `0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82` - - Minter: `0x8A791620dd6260079BF849Dc5567aDC3F2FdC318` - - All other contracts deployed successfully -4. **Developer Permissions Set**: - - Developer (`0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e`) is owner of Genesis - - Developer has ZERO_FEE_ROLE on Minter -5. **Tokens Minted**: 1000 stETH and 1000 wstETH to developer address -6. **Frontend Configuration Created**: `FRONTEND-CONFIG-NEW-DEPLOYMENT.md` - ---- - -## ⏳ Remaining Steps - -### 1. Start Docker Desktop -```bash -# Open Docker Desktop application -# Wait for it to fully start (whale icon in menu bar) -``` - -### 2. Start Graph Node -```bash -cd graph-node-local -docker compose up -d -``` - -Wait for services to start (about 30 seconds), then verify: -```bash -curl http://localhost:8000 > /dev/null && echo "✅ Graph Node is running" -``` - -### 3. Update Subgraph Configuration - -Navigate to your subgraph directory and update `subgraph.yaml`: - -```yaml -specVersion: 0.0.5 -schema: - file: ./schema.graphql -dataSources: - - kind: ethereum - name: Genesis - network: anvil # ← Must be "anvil" - source: - address: "0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82" # ← New Genesis address - abi: Genesis - startBlock: 55 # ← Deployment block - mapping: - kind: ethereum/events - apiVersion: 0.0.7 - language: wasm/assemblyscript - entities: - - Deposit - - Withdrawal - - GenesisEnd - - UserHarborMarks - abis: - - name: Genesis - file: ./abis/Genesis.json - eventHandlers: - - event: Deposit(indexed address,indexed address,uint256) - handler: handleDeposit - - event: Withdraw(indexed address,indexed address,uint256) - handler: handleWithdraw - - event: GenesisEnds() - handler: handleGenesisEnd - file: ./src/genesis.ts -``` - -### 4. Deploy Subgraph - -From your subgraph directory: - -```bash -# Create subgraph on local node -graph create --node http://localhost:8020/ harbor-marks-local - -# Build the subgraph -graph build - -# Deploy to local node -graph deploy --node http://localhost:8020/ \ - --ipfs http://localhost:5001 \ - harbor-marks-local -``` - -### 5. Verify Deployment - -```bash -# Check indexing status -curl -X POST http://localhost:8030/graphql \ - -H "Content-Type: application/json" \ - -d '{"query":"{ indexingStatuses { subgraph health synced chains { latestBlock { number } chainHeadBlock { number } } } }"}' - -# Test GraphQL query -curl -X POST http://localhost:8000/subgraphs/name/harbor-marks-local \ - -H "Content-Type: application/json" \ - -d '{"query":"{ userHarborMarks { id totalDeposited totalWithdrawn } }"}' -``` - ---- - -## 📋 Contract Addresses Summary - -| Contract | Address | -|----------|---------| -| Genesis | `0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82` | -| Minter | `0x8A791620dd6260079BF849Dc5567aDC3F2FdC318` | -| Pegged Token (haPB) | `0x0165878A594ca255338adfa4d48449f69242Eb8F` | -| Leveraged Token | `0xa513E6E4b8f2a923D98304ec87F64353C4D5C853` | -| Reserve Pool | `0x610178dA211FEF7D417bC0e6FeD39F05609AD788` | -| Stability Pool Manager | `0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0` | -| Fee Receiver | `0xB7f8BC63BbcaD18155201308C8f3540b07f84F5e` | -| Mock stETH | `0x5FbDB2315678afecb367f032d93F642f64180aa3` | -| Mock wstETH | `0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512` | - ---- - -## 🔗 Endpoints (After Graph Node Starts) - -- **GraphQL**: `http://localhost:8000/subgraphs/name/harbor-marks-local` -- **JSON-RPC**: `http://localhost:8020/` -- **IPFS**: `http://localhost:5001/` -- **Index Status**: `http://localhost:8030/graphql` - ---- - -## 📄 Documentation Files - -- **Frontend Config**: `FRONTEND-CONFIG-NEW-DEPLOYMENT.md` - Complete frontend integration guide -- **This Summary**: `SETUP-COMPLETE-SUMMARY.md` - Current document - ---- - -## 🎯 Quick Reference - -**Network**: anvil (Chain ID: 31337) -**RPC**: http://localhost:8545 -**Genesis Block**: 55 -**Developer**: `0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e` (has tokens and permissions) - ---- - -**Next Action**: Start Docker Desktop, then run `cd graph-node-local && docker compose up -d` - - - - - diff --git a/doc/guides/STABILITY-POOL-MANAGER-FUNCTIONS-FRONTEND.txt b/doc/guides/STABILITY-POOL-MANAGER-FUNCTIONS-FRONTEND.txt deleted file mode 100644 index b28b04f6..00000000 --- a/doc/guides/STABILITY-POOL-MANAGER-FUNCTIONS-FRONTEND.txt +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/doc/guides/STABILITY-POOL-REWARDS-EXPLAINED.md b/doc/guides/STABILITY-POOL-REWARDS-EXPLAINED.md deleted file mode 100644 index e0a6dc3e..00000000 --- a/doc/guides/STABILITY-POOL-REWARDS-EXPLAINED.md +++ /dev/null @@ -1,266 +0,0 @@ -# Stability Pool Rewards - Explained Simply - -## What Are Stability Pools? - -Think of stability pools as **savings accounts** where you deposit your anchor tokens (haPB). You're essentially providing liquidity to help stabilize the system. - -## How Do You Earn Rewards? - -There are **two main ways** you earn rewards from stability pools: - -### 1. **Liquidation Rewards** (When System Rebalances) - -**What happens:** -- When the system's collateral ratio drops too low (below the rebalance threshold), it needs to "rebalance" -- The system takes some of your deposited anchor tokens (haPB) and liquidates them -- In exchange, you get **collateral tokens (wstETH)** back - usually **more than you put in**! - -**Simple analogy:** -- You deposit $100 worth of anchor tokens -- System needs to rebalance, so it takes your tokens -- You get back $105 worth of wstETH (the extra $5 is your reward!) - -**Key points:** -- You earn rewards **proportionally** to your deposit size -- If you have 10% of the pool, you get 10% of the rewards -- The rewards come from the collateral that was freed up during rebalancing - -### 2. **Harvest Rewards** (Periodic Distribution) - -**What happens:** -- The Minter contract accumulates rewards over time (from fees, interest, etc.) -- Periodically, someone calls `harvest()` to distribute these rewards -- The rewards are split between the two stability pools (collateral pool and leveraged pool) -- You earn rewards **proportionally** to your deposit - -**Simple analogy:** -- The system collects fees/interest in a "reward pot" -- Every so often, the pot is distributed to all stability pool depositors -- If you have 5% of the pool, you get 5% of the rewards - -**Distribution:** -- Rewards are split between collateral pool and leveraged pool based on their sizes -- A small "bounty" goes to whoever triggers the harvest (incentive for keepers) -- A small "cut" goes to the fee receiver (protocol revenue) - -## How Rewards Are Calculated - -### Proportional Distribution -- **Your share** = Your deposit / Total deposits in the pool -- **Your rewards** = Total rewards × Your share - -**Example:** -- Total pool: 1,000,000 haPB -- Your deposit: 100,000 haPB (10% of pool) -- Total rewards: 50,000 wstETH -- **Your reward: 5,000 wstETH** (10% of 50,000) - -### Time-Based Accumulation -- Rewards accumulate over time -- The longer you stay deposited, the more rewards you earn -- Rewards are calculated using a "compounding" system that tracks your share over time - -## The Two Types of Stability Pools - -### 1. **Collateral Stability Pool** -- You deposit: **haPB** (anchor tokens) -- When liquidated, you get: **wstETH** (collateral) -- Used when system needs more collateral - -### 2. **Leveraged Stability Pool** -- You deposit: **haPB** (anchor tokens) -- When liquidated, you get: **hsPB** (leveraged tokens) -- Used when system needs to adjust leverage - -## When Do You Get Rewards? - -### Liquidation Rewards -- **Trigger:** System collateral ratio drops below threshold (e.g., 1.3x) -- **Who triggers:** Anyone can call `rebalance()` (keepers/arbitrageurs) -- **Your reward:** Automatic - your deposit is converted to collateral/leveraged tokens at a favorable rate - -### Harvest Rewards -- **Trigger:** When `harvestable()` > 0 (rewards have accumulated) -- **Who triggers:** Anyone can call `harvest()` (keepers) -- **Your reward:** Distributed proportionally to all depositors - -## Important Notes - -### ✅ Benefits -- **Passive income:** Just deposit and earn rewards -- **Proportional:** Bigger deposits = bigger rewards -- **Automatic:** Rewards are calculated and distributed automatically - -### ⚠️ Risks -- **Liquidation risk:** Your tokens can be liquidated when system rebalances -- **Loss risk:** If system is unhealthy, you might get less back than you put in -- **Early withdrawal fees:** Withdrawing too early may incur fees - -### 💡 Best Practices -- **Monitor collateral ratio:** Lower ratios = more frequent rebalancing = more rewards -- **Stay deposited longer:** Rewards accumulate over time -- **Diversify:** Consider both pools for different risk/reward profiles - -## Real-World Example - -**Scenario:** -1. You deposit **100,000 haPB** into the collateral stability pool -2. Pool total: **1,000,000 haPB** (you have 10%) -3. System collateral ratio drops to 1.25x (below 1.3x threshold) -4. Someone triggers `rebalance()` -5. System liquidates **200,000 haPB** from the pool (20% of total) -6. Your share: **20,000 haPB** gets liquidated (20% of your deposit) -7. You receive: **~21,000 wstETH** (5% bonus = your reward!) -8. Your remaining deposit: **80,000 haPB** still in the pool - -**Plus:** -- If someone triggers `harvest()` and there are 10,000 wstETH rewards -- You get: **1,000 wstETH** (10% of rewards based on your remaining deposit) - -## Summary - -**Stability pool rewards = Free money for providing liquidity!** - -- Deposit your anchor tokens -- Earn rewards when system rebalances (liquidation rewards) -- Earn rewards from accumulated fees/interest (harvest rewards) -- Rewards are proportional to your deposit size -- The longer you stay, the more you earn - -It's like earning interest on a savings account, but with the potential for bonus rewards when the system needs to rebalance! - - - -## What Are Stability Pools? - -Think of stability pools as **savings accounts** where you deposit your anchor tokens (haPB). You're essentially providing liquidity to help stabilize the system. - -## How Do You Earn Rewards? - -There are **two main ways** you earn rewards from stability pools: - -### 1. **Liquidation Rewards** (When System Rebalances) - -**What happens:** -- When the system's collateral ratio drops too low (below the rebalance threshold), it needs to "rebalance" -- The system takes some of your deposited anchor tokens (haPB) and liquidates them -- In exchange, you get **collateral tokens (wstETH)** back - usually **more than you put in**! - -**Simple analogy:** -- You deposit $100 worth of anchor tokens -- System needs to rebalance, so it takes your tokens -- You get back $105 worth of wstETH (the extra $5 is your reward!) - -**Key points:** -- You earn rewards **proportionally** to your deposit size -- If you have 10% of the pool, you get 10% of the rewards -- The rewards come from the collateral that was freed up during rebalancing - -### 2. **Harvest Rewards** (Periodic Distribution) - -**What happens:** -- The Minter contract accumulates rewards over time (from fees, interest, etc.) -- Periodically, someone calls `harvest()` to distribute these rewards -- The rewards are split between the two stability pools (collateral pool and leveraged pool) -- You earn rewards **proportionally** to your deposit - -**Simple analogy:** -- The system collects fees/interest in a "reward pot" -- Every so often, the pot is distributed to all stability pool depositors -- If you have 5% of the pool, you get 5% of the rewards - -**Distribution:** -- Rewards are split between collateral pool and leveraged pool based on their sizes -- A small "bounty" goes to whoever triggers the harvest (incentive for keepers) -- A small "cut" goes to the fee receiver (protocol revenue) - -## How Rewards Are Calculated - -### Proportional Distribution -- **Your share** = Your deposit / Total deposits in the pool -- **Your rewards** = Total rewards × Your share - -**Example:** -- Total pool: 1,000,000 haPB -- Your deposit: 100,000 haPB (10% of pool) -- Total rewards: 50,000 wstETH -- **Your reward: 5,000 wstETH** (10% of 50,000) - -### Time-Based Accumulation -- Rewards accumulate over time -- The longer you stay deposited, the more rewards you earn -- Rewards are calculated using a "compounding" system that tracks your share over time - -## The Two Types of Stability Pools - -### 1. **Collateral Stability Pool** -- You deposit: **haPB** (anchor tokens) -- When liquidated, you get: **wstETH** (collateral) -- Used when system needs more collateral - -### 2. **Leveraged Stability Pool** -- You deposit: **haPB** (anchor tokens) -- When liquidated, you get: **hsPB** (leveraged tokens) -- Used when system needs to adjust leverage - -## When Do You Get Rewards? - -### Liquidation Rewards -- **Trigger:** System collateral ratio drops below threshold (e.g., 1.3x) -- **Who triggers:** Anyone can call `rebalance()` (keepers/arbitrageurs) -- **Your reward:** Automatic - your deposit is converted to collateral/leveraged tokens at a favorable rate - -### Harvest Rewards -- **Trigger:** When `harvestable()` > 0 (rewards have accumulated) -- **Who triggers:** Anyone can call `harvest()` (keepers) -- **Your reward:** Distributed proportionally to all depositors - -## Important Notes - -### ✅ Benefits -- **Passive income:** Just deposit and earn rewards -- **Proportional:** Bigger deposits = bigger rewards -- **Automatic:** Rewards are calculated and distributed automatically - -### ⚠️ Risks -- **Liquidation risk:** Your tokens can be liquidated when system rebalances -- **Loss risk:** If system is unhealthy, you might get less back than you put in -- **Early withdrawal fees:** Withdrawing too early may incur fees - -### 💡 Best Practices -- **Monitor collateral ratio:** Lower ratios = more frequent rebalancing = more rewards -- **Stay deposited longer:** Rewards accumulate over time -- **Diversify:** Consider both pools for different risk/reward profiles - -## Real-World Example - -**Scenario:** -1. You deposit **100,000 haPB** into the collateral stability pool -2. Pool total: **1,000,000 haPB** (you have 10%) -3. System collateral ratio drops to 1.25x (below 1.3x threshold) -4. Someone triggers `rebalance()` -5. System liquidates **200,000 haPB** from the pool (20% of total) -6. Your share: **20,000 haPB** gets liquidated (20% of your deposit) -7. You receive: **~21,000 wstETH** (5% bonus = your reward!) -8. Your remaining deposit: **80,000 haPB** still in the pool - -**Plus:** -- If someone triggers `harvest()` and there are 10,000 wstETH rewards -- You get: **1,000 wstETH** (10% of rewards based on your remaining deposit) - -## Summary - -**Stability pool rewards = Free money for providing liquidity!** - -- Deposit your anchor tokens -- Earn rewards when system rebalances (liquidation rewards) -- Earn rewards from accumulated fees/interest (harvest rewards) -- Rewards are proportional to your deposit size -- The longer you stay, the more you earn - -It's like earning interest on a savings account, but with the potential for bonus rewards when the system needs to rebalance! - - - - - diff --git a/doc/guides/STABILITY-POOL-TRACKING-STATUS.md b/doc/guides/STABILITY-POOL-TRACKING-STATUS.md deleted file mode 100644 index 81212558..00000000 --- a/doc/guides/STABILITY-POOL-TRACKING-STATUS.md +++ /dev/null @@ -1,147 +0,0 @@ -# Stability Pool Tracking Status - -## Current Status - -✅ **Subgraph Configuration**: Added static data sources for both stability pools in `subgraph.yaml` - -- StabilityPoolCollateral: `0x3aAde2dCD2Df6a8cAc689EE797591b2913658659` -- StabilityPoolLeveraged: `0x525C7063E7C20997BaaE9bDa922159152D0e8417` - -✅ **Code Generation**: `yarn codegen` completed successfully - types generated for both pools - -❌ **Compilation**: `yarn build` is failing with AssemblyScript compiler crash in `stabilityPool.ts` - -## Issue - -The AssemblyScript compiler is crashing when compiling `stabilityPool.ts`. The error occurs during binary expression compilation in an if statement, likely related to: - -- String comparisons in `getPoolType()` function -- Or type mismatches between the two pool data sources - -## Solution Options - -### Option 1: Separate Handler Files (Recommended) - -Create separate handler files for each pool: - -- `src/stabilityPoolCollateral.ts` - handles collateral pool events -- `src/stabilityPoolLeveraged.ts` - handles leveraged pool events - -This avoids type conflicts and makes the code cleaner. - -### Option 2: Simplify Current Handler - -Further simplify `stabilityPool.ts` to remove complex logic that might be causing the compiler crash. - -### Option 3: Use Templates (Future) - -Once compilation works, we can convert to templates for dynamic pool addition. - -## Next Steps - -1. **Immediate**: Fix the compilation error in `stabilityPool.ts` -2. **Test**: Deploy subgraph and verify stability pool events are indexed -3. **Verify**: Check that marks are calculated correctly for stability pool deposits - -## Multiplier Configuration - -Currently all sources use **1.0x multiplier**: - -- Ha tokens: 1.0x (1 mark/dollar/day) -- Stability Pool Collateral: 1.0x (1 mark/dollar/day) -- Stability Pool Sail: 1.0x (1 mark/dollar/day) - -Each pool can have its own multiplier configured via the `MarksMultiplier` entity in the future. - -## Frontend Integration - -Once tracking is enabled, the frontend should query: - -```graphql -query GetAnchorLedgerMarks($userAddress: Bytes!) { - haTokenBalances(where: { user: $userAddress }) { - accumulatedMarks - } - stabilityPoolDeposits(where: { user: $userAddress }) { - accumulatedMarks - poolType - } -} -``` - -Then sum: `totalAnchorLedgerMarks = haTokenMarks + stabilityPoolMarks` - -## Current Status - -✅ **Subgraph Configuration**: Added static data sources for both stability pools in `subgraph.yaml` - -- StabilityPoolCollateral: `0x3aAde2dCD2Df6a8cAc689EE797591b2913658659` -- StabilityPoolLeveraged: `0x525C7063E7C20997BaaE9bDa922159152D0e8417` - -✅ **Code Generation**: `yarn codegen` completed successfully - types generated for both pools - -❌ **Compilation**: `yarn build` is failing with AssemblyScript compiler crash in `stabilityPool.ts` - -## Issue - -The AssemblyScript compiler is crashing when compiling `stabilityPool.ts`. The error occurs during binary expression compilation in an if statement, likely related to: - -- String comparisons in `getPoolType()` function -- Or type mismatches between the two pool data sources - -## Solution Options - -### Option 1: Separate Handler Files (Recommended) - -Create separate handler files for each pool: - -- `src/stabilityPoolCollateral.ts` - handles collateral pool events -- `src/stabilityPoolLeveraged.ts` - handles leveraged pool events - -This avoids type conflicts and makes the code cleaner. - -### Option 2: Simplify Current Handler - -Further simplify `stabilityPool.ts` to remove complex logic that might be causing the compiler crash. - -### Option 3: Use Templates (Future) - -Once compilation works, we can convert to templates for dynamic pool addition. - -## Next Steps - -1. **Immediate**: Fix the compilation error in `stabilityPool.ts` -2. **Test**: Deploy subgraph and verify stability pool events are indexed -3. **Verify**: Check that marks are calculated correctly for stability pool deposits - -## Multiplier Configuration - -Currently all sources use **1.0x multiplier**: - -- Ha tokens: 1.0x (1 mark/dollar/day) -- Stability Pool Collateral: 1.0x (1 mark/dollar/day) -- Stability Pool Sail: 1.0x (1 mark/dollar/day) - -Each pool can have its own multiplier configured via the `MarksMultiplier` entity in the future. - -## Frontend Integration - -Once tracking is enabled, the frontend should query: - -```graphql -query GetAnchorLedgerMarks($userAddress: Bytes!) { - haTokenBalances(where: { user: $userAddress }) { - accumulatedMarks - } - stabilityPoolDeposits(where: { user: $userAddress }) { - accumulatedMarks - poolType - } -} -``` - -Then sum: `totalAnchorLedgerMarks = haTokenMarks + stabilityPoolMarks` - - - - diff --git a/doc/guides/STOP-SUBGRAPH-PLAN.md b/doc/guides/STOP-SUBGRAPH-PLAN.md deleted file mode 100644 index e9630212..00000000 --- a/doc/guides/STOP-SUBGRAPH-PLAN.md +++ /dev/null @@ -1,104 +0,0 @@ -# Plan: Stop Subgraph, Keep Anvil Running - -## Current Status - -✅ **Anvil**: Running (PID 93392, port 8545) - **KEEP RUNNING** -🟡 **Graph Node Services**: Running (Docker) - **STOP THESE** - -### Docker Containers Running: -- `graph-node-local-graph-node-1` - Graph Node service -- `graph-node-local-postgres-1` - PostgreSQL database -- `graph-node-local-ipfs-1` - IPFS node - -## Plan Overview - -### What We'll Do (I can do this for you): -1. ✅ Stop Graph Node Docker services (postgres, ipfs, graph-node) -2. ✅ Verify Anvil is still running -3. ✅ Create restart script for later - -### What Will Be Preserved: -- ✅ **Anvil chain state** - All contracts, balances, transactions remain -- ✅ **Docker volumes** - Subgraph data is saved (can resume indexing later) -- ✅ **Contract addresses** - All remain the same - -### What Will Be Lost (Temporary): -- ⚠️ **Subgraph indexing** - Will pause (can resume later) -- ⚠️ **GraphQL queries** - Won't work until restarted -- ⚠️ **Subgraph sync** - Will need to catch up when restarted (but data is saved) - -## Commands to Execute - -### Step 1: Stop Graph Node Services -```bash -cd graph-node-local -docker compose down -``` - -This will: -- Stop all 3 containers (graph-node, postgres, ipfs) -- **Keep volumes intact** (data is preserved) -- Free up CPU, memory, and disk I/O - -### Step 2: Verify Anvil Still Running -```bash -pgrep -fl anvil -# Should show: 93392 anvil --host 0.0.0.0 --port 8545 -``` - -### Step 3: Verify Docker Containers Stopped -```bash -docker ps -# Should show no graph-node-local containers -``` - -## Resource Savings - -**Before:** -- Docker Desktop: ~2-4 GB RAM -- PostgreSQL: ~200-500 MB RAM -- IPFS: ~100-200 MB RAM -- Graph Node: ~500 MB - 1 GB RAM -- **Total**: ~3-6 GB RAM freed - -**After:** -- Anvil only: ~50-100 MB RAM -- **Savings**: ~3-6 GB RAM - -## Restart Later (When Needed) - -When you want to resume subgraph indexing: - -```bash -cd graph-node-local -docker compose up -d -``` - -The subgraph will: -- Resume from where it left off (data is preserved) -- Catch up to current block -- Restore GraphQL endpoint - -## What I Can Do For You - -I can execute: -1. ✅ Stop Docker services (`docker compose down`) -2. ✅ Verify Anvil is still running -3. ✅ Create a restart script for convenience -4. ✅ Document the current state - -**You need to do:** -- Nothing! Just confirm you want me to proceed. - -## Confirmation - -**Ready to proceed?** I'll: -1. Stop the Graph Node Docker services -2. Verify Anvil continues running -3. Create a restart script -4. Show you the resource savings - -**Type "yes" or "proceed" to continue, or let me know if you want to modify the plan.** - - - diff --git a/doc/guides/SUBGRAPH-DEPLOYED-SUCCESS.txt b/doc/guides/SUBGRAPH-DEPLOYED-SUCCESS.txt deleted file mode 100644 index 9fa4a286..00000000 --- a/doc/guides/SUBGRAPH-DEPLOYED-SUCCESS.txt +++ /dev/null @@ -1,16 +0,0 @@ -=== Subgraph Successfully Redeployed === - -✅ Build: Successful -✅ Deploy: Successful -✅ Version: v1.0.1 - -Configuration: -- Genesis Address: 0xA4899D35897033b927acFCf422bc745916139776 (PROXY) -- Start Block: 93 -- Network: anvil - -Endpoints: -- GraphQL: http://localhost:8000/subgraphs/name/harbor-marks-local -- Status: http://localhost:8030/graphql - -The subgraph is now indexing events from the correct Genesis contract. diff --git a/doc/guides/SUBGRAPH-MULTIPLIER-REQUIREMENTS.md b/doc/guides/SUBGRAPH-MULTIPLIER-REQUIREMENTS.md deleted file mode 100644 index 4629c47c..00000000 --- a/doc/guides/SUBGRAPH-MULTIPLIER-REQUIREMENTS.md +++ /dev/null @@ -1,87 +0,0 @@ -# Subgraph Multiplier Requirements - -## Current Status - -✅ **Schema**: `MarksMultiplier` entity exists in schema.graphql -✅ **Fields**: `accumulatedMarks`, `marksPerDay`, `lastUpdated` are already stored -✅ **Frontend Ready**: Documentation updated to query and use multipliers - -## What Needs to Be Verified in Subgraph - -### 1. Multiplier Query in Handlers - -Ensure handlers (`haToken.ts`, `stabilityPoolCollateral.ts`, `stabilityPoolLeveraged.ts`) are: - -- ✅ Querying `MarksMultiplier` entity when calculating `marksPerDay` -- ✅ Applying multiplier to the base rate (1 mark/dollar/day) -- ✅ Storing the multiplied rate in `marksPerDay` field - -**Example pattern:** -```typescript -// In accumulateMarks or similar function -const multiplier = getHaTokenMultiplier(tokenAddress, timestamp); -const baseMarksPerDollarPerDay = BigDecimal.fromString("1.0"); -const marksPerDollarPerDay = baseMarksPerDollarPerDay.times(multiplier); -const marksPerDay = balanceUSD.times(marksPerDollarPerDay); -``` - -### 2. Multiplier Lookup Functions - -Verify these functions exist and work correctly: - -- `getHaTokenMultiplier(tokenAddress, timestamp)` - Returns multiplier for ha tokens -- `getStabilityPoolMultiplier(poolAddress, poolType, timestamp)` - Returns multiplier for pools - -**Expected behavior:** -- Returns `1.0` if no multiplier found (default) -- Returns most recent multiplier for the source -- Handles multiplier changes over time correctly - -### 3. Multiplier Entity Updates - -When multipliers change, ensure: - -- New `MarksMultiplier` entity is created with new `effectiveFrom` timestamp -- Old multipliers remain in database (for historical queries) -- Handlers query for the most recent multiplier - -### 4. Schema Verification - -Verify `MarksMultiplier` entity has these fields: - -```graphql -type MarksMultiplier @entity(immutable: false) { - id: ID! # {sourceType}-{sourceAddress} or "global" - sourceType: String! # "haToken", "stabilityPoolCollateral", "stabilityPoolSail", "genesis", or "global" - sourceAddress: Bytes # Contract address (null for global) - multiplier: BigDecimal! # Multiplier (1.0 = 1 mark/dollar/day, 2.0 = 2 marks/dollar/day, etc.) - effectiveFrom: BigInt! # Block timestamp when multiplier became effective - updatedAt: BigInt! # Last update timestamp - updatedBy: Bytes # Address that updated (null if system) -} -``` - -## Testing Checklist - -- [ ] Query `marksMultipliers` from GraphQL - returns expected multipliers -- [ ] Verify `marksPerDay` includes multiplier (e.g., 2.0x multiplier = 2x marksPerDay) -- [ ] Test multiplier change: old marks preserved, new rate applied going forward -- [ ] Verify frontend estimation works correctly with multipliers - -## No Changes Needed If... - -If the handlers already: -1. Query `MarksMultiplier` when calculating `marksPerDay` -2. Apply multiplier to base rate -3. Store multiplied rate in `marksPerDay` - -Then **no subgraph changes are needed** - the frontend will automatically use the correct multipliers because `marksPerDay` already includes them. - -## Summary - -**Subgraph Status**: ✅ Ready (assuming handlers apply multipliers to `marksPerDay`) -**Frontend Status**: ✅ Ready (documentation updated) -**Action Required**: Verify handlers apply multipliers correctly (likely already done) - - - diff --git a/doc/guides/SUBGRAPH-STATUS-FOR-FRONTEND.md b/doc/guides/SUBGRAPH-STATUS-FOR-FRONTEND.md deleted file mode 100644 index 9c86b04c..00000000 --- a/doc/guides/SUBGRAPH-STATUS-FOR-FRONTEND.md +++ /dev/null @@ -1,89 +0,0 @@ -# Subgraph Status for Frontend Integration - -## ✅ No Subgraph Changes Needed! - -The subgraph **already has everything** needed for the zero-gas frontend estimation approach: - -### Required Fields (Already Exist) - -1. **`accumulatedMarks`** - Marks calculated up to the last event -2. **`marksPerDay`** - Current earning rate (already includes multiplier) -3. **`lastUpdated`** - Timestamp of last event - -### How It Works - -1. **Subgraph stores** marks when events occur (Transfer, Deposit, Withdraw) -2. **Frontend calculates** estimated marks in real-time: `estimatedMarks = accumulatedMarks + (marksPerDay × daysSinceLastUpdate)` -3. **Natural events sync** - when user transfers/deposits/withdraws, subgraph recalculates actual marks - -## Multiplier Support - -### Current Status - -- **Multipliers are already applied** by the subgraph when calculating `marksPerDay` -- Each source can have its own multiplier: - - Ha tokens: per-token multiplier - - Stability Pool Collateral: per-pool multiplier - - Stability Pool Sail: per-pool multiplier - -### How Multipliers Work - -- The subgraph queries the `MarksMultiplier` entity to get the current multiplier for each source -- When calculating `marksPerDay`, it applies: `marksPerDay = balanceUSD × baseRate × multiplier` -- The frontend receives `marksPerDay` with the multiplier already included - -### Frontend Doesn't Need to Do Anything Special - -```typescript -// marksPerDay already includes the multiplier! -const estimatedMarks = accumulatedMarks + marksPerDay * daysSinceLastUpdate; -``` - -No need to query or apply multipliers manually - the subgraph handles it! - -## Documentation Updated - -The `FRONTEND-HA-TOKEN-MARKS.md` file has been updated with: - -1. ✅ Zero-gas estimation approach -2. ✅ Multiplier querying (optional, for display purposes) -3. ✅ Examples showing how different multipliers work -4. ✅ Confirmation that no subgraph changes are needed - -## Next Steps - -1. **Frontend**: Use the updated `FRONTEND-HA-TOKEN-MARKS.md` documentation -2. **Subgraph**: No changes needed - everything is already in place -3. **Testing**: Verify that `marksPerDay` values match expected rates with multipliers - -## Example: Different Multipliers - -If you have: - -- Ha tokens: 1.0x multiplier → `marksPerDay = $100k × 1.0 = 100,000 marks/day` -- Collateral pool: 2.0x multiplier → `marksPerDay = $50k × 2.0 = 100,000 marks/day` -- Sail pool: 0.5x multiplier → `marksPerDay = $50k × 0.5 = 25,000 marks/day` - -The subgraph will return: - -```json -{ - "haTokenBalances": [ - { - "marksPerDay": "100000" // Already includes 1.0x multiplier - } - ], - "stabilityPoolDeposits": [ - { - "marksPerDay": "100000" // Already includes 2.0x multiplier - }, - { - "marksPerDay": "25000" // Already includes 0.5x multiplier - } - ] -} -``` - -Frontend just sums them - no multiplier calculation needed! - - diff --git a/doc/guides/SUBGRAPH-STOPPED-SUMMARY.md b/doc/guides/SUBGRAPH-STOPPED-SUMMARY.md deleted file mode 100644 index 12c68bcb..00000000 --- a/doc/guides/SUBGRAPH-STOPPED-SUMMARY.md +++ /dev/null @@ -1,107 +0,0 @@ -# Subgraph Stopped - Summary - -**Date**: $(date) -**Status**: ✅ Subgraph services stopped, Anvil still running - -## What Was Done - -1. ✅ Stopped Graph Node Docker services - - PostgreSQL container stopped - - IPFS container stopped - - Graph Node container stopped - -2. ✅ Verified Anvil is still running - - Anvil process confirmed active - - RPC endpoint accessible at `http://localhost:8545` - -3. ✅ Created restart script - - Location: `graph-node-local/restart-subgraph.sh` - - Usage: `./graph-node-local/restart-subgraph.sh` - -## Current State - -### Running Services -- ✅ **Anvil**: Running on port 8545 -- ✅ **Docker Desktop**: Still running (but no containers active) - -### Stopped Services -- ⏸️ **Graph Node**: Stopped -- ⏸️ **PostgreSQL**: Stopped -- ⏸️ **IPFS**: Stopped - -## Data Preservation - -✅ **All data is preserved:** -- Docker volumes remain intact -- Subgraph indexing data saved -- Anvil chain state unchanged -- Contract addresses unchanged - -When you restart, the subgraph will: -- Resume from the last indexed block -- Catch up to current chain state -- Restore all indexed data - -## Resource Savings - -**Before:** -- Docker containers: ~3-6 GB RAM -- Total: ~3-6 GB RAM - -**After:** -- Anvil only: ~50-100 MB RAM -- **Savings: ~3-6 GB RAM** 🎉 - -## Restart Instructions - -When you need the subgraph again: - -```bash -cd graph-node-local -./restart-subgraph.sh -``` - -Or manually: -```bash -cd graph-node-local -docker compose up -d -``` - -## What Still Works - -✅ **Anvil RPC**: `http://localhost:8545` -- Contract calls work -- Transactions work -- Chain state preserved - -❌ **GraphQL**: Not available (subgraph stopped) -- Frontend can't query subgraph -- Can still query contracts directly - -## Next Steps - -1. Continue building/testing your app -2. Use direct contract calls instead of subgraph queries -3. Restart subgraph when you need marks/event queries - -## Quick Reference - -**Check Anvil Status:** -```bash -pgrep -fl anvil -cast block-number --rpc-url http://localhost:8545 -``` - -**Restart Subgraph:** -```bash -./graph-node-local/restart-subgraph.sh -``` - -**View Docker Status:** -```bash -docker ps -docker compose -f graph-node-local/docker-compose.yml ps -``` - - - diff --git a/doc/guides/SUBGRAPH-SYNC-ISSUE.md b/doc/guides/SUBGRAPH-SYNC-ISSUE.md deleted file mode 100644 index 927ecb67..00000000 --- a/doc/guides/SUBGRAPH-SYNC-ISSUE.md +++ /dev/null @@ -1,180 +0,0 @@ -# Subgraph Sync Issue - Deposits Not Showing - -## 🔍 Problem Diagnosis - -**Issue**: Deposit of 100 wstETH was made, but UI shows $0 deposits. - -**Root Cause**: -- ✅ Deposit event **was emitted** on-chain at block 71 -- ❌ Subgraph is only indexed to **block 29** -- ⚠️ Subgraph is **42 blocks behind** and hasn't indexed the deposit yet - -## 📊 Current Status - -- **Anvil Current Block**: 71 -- **Subgraph Indexed Block**: 29 -- **Genesis Deployment Block**: 55 -- **Deposit Block**: 71 - -## 🔧 Solutions - -### Option 1: Wait for Subgraph to Sync (Automatic) - -The subgraph should automatically catch up. Monitor progress: - -```bash -# Check sync status -curl -X POST http://localhost:8030/graphql \ - -H "Content-Type: application/json" \ - -d '{"query":"{ indexingStatuses { subgraph chains { latestBlock { number } chainHeadBlock { number } } synced } }"}' -``` - -### Option 2: Verify Subgraph Configuration - -The subgraph may be configured with the **wrong Genesis address**. It should be: - -```yaml -network: anvil -source: - address: "0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82" # ← Correct address - startBlock: 55 # ← Correct start block -``` - -If it's using the old address (`0xDeF8a62f50BA3B9f319B473c48928595A333acba`), you need to redeploy: - -```bash -# Update subgraph.yaml with correct address and startBlock -# Then redeploy: -graph deploy --node http://localhost:8020/ \ - --ipfs http://localhost:5001 \ - harbor-marks-local -``` - -### Option 3: Check Graph Node Logs - -```bash -cd graph-node-local -docker compose logs -f graph-node -``` - -Look for errors or warnings about block processing. - -## ✅ Verification - -Once the subgraph catches up, verify with: - -```bash -# Query deposits -curl -X POST http://localhost:8000/subgraphs/name/harbor-marks-local \ - -H "Content-Type: application/json" \ - -d '{"query":"{ deposits { id user amount timestamp blockNumber } }"}' - -# Query user marks -curl -X POST http://localhost:8000/subgraphs/name/harbor-marks-local \ - -H "Content-Type: application/json" \ - -d '{"query":"{ userHarborMarks { id totalDeposited currentBalance } }"}' -``` - -## 🎯 Expected Result - -After sync, you should see: -- Deposit with amount: 100000000000000000000 (100 wstETH in wei) -- User marks with totalDeposited: 100000000000000000000 -- Current balance reflecting the deposit - ---- - -**Note**: The subgraph syncs automatically but may take a few minutes. If it's stuck, check the configuration and Graph Node logs. - - - -## 🔍 Problem Diagnosis - -**Issue**: Deposit of 100 wstETH was made, but UI shows $0 deposits. - -**Root Cause**: -- ✅ Deposit event **was emitted** on-chain at block 71 -- ❌ Subgraph is only indexed to **block 29** -- ⚠️ Subgraph is **42 blocks behind** and hasn't indexed the deposit yet - -## 📊 Current Status - -- **Anvil Current Block**: 71 -- **Subgraph Indexed Block**: 29 -- **Genesis Deployment Block**: 55 -- **Deposit Block**: 71 - -## 🔧 Solutions - -### Option 1: Wait for Subgraph to Sync (Automatic) - -The subgraph should automatically catch up. Monitor progress: - -```bash -# Check sync status -curl -X POST http://localhost:8030/graphql \ - -H "Content-Type: application/json" \ - -d '{"query":"{ indexingStatuses { subgraph chains { latestBlock { number } chainHeadBlock { number } } synced } }"}' -``` - -### Option 2: Verify Subgraph Configuration - -The subgraph may be configured with the **wrong Genesis address**. It should be: - -```yaml -network: anvil -source: - address: "0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82" # ← Correct address - startBlock: 55 # ← Correct start block -``` - -If it's using the old address (`0xDeF8a62f50BA3B9f319B473c48928595A333acba`), you need to redeploy: - -```bash -# Update subgraph.yaml with correct address and startBlock -# Then redeploy: -graph deploy --node http://localhost:8020/ \ - --ipfs http://localhost:5001 \ - harbor-marks-local -``` - -### Option 3: Check Graph Node Logs - -```bash -cd graph-node-local -docker compose logs -f graph-node -``` - -Look for errors or warnings about block processing. - -## ✅ Verification - -Once the subgraph catches up, verify with: - -```bash -# Query deposits -curl -X POST http://localhost:8000/subgraphs/name/harbor-marks-local \ - -H "Content-Type: application/json" \ - -d '{"query":"{ deposits { id user amount timestamp blockNumber } }"}' - -# Query user marks -curl -X POST http://localhost:8000/subgraphs/name/harbor-marks-local \ - -H "Content-Type: application/json" \ - -d '{"query":"{ userHarborMarks { id totalDeposited currentBalance } }"}' -``` - -## 🎯 Expected Result - -After sync, you should see: -- Deposit with amount: 100000000000000000000 (100 wstETH in wei) -- User marks with totalDeposited: 100000000000000000000 -- Current balance reflecting the deposit - ---- - -**Note**: The subgraph syncs automatically but may take a few minutes. If it's stuck, check the configuration and Graph Node logs. - - - - - diff --git a/doc/guides/SUBGRAPH-UPDATE-REQUIRED.txt b/doc/guides/SUBGRAPH-UPDATE-REQUIRED.txt deleted file mode 100644 index cbfcd638..00000000 --- a/doc/guides/SUBGRAPH-UPDATE-REQUIRED.txt +++ /dev/null @@ -1,30 +0,0 @@ -=== SUBGRAPH UPDATE REQUIRED === - -The subgraph is currently configured with an OLD Genesis address. - -Current (WRONG): -- Address: 0x6732128F9cc0c4344b2d4DC6285BCd516b7E59E6 -- Start Block: 0 - -Updated (CORRECT): -- Address: 0xA4899D35897033b927acFCf422bc745916139776 (Genesis PROXY) -- Start Block: 93 - -The subgraph.yaml has been updated. You need to: - -1. Navigate to subgraph directory: - cd /Users/andrewyoung/Harbor-App/harbor-app/subgraph - -2. Build the subgraph: - graph build - -3. Redeploy: - graph deploy --node http://localhost:8020/ \ - --ipfs http://localhost:5001 \ - harbor-marks-local \ - --version-label v1.0.1 - -Or use the quick script if available: - ./QUICK-UPDATE-SUBGRAPH.sh - -After redeployment, the subgraph will index events from the new Genesis contract. diff --git a/doc/guides/USER-MARKS-SUMMARY.txt b/doc/guides/USER-MARKS-SUMMARY.txt deleted file mode 100644 index 1e845289..00000000 --- a/doc/guides/USER-MARKS-SUMMARY.txt +++ /dev/null @@ -1,32 +0,0 @@ -=== User Marks Summary === - -Wallet: 0xae7dbb17bc40d53a6363409c6b1ed88d3cfdc31e - -Current Marks: 40,110,277.78 marks - -Breakdown: -- Deposit: 200 wstETH -- Deposit Value: $400,000 USD -- Marks Rate: 10 marks per dollar per day (during genesis) -- Time Accumulated: ~10.03 days -- Genesis Status: Ended (bonus marks included) - -Marks Sources: -- Genesis Deposits: 40,110,277.78 marks -- ha Token Balances: 0 marks (none held) -- Stability Pool Deposits: 0 marks (no deposits) - -Total Marks: 40,110,277.78 marks - -GraphQL Query: -{ - userHarborMarks(id: "0xa4899d35897033b927acfcf422bc745916139776-0xae7dbb17bc40d53a6363409c6b1ed88d3cfdc31e") { - currentMarks - totalMarksEarned - marksPerDay - bonusMarks - genesisEnded - } -} - -See FRONTEND-MARKS-DISPLAY-GUIDE.md for complete frontend integration guide. diff --git a/doc/guides/USER-TESTING-PLAN.md b/doc/guides/USER-TESTING-PLAN.md deleted file mode 100644 index 643be8c0..00000000 --- a/doc/guides/USER-TESTING-PLAN.md +++ /dev/null @@ -1,1099 +0,0 @@ -# Harbor Protocol - Comprehensive User Testing Plan - -This document provides a detailed testing plan covering all user-facing features of the Harbor protocol from an end-user perspective. - -## Testing Overview - -### Test Environment Setup -- **Network**: Local Anvil (or testnet) -- **Test Accounts**: - - Primary user account (with funds) - - Secondary user account (for multi-user scenarios) - - Admin/owner account (for governance actions) -- **Initial State**: Clean deployment with Genesis ended, tokens available - -### Testing Principles -1. **User Journey Focus**: Test complete workflows, not just individual features -2. **Real-World Scenarios**: Test common use cases and edge cases -3. **Error Handling**: Verify graceful error messages and recovery -4. **UI/UX Validation**: Check clarity, feedback, and user guidance -5. **Data Accuracy**: Verify all displayed data matches on-chain state - ---- - -## Phase 1: Initial Setup & Discovery - -### 1.1 Wallet Connection -**Objective**: Verify wallet connection works correctly - -**Test Steps**: -1. Open Harbor app -2. Click "Connect Wallet" -3. Select wallet provider (MetaMask, WalletConnect, etc.) -4. Approve connection -5. Verify wallet address displays correctly -6. Check wallet balance is shown - -**Expected Results**: -- ✅ Wallet connects successfully -- ✅ User address displays in header/nav -- ✅ Balance shows correct amount -- ✅ Network matches expected (local/testnet/mainnet) - -**Edge Cases**: -- Wrong network selected → Shows network switch prompt -- Wallet disconnected → Shows reconnection prompt -- Insufficient balance → Shows appropriate message - ---- - -### 1.2 Dashboard Overview -**Objective**: Verify main dashboard displays correctly - -**Test Steps**: -1. After connecting, view main dashboard -2. Check all key metrics display: - - Collateral ratio - - Total TVL - - Stability pool sizes - - Volatility risk indicator -3. Verify market selector (if multiple markets) -4. Check navigation menu works - -**Expected Results**: -- ✅ All metrics display with correct values -- ✅ Market selector shows available markets -- ✅ Navigation is intuitive -- ✅ Data refreshes appropriately - -**Edge Cases**: -- No data available → Shows "No data" or loading state -- Network error → Shows error message with retry option - ---- - -## Phase 2: Genesis Phase (If Applicable) - -### 2.1 View Genesis Status -**Objective**: Verify Genesis phase information displays - -**Test Steps**: -1. Navigate to Genesis page -2. Check if Genesis is active or ended -3. View Genesis details: - - Total deposits - - Time remaining (if active) - - Claimable tokens (if ended) -4. Check token allocation breakdown - -**Expected Results**: -- ✅ Genesis status clearly displayed -- ✅ All relevant information visible -- ✅ Claim button available if Genesis ended -- ✅ Historical data accurate - ---- - -### 2.2 Genesis Deposit (If Active) -**Objective**: Test depositing to Genesis - -**Test Steps**: -1. Navigate to Genesis page -2. Enter deposit amount -3. Check displayed: - - Expected ha token allocation - - Expected hs token allocation - - Fee (if any) -4. Approve token spending -5. Submit deposit transaction -6. Wait for confirmation -7. Verify deposit appears in "My Deposits" - -**Expected Results**: -- ✅ Deposit amount validation works -- ✅ Expected allocations calculated correctly -- ✅ Transaction succeeds -- ✅ Deposit reflected in UI immediately -- ✅ Balance updates correctly - -**Edge Cases**: -- Amount below minimum → Shows error -- Amount exceeds balance → Shows insufficient funds -- Transaction fails → Shows error with reason -- Genesis ends during deposit → Handles gracefully - ---- - -### 2.3 Genesis Claim (If Ended) -**Objective**: Test claiming Genesis tokens - -**Test Steps**: -1. Navigate to Genesis page -2. View claimable tokens: - - ha tokens claimable - - hs tokens claimable -3. Click "Claim" button -4. Approve transaction -5. Wait for confirmation -6. Verify tokens received in wallet - -**Expected Results**: -- ✅ Claimable amounts accurate -- ✅ Claim transaction succeeds -- ✅ Tokens appear in wallet -- ✅ UI updates to show claimed status - ---- - -## Phase 3: Minting Tokens - -### 3.1 Mint Pegged Token (haPB) -**Objective**: Test minting anchor tokens - -**Test Steps**: -1. Navigate to "Mint" page -2. Select "Mint Anchor Token (haPB)" -3. Enter collateral amount (e.g., 1 wstETH) -4. Review displayed information: - - Expected ha tokens to receive - - Current fee percentage - - Fee amount in USD - - Collateral ratio impact -5. Check fee explanation (why fee is this amount) -6. Approve collateral token spending -7. Submit mint transaction -8. Wait for confirmation -9. Verify: - - ha tokens received in wallet - - Balance updated - - Transaction history updated - -**Expected Results**: -- ✅ Fee calculation accurate -- ✅ Expected tokens match received amount -- ✅ Fee explanation clear -- ✅ Transaction succeeds -- ✅ UI updates immediately - -**Edge Cases**: -- Amount below minimum → Shows error -- Insufficient balance → Shows error -- Fee too high (system unhealthy) → Shows warning -- Transaction fails → Shows error with reason -- Slippage protection → Handles if needed - -**Test Different Collateral Ratios**: -- Test when system is healthy (low fee) -- Test when system is stressed (high fee) -- Test when system is at risk (very high fee) -- Verify fee changes appropriately - ---- - -### 3.2 Mint Leveraged Token (hsPB) -**Objective**: Test minting sail tokens - -**Test Steps**: -1. Navigate to "Mint" page -2. Select "Mint Sail Token (hsPB)" -3. Enter collateral amount -4. Review displayed information: - - Expected hs tokens to receive - - Current fee/discount - - Leverage ratio impact - - Collateral ratio impact -5. Approve collateral token spending -6. Submit mint transaction -7. Wait for confirmation -8. Verify hs tokens received - -**Expected Results**: -- ✅ Discount shown when system unhealthy (negative fee) -- ✅ Fee shown when system healthy -- ✅ Leverage impact explained -- ✅ Transaction succeeds - -**Edge Cases**: -- System very unhealthy → Shows discount (negative fee) -- System healthy → Shows normal fee -- Maximum leverage reached → Shows error - ---- - -## Phase 4: Stability Pool Deposits - -### 4.1 View Stability Pools -**Objective**: Verify stability pool information displays - -**Test Steps**: -1. Navigate to "Stability Pools" page -2. View both pools: - - Collateral Pool - - Leveraged Pool -3. Check displayed metrics for each: - - Total TVL - - Current APR - - Reward tokens available - - Number of depositors -4. Compare pools side-by-side - -**Expected Results**: -- ✅ Both pools visible -- ✅ All metrics accurate -- ✅ APR calculations correct -- ✅ Reward tokens listed - ---- - -### 4.2 Deposit to Collateral Pool -**Objective**: Test depositing ha tokens to stability pool - -**Test Steps**: -1. Navigate to Collateral Pool -2. Click "Deposit" -3. Enter deposit amount (e.g., 100 ha tokens) -4. Review displayed information: - - Minimum deposit requirement - - Expected rewards (APR) - - Reward tokens you'll earn - - Withdrawal terms (delay, fees) -5. Approve ha token spending -6. Submit deposit transaction -7. Wait for confirmation -8. Verify: - - Deposit appears in "My Positions" - - Balance updated - - Rewards section shows pending rewards - -**Expected Results**: -- ✅ Deposit succeeds -- ✅ Position appears immediately -- ✅ Rewards start accruing -- ✅ Withdrawal terms clearly explained - -**Edge Cases**: -- Amount below minimum → Shows error -- Insufficient balance → Shows error -- First deposit (pool empty) → Handles correctly -- Pool at capacity → Shows appropriate message - ---- - -### 4.3 Deposit to Leveraged Pool -**Objective**: Test depositing to leveraged pool - -**Test Steps**: -1. Navigate to Leveraged Pool -2. Follow same steps as Collateral Pool -3. Verify different reward structure (if applicable) -4. Check leverage ratio impact - -**Expected Results**: -- ✅ Deposit succeeds -- ✅ Position tracked separately -- ✅ Rewards calculated correctly - ---- - -### 4.4 View Stability Pool Position -**Objective**: Verify position details display correctly - -**Test Steps**: -1. Navigate to "My Positions" or pool detail page -2. View position information: - - Deposited amount - - Current value (USD) - - Claimable rewards (by token) - - Total rewards earned - - APR breakdown -3. Check reward tokens: - - List of all reward tokens - - Claimable amount for each - - USD value of claimable -4. Verify historical data: - - Deposit history - - Reward accrual over time - -**Expected Results**: -- ✅ All position data accurate -- ✅ Rewards update in real-time -- ✅ Historical data available -- ✅ USD values calculated correctly - ---- - -## Phase 5: Rewards & Claiming - -### 5.1 View Claimable Rewards -**Objective**: Verify rewards display correctly - -**Test Steps**: -1. Navigate to rewards section -2. View claimable rewards: - - Total claimable value (USD) - - Breakdown by reward token - - Individual token amounts - - USD value per token -3. Check reward sources: - - Harvest rewards - - Liquidation rewards (if any) -4. Verify APR display: - - Current APR - - Projected APR - - APR breakdown by reward token - -**Expected Results**: -- ✅ Claimable amounts accurate -- ✅ All reward tokens listed -- ✅ USD values correct -- ✅ APR calculations reasonable - -**Edge Cases**: -- No rewards yet → Shows "No rewards" or "0.00" -- Rewards vesting → Shows vesting progress -- Multiple reward tokens → All displayed correctly - ---- - -### 5.2 Claim Rewards -**Objective**: Test claiming rewards - -**Test Steps**: -1. Navigate to rewards section -2. View claimable rewards -3. Click "Claim" button -4. Review transaction details: - - Tokens to receive - - Amounts per token - - Gas estimate -5. Approve transaction -6. Wait for confirmation -7. Verify: - - Tokens received in wallet - - Claimable amount reset - - Transaction history updated - -**Expected Results**: -- ✅ Claim succeeds -- ✅ All reward tokens received -- ✅ Amounts match displayed -- ✅ UI updates immediately - -**Edge Cases**: -- No claimable rewards → Button disabled -- Partial claim → Handles correctly -- Claim fails → Shows error -- Multiple reward tokens → All claimed - ---- - -### 5.3 Reward Vesting Display -**Objective**: Verify vesting information displays - -**Test Steps**: -1. After deposit, view rewards section -2. Check vesting information: - - Time until next claimable amount - - Vesting progress bar - - Estimated full vesting time -3. Wait for time to pass (or advance blocks) -4. Verify claimable amount increases - -**Expected Results**: -- ✅ Vesting progress visible -- ✅ Time remaining accurate -- ✅ Claimable updates over time -- ✅ Visual feedback clear - ---- - -## Phase 6: Withdrawals - -### 6.1 Request Withdrawal -**Objective**: Test withdrawal request mechanism - -**Test Steps**: -1. Navigate to stability pool position -2. Click "Withdraw" or "Request Withdrawal" -3. Enter withdrawal amount -4. Review withdrawal information: - - Current withdrawal request status - - Early withdrawal fee (if applicable) - - Withdrawal window timing - - Fee-free window explanation -5. Submit withdrawal request -6. Wait for confirmation -7. Verify: - - Withdrawal request appears - - Timer shows time until withdrawal window - - Fee information displayed - -**Expected Results**: -- ✅ Withdrawal request succeeds -- ✅ Timer accurate -- ✅ Fee information clear -- ✅ Window timing explained - -**Edge Cases**: -- Amount exceeds balance → Shows error -- Already has withdrawal request → Shows existing request -- Within fee-free window → Shows no fee -- Outside window → Shows fee amount - ---- - -### 6.2 Wait for Withdrawal Window -**Objective**: Test waiting period - -**Test Steps**: -1. After requesting withdrawal, wait for window -2. Monitor countdown timer -3. Check fee-free window timing: - - Start time - - End time - - Current status -4. Verify UI updates as time passes - -**Expected Results**: -- ✅ Timer counts down correctly -- ✅ Window status updates -- ✅ Fee information updates -- ✅ User can see when fee-free window starts - ---- - -### 6.3 Execute Withdrawal -**Objective**: Test completing withdrawal - -**Test Steps**: -1. Wait for withdrawal window (or test with early withdrawal) -2. Navigate to withdrawal request -3. Click "Withdraw" or "Complete Withdrawal" -4. Review: - - Amount to receive - - Fee (if early) - - Expected tokens -5. Submit withdrawal transaction -6. Wait for confirmation -7. Verify: - - Tokens received in wallet - - Position updated - - Withdrawal request cleared - -**Expected Results**: -- ✅ Withdrawal succeeds -- ✅ Correct amount received -- ✅ Fee deducted (if early) -- ✅ Position updated - -**Edge Cases**: -- Early withdrawal → Fee applied correctly -- Within window → No fee -- After window → Fee applied -- Partial withdrawal → Handles correctly - ---- - -### 6.4 Cancel Withdrawal Request -**Objective**: Test canceling withdrawal request - -**Test Steps**: -1. After requesting withdrawal -2. Click "Cancel Withdrawal Request" -3. Confirm cancellation -4. Submit transaction -5. Verify request canceled - -**Expected Results**: -- ✅ Cancellation succeeds -- ✅ Request removed -- ✅ Can deposit again - ---- - -## Phase 7: Redemptions - -### 7.1 Redeem Pegged Token (haPB) -**Objective**: Test redeeming ha tokens for collateral - -**Test Steps**: -1. Navigate to "Redeem" page -2. Select "Redeem Anchor Token (haPB)" -3. Enter amount to redeem -4. Review displayed information: - - Expected collateral to receive - - Current fee/discount - - Collateral ratio impact - - Why fee is this amount -5. Approve ha token spending -6. Submit redemption transaction -7. Wait for confirmation -8. Verify collateral received - -**Expected Results**: -- ✅ Redemption succeeds -- ✅ Discount shown when system unhealthy -- ✅ Fee shown when system healthy -- ✅ Amount received matches expected - -**Edge Cases**: -- System unhealthy → Shows discount (negative fee) -- System healthy → Shows normal fee -- Insufficient balance → Shows error -- Slippage protection → Handles if needed - ---- - -### 7.2 Redeem Leveraged Token (hsPB) -**Objective**: Test redeeming hs tokens - -**Test Steps**: -1. Navigate to "Redeem" page -2. Select "Redeem Sail Token (hsPB)" -3. Enter amount to redeem -4. Review fee information -5. Submit redemption -6. Verify collateral received - -**Expected Results**: -- ✅ Redemption succeeds -- ✅ Fee structure appropriate -- ✅ Amount correct - ---- - -## Phase 8: Viewing Data & Analytics - -### 8.1 Collateral Ratio Display -**Objective**: Verify collateral ratio information - -**Test Steps**: -1. Navigate to dashboard or market page -2. View collateral ratio: - - Current ratio - - Minimum ratio - - Rebalance threshold - - Health indicator -3. Check visual indicators: - - Health status (healthy/warning/critical) - - Color coding - - Progress bars -4. Verify tooltips/explanations - -**Expected Results**: -- ✅ Ratio accurate -- ✅ Visual indicators clear -- ✅ Explanations helpful -- ✅ Updates in real-time - ---- - -### 8.2 Volatility Risk Display -**Objective**: Verify volatility risk indicator - -**Test Steps**: -1. Navigate to market page -2. View volatility risk indicator -3. Check displayed information: - - Price drop needed to drain pools - - Current safety margin - - Historical context -4. Verify calculation accuracy - -**Expected Results**: -- ✅ Risk indicator visible -- ✅ Calculation accurate -- ✅ Context provided -- ✅ Updates with pool changes - ---- - -### 8.3 Token Prices & Values -**Objective**: Verify price displays - -**Test Steps**: -1. Check all token price displays: - - ha token price (should be ~$1) - - hs token price (variable) - - Collateral price (wstETH/stETH) -2. Verify USD conversions -3. Check price sources displayed -4. Verify price updates - -**Expected Results**: -- ✅ Prices accurate -- ✅ USD conversions correct -- ✅ Price sources visible -- ✅ Updates appropriately - ---- - -### 8.4 Transaction History -**Objective**: Verify transaction history - -**Test Steps**: -1. Navigate to "History" or "Transactions" -2. View transaction list: - - All transactions - - Filter by type - - Sort by date -3. Check transaction details: - - Type (mint, redeem, deposit, withdraw, claim) - - Amounts - - Timestamp - - Transaction hash - - Status -4. Verify links to block explorer - -**Expected Results**: -- ✅ All transactions listed -- ✅ Details accurate -- ✅ Filters work -- ✅ Explorer links work - ---- - -## Phase 9: Error Handling & Edge Cases - -### 9.1 Insufficient Balance -**Objective**: Test handling of insufficient funds - -**Test Steps**: -1. Try to mint with insufficient balance -2. Try to deposit with insufficient balance -3. Try to redeem more than owned -4. Verify error messages - -**Expected Results**: -- ✅ Clear error messages -- ✅ Suggests solutions -- ✅ Prevents invalid transactions - ---- - -### 9.2 Network Issues -**Objective**: Test network error handling - -**Test Steps**: -1. Disconnect network -2. Try to perform action -3. Reconnect network -4. Verify recovery - -**Expected Results**: -- ✅ Shows network error -- ✅ Retry option available -- ✅ Recovers when reconnected - ---- - -### 9.3 Transaction Failures -**Objective**: Test transaction failure handling - -**Test Steps**: -1. Cause transaction to fail (e.g., slippage, revert) -2. Verify error message -3. Check if state is preserved -4. Verify can retry - -**Expected Results**: -- ✅ Error message clear -- ✅ Reason provided -- ✅ State not corrupted -- ✅ Can retry - ---- - -### 9.4 Slippage Protection -**Objective**: Test slippage handling - -**Test Steps**: -1. Set very low slippage tolerance -2. Try to mint/redeem -3. Verify transaction fails if slippage too high -4. Adjust slippage and retry - -**Expected Results**: -- ✅ Slippage protection works -- ✅ Clear error if exceeded -- ✅ Can adjust and retry - ---- - -### 9.5 System Health Changes -**Objective**: Test UI updates when system health changes - -**Test Steps**: -1. Perform action that changes collateral ratio -2. Verify UI updates: - - Fee changes - - Health indicator updates - - Warnings appear (if needed) -3. Check all dependent displays update - -**Expected Results**: -- ✅ UI updates immediately -- ✅ Fee changes reflected -- ✅ Health indicators accurate -- ✅ Warnings appropriate - ---- - -## Phase 10: Multi-User Scenarios - -### 10.1 Multiple Depositors -**Objective**: Test with multiple users - -**Test Steps**: -1. User 1 deposits to pool -2. User 2 deposits to pool -3. Verify both positions tracked separately -4. Check rewards distributed proportionally -5. Verify each user sees only their data - -**Expected Results**: -- ✅ Positions separate -- ✅ Rewards proportional -- ✅ Privacy maintained -- ✅ No data leakage - ---- - -### 10.2 Pool Dynamics -**Objective**: Test pool behavior with multiple users - -**Test Steps**: -1. Multiple users deposit -2. One user withdraws -3. Verify: - - Other users' positions unaffected - - Rewards recalculate correctly - - Pool TVL updates - - APR adjusts - -**Expected Results**: -- ✅ Withdrawal doesn't affect others -- ✅ Rewards recalculate -- ✅ Metrics update correctly - ---- - -## Phase 11: Advanced Features - -### 11.1 Rebalancing -**Objective**: Test rebalancing display and impact - -**Test Steps**: -1. Monitor system approaching rebalance threshold -2. Verify warnings/alerts appear -3. Trigger rebalance (or wait for it) -4. Check: - - Rebalance notification - - Impact on positions - - Liquidation rewards distributed - - System health after rebalance - -**Expected Results**: -- ✅ Warnings before rebalance -- ✅ Rebalance notification clear -- ✅ Rewards distributed correctly -- ✅ System health improves - ---- - -### 11.2 Harvest -**Objective**: Test harvest display - -**Test Steps**: -1. Monitor harvestable amount -2. Check if harvest is available -3. View harvest information: - - Harvestable amount - - Expected distribution - - Bounty and cut amounts -4. After harvest, verify: - - Rewards deposited to pools - - APR updates - - Claimable amounts increase - -**Expected Results**: -- ✅ Harvest info accurate -- ✅ Rewards distributed correctly -- ✅ APR updates -- ✅ Users benefit from harvest - ---- - -## Phase 12: Mobile & Responsive Design - -### 12.1 Mobile View -**Objective**: Test mobile responsiveness - -**Test Steps**: -1. Open app on mobile device/browser -2. Test all major features: - - Wallet connection - - Deposits - - Withdrawals - - Rewards claiming -3. Verify: - - Layout is usable - - Buttons accessible - - Data readable - - Navigation works - -**Expected Results**: -- ✅ Mobile layout functional -- ✅ All features accessible -- ✅ No horizontal scrolling -- ✅ Touch targets adequate - ---- - -### 12.2 Tablet View -**Objective**: Test tablet layout - -**Test Steps**: -1. Open app on tablet -2. Verify layout adapts -3. Check all features work - -**Expected Results**: -- ✅ Tablet layout optimized -- ✅ Features accessible - ---- - -## Phase 13: Performance & Loading - -### 13.1 Loading States -**Objective**: Test loading indicators - -**Test Steps**: -1. Perform slow operations -2. Verify loading indicators appear -3. Check loading messages are helpful -4. Verify data loads correctly - -**Expected Results**: -- ✅ Loading indicators visible -- ✅ Messages helpful -- ✅ No blank screens - ---- - -### 13.2 Data Refresh -**Objective**: Test data update frequency - -**Test Steps**: -1. Perform on-chain action -2. Verify UI updates: - - Immediately after transaction - - After block confirmation - - On periodic refresh -3. Check update frequency is reasonable - -**Expected Results**: -- ✅ Updates promptly -- ✅ Not too frequent (performance) -- ✅ Not too slow (stale data) - ---- - -## Phase 14: Documentation & Help - -### 14.1 Tooltips & Help Text -**Objective**: Verify help information - -**Test Steps**: -1. Check all tooltips (?) icons -2. Verify explanations are: - - Clear - - Accurate - - Helpful -3. Test help documentation links - -**Expected Results**: -- ✅ Tooltips present -- ✅ Explanations clear -- ✅ Documentation accessible - ---- - -### 14.2 Error Messages -**Objective**: Verify error messages are helpful - -**Test Steps**: -1. Trigger various errors -2. Verify error messages: - - Explain what went wrong - - Suggest solutions - - Are user-friendly -3. Check error recovery options - -**Expected Results**: -- ✅ Errors clear -- ✅ Solutions suggested -- ✅ Recovery possible - ---- - -## Phase 15: Security & Permissions - -### 15.1 Transaction Approvals -**Objective**: Test approval flows - -**Test Steps**: -1. Perform actions requiring approvals -2. Verify: - - Approval prompts clear - - Can approve exact amount - - Can revoke approvals - - Approval status displayed - -**Expected Results**: -- ✅ Approvals work correctly -- ✅ Can manage approvals -- ✅ Status visible - ---- - -### 15.2 Read-Only Operations -**Objective**: Test operations that don't require transactions - -**Test Steps**: -1. View all read-only data: - - Positions - - Rewards - - Market data - - History -2. Verify no unnecessary transactions -3. Check data accuracy - -**Expected Results**: -- ✅ Read operations work -- ✅ No unnecessary transactions -- ✅ Data accurate - ---- - -## Testing Checklist Summary - -### Critical Paths (Must Work) -- [ ] Wallet connection -- [ ] Mint pegged token -- [ ] Mint leveraged token -- [ ] Deposit to stability pool -- [ ] View rewards -- [ ] Claim rewards -- [ ] Request withdrawal -- [ ] Complete withdrawal -- [ ] Redeem tokens -- [ ] View positions - -### Important Features (Should Work) -- [ ] Genesis deposit/claim -- [ ] Cancel withdrawal request -- [ ] Transaction history -- [ ] Collateral ratio display -- [ ] Volatility risk indicator -- [ ] APR calculations -- [ ] Fee explanations - -### Edge Cases (Nice to Have) -- [ ] Error handling -- [ ] Network issues -- [ ] Slippage protection -- [ ] System health changes -- [ ] Multi-user scenarios - -### Polish (Quality of Life) -- [ ] Mobile responsiveness -- [ ] Loading states -- [ ] Tooltips -- [ ] Error messages -- [ ] Performance - ---- - -## Testing Schedule - -### Week 1: Core Functionality -- Days 1-2: Setup, wallet, dashboard -- Days 3-4: Minting and redemptions -- Day 5: Stability pool deposits - -### Week 2: Rewards & Withdrawals -- Days 1-2: Rewards viewing and claiming -- Days 3-4: Withdrawal requests and execution -- Day 5: Edge cases and error handling - -### Week 3: Advanced & Polish -- Days 1-2: Multi-user scenarios, rebalancing -- Days 3-4: Mobile, performance, documentation -- Day 5: Final review and bug fixes - ---- - -## Success Criteria - -### Functional -- ✅ All critical paths work end-to-end -- ✅ No data inconsistencies -- ✅ Transactions succeed when they should -- ✅ Errors handled gracefully - -### User Experience -- ✅ Interface is intuitive -- ✅ Feedback is clear -- ✅ Loading states appropriate -- ✅ Error messages helpful - -### Performance -- ✅ Page loads < 3 seconds -- ✅ Transactions submit promptly -- ✅ Data updates within 5 seconds -- ✅ No unnecessary re-renders - -### Security -- ✅ No unauthorized access -- ✅ Approvals work correctly -- ✅ Read operations don't require transactions -- ✅ User data privacy maintained - ---- - -## Reporting Template - -For each test: -- **Test ID**: [Unique identifier] -- **Feature**: [Feature name] -- **Steps**: [What was tested] -- **Expected**: [What should happen] -- **Actual**: [What actually happened] -- **Status**: [Pass/Fail/Blocked] -- **Notes**: [Additional observations] -- **Screenshots**: [If applicable] -- **Severity**: [Critical/High/Medium/Low] - ---- - -## Notes - -- Test with real transactions on testnet/local -- Document all bugs with steps to reproduce -- Test with different account balances -- Test with different system health states -- Verify all calculations match on-chain data -- Test error recovery flows -- Verify mobile experience -- Check browser console for errors -- Monitor gas usage -- Verify transaction confirmations - ---- - -*This testing plan should be executed systematically, with results documented for each phase. Adjust based on actual app features and priorities.* - - - diff --git a/doc/guides/WITHDRAWAL-MARKS-FIX.md b/doc/guides/WITHDRAWAL-MARKS-FIX.md deleted file mode 100644 index 77a9e262..00000000 --- a/doc/guides/WITHDRAWAL-MARKS-FIX.md +++ /dev/null @@ -1,290 +0,0 @@ -# Subgraph Withdrawal Marks Forfeiture Fix - -## Problem - -When users withdraw from Genesis, ALL marks are being forfeited instead of a proportional amount. If a user withdraws 50% of their deposit, they should lose 50% of their marks, not 100%. - -## Root Cause - -The subgraph code in `subgraph/src/genesis.ts` in the `handleWithdraw` function needs to ensure: - -1. Marks are accumulated BEFORE calculating forfeiture -2. Forfeiture is calculated proportionally based on withdrawal percentage -3. The deposit amount BEFORE withdrawal is used for both accumulation and percentage calculation - -## Current Code Location - -File: `subgraph/src/genesis.ts` -Function: `handleWithdraw` -Lines: 149-236 - -## Required Fix - -The code should: - -1. Store deposit and marks values BEFORE withdrawal -2. Accumulate marks using the PRE-withdrawal deposit amount -3. Calculate forfeiture proportionally: `marksForfeited = totalMarks * (withdrawalAmount / depositBeforeWithdrawal)` -4. Update marks: `currentMarks = totalMarks - marksForfeited` - -## Expected Behavior - -- User deposits 100 wstETH → accumulates marks over time -- User has 1000 marks total -- User withdraws 50 wstETH (50% of deposit) -- **Expected**: Forfeit 500 marks (50%), keep 500 marks -- **Current Bug**: Forfeits all 1000 marks (100%) - -## Implementation Details - -### Key Changes (lines 160-210) - -```typescript -// Store values BEFORE withdrawal for correct calculation -const depositBeforeWithdrawal = userMarks.currentDeposit; -const depositUSDBeforeWithdrawal = userMarks.currentDepositUSD; -const marksBeforeAccumulation = userMarks.currentMarks; - -// First, accumulate marks for the time period BEFORE withdrawal -// Use the deposit amount BEFORE withdrawal for accumulation -let marksAfterAccumulation = marksBeforeAccumulation; -if (!userMarks.genesisEnded && userMarks.genesisStartDate.gt(BigInt.fromI32(0)) && depositUSDBeforeWithdrawal.gt(BigDecimal.fromString("0"))) { - const timeSinceLastUpdate = timestamp.minus(userMarks.lastUpdated); - const timeSinceLastUpdateBD = timeSinceLastUpdate.toBigDecimal(); - const daysSinceLastUpdate = timeSinceLastUpdateBD.div(SECONDS_PER_DAY); - - // Accumulate marks for the deposit BEFORE withdrawal - const marksAccumulated = depositUSDBeforeWithdrawal.times(MARKS_PER_DOLLAR_PER_DAY).times(daysSinceLastUpdate); - marksAfterAccumulation = marksBeforeAccumulation.plus(marksAccumulated); - userMarks.totalMarksEarned = userMarks.totalMarksEarned.plus(marksAccumulated); -} - -// Calculate forfeited marks proportionally based on withdrawal -let marksForfeited = BigDecimal.fromString("0"); - -if (depositBeforeWithdrawal.gt(BigInt.fromI32(0)) && marksAfterAccumulation.gt(BigDecimal.fromString("0"))) { - const depositBeforeWithdrawalBD = depositBeforeWithdrawal.toBigDecimal(); - const amountBD = amount.toBigDecimal(); - - if (depositBeforeWithdrawalBD.gt(BigDecimal.fromString("0")) && amountBD.le(depositBeforeWithdrawalBD)) { - const withdrawalPercentage = amountBD.div(depositBeforeWithdrawalBD); - - // Forfeit marks proportional to withdrawal from the total marks (after accumulation) - marksForfeited = marksAfterAccumulation.times(withdrawalPercentage); - - // Update user marks - userMarks.currentMarks = marksAfterAccumulation.minus(marksForfeited); - userMarks.totalMarksForfeited = userMarks.totalMarksForfeited.plus(marksForfeited); - } -} -``` - -## Verification - -After redeploying, test: - -1. Make a deposit -2. Wait for marks to accumulate -3. Withdraw 50% of deposit -4. Verify only 50% of marks are forfeited - -## Deployment Status - -✅ **Fix Implemented**: Lines 160-210 in `subgraph/src/genesis.ts` -✅ **Subgraph Deployed**: Version v2 -✅ **Status**: Synced and healthy -✅ **GraphQL Endpoint**: `http://localhost:8000/subgraphs/name/harbor-marks-local` - -## Testing Steps - -1. **Deposit**: User deposits 100 wstETH - - Verify marks start accumulating - -2. **Wait**: Let marks accumulate over time - - Check `currentMarks` increases - -3. **Withdraw**: User withdraws 50 wstETH (50% of deposit) - - **Expected**: `marksForfeited` = 50% of total marks - - **Expected**: `currentMarks` = 50% of total marks remaining - -4. **Verify**: Query subgraph to confirm proportional forfeiture - -## Query Examples - -### Check User Marks Before Withdrawal -```graphql -query GetUserMarks($user: Bytes!) { - userHarborMarks(where: { user: $user }) { - id - currentMarks - totalMarksEarned - totalMarksForfeited - currentDeposit - } -} -``` - -### Check Withdrawal Event -```graphql -query GetWithdrawals($user: Bytes!) { - withdrawals(where: { user: $user }, orderBy: timestamp, orderDirection: desc) { - id - amount - marksForfeited - timestamp - } -} -``` - ---- - -**Last Updated**: After deployment of v2 -**Status**: ✅ Fix deployed and active - - - -## Problem - -When users withdraw from Genesis, ALL marks are being forfeited instead of a proportional amount. If a user withdraws 50% of their deposit, they should lose 50% of their marks, not 100%. - -## Root Cause - -The subgraph code in `subgraph/src/genesis.ts` in the `handleWithdraw` function needs to ensure: - -1. Marks are accumulated BEFORE calculating forfeiture -2. Forfeiture is calculated proportionally based on withdrawal percentage -3. The deposit amount BEFORE withdrawal is used for both accumulation and percentage calculation - -## Current Code Location - -File: `subgraph/src/genesis.ts` -Function: `handleWithdraw` -Lines: 149-236 - -## Required Fix - -The code should: - -1. Store deposit and marks values BEFORE withdrawal -2. Accumulate marks using the PRE-withdrawal deposit amount -3. Calculate forfeiture proportionally: `marksForfeited = totalMarks * (withdrawalAmount / depositBeforeWithdrawal)` -4. Update marks: `currentMarks = totalMarks - marksForfeited` - -## Expected Behavior - -- User deposits 100 wstETH → accumulates marks over time -- User has 1000 marks total -- User withdraws 50 wstETH (50% of deposit) -- **Expected**: Forfeit 500 marks (50%), keep 500 marks -- **Current Bug**: Forfeits all 1000 marks (100%) - -## Implementation Details - -### Key Changes (lines 160-210) - -```typescript -// Store values BEFORE withdrawal for correct calculation -const depositBeforeWithdrawal = userMarks.currentDeposit; -const depositUSDBeforeWithdrawal = userMarks.currentDepositUSD; -const marksBeforeAccumulation = userMarks.currentMarks; - -// First, accumulate marks for the time period BEFORE withdrawal -// Use the deposit amount BEFORE withdrawal for accumulation -let marksAfterAccumulation = marksBeforeAccumulation; -if (!userMarks.genesisEnded && userMarks.genesisStartDate.gt(BigInt.fromI32(0)) && depositUSDBeforeWithdrawal.gt(BigDecimal.fromString("0"))) { - const timeSinceLastUpdate = timestamp.minus(userMarks.lastUpdated); - const timeSinceLastUpdateBD = timeSinceLastUpdate.toBigDecimal(); - const daysSinceLastUpdate = timeSinceLastUpdateBD.div(SECONDS_PER_DAY); - - // Accumulate marks for the deposit BEFORE withdrawal - const marksAccumulated = depositUSDBeforeWithdrawal.times(MARKS_PER_DOLLAR_PER_DAY).times(daysSinceLastUpdate); - marksAfterAccumulation = marksBeforeAccumulation.plus(marksAccumulated); - userMarks.totalMarksEarned = userMarks.totalMarksEarned.plus(marksAccumulated); -} - -// Calculate forfeited marks proportionally based on withdrawal -let marksForfeited = BigDecimal.fromString("0"); - -if (depositBeforeWithdrawal.gt(BigInt.fromI32(0)) && marksAfterAccumulation.gt(BigDecimal.fromString("0"))) { - const depositBeforeWithdrawalBD = depositBeforeWithdrawal.toBigDecimal(); - const amountBD = amount.toBigDecimal(); - - if (depositBeforeWithdrawalBD.gt(BigDecimal.fromString("0")) && amountBD.le(depositBeforeWithdrawalBD)) { - const withdrawalPercentage = amountBD.div(depositBeforeWithdrawalBD); - - // Forfeit marks proportional to withdrawal from the total marks (after accumulation) - marksForfeited = marksAfterAccumulation.times(withdrawalPercentage); - - // Update user marks - userMarks.currentMarks = marksAfterAccumulation.minus(marksForfeited); - userMarks.totalMarksForfeited = userMarks.totalMarksForfeited.plus(marksForfeited); - } -} -``` - -## Verification - -After redeploying, test: - -1. Make a deposit -2. Wait for marks to accumulate -3. Withdraw 50% of deposit -4. Verify only 50% of marks are forfeited - -## Deployment Status - -✅ **Fix Implemented**: Lines 160-210 in `subgraph/src/genesis.ts` -✅ **Subgraph Deployed**: Version v2 -✅ **Status**: Synced and healthy -✅ **GraphQL Endpoint**: `http://localhost:8000/subgraphs/name/harbor-marks-local` - -## Testing Steps - -1. **Deposit**: User deposits 100 wstETH - - Verify marks start accumulating - -2. **Wait**: Let marks accumulate over time - - Check `currentMarks` increases - -3. **Withdraw**: User withdraws 50 wstETH (50% of deposit) - - **Expected**: `marksForfeited` = 50% of total marks - - **Expected**: `currentMarks` = 50% of total marks remaining - -4. **Verify**: Query subgraph to confirm proportional forfeiture - -## Query Examples - -### Check User Marks Before Withdrawal -```graphql -query GetUserMarks($user: Bytes!) { - userHarborMarks(where: { user: $user }) { - id - currentMarks - totalMarksEarned - totalMarksForfeited - currentDeposit - } -} -``` - -### Check Withdrawal Event -```graphql -query GetWithdrawals($user: Bytes!) { - withdrawals(where: { user: $user }, orderBy: timestamp, orderDirection: desc) { - id - amount - marksForfeited - timestamp - } -} -``` - ---- - -**Last Updated**: After deployment of v2 -**Status**: ✅ Fix deployed and active - - - - - diff --git a/doc/guides/contract-addresses-and-tokens.txt b/doc/guides/contract-addresses-and-tokens.txt deleted file mode 100644 index b28b04f6..00000000 --- a/doc/guides/contract-addresses-and-tokens.txt +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/doc/guides/contract-addresses-fresh-deployment.txt b/doc/guides/contract-addresses-fresh-deployment.txt deleted file mode 100644 index b28b04f6..00000000 --- a/doc/guides/contract-addresses-fresh-deployment.txt +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/doc/guides/contract-addresses-new-deployment.txt b/doc/guides/contract-addresses-new-deployment.txt deleted file mode 100644 index 58803451..00000000 --- a/doc/guides/contract-addresses-new-deployment.txt +++ /dev/null @@ -1,49 +0,0 @@ -================================================================================ -NEW DEPLOYMENT - Contract Addresses for Frontend -Forked from block: 23829220 (after problematic block) -================================================================================ - -CONTRACT ADDRESSES ------------------- -Genesis: 0xDeF8a62f50BA3B9f319B473c48928595A333acba -Minter: 0xdb9Bc1Cdc816B727d924C9ebEba73F04F26a318a -Pegged Token (haPB): 0x4c07ce6454D5340591f62fD7d3978B6f42Ef953e -Leveraged Token: 0x1687d4BDE380019748605231C956335a473Fd3dc -Reserve Pool: 0xF1a7a5060f22edA40b1A94a858995fa2bcf5E75A -Stability Pool Manager: 0xDF3201eB257FB75E57E394b53AA1A215025230Dc -Fee Receiver: 0x18903fF6E49c98615Ab741aE33b5CD202Ccc0158 -Price Oracle: 0xe0a8d99BE93AeDEe411C645999681fbb4453973e -Collateral Token (stETH): 0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84 -Wrapped Collateral (wstETH): 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0 -Stability Pool Collateral: 0x90Dd5250fD06b9E6E3d048cAF7f26Da609cb67cC -Stability Pool Leveraged: 0x93d027eCAbF0b383F61cFad54D7D8FcAE7972d33 - -TOKEN NAMES ------------ -Pegged Token: Harbor Anchored PB (haPB) -Leveraged Token: Harbor Sail hsPBxstETH (hshsPBxstETH) - -DEVELOPER ADDRESS ------------------ -0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e - -VERIFICATION ------------- -✅ Developer is owner of Genesis contract -✅ Developer has ZERO_FEE_ROLE on Minter - -NETWORK -------- -Network: anvil -Chain ID: 31337 -RPC URL: http://localhost:8545 - -GRAPHQL ENDPOINT ----------------- -http://localhost:8000/subgraphs/name/harbor-marks-local - -SUBGRAPH START BLOCK --------------------- -23829229 - -================================================================================ diff --git a/doc/guides/contracts-diagram.md b/doc/guides/contracts-diagram.md deleted file mode 100644 index df720b70..00000000 --- a/doc/guides/contracts-diagram.md +++ /dev/null @@ -1,70 +0,0 @@ -```mermaid ---- -config: - layout: elk ---- -flowchart LR - 0x1c3cA001BfD389a155682122057faDC870B0Bb0c["minter
Minter_v1
0x1c3cA001BfD389a155682122057faDC870B0Bb0c
"] - 0x40310c9F0E822697Dd9e6F44d88528aB93b529C0["stabilityPoolManager
StabilityPoolManager_v1
0x40310c9F0E822697Dd9e6F44d88528aB93b529C0
"] - 0xeA778A25b818EE9346E250Eb3f81b1439E23d711["peggedToken
MintableBurnableERC20_v1
0xeA778A25b818EE9346E250Eb3f81b1439E23d711
"] - 0xdC14b6DB8F77ea6C4B3Cc7399fE82Db013f6F012["leveragedToken
MintableBurnableERC20_v1
0xdC14b6DB8F77ea6C4B3Cc7399fE82Db013f6F012
"] - 0x02990E399E2c3EB3EE526722046C031fbc5Cf0b6["STEAM
Steam_v1
0x02990E399E2c3EB3EE526722046C031fbc5Cf0b6
"] - 0xF518E453A48d7209C5614f61E8B400A43E3db6C9["veSTEAM
VotingEscrow_v1
0xF518E453A48d7209C5614f61E8B400A43E3db6C9
"] - 0x9b4A8ceE08bbA4B8183776745Ba5d6D6206f9f3F["stakedETHWrappedPriceOracle
StakedETHWrappedPriceOracle_v1
0x9b4A8ceE08bbA4B8183776745Ba5d6D6206f9f3F
"] - 0x331c36A45306c9A63f9A004Df82eD83665825017["reservePool
ReservePool_v1
0x331c36A45306c9A63f9A004Df82eD83665825017
"] - 0x921634fd898582c7e51A2e2A5F7b93328394a408["feeReceiver
TokenDistributor_v1
0x921634fd898582c7e51A2e2A5F7b93328394a408
"] - 0xdb71d1A0CACD62e37dB898a8E5f005a02F9B885b["genesis
Genesis_v1
0xdb71d1A0CACD62e37dB898a8E5f005a02F9B885b
"] - 0xE72B348bCA4DAAD3d8886342557d581B50Bf3971["MockWrappedPriceOracle
MockWrappedPriceOracle
0xE72B348bCA4DAAD3d8886342557d581B50Bf3971
"] - 0xf386d6DCd8FC8941d6A01A64c2f268A082C1533A["gaugeController
GaugeController
0xf386d6DCd8FC8941d6A01A64c2f268A082C1533A
"] - 0x5676d41849868c68A2f7cd51746317202E851EfB["steamMinter
SteamMinter
0x5676d41849868c68A2f7cd51746317202E851EfB
"] - 0x349688fEA9C1D3631ce7ec493B986a2ABC64A9e9["stabilityPoolCollateralStake
MintableBurnableERC20_v1
0x349688fEA9C1D3631ce7ec493B986a2ABC64A9e9
"] - 0xD87De02c97F1eBd372d001fF5FD280709B0c5454["stabilityPoolCollateralGauge
LiquidityGaugeV6
0xD87De02c97F1eBd372d001fF5FD280709B0c5454
"] - 0xd131B84Df8194Aa18BB3D5044bE976362b0Bc14F["stabilityPoolCollateral
StabilityPool_v1
0xd131B84Df8194Aa18BB3D5044bE976362b0Bc14F
"] - 0xdf5Ae966f5ac119c53378473E430Dd34304107c1["stabilityPoolSteamedStake
MintableBurnableERC20_v1
0xdf5Ae966f5ac119c53378473E430Dd34304107c1
"] - 0x23b9efEC6328249538614171626feAf27031791b["stabilityPoolSteamedGauge
LiquidityGaugeV6
0x23b9efEC6328249538614171626feAf27031791b
"] - 0x4cDA739ae3b19347ADa57990eE6d0eb53A547600["stabilityPoolSteamed
StabilityPool_v1
0x4cDA739ae3b19347ADa57990eE6d0eb53A547600
"] - 0x1c3cA001BfD389a155682122057faDC870B0Bb0c -->|LEVERAGED_TOKEN| 0xdC14b6DB8F77ea6C4B3Cc7399fE82Db013f6F012 - 0x1c3cA001BfD389a155682122057faDC870B0Bb0c -->|PEGGED_TOKEN| 0xeA778A25b818EE9346E250Eb3f81b1439E23d711 - 0x1c3cA001BfD389a155682122057faDC870B0Bb0c -->|WRAPPED_COLLATERAL_TOKEN| 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0 - 0x1c3cA001BfD389a155682122057faDC870B0Bb0c -->|feeReceiver| 0x921634fd898582c7e51A2e2A5F7b93328394a408 - 0x1c3cA001BfD389a155682122057faDC870B0Bb0c -->|priceOracle| 0xE72B348bCA4DAAD3d8886342557d581B50Bf3971 - 0x1c3cA001BfD389a155682122057faDC870B0Bb0c -->|reservePool| 0x331c36A45306c9A63f9A004Df82eD83665825017 - 0x40310c9F0E822697Dd9e6F44d88528aB93b529C0 -->|LEVERAGED_TOKEN| 0xdC14b6DB8F77ea6C4B3Cc7399fE82Db013f6F012 - 0x40310c9F0E822697Dd9e6F44d88528aB93b529C0 -->|MINTER| 0x1c3cA001BfD389a155682122057faDC870B0Bb0c - 0x40310c9F0E822697Dd9e6F44d88528aB93b529C0 -->|PEGGED_TOKEN| 0xeA778A25b818EE9346E250Eb3f81b1439E23d711 - 0x40310c9F0E822697Dd9e6F44d88528aB93b529C0 -->|TREASURY| 0x3dFc49e5112005179Da613BdE5973229082dAc35 - 0x40310c9F0E822697Dd9e6F44d88528aB93b529C0 -->|WRAPPED_COLLATERAL_TOKEN| 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0 - 0x40310c9F0E822697Dd9e6F44d88528aB93b529C0 -->|feeReceiver| 0x921634fd898582c7e51A2e2A5F7b93328394a408 - 0xF518E453A48d7209C5614f61E8B400A43E3db6C9 -->|token| 0x02990E399E2c3EB3EE526722046C031fbc5Cf0b6 - 0x9b4A8ceE08bbA4B8183776745Ba5d6D6206f9f3F -->|STETH_FEED| 0xCfE54B5cD566aB89272946F602D76Ea879CAb4a8 - 0x9b4A8ceE08bbA4B8183776745Ba5d6D6206f9f3F -->|WSTETH| 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0 - 0xdb71d1A0CACD62e37dB898a8E5f005a02F9B885b -->|LEVERAGED_TOKEN| 0xdC14b6DB8F77ea6C4B3Cc7399fE82Db013f6F012 - 0xdb71d1A0CACD62e37dB898a8E5f005a02F9B885b -->|MINTER| 0x1c3cA001BfD389a155682122057faDC870B0Bb0c - 0xdb71d1A0CACD62e37dB898a8E5f005a02F9B885b -->|PEGGED_TOKEN| 0xeA778A25b818EE9346E250Eb3f81b1439E23d711 - 0xdb71d1A0CACD62e37dB898a8E5f005a02F9B885b -->|WRAPPED_COLLATERAL_TOKEN| 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0 - 0xf386d6DCd8FC8941d6A01A64c2f268A082C1533A -->|token| 0x02990E399E2c3EB3EE526722046C031fbc5Cf0b6 - 0xf386d6DCd8FC8941d6A01A64c2f268A082C1533A -->|voting_escrow| 0xF518E453A48d7209C5614f61E8B400A43E3db6C9 - 0x5676d41849868c68A2f7cd51746317202E851EfB -->|token| 0x02990E399E2c3EB3EE526722046C031fbc5Cf0b6 - 0xD87De02c97F1eBd372d001fF5FD280709B0c5454 -->|crv| 0x02990E399E2c3EB3EE526722046C031fbc5Cf0b6 - 0xD87De02c97F1eBd372d001fF5FD280709B0c5454 -->|escrow| 0xF518E453A48d7209C5614f61E8B400A43E3db6C9 - 0xD87De02c97F1eBd372d001fF5FD280709B0c5454 -->|escrow_boost| 0xF518E453A48d7209C5614f61E8B400A43E3db6C9 - 0xD87De02c97F1eBd372d001fF5FD280709B0c5454 -->|gauge_controller| 0xf386d6DCd8FC8941d6A01A64c2f268A082C1533A - 0xD87De02c97F1eBd372d001fF5FD280709B0c5454 -->|lp_token| 0x349688fEA9C1D3631ce7ec493B986a2ABC64A9e9 - 0xD87De02c97F1eBd372d001fF5FD280709B0c5454 -->|minter| 0x5676d41849868c68A2f7cd51746317202E851EfB - 0xd131B84Df8194Aa18BB3D5044bE976362b0Bc14F -->|ASSET_TOKEN| 0xeA778A25b818EE9346E250Eb3f81b1439E23d711 - 0xd131B84Df8194Aa18BB3D5044bE976362b0Bc14F -->|GAUGE_REWARD_TOKEN| 0x02990E399E2c3EB3EE526722046C031fbc5Cf0b6 - 0xd131B84Df8194Aa18BB3D5044bE976362b0Bc14F -->|GAUGE_STAKE_TOKEN| 0x349688fEA9C1D3631ce7ec493B986a2ABC64A9e9 - 0xd131B84Df8194Aa18BB3D5044bE976362b0Bc14F -->|LIQUIDATION_TOKEN| 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0 - 0xd131B84Df8194Aa18BB3D5044bE976362b0Bc14F -->|gauge| 0xD87De02c97F1eBd372d001fF5FD280709B0c5454 - 0x23b9efEC6328249538614171626feAf27031791b -->|crv| 0x02990E399E2c3EB3EE526722046C031fbc5Cf0b6 - 0x23b9efEC6328249538614171626feAf27031791b -->|escrow| 0xF518E453A48d7209C5614f61E8B400A43E3db6C9 - 0x23b9efEC6328249538614171626feAf27031791b -->|escrow_boost| 0xF518E453A48d7209C5614f61E8B400A43E3db6C9 - 0x23b9efEC6328249538614171626feAf27031791b -->|gauge_controller| 0xf386d6DCd8FC8941d6A01A64c2f268A082C1533A - 0x23b9efEC6328249538614171626feAf27031791b -->|lp_token| 0xdf5Ae966f5ac119c53378473E430Dd34304107c1 - 0x23b9efEC6328249538614171626feAf27031791b -->|minter| 0x5676d41849868c68A2f7cd51746317202E851EfB - 0x4cDA739ae3b19347ADa57990eE6d0eb53A547600 -->|ASSET_TOKEN| 0xeA778A25b818EE9346E250Eb3f81b1439E23d711 - 0x4cDA739ae3b19347ADa57990eE6d0eb53A547600 -->|GAUGE_REWARD_TOKEN| 0x02990E399E2c3EB3EE526722046C031fbc5Cf0b6 - 0x4cDA739ae3b19347ADa57990eE6d0eb53A547600 -->|GAUGE_STAKE_TOKEN| 0xdf5Ae966f5ac119c53378473E430Dd34304107c1 - 0x4cDA739ae3b19347ADa57990eE6d0eb53A547600 -->|LIQUIDATION_TOKEN| 0xdC14b6DB8F77ea6C4B3Cc7399fE82Db013f6F012 - 0x4cDA739ae3b19347ADa57990eE6d0eb53A547600 -->|gauge| 0x23b9efEC6328249538614171626feAf27031791b -``` diff --git a/doc/guides/contracts.md b/doc/guides/contracts.md deleted file mode 100644 index c84f68f9..00000000 --- a/doc/guides/contracts.md +++ /dev/null @@ -1,21 +0,0 @@ -| Contract | Contract Type | Address | -| --- | --- | --- | -| minter | [Minter_v1](https://github.com/baofinance/bao-minter/blob/main/src/minter/Minter_v1.sol) | [`0x1c3cA001BfD389a155682122057faDC870B0Bb0c`](https://etherscan.io/address/0x1c3cA001BfD389a155682122057faDC870B0Bb0c) | -| stabilityPoolManager | [StabilityPoolManager_v1](https://github.com/baofinance/bao-minter/blob/main/src/minter/StabilityPoolManager_v1.sol) | [`0x40310c9F0E822697Dd9e6F44d88528aB93b529C0`](https://etherscan.io/address/0x40310c9F0E822697Dd9e6F44d88528aB93b529C0) | -| peggedToken | [MintableBurnableERC20_v1](https://github.com/baofinance/bao-base/blob/main//src/MintableBurnableERC20_v1.sol) | [`0xeA778A25b818EE9346E250Eb3f81b1439E23d711`](https://etherscan.io/address/0xeA778A25b818EE9346E250Eb3f81b1439E23d711) | -| leveragedToken | [MintableBurnableERC20_v1](https://github.com/baofinance/bao-base/blob/main//src/MintableBurnableERC20_v1.sol) | [`0xdC14b6DB8F77ea6C4B3Cc7399fE82Db013f6F012`](https://etherscan.io/address/0xdC14b6DB8F77ea6C4B3Cc7399fE82Db013f6F012) | -| STEAM | [Steam_v1](https://github.com/baofinance/bao-minter/blob/main/src/reward/steam/Steam_v1.sol) | [`0x02990E399E2c3EB3EE526722046C031fbc5Cf0b6`](https://etherscan.io/address/0x02990E399E2c3EB3EE526722046C031fbc5Cf0b6) | -| veSTEAM | [VotingEscrow_v1](https://github.com/baofinance/bao-minter/blob/main/src/reward/voting-escrow/VotingEscrow_v1.sol) | [`0xF518E453A48d7209C5614f61E8B400A43E3db6C9`](https://etherscan.io/address/0xF518E453A48d7209C5614f61E8B400A43E3db6C9) | -| stakedETHWrappedPriceOracle | [StakedETHWrappedPriceOracle_v1](https://github.com/baofinance/bao-minter/blob/main/src/price/StakedETHWrappedPriceOracle_v1.sol) | [`0x9b4A8ceE08bbA4B8183776745Ba5d6D6206f9f3F`](https://etherscan.io/address/0x9b4A8ceE08bbA4B8183776745Ba5d6D6206f9f3F) | -| reservePool | [ReservePool_v1](https://github.com/baofinance/bao-minter/blob/main/src/minter/ReservePool_v1.sol) | [`0x331c36A45306c9A63f9A004Df82eD83665825017`](https://etherscan.io/address/0x331c36A45306c9A63f9A004Df82eD83665825017) | -| feeReceiver | [TokenDistributor_v1](https://github.com/baofinance/bao-minter/blob/main/src/minter/TokenDistributor_v1.sol) | [`0x921634fd898582c7e51A2e2A5F7b93328394a408`](https://etherscan.io/address/0x921634fd898582c7e51A2e2A5F7b93328394a408) | -| genesis | [Genesis_v1](https://github.com/baofinance/bao-minter/blob/main/src/minter/Genesis_v1.sol) | [`0xdb71d1A0CACD62e37dB898a8E5f005a02F9B885b`](https://etherscan.io/address/0xdb71d1A0CACD62e37dB898a8E5f005a02F9B885b) | -| MockWrappedPriceOracle | [MockWrappedPriceOracle](https://github.com/baofinance/bao-minter/blob/main/test/mock/MockWrappedPriceOracle.sol) | [`0xE72B348bCA4DAAD3d8886342557d581B50Bf3971`](https://etherscan.io/address/0xE72B348bCA4DAAD3d8886342557d581B50Bf3971) | -| gaugeController | [GaugeController](https://github.com/baofinance/bao-minter/blob/main/src/reward/gauge/GaugeController.vy) | [`0xf386d6DCd8FC8941d6A01A64c2f268A082C1533A`](https://etherscan.io/address/0xf386d6DCd8FC8941d6A01A64c2f268A082C1533A) | -| steamMinter | [SteamMinter](https://github.com/baofinance/bao-minter/blob/main/src/reward/steam/SteamMinter.vy) | [`0x5676d41849868c68A2f7cd51746317202E851EfB`](https://etherscan.io/address/0x5676d41849868c68A2f7cd51746317202E851EfB) | -| stabilityPoolCollateralStake | [MintableBurnableERC20_v1](https://github.com/baofinance/bao-base/blob/main//src/MintableBurnableERC20_v1.sol) | [`0x349688fEA9C1D3631ce7ec493B986a2ABC64A9e9`](https://etherscan.io/address/0x349688fEA9C1D3631ce7ec493B986a2ABC64A9e9) | -| stabilityPoolCollateralGauge | [LiquidityGaugeV6](https://github.com/baofinance/bao-minter/blob/main/src/reward/gauge/LiquidityGaugeV6.vy) | [`0xD87De02c97F1eBd372d001fF5FD280709B0c5454`](https://etherscan.io/address/0xD87De02c97F1eBd372d001fF5FD280709B0c5454) | -| stabilityPoolCollateral | [StabilityPool_v1](https://github.com/baofinance/bao-minter/blob/main/src/minter/StabilityPool_v1.sol) | [`0xd131B84Df8194Aa18BB3D5044bE976362b0Bc14F`](https://etherscan.io/address/0xd131B84Df8194Aa18BB3D5044bE976362b0Bc14F) | -| stabilityPoolSteamedStake | [MintableBurnableERC20_v1](https://github.com/baofinance/bao-base/blob/main//src/MintableBurnableERC20_v1.sol) | [`0xdf5Ae966f5ac119c53378473E430Dd34304107c1`](https://etherscan.io/address/0xdf5Ae966f5ac119c53378473E430Dd34304107c1) | -| stabilityPoolSteamedGauge | [LiquidityGaugeV6](https://github.com/baofinance/bao-minter/blob/main/src/reward/gauge/LiquidityGaugeV6.vy) | [`0x23b9efEC6328249538614171626feAf27031791b`](https://etherscan.io/address/0x23b9efEC6328249538614171626feAf27031791b) | -| stabilityPoolSteamed | [StabilityPool_v1](https://github.com/baofinance/bao-minter/blob/main/src/minter/StabilityPool_v1.sol) | [`0x4cDA739ae3b19347ADa57990eE6d0eb53A547600`](https://etherscan.io/address/0x4cDA739ae3b19347ADa57990eE6d0eb53A547600) | diff --git a/doc/guides/deployment-addresses-fresh.txt b/doc/guides/deployment-addresses-fresh.txt deleted file mode 100644 index b28b04f6..00000000 --- a/doc/guides/deployment-addresses-fresh.txt +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/doc/guides/deployment-addresses.md b/doc/guides/deployment-addresses.md deleted file mode 100644 index b28b04f6..00000000 --- a/doc/guides/deployment-addresses.md +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/doc/guides/deployment.md b/doc/guides/deployment.md new file mode 100644 index 00000000..01bc9d9b --- /dev/null +++ b/doc/guides/deployment.md @@ -0,0 +1,134 @@ +# Deployment + +## Mainnet Contract Addresses + +| Contract | Type | Address | +|----------|------|---------| +| minter | Minter_v1 | `0x1c3cA001BfD389a155682122057faDC870B0Bb0c` | +| stabilityPoolManager | StabilityPoolManager_v1 | `0x40310c9F0E822697Dd9e6F44d88528aB93b529C0` | +| peggedToken | MintableBurnableERC20_v1 | `0xeA778A25b818EE9346E250Eb3f81b1439E23d711` | +| leveragedToken | MintableBurnableERC20_v1 | `0xdC14b6DB8F77ea6C4B3Cc7399fE82Db013f6F012` | +| STEAM | Steam_v1 | `0x02990E399E2c3EB3EE526722046C031fbc5Cf0b6` | +| veSTEAM | VotingEscrow_v1 | `0xF518E453A48d7209C5614f61E8B400A43E3db6C9` | +| stakedETHWrappedPriceOracle | StakedETHWrappedPriceOracle_v1 | `0x9b4A8ceE08bbA4B8183776745Ba5d6D6206f9f3F` | +| reservePool | ReservePool_v1 | `0x331c36A45306c9A63f9A004Df82eD83665825017` | +| feeReceiver | TokenDistributor_v1 | `0x921634fd898582c7e51A2e2A5F7b93328394a408` | +| genesis | Genesis_v1 | `0xdb71d1A0CACD62e37dB898a8E5f005a02F9B885b` | +| MockWrappedPriceOracle | MockWrappedPriceOracle | `0xE72B348bCA4DAAD3d8886342557d581B50Bf3971` | +| gaugeController | GaugeController | `0xf386d6DCd8FC8941d6A01A64c2f268A082C1533A` | +| steamMinter | SteamMinter | `0x5676d41849868c68A2f7cd51746317202E851EfB` | +| stabilityPoolCollateralStake | MintableBurnableERC20_v1 | `0x349688fEA9C1D3631ce7ec493B986a2ABC64A9e9` | +| stabilityPoolCollateralGauge | LiquidityGaugeV6 | `0xD87De02c97F1eBd372d001fF5FD280709B0c5454` | +| stabilityPoolCollateral | StabilityPool_v1 | `0xd131B84Df8194Aa18BB3D5044bE976362b0Bc14F` | +| stabilityPoolSteamedStake | MintableBurnableERC20_v1 | `0xdf5Ae966f5ac119c53378473E430Dd34304107c1` | +| stabilityPoolSteamedGauge | LiquidityGaugeV6 | `0x23b9efEC6328249538614171626feAf27031791b` | +| stabilityPoolSteamed | StabilityPool_v1 | `0x4cDA739ae3b19347ADa57990eE6d0eb53A547600` | + +### External Dependencies + +- **wstETH**: `0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0` +- **Treasury**: `0x3dFc49e5112005179Da613BdE5973229082dAc35` +- **stETH Chainlink Feed**: `0xCfE54B5cD566aB89272946F602D76Ea879CAb4a8` + +## Contract Relationship Diagram + +```mermaid +flowchart LR + minter -->|LEVERAGED_TOKEN| leveragedToken + minter -->|PEGGED_TOKEN| peggedToken + minter -->|WRAPPED_COLLATERAL_TOKEN| wstETH + minter -->|feeReceiver| feeReceiver + minter -->|priceOracle| MockWrappedPriceOracle + minter -->|reservePool| reservePool + + stabilityPoolManager -->|MINTER| minter + stabilityPoolManager -->|PEGGED_TOKEN| peggedToken + stabilityPoolManager -->|LEVERAGED_TOKEN| leveragedToken + stabilityPoolManager -->|TREASURY| treasury + stabilityPoolManager -->|WRAPPED_COLLATERAL_TOKEN| wstETH + stabilityPoolManager -->|feeReceiver| feeReceiver + + genesis -->|MINTER| minter + genesis -->|PEGGED_TOKEN| peggedToken + genesis -->|LEVERAGED_TOKEN| leveragedToken + genesis -->|WRAPPED_COLLATERAL_TOKEN| wstETH + + veSTEAM -->|token| STEAM + gaugeController -->|token| STEAM + gaugeController -->|voting_escrow| veSTEAM + steamMinter -->|token| STEAM + + stabilityPoolCollateral -->|ASSET_TOKEN| peggedToken + stabilityPoolCollateral -->|LIQUIDATION_TOKEN| wstETH + stabilityPoolCollateral -->|gauge| stabilityPoolCollateralGauge + stabilityPoolCollateralGauge -->|lp_token| stabilityPoolCollateralStake + + stabilityPoolSteamed -->|ASSET_TOKEN| peggedToken + stabilityPoolSteamed -->|LIQUIDATION_TOKEN| leveragedToken + stabilityPoolSteamed -->|gauge| stabilityPoolSteamedGauge + stabilityPoolSteamedGauge -->|lp_token| stabilityPoolSteamedStake +``` + +## Diagnosing a Stuck Deployment + +### Check Docker Services +```bash +cd graph-node-local +docker compose ps +``` +All services should be "Up" and "healthy". + +### Check Graph Node Logs +```bash +docker compose logs graph-node --tail 50 +``` +Look for ERROR messages, "Block data unavailable" errors, or deployment-related messages. + +### Check Service Endpoints +```bash +curl http://localhost:8020 # Graph Node JSON-RPC +curl http://localhost:5001 # IPFS +curl http://localhost:8000 # GraphQL +``` + +### Check Anvil Connection +```bash +curl -X POST http://localhost:8545 \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' +``` + +### Common Issues + +**Block Ingestor Stuck** (repeated "Block data unavailable"): +```bash +cd graph-node-local +docker compose down -v # Remove volumes +docker compose up -d # Start fresh +``` + +**IPFS Not Responding**: +```bash +docker compose restart ipfs +``` + +**Deployment Hangs on IPFS Upload**: +```bash +graph deploy --node http://localhost:8020/ --ipfs http://localhost:5001 harbor-marks-local -v +``` + +### Manual Deployment Steps + +```bash +# Build +graph build + +# Create subgraph (if needed) +graph create --node http://localhost:8020/ harbor-marks-local + +# Deploy with verbose output +graph deploy --node http://localhost:8020/ \ + --ipfs http://localhost:5001 \ + harbor-marks-local \ + -v +``` diff --git a/doc/guides/fee-structure.md b/doc/guides/fee-structure.md new file mode 100644 index 00000000..f59523db --- /dev/null +++ b/doc/guides/fee-structure.md @@ -0,0 +1,212 @@ +# Fee Structure + +## Overview + +The Minter contract uses a health-based fee structure that dynamically adjusts fees based on the current collateral ratio. This incentivizes actions that improve system health and discourages actions that worsen it. + +## Token Types + +- **ha tokens** = Anchor (Pegged) Tokens +- **hs tokens** = Sail (Leveraged) Tokens + +## Key Principles + +1. **Minting ha tokens**: Discouraged when system is unhealthy (expensive fees) +2. **Redeeming ha tokens**: Encouraged when system is unhealthy (discounts/free) +3. **Minting hs tokens**: Encouraged when system is unhealthy (discounts) +4. **Redeeming hs tokens**: Discouraged when system is unhealthy (expensive fees/blocked) + +## Fee Tables by Collateral Ratio + +### Mint Anchor (ha) Tokens + +| Collateral Ratio | Fee | Behavior | +|-----------------|-----|----------| +| < 1.0x | **100% (BLOCKED)** | Cannot mint -- system undercollateralized | +| 1.0x - 1.05x | **50%** | Very expensive -- system at risk | +| 1.05x - 1.1x | **20%** | High fee -- system stressed | +| 1.1x - 1.2x | **10%** | Medium fee -- system recovering | +| 1.2x - 1.3x | **5%** | Low fee -- system healthy | +| 1.3x - 1.5x | **2%** | Very low fee -- system very healthy | +| 1.5x - 2.0x | **1%** | Minimal fee -- system extremely healthy | +| > 2.0x | **0.5%** | Minimal fee -- system overcollateralized | + +### Redeem Anchor (ha) Tokens + +| Collateral Ratio | Fee/Discount | Behavior | +|-----------------|--------------|----------| +| < 1.0x | **-10% (Discount)** | You get 10% bonus -- strongly encouraged | +| 1.0x - 1.05x | **-5% (Discount)** | You get 5% bonus -- encouraged | +| 1.05x - 1.1x | **0% (FREE)** | No fee -- system needs help | +| 1.1x - 1.2x | **1%** | Low fee -- system recovering | +| 1.2x - 1.3x | **2%** | Small fee -- system healthy | +| 1.3x - 1.5x | **3%** | Moderate fee -- system very healthy | +| 1.5x - 2.0x | **4%** | Higher fee -- system extremely healthy | +| > 2.0x | **5%** | Standard fee -- system overcollateralized | + +### Mint Sail (hs) Tokens + +| Collateral Ratio | Fee/Discount | Behavior | +|-----------------|--------------|----------| +| < 1.0x | **-15% (Discount)** | You get 15% bonus -- strongly encouraged | +| 1.0x - 1.05x | **-10% (Discount)** | You get 10% bonus -- encouraged | +| 1.05x - 1.1x | **-5% (Discount)** | You get 5% bonus -- small incentive | +| 1.1x - 1.2x | **-2% (Discount)** | You get 2% bonus -- minimal incentive | +| 1.2x - 1.3x | **0% (FREE)** | No fee -- system healthy | +| 1.3x - 1.5x | **1%** | Small fee -- system very healthy | +| 1.5x - 2.0x | **2%** | Moderate fee -- system extremely healthy | +| > 2.0x | **3%** | Standard fee -- system overcollateralized | + +### Redeem Sail (hs) Tokens + +| Collateral Ratio | Fee | Behavior | +|-----------------|-----|----------| +| < 1.0x | **100% (BLOCKED)** | Cannot redeem -- would worsen system health | +| 1.0x - 1.05x | **30%** | Very expensive -- system at risk | +| 1.05x - 1.1x | **15%** | High fee -- system stressed | +| 1.1x - 1.2x | **8%** | Medium-high fee -- system recovering | +| 1.2x - 1.3x | **5%** | Medium fee -- system healthy | +| 1.3x - 1.5x | **3%** | Low fee -- system very healthy | +| 1.5x - 2.0x | **2%** | Very low fee -- system extremely healthy | +| > 2.0x | **1.5%** | Minimal fee -- system overcollateralized | + +## Incentive Ratio Format + +- **Positive values**: Fees (0 to 1.0 ether = 0% to 100%) +- **Negative values**: Discounts (-1.0 to 0 ether = -100% to 0%) +- **1.0 ether**: Disallow (100% fee = blocked) +- **0 ether**: No fee, no discount + +### Validation Rules + +1. **Mint Pegged / Redeem Leveraged**: Values in [0, 1 ether]. Can have disallow (1.0 ether) at index 0. Cannot have discounts (negative values). +2. **Redeem Pegged / Mint Leveraged**: Values in (-1 ether, 1 ether). Can have discounts (negative values). Cannot have disallow (1.0 ether). + +### Collateral Ratio Bands + +- Bands are defined by `collateralRatioBandUpperBounds` +- Each band has one `incentiveRatio` +- First band must start at 1.0x (minimum collateral ratio) +- Bands must be strictly increasing + +## Example Scenarios + +### System at 1.05x (Stressed) +- Mint ha: **20% fee** (expensive) +- Redeem ha: **-5% discount** (encouraged) +- Mint hs: **-10% discount** (encouraged) +- Redeem hs: **30% fee** (discouraged) + +### System at 1.25x (Healthy) +- Mint ha: **5% fee** (reasonable) +- Redeem ha: **2% fee** (normal) +- Mint hs: **-2% discount** (small incentive) +- Redeem hs: **5% fee** (normal) + +### System at 0.98x (Undercollateralized) +- Mint ha: **BLOCKED** +- Redeem ha: **-10% discount** (strongly encouraged) +- Mint hs: **-15% discount** (strongly encouraged) +- Redeem hs: **BLOCKED** + +## Where Fees Go + +All mint/redeem fees are sent directly to the `feeReceiver` address: + +```solidity +if (wrappedFee > 0) { + IERC20(WRAPPED_COLLATERAL_TOKEN).safeTransfer($.feeReceiver, wrappedFee); +} +``` + +Fees accumulate at the `feeReceiver` address. There is no automatic distribution to stability pools. + +### Depositing Fees to Stability Pools + +Fees can be manually deposited to stability pools using the `depositReward()` function: + +```solidity +IMultipleRewardDistributor(pool).depositReward(token, amount) +``` + +**Who can call it:** +1. Owner of the stability pool +2. Any address with `REWARD_DEPOSITOR_ROLE` on the pool + +**Options for distribution:** +- **Equal split**: half to each pool +- **Proportional**: based on pool sizes +- **All to one pool**: target a specific pool + +Rewards deposited this way vest over 7 days (same as harvest rewards). + +## Harvest Bounty and Cut + +The harvest bounty and cut ratios are configurable parameters set by the contract owner. Both default to 0 and must be set after deployment. + +```solidity +uint256 bountyAmount = (harvestableAmount * harvestBountyRatio) / 1 ether; +uint256 cutAmount = (harvestableAmount * harvestCutRatio) / 1 ether; +uint256 remainder = harvestableAmount - bountyAmount - cutAmount; +``` + +| Destination | Typical Range | Recipient | +|-------------|---------------|-----------| +| **Bounty** | 1-10% | `bountyReceiver` (whoever calls `harvest()`) | +| **Cut** | 5-20% | `feeReceiver` (or treasury) | +| **Remainder** | 80-95% | Automatically deposited to stability pools | + +### Setting Ratios + +```solidity +// Owner sets bounty ratio (e.g., 5%) +stabilityPoolManager.updateHarvestBountyRatio(0.05 ether); + +// Owner sets cut ratio (e.g., 10%) +stabilityPoolManager.updateHarvestCutRatio(0.1 ether); +``` + +Both ratios must be <= 1 ether (100%) and can be updated at any time by the owner. + +## Empty System Edge Case + +When the system is empty (no pegged tokens): +1. Collateral ratio = infinity (encoded as `1e36`) +2. `_findBand()` ends up in the last band (> 2.0x) +3. Fees show 0.5% for mint ha, which may round to 0% in the UI + +Fees display correctly after the first deposit establishes a real collateral ratio. + +## Applying the Fee Configuration + +### Using the Helper Script +```bash +export MINTER_ADDRESS=0x... +export RPC_URL=http://localhost:8545 +export PRIVATE_KEY=0x... +./script/apply-fee-config.sh +``` + +### Using Forge Script +```bash +export MINTER_ADDRESS=0x... +forge script script/UpdateMinterFees.s.sol:UpdateMinterFees \ + --rpc-url http://localhost:8545 \ + --broadcast \ + --private-key 0x... +``` + +### Using Cast +```bash +cast send $MINTER_ADDRESS \ + "updateConfig(((uint256[],int256[]),(uint256[],int256[]),(uint256[],int256[]),(uint256[],int256[])))" \ + "(($MINT_PEGGED_BOUNDS,$MINT_PEGGED_RATIOS),($REDEEM_PEGGED_BOUNDS,$REDEEM_PEGGED_RATIOS),($MINT_LEVERAGED_BOUNDS,$MINT_LEVERAGED_RATIOS),($REDEEM_LEVERAGED_BOUNDS,$REDEEM_LEVERAGED_RATIOS))" \ + --rpc-url http://localhost:8545 \ + --private-key 0x... +``` + +## Configuration Files + +- **Config JSON**: `script/minter-fee-config-health-based.json` +- **Forge Script**: `script/UpdateMinterFees.s.sol` +- **Helper Script**: `script/apply-fee-config.sh` diff --git a/doc/guides/graph-node-local-setup.md b/doc/guides/graph-node-local-setup.md deleted file mode 100644 index b28b04f6..00000000 --- a/doc/guides/graph-node-local-setup.md +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/doc/guides/marks-system.md b/doc/guides/marks-system.md new file mode 100644 index 00000000..bbcd056b --- /dev/null +++ b/doc/guides/marks-system.md @@ -0,0 +1,100 @@ +# Marks System + +## Anchor Ledger Marks + +Anchor Ledger Marks represent marks earned from holding or depositing ha tokens (anchor tokens). They are the sum of two sources: + +### 1. Ha Token Holdings (Wallet Balances) +- Holding ha tokens in your wallet +- Earns: 1 mark per dollar per day (1.0x multiplier) +- Tracked via: `haTokenBalances` entity + +### 2. Stability Pool Deposits +- Depositing ha tokens in stability pools (collateral or sail pools) +- Earns: 1 mark per dollar per day (1.0x multiplier) +- Tracked via: `stabilityPoolDeposits` entity + +**Total Anchor Ledger Marks** = Ha Token Marks + Stability Pool Marks + +## Current Multipliers + +| Source | Multiplier | +|--------|-----------| +| Ha tokens | 1.0x | +| Stability Pool Collateral | 1.0x | +| Stability Pool Sail | 1.0x | +| Sail tokens (hs) | 5.0x | + +Multipliers can be configured per pool/token type via the `MarksMultiplier` entity. + +## Querying Marks + +```graphql +query GetAnchorLedgerMarks($userAddress: Bytes!) { + haTokenBalances(where: { user: $userAddress }) { + accumulatedMarks + marksPerDay + } + stabilityPoolDeposits(where: { user: $userAddress }) { + accumulatedMarks + marksPerDay + poolType + } +} +``` + +Sum: `totalAnchorLedgerMarks = haTokenMarks + stabilityPoolMarks` + +### Example + +User has: +- 200,000 ha tokens in wallet ($200,000 value) = 200,000 marks/day +- 100,000 ha tokens in stability pool ($100,000 value) = 100,000 marks/day +- **Total**: 300,000 marks/day + +## Withdrawal Marks Forfeiture + +When users withdraw from Genesis, marks are forfeited **proportionally** to the withdrawal amount: + +``` +marksForfeited = totalMarks * (withdrawalAmount / depositBeforeWithdrawal) +``` + +### Example +- User has 1000 marks total with 100 wstETH deposited +- User withdraws 50 wstETH (50% of deposit) +- Forfeit 500 marks (50%), keep 500 marks + +### Implementation + +The subgraph code in `subgraph/src/genesis.ts` (`handleWithdraw` function) must: + +1. Store deposit and marks values **before** withdrawal +2. Accumulate marks using the pre-withdrawal deposit amount +3. Calculate forfeiture proportionally: + ```typescript + const withdrawalPercentage = amountBD.div(depositBeforeWithdrawalBD); + marksForfeited = marksAfterAccumulation.times(withdrawalPercentage); + userMarks.currentMarks = marksAfterAccumulation.minus(marksForfeited); + ``` + +### Querying Withdrawal Events + +```graphql +query GetUserMarks($user: Bytes!) { + userHarborMarks(where: { user: $user }) { + currentMarks + totalMarksEarned + totalMarksForfeited + currentDeposit + } +} + +query GetWithdrawals($user: Bytes!) { + withdrawals(where: { user: $user }, orderBy: timestamp, orderDirection: desc) { + amount + marksForfeited + timestamp + } +} +``` diff --git a/doc/guides/oracle-price-feeds.md b/doc/guides/oracle-price-feeds.md new file mode 100644 index 00000000..e2735cdf --- /dev/null +++ b/doc/guides/oracle-price-feeds.md @@ -0,0 +1,82 @@ +# Oracle Price Feeds + +## Three Price Types + +The price oracle returns two prices (min and max), and the Minter derives three variants: + +| Price Type | Function | Value | Used For | +|------------|----------|-------|----------| +| **Min** | `_fetchMin()` | Lowest price | Protecting system (conservative) | +| **Mid** | `_fetchMid()` | (min + max) / 2 | Normal operations (fair) | +| **Max** | `_fetchMax()` | Highest price | User rewards (generous) | + +### When Each Is Used + +**Mid Price** (most common): +- Normal minting (`mintPeggedToken`) +- Normal redemption (`redeemPeggedToken`) +- Collateral ratio calculations +- Token price queries and most view functions + +**Max Price** (favorable to users): +- Liquidation rewards (`freeRedeemPeggedToken` during rebalancing) +- Leveraged token redemption (`freeRedeemLeveragedToken`) + +**Min Price** (conservative): +- Leveraged token minting (`freeMintLeveragedToken`) +- Operations that need to protect the system from overvaluation + +## Current Implementation + +In the current `StakedETHWrappedPriceOracle_v1` implementation: + +```solidity +minUnderlyingPrice = maxUnderlyingPrice = PriceOracle_v1.latestAnswer(feed, constraints); +``` + +All three price types return the same value because Chainlink provides a single price. The min/max design exists for future flexibility (multiple feeds, bid/ask spreads, price buffers). + +The liquidation reward advantage currently comes from **no fees** and **system health improvement**, not from a price difference. + +## Token Prices + +| Asset | Price | Source | +|-------|-------|--------| +| stETH | Chainlink stETH/USD feed | 8 decimals | +| wstETH | Chainlink wstETH/USD feed | 8 decimals | +| ha token (pegged) | $1.00 fixed peg | Not oracle-dependent | +| hs token (leveraged) | Variable: `collateralValue / leveragedTokenSupply` | Derived from CR | + +## Querying Prices + +### Chainlink Feeds (Direct) +```bash +# stETH/USD +cast call $STETH_USD_FEED "latestAnswer()(int256)" --rpc-url $RPC_URL + +# stETH/ETH +cast call $STETH_ETH_FEED "latestAnswer()(int256)" --rpc-url $RPC_URL +``` + +### Minter Price Oracle +```bash +# Get price oracle address +cast call $MINTER "priceOracle()(address)" --rpc-url $RPC_URL + +# Get latest answer (returns: minPrice, maxPrice, minRate, maxRate) +cast call $PRICE_ORACLE "latestAnswer()(uint256,uint256,uint256,uint256)" --rpc-url $RPC_URL +``` + +### Token Prices via Minter +```bash +cast call $MINTER "peggedTokenPrice()(uint256)" --rpc-url $RPC_URL +cast call $MINTER "leveragedTokenPrice()(uint256)" --rpc-url $RPC_URL +``` + +## Future Min/Max Use Cases + +The min/max design supports: +- **Multiple price feeds**: Take min/max across different Chainlink sources +- **Price spreads/buffers**: Apply a spread (e.g., +/-0.1%) for slippage/volatility +- **Bid/ask prices**: Integrate with DEX aggregators for real bid/ask spreads +- **Price uncertainty**: Use historical volatility for confidence intervals diff --git a/doc/guides/rewards.md b/doc/guides/rewards.md new file mode 100644 index 00000000..77c2a401 --- /dev/null +++ b/doc/guides/rewards.md @@ -0,0 +1,182 @@ +# Stability Pool Rewards + +## Overview + +Stability pool depositors earn rewards from two sources: liquidation rewards (during rebalancing) and harvest rewards (periodic yield distribution). + +## Two Types of Stability Pools + +### Collateral Stability Pool +- Deposit: **ha tokens** (anchor tokens) +- Liquidation payout: **wstETH** (collateral) +- Used when the system needs more collateral + +### Leveraged Stability Pool +- Deposit: **ha tokens** (anchor tokens) +- Liquidation payout: **hs tokens** (leveraged tokens) +- Used when the system needs to adjust leverage + +## Liquidation Rewards + +When the collateral ratio drops below the rebalance threshold (e.g., 1.3x), anyone can call `rebalance()`. The system takes a portion of deposited ha tokens from the stability pool and redeems them, distributing the resulting collateral/leveraged tokens back to depositors proportionally. + +### How Liquidation is Calculated + +``` +collateralOut = (peggedTokens * peggedTokenPrice) / collateralPrice +``` + +Key details: +- Liquidation uses `_fetchMax()` oracle (the highest price, most favorable to depositors) +- Liquidation calls `freeRedeemPeggedToken()` (no fees, unlike normal redemption) +- A small bounty is taken for whoever triggered the rebalance +- The remainder goes back to the stability pool + +### Net Effect + +- You receive collateral/leveraged tokens at a favorable rate (max price, no fees) +- Rebalancing improves the system's collateral ratio +- Your remaining deposit becomes more valuable as system health improves +- If the system is severely depegged (CR < 1.0), you may get back less than you put in -- this is the risk of providing liquidity + +## Harvest Rewards + +The Minter accumulates yield over time from staking rewards (wstETH rate increases). Anyone can call `harvest()` to distribute this yield to stability pools. + +### Harvest Flow + +``` +harvest() + | + v +Sweep tokens from Minter to StabilityPoolManager + | + v +Deduct bounty (to harvester) + cut (to fee receiver) + | + v +Deposit remainder to stability pools via depositReward() + | + v +Rewards enter linear vesting schedule (7 days) +``` + +### Step-by-Step Code Flow + +**Step 1**: Sweep from Minter +```solidity +ITokenHolder(MINTER).sweep(WRAPPED_COLLATERAL_TOKEN, harvestableAmount, address(this)); +``` + +**Step 2**: Calculate deductions +```solidity +uint256 bountyAmount = (harvestableAmount * $.harvestBountyRatio) / 1 ether; +uint256 cutAmount = (harvestableAmount * $.harvestCutRatio) / 1 ether; +uint256 harvestableRemaining = harvestableAmount - bountyAmount - cutAmount; +``` + +**Step 3**: Distribute +```solidity +// Bounty to harvester +IERC20(WRAPPED_COLLATERAL_TOKEN).safeTransfer(bountyReceiver, bountyAmount); +// Cut to fee receiver +IERC20(WRAPPED_COLLATERAL_TOKEN).safeTransfer(cutReceiver, cutAmount); +``` + +**Step 4**: Deposit remainder to pools +```solidity +_harvestToPool(harvestedToCollateral, _STABILITY_POOL_COLLATERAL); +_harvestToPool(harvestableRemaining - harvestedToCollateral, _STABILITY_POOL_LEVERAGED); +``` + +Where `_harvestToPool()` calls: +```solidity +IMultipleRewardDistributor(pool).depositReward(WRAPPED_COLLATERAL_TOKEN, amount); +``` + +### Distribution Split (Example: 100 wstETH) + +| Portion | Amount | Destination | +|---------|--------|-------------| +| Bounty | ~1-5% (e.g., 5 wstETH) | Harvester/keeper | +| Cut | ~1-5% (e.g., 10 wstETH) | Fee receiver / treasury | +| Remainder | ~90-98% (e.g., 85 wstETH) | Stability pools (auto-deposited) | + +The remainder is split between the collateral pool and leveraged pool proportionally to their sizes. + +### Linear Vesting + +Harvest rewards are **not immediately claimable**. They vest linearly over the reward period (typically 7 days). + +``` +claimable = (timeElapsed / REWARD_PERIOD_LENGTH) * totalRewards +``` + +| Time After Harvest | Claimable | +|--------------------|-----------| +| Day 0 | 0% | +| Day 3.5 | 50% | +| Day 7 | 100% | + +Users can view pending rewards via `claimable(user, token)` and claim via `claim()` at any time once vested. + +### Harvest vs Liquidation Comparison + +| Aspect | Liquidation Rewards | Harvest Rewards | +|--------|-------------------|-----------------| +| **Trigger** | `rebalance()` when CR < threshold | `harvest()` when yield has accumulated | +| **Distribution** | Immediate | Linear vesting (7 days) | +| **Token received** | wstETH or hs tokens | wstETH | +| **Who triggers** | Anyone (keepers/arbitrageurs) | Anyone (keepers) | + +## Checking Harvestable Amount + +### Using cast +```bash +# On the Minter +cast call "harvestable()(uint256)" --rpc-url http://localhost:8545 + +# Human-readable +cast call "harvestable()(uint256)" --rpc-url http://localhost:8545 | cast --to-unit eth +``` + +### How harvestable() Works + +The function returns the amount of wstETH that has accumulated as yield: +- The Minter holds wstETH +- Over time, the wstETH rate increases (staking rewards) +- The harvestable amount is the difference between the current wstETH balance and the original underlying collateral converted back at the current rate + +Returns 0 if no yield has accumulated yet. + +### Using TypeScript + +```typescript +const MINTER_ABI = [ + "function harvestable() external view returns (uint256 wrappedAmount)", +] as const; + +const minter = new Contract(minterAddress, MINTER_ABI, provider); +const harvestable = await minter.harvestable(); +``` + +### Getting the Full Breakdown + +```typescript +const totalHarvestable = await minter.harvestable(); +const bountyRatio = await manager.harvestBountyRatio(); +const cutRatio = await manager.harvestCutRatio(); + +const bountyAmount = (totalHarvestable * bountyRatio) / ethers.parseEther("1"); +const cutAmount = (totalHarvestable * cutRatio) / ethers.parseEther("1"); +const remainderForPools = totalHarvestable - bountyAmount - cutAmount; +``` + +## Proportional Distribution + +Your share of rewards is proportional to your deposit: + +- **Your share** = Your deposit / Total deposits in the pool +- **Your rewards** = Total rewards * Your share + +Rewards accumulate over time using a compounding system that tracks your share. diff --git a/doc/guides/risk-parameters.md b/doc/guides/risk-parameters.md new file mode 100644 index 00000000..dc6a67ee --- /dev/null +++ b/doc/guides/risk-parameters.md @@ -0,0 +1,146 @@ +# Risk Parameters + +## Data-Driven Configuration + +Market configs are data-driven -- minimum collateral ratios are set based on the largest historical single-day price movements between the collateral and pegged assets. + +- Historical analysis: BTC can drop ~20% in a single day (May 2022), ETH can see 40-50% drops in extreme events (March 2020 COVID crash) +- Rebalance threshold of 1.3x-1.4x provides a 30-40% buffer above the 1.0x minimum +- Stress tested against historical crashes and Monte Carlo simulations + +## Rebalance Threshold + +**Parameter**: `rebalanceThreshold` (StabilityPoolManager) + +| Profile | Range | Use Case | +|---------|-------|----------| +| Conservative | 1.35x - 1.4x | Volatile collateral, larger safety buffer | +| Balanced (default) | 1.3x | Good balance between safety and efficiency | +| Aggressive | 1.25x - 1.3x | Very stable collateral only | + +```solidity +stabilityPoolManager.updateRebalanceThreshold(1.35e18); // 1.35x +``` + +Monitor `collateralRatio()` vs `rebalanceThreshold()` continuously to ensure adequate buffer. + +## Stability Pool Minimums + +**Set in constructor (immutable):** + +- **MIN_DEPOSIT**: Prevents dust attacks while allowing small users. Typical: 100-1000 tokens. +- **MIN_TOTAL_ASSET_SUPPLY**: Prevents complete pool drain. Size based on expected stress scenarios. Typical: 5-10% of total pegged token supply. + +## Early Withdrawal Fees + +**Set in constructor (immutable):** + +| Parameter | Recommended | Purpose | +|-----------|-------------|---------| +| `WITHDRAWAL_START_DELAY` | 1-7 days | Prevents panic withdrawals during short-term volatility | +| `WITHDRAWAL_END_WINDOW` | 24-48 hours | Provides reasonable fee-free withdrawal window | +| `MAX_EARLY_WITHDRAWAL_FEE` | 5-10% (0.05e18 - 0.10e18) | Discourages panic exits without being unfair | + +## Oracle Constraints + +**Set in constructor (immutable) on `StakedETHWrappedPriceOracle`:** + +### Max Answer Age (Staleness) +```solidity +maxAnswerAge = 3600; // 1 hour (typical) +// Volatile markets: 1800 (30 min), Stable markets: 7200 (2 hours) +``` + +### Max Relative Deviation +```solidity +maxRelativeDeviation = 0.20e18; // 20% (typical) +// Volatile markets: 0.30e18, Stable markets: 0.15e18 +``` + +### Max Absolute Deviation +```solidity +maxAbsoluteDeviation = 1000e18; // $1000 (adjust based on asset price) +``` + +### Max Trend Reversal Deviation +```solidity +maxTrendReversalDeviation = 0.10e18; // 10% (detects suspicious reversals) +``` + +### Oracle Address +```solidity +minter.updatePriceOracle(newOracleAddress); // Requires owner role +``` + +Validation: oracle address is not zero, implements required interface, has fresh price data, constraints are appropriate. + +## Harvest Configuration + +```solidity +stabilityPoolManager.updateHarvestBountyRatio(0.02e18); // 2% +stabilityPoolManager.updateHarvestCutRatio(0.03e18); // 3% +stabilityPoolManager.updateFeeReceiver(newFeeReceiver); // Multisig recommended +``` + +| Parameter | Recommended | Purpose | +|-----------|-------------|---------| +| `harvestBountyRatio` | 1-5% | Incentivizes keepers to harvest | +| `harvestCutRatio` | 1-5% | Protocol revenue | +| `feeReceiver` | Multisig | Receives harvest cut | + +## Fee Structure Validation + +- Mint ha blocked below 1.0x +- Redeem ha has discounts below 1.1x +- Mint hs has discounts below 1.2x +- Redeem hs blocked below 1.0x +- Fees increase smoothly (no sudden jumps) +- Bands cover all possible collateral ratios + +## Configuration Best Practices + +1. **Start conservative, relax over time**: Higher thresholds, stricter oracle constraints, higher fees initially. +2. **Make changes incrementally**: One parameter at a time, test on testnet first. +3. **Multi-signature governance**: 3-of-5 or 4-of-7 signatures, timelock for major changes (48-72 hours). +4. **Continuous monitoring**: Collateral ratio, stability pool sizes, oracle error rates, fee effectiveness. + +### Alert Thresholds + +- Collateral ratio < 1.15x (approaching rebalance) +- Stability pool < 2x MIN_TOTAL_ASSET_SUPPLY +- Oracle errors > 5% of calls + +## Emergency Procedures + +### Rapid Collateral Ratio Drop +1. Verify oracle is functioning correctly +2. Check for manipulation attempts +3. Monitor stability pool sizes +4. Increase rebalance threshold if too low +5. Adjust fee structure if not working +6. Direct protocol fees to stability pools + +### Oracle Failure +1. Pause operations requiring oracle +2. Switch to backup oracle (if available) +3. Fix or replace oracle, update address +4. Resume operations gradually + +### Stability Pool Drain +1. Analyze cause (price drop, manipulation) +2. Increase rebalance threshold +3. Adjust fees to encourage deposits +4. Direct protocol revenue to pools + +## Pre-Deployment Checklist + +- [ ] Rebalance threshold set (1.3x-1.4x) +- [ ] Stability pool minimums set +- [ ] Early withdrawal fees configured +- [ ] Fee structure configured and validated +- [ ] Oracle constraints set (staleness, deviations) +- [ ] Price oracle address configured +- [ ] Reserve pool funded +- [ ] Harvest parameters configured +- [ ] Fee receiver set (multisig) +- [ ] All parameters tested on testnet diff --git a/doc/guides/sail-token-setup.md b/doc/guides/sail-token-setup.md new file mode 100644 index 00000000..84c2e61c --- /dev/null +++ b/doc/guides/sail-token-setup.md @@ -0,0 +1,136 @@ +# Sail Token Setup + +## Overview + +Sail tokens (leveraged tokens, `hs` tokens) earn marks at **5x the rate** of ha tokens (anchor tokens). + +| Token Type | Marks Rate | Default Multiplier | +|------------|------------|-------------------| +| Ha Tokens | 1 mark per dollar per day | 1.0x | +| Sail Tokens | 5 marks per dollar per day | 5.0x | +| Stability Pools | 1 mark per dollar per day | 1.0x | + +Each sail token can have its own multiplier, but the default is 5x. + +## Subgraph Schema + +Add the `SailTokenBalance` entity to `schema.graphql`: + +```graphql +type SailTokenBalance @entity(immutable: false) { + id: ID! # {tokenAddress}-{userAddress} + tokenAddress: Bytes! # Sail token contract address + user: Bytes! # User address + balance: BigInt! # Current token balance + balanceUSD: BigDecimal! # Current balance in USD + marksPerDay: BigDecimal! # Current marks per day rate (includes multiplier) + accumulatedMarks: BigDecimal! # Marks accumulated from this balance + totalMarksEarned: BigDecimal! # Total marks ever earned from this token + firstSeenAt: BigInt! # First time user had balance > 0 + lastUpdated: BigInt! # Last block timestamp when updated + marketId: String # Market identifier (optional, for grouping) +} +``` + +## Subgraph Data Source + +Add to `subgraph.yaml` in the `dataSources` section, after the `HaToken_haPB` entry: + +```yaml + - kind: ethereum + name: SailToken_hsPB + network: anvil + source: + address: "0x367761085BF3C12e5DA2Df99AC6E1a824612b8fb" + abi: ERC20 + startBlock: 93 + mapping: + kind: ethereum/events + apiVersion: 0.0.7 + language: wasm/assemblyscript + entities: + - SailTokenBalance + - MarksMultiplier + - UserTotalMarks + - PriceFeed + abis: + - name: ERC20 + file: ./abis/ERC20.json + - name: ChainlinkAggregator + file: ./abis/ChainlinkAggregator.json + eventHandlers: + - event: Transfer(indexed address,indexed address,uint256) + handler: handleSailTokenTransfer + file: ./src/sailToken.ts +``` + +Ensure there is only one `SailToken_hsPB` entry, placed after `HaToken_haPB` and before `StabilityPoolCollateral`. + +## Handler + +Create `src/sailToken.ts`. Key differences from `haToken.ts`: +- Uses `SailTokenBalance` entity instead of `HaTokenBalance` +- Default multiplier is **5.0x** instead of 1.0x +- Source type is `"sailToken"` instead of `"haToken"` + +## Build and Deploy + +```bash +cd subgraph +yarn codegen +yarn build +graph deploy --node http://localhost:8020/ --ipfs http://localhost:5001 harbor-marks-local --version-label v1.1.0 +``` + +## Multiplier Configuration + +Per-token multipliers are managed via the `MarksMultiplier` entity: + +```graphql +type MarksMultiplier @entity(immutable: false) { + id: ID! # {sourceType}-{sourceAddress} or "global" + sourceType: String! # "haToken", "sailToken", "stabilityPoolCollateral", etc. + sourceAddress: Bytes # Contract address (null for global) + multiplier: BigDecimal! # e.g., 5.0 for sail tokens + effectiveFrom: BigInt! # Block timestamp when effective + updatedAt: BigInt! + updatedBy: Bytes +} +``` + +Example multipliers: +``` +hsPB: 5.0x -> 5 marks/dollar/day +hsETH: 10.0x -> 10 marks/dollar/day +hsBTC: 3.0x -> 3 marks/dollar/day +``` + +## Frontend Integration + +The `marksPerDay` field already includes the multiplier, so the frontend does not need to apply it manually: + +```typescript +const estimatedMarks = accumulatedMarks + (marksPerDay * daysSinceLastUpdate); +``` + +### GraphQL Query + +```graphql +query GetSailTokenMarks($userAddress: Bytes!) { + sailTokenBalances(where: { user: $userAddress }) { + id + tokenAddress + balance + balanceUSD + accumulatedMarks + marksPerDay + lastUpdated + } +} +``` + +### Example Calculation + +User holds 100,000 sail tokens worth $100,000 with 5.0x multiplier: +- Marks per day: $100,000 * 5.0 = **500,000 marks/day** +- After 2 days: **1,000,000 marks** diff --git a/doc/guides/sepolia-deployment-analysis.md b/doc/guides/sepolia-deployment-analysis.md deleted file mode 100644 index b28b04f6..00000000 --- a/doc/guides/sepolia-deployment-analysis.md +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/doc/ideas/autocompounding-vault-design.md b/doc/ideas/autocompounding-vault-design.md new file mode 100644 index 00000000..5294a1d8 --- /dev/null +++ b/doc/ideas/autocompounding-vault-design.md @@ -0,0 +1,210 @@ +# Autocompounding Vault: Design & Requirements + +## 1. Overview + +The system has two layers: + +- **SP Wrappers** — one per stability pool. Each wraps a single rebasing SP token (hpXXX.YYY) into a non-rebasing ERC4626 share. Handles compounding for that one SP. + +- **Peg Vault** — one per peg (XXX). Combines all SP Wrappers for that peg plus equivalent token holdings into a single interest-bearing ERC4626 token. This is what users hold for composable, auto-compounding exposure to a peg. + +A prerequisite: making the SP a rebasing ERC20 token with transferable positions. + +## 2. Token Naming & Structure + +### Tokens + +| Token | Type | Description | Example | +|-------|------|-------------|---------| +| `haXXX` | ERC20 | Pegged token | haETH, haBTC, haUSD | +| `hpXXX.YYY` | Rebasing ERC20 | Stability pool token | hpUSD.fxUSD, hpETH.stETH, hpBTC.hsFXUSD | +| SP Wrapper share | ERC4626 | Non-rebasing wrapper for one SP | One per hpXXX.YYY | +| `wXXX1`, `wXXX2` | ERC4626 (or wrappable) | Interest-bearing equivalent tokens denominated in XXX | wstETH (ETH peg), fxSAVE (USD peg) | +| Peg Vault share | ERC4626 | Combined interest-bearing token for peg XXX | One per peg | + +### Stability Pool Naming + +`hpXXX.YYY` where XXX is the peg and YYY is the collateral or liquidation token: +- `hpXXX.col1` — collateral pool, first collateral type +- `hpXXX.lev1` — leveraged pool, first collateral type +- `hpXXX.col2` — collateral pool, second collateral type +- `hpXXX.lev2` — leveraged pool, second collateral type + +## 3. Architecture + +### Two-Layer Design + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Peg Vault (XXX) │ +│ ERC4626 share │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌────────┐ ┌────────┐ │ +│ │ SP Wrapper │ │ SP Wrapper │ │ wXXX1 │ │ wXXX2 │ │ +│ │ hpXXX.col1 │ │ hpXXX.lev1 │ │(equiv) │ │(equiv) │ │ +│ │ ERC4626 │ │ ERC4626 │ │ERC4626 │ │ERC4626 │ │ +│ └──────┬───────┘ └──────┬───────┘ └────────┘ └────────┘ │ +│ │ │ │ +│ ┌──────┴───────┐ ┌──────┴───────┐ │ +│ │ SP Wrapper │ │ SP Wrapper │ │ +│ │ hpXXX.col2 │ │ hpXXX.lev2 │ │ +│ │ ERC4626 │ │ ERC4626 │ │ +│ └──────┬───────┘ └──────┴───────┘ │ +└─────────┼──────────────────┼────────────────────────────────┘ + │ │ + ┌──────┴───────┐ ┌──────┴───────┐ + │ StabilityPool │ │ StabilityPool │ + │ hpXXX.col2 │ │ hpXXX.lev2 │ + │ Rebasing ERC20│ │ Rebasing ERC20│ + └───────────────┘ └──────────────┘ +``` + +### SP Wrapper + +One per stability pool. Wraps a single rebasing hpXXX.YYY token into a non-rebasing ERC4626 share. The SP Wrapper: + +- **Asset:** hpXXX.YYY (the rebasing SP token) +- **Compounds:** claims harvest rewards from its SP, mints haXXX via the minter, deposits back into the SP +- **Holds equivalent:** when minting fails (high fee), swaps collateral to preferred wXXX equivalent +- **Share price:** increases via compounding, decreases on SP rebalance (loss passthrough) + +Each SP Wrapper is independently compounded. The hpXXX.YYY tokens it wraps could also be wrapped in a standalone ERC4626 interface for users who want single-SP exposure without the Peg Vault. + +### Peg Vault + +One per peg. Combines all SP Wrappers for that peg into a single ERC4626 token. The Peg Vault: + +- **Holds:** SP Wrapper shares + equivalent tokens (wXXX1, wXXX2) +- **totalAssets():** sum of all SP Wrapper values + equivalent token values, priced in haXXX terms +- **Multiple entry points (EIP-7575):** accepts deposits of any hpXXX.YYY token (routed to the appropriate SP Wrapper) and issues one share token + +``` +User deposits hpXXX.col1 ──> SP Wrapper(col1) ──┐ +User deposits hpXXX.lev1 ──> SP Wrapper(lev1) ──┤──> Peg Vault(XXX) ──> vault shares +User deposits hpXXX.col2 ──> SP Wrapper(col2) ──┤ +User deposits hpXXX.lev2 ──> SP Wrapper(lev2) ──┘ +``` + +### ERC4626 Composability + +All components present an ERC4626 interface: + +- **SP Wrappers** — ERC4626 with asset = hpXXX.YYY +- **Equivalent tokens** — wXXX1, wXXX2 are ERC4626-compatible (or trivially wrappable to be so). This means the Peg Vault holds a portfolio of ERC4626 tokens. +- **Peg Vault** — ERC4626 that holds other ERC4626 tokens. A vault-of-vaults. + +This uniform interface means any ERC4626-aware protocol can integrate with any layer. + +### Contract Structure + +Whether SP Wrappers are separate contracts or internal accounting within the Peg Vault is a gas/size trade-off: + +- **Separate contracts:** cleaner separation, each SP Wrapper is independently deployable and usable. Users can hold SP Wrapper shares directly for single-SP exposure. More gas for cross-contract calls. +- **Internal accounting:** single contract, less gas, but contract size may be prohibitive. Users can't hold individual SP Wrapper shares. + +## 4. Motivation + +### Problem +SP depositors earn wrapped collateral from harvests but must manually claim and reinvest. This delivers simple interest. + +### Solution +Automate claim-convert-redeposit. Compound interest. Long-term holders benefit more. + +### Fairness Guarantee + +`totalAssets()` includes pending claimable rewards via SP's `claimable()` view function. New depositors buy at correct price — no dilution. + +## 5. Design Decisions + +### 5.1 SP as Rebasing ERC20 + +**Decision:** `balanceOf()` returns compounded real value. `totalSupply()` returns `totalAssetSupply()`. New: `transfer`, `transferFrom`, `approve`, `allowance`. + +**Why rebasing:** Non-rebasing would duplicate the SP Wrapper's role. The SP Wrapper IS the non-rebasing wrapped version. Like stETH/wstETH. + +**Transfer:** No minimum constraints — total supply invariant is preserved since transfer doesn't change total supply. + +**Approval:** Rebases downward on liquidation — approval may exceed balance. Same as stETH. + +### 5.2 SP Wrapper Valuation + +Per SP Wrapper: `totalAssets()` = `hpXXX.YYY.balanceOf(wrapper)` + pending claimable (via `claimable()` + mint dry-run) + equivalent holdings attributed to this wrapper. + +On SP liquidation, `balanceOf(wrapper)` drops automatically. Share price drops. + +### 5.3 Peg Vault Valuation + +`totalAssets()` = sum of all SP Wrapper share values + all equivalent token values, priced in haXXX terms. + +Equivalent tokens (wXXX1, wXXX2) are denominated in the same underlying as haXXX, priced via the minter's oracle. + +### 5.4 Minting: Fees and maxFeeRatio + +Use `mintPeggedToken()` with fees. Add `mintPeggedTokenCapped` with `maxFeeRatio` parameter. + +```solidity +// New: stops at fee threshold +function mintPeggedTokenCapped( + uint256 wrappedIn, address receiver, uint256 minPeggedOut, int256 maxFeeRatio +) returns (uint256 peggedOut, uint256 wrappedCollateralUsed) +``` + +### 5.5 Compound Flow + +Per SP Wrapper, independently: +``` +compound() + 1. Claim all rewards from this SP + 2. mintPeggedTokenCapped(collateral, wrapper, 0, maxFeeRatio) + 3. Deposit minted haXXX into SP + 4. Remaining collateral -> swap to preferred wXXX equivalent +``` + +At the Peg Vault level: +``` + 5. Check equivalent holdings -> if fees acceptable, convert wXXX -> haXXX -> deposit into SP +``` + +### 5.6 Compound Trigger + +StabilityPoolManager calls compound during harvest and rebalance. Also permissionless. + +### 5.7 Equivalent Token Management + +Preference-ordered list of interest-bearing tokens denominated in XXX, updatable by keeper/bot. + +**Key properties:** +- Equivalent tokens are interest-bearing, denominated in the same underlying as haXXX +- Many are already ERC4626-compatible (e.g. fxSAVE wraps fxUSD, yield-bearing). Those that aren't can be trivially wrapped. +- NOT per-collateral — equivalents are per-peg. Harvest collateral from any SP is swapped to the preferred wXXX +- The Peg Vault's portfolio is: N SP Wrapper shares + M equivalent tokens — all ERC4626 + +**User access:** +- `depositEquivalent(token, amount, receiver)` -> mint Peg Vault shares +- `withdrawEquivalent(token, shares, receiver)` -> return equivalent tokens if available + +### 5.8 Withdrawal Time Lock + +No time lock in SP Wrapper or Peg Vault. SP's existing time lock governs haXXX withdrawals. + +## 6. Access Control + +| Role | On Contract | Purpose | +|------|------------|---------| +| `KEEPER_ROLE` | Peg Vault | Swap execution + equivalent list ordering | +| Owner | Peg Vault | Configure swapper, maxFeeRatio, upgrade | +| Anyone | Both | `deposit`, `redeem`, `compound`, `convertEquivalent` | + +## 7. Contracts + +| Contract | Action | Purpose | +|----------|--------|---------| +| SP Wrapper | Create | ERC4626 per SP, compounds one SP | +| Peg Vault | Create | ERC4626 per peg, combines SP Wrappers + equivalents | +| `StabilityPool_v3` | Done | Rebasing ERC20 | +| `Minter_v2` | Modify | Add `mintPeggedTokenCapped` | +| `StabilityPoolManager_v1` | Modify | Add compound triggers | + +## 8. Future Directions + +- **On-chain APY calculation:** For automated equivalent token ordering without off-chain bot dependency. diff --git a/doc/ideas/sp-auto-compounding-harvests.md b/doc/ideas/sp-auto-compounding-harvests.md new file mode 100644 index 00000000..c558b0e2 --- /dev/null +++ b/doc/ideas/sp-auto-compounding-harvests.md @@ -0,0 +1,203 @@ +# Auto-Compounding of Harvests Within a Stability Pool + +**Status: Possible implementation — under consideration** + +## 1. Goal + +Auto-compound harvest rewards for ALL stability pool depositors directly within the SP, without requiring a wrapper or vault. When a harvest is deposited, the SP converts the collateral to haXXX and grows everyone's balance proportionally. + +## 2. Problem + +Today, harvest rewards are distributed as wrapped collateral via the reward integral, linearly over 1 week. Depositors must manually claim the collateral, mint haXXX, and deposit back. This delivers simple interest — rewards don't earn further rewards. + +Auto-compounding within the SP would give all depositors compound interest automatically. No claiming, no wrapper, no user action needed. + +## 3. Mechanism + +### Overview + +On harvest, the SP: +1. Receives wrapped collateral from the StabilityPoolManager +2. Mints haXXX from the collateral (via the Minter, with fees) +3. Distributes the minted haXXX as a reward via the integral +4. On each user's next interaction (checkpoint), the haXXX reward is collapsed into their stored balance — effectively depositing it for them + +### Two-Product Factor + +The SP currently uses a **loss product** (DecrementalFloatingPoint) to track cumulative losses. A user's compounded balance after losses is: + +``` +balance = storedAmount * currentLossProduct / userLossProduct +``` + +To support compounding, a second product is added — the **compound product** (simple uint256, scaled by 1e18). It tracks cumulative growth from auto-compounded harvests: + +``` +balance = storedAmount + * currentCompoundProduct / userCompoundProduct + * currentLossProduct / userLossProduct +``` + +### Why Two Products + +| | Loss Product | Compound Product | +|---|---|---| +| Direction | Decreases toward zero | Increases away from zero | +| Encoding | DecrementalFloatingPoint (uint128) | Simple uint256 (1e18 scaled) | +| Precision concern | Yes — approaches zero after extreme losses | No — grows, naturally precise | +| Range | ~1e-108 to 1.0 (108 decades, 36-digit precision) | 1.0 to ~1e59 (uint256/1e18 headroom) | +| Event | Rebalance (notifyLoss) | Harvest (depositRewardAndCompound) | + +The loss product requires DecrementalFloatingPoint because repeated large losses drive it toward zero where integer math loses precision. The compound product only grows — a simple uint256 has more than enough range and precision. + +### Storage + +`TokenBalance` struct gains one field: + +```solidity +struct TokenBalance { + uint128 product; // loss product (DFP, existing) + uint104 amount; // stored balance + uint40 updatedAt; // timestamp + uint256 compoundProduct; // compound product (new) +} // 2 slots (was 1) +``` + +Global `totalAssetSupply` and per-user `assetBalances` both store the compound product. + +### Balance Calculation + +```solidity +function _getCompoundedBalance( + uint256 storedAmount, + uint128 userLossProduct, + uint128 currentLossProduct, + uint256 userCompoundProduct, + uint256 currentCompoundProduct +) internal pure returns (uint256) { + // Apply compound growth + uint256 afterCompound = Math.mulDiv(storedAmount, currentCompoundProduct, userCompoundProduct); + // Apply loss shrinkage (existing DFP math) + return _scaleAdjustedValue(afterCompound, currentLossProduct, userLossProduct); +} +``` + +### Checkpoint + +On checkpoint, both products are collapsed into the stored amount: + +```solidity +function _checkpoint(address account) internal override { + // ... existing reward distribution ... + + TokenBalance memory balance = $.assetBalances[account]; + TokenBalance memory supply = $.totalAssetSupply; + + uint256 newBalance = _getCompoundedBalance( + balance.amount, + balance.product, supply.product, + balance.compoundProduct, supply.compoundProduct + ); + + balance.amount = uint104(newBalance); + balance.product = supply.product; + balance.compoundProduct = supply.compoundProduct; + balance.updatedAt = uint40(block.timestamp); + + $.assetBalances[account] = balance; +} +``` + +### Compound Event (on harvest) + +```solidity +function depositRewardAndCompound(address token, uint256 amount) external { + // Transfer collateral in + IERC20(token).safeTransferFrom(msg.sender, address(this), amount); + + // Mint haXXX from the collateral + IERC20(token).approve(MINTER, amount); + (uint256 peggedMinted, uint256 collateralUsed) = + IMinter(MINTER).mintPeggedTokenCapped(amount, address(this), 0, maxFeeRatio); + + if (peggedMinted > 0) { + // Update compound product: everyone's balance grows proportionally + TokenBalance memory supply = $.totalAssetSupply; + supply.compoundProduct = Math.mulDiv( + supply.compoundProduct, + supply.amount + uint104(peggedMinted), + supply.amount + ); + supply.amount += uint104(peggedMinted); + _recordTotalSupply(supply); + } + + // Unminted collateral: distribute as normal reward (linear, 1 week) + // Attributed to current depositors at this point in the integral + uint256 remainder = amount - collateralUsed; + if (remainder > 0) { + _notifyReward(token, remainder); + } +} +``` + +### Unminted Collateral Handling + +When minting fails partially or fully (fee too high): + +- The unminted collateral is distributed via `_notifyReward(WRAPPED_COLLATERAL, remainder)` — linear over 1 week +- This attributes it to depositors at the current point in the integral (fair to original depositors, new depositors after this point don't benefit) +- On the next harvest, the SP tries again with whatever new collateral arrives +- The previously distributed collateral is claimable by depositors (or a wrapper/vault layer converts it to equivalent tokens) + +### Interaction with assetBalanceOf + +`assetBalanceOf(account)` must include the compound product: + +```solidity +function assetBalanceOf(address account) external view returns (uint256) { + TokenBalance memory balance = $.assetBalances[account]; + TokenBalance memory supply = $.totalAssetSupply; + return _getCompoundedBalance( + balance.amount, + balance.product, supply.product, + balance.compoundProduct, supply.compoundProduct + ); +} +``` + +This means: +- The SP's position as seen by the SPM (for proportional harvest distribution) includes compound growth +- The SP gets a fair share of subsequent harvests because its effective total balance reflects compounding +- `balanceOf` (ERC20, same as `assetBalanceOf`) also reflects compound growth + +### Interaction with Reward Aliases + +Aliases are NOT needed for compound logic. The minter fee mechanism handles harvest vs liquidation naturally — the fee at mint time depends on the current CR, not the reward source. + +Aliases remain useful for **observability** — separating harvest APR from rebalance APR in a UI. + +## 4. Interaction with Wrapper / Peg Vault + +With auto-compounding in the SP: + +- **SP Wrapper** becomes thinner — it only wraps the rebasing SP token into a non-rebasing ERC4626 share. No compound logic needed since the SP compounds internally. +- **Peg Vault** only handles equivalent token management — converting unminted collateral (the fallback case) to interest-bearing tokens (wXXX). +- **Leveraged SPs** — harvest rewards are auto-compounded in the SP. Leveraged token rewards are handled separately (selective claim, wrapper/user manages them). + +## 5. Contract Size Considerations + +The SP gains: +- `depositRewardAndCompound` function (minter interaction + product update) +- Modified `_getCompoundedBalance` (one additional mulDiv) +- Modified `_checkpoint` (one additional product update) +- `compoundProduct` in TokenBalance (extra slot) + +Current SP v3: 22,534 bytes with 2,042 spare. The additional code may fit within the headroom. If not, the minting logic could be in a separate helper contract called via delegatecall, or the compound event could be triggered externally (SPM calls compound after depositReward). + +## 6. Open Questions + +- **maxFeeRatio configuration:** who sets it, how is it stored, can it be updated? +- **Selective claim:** needed for leveraged SPs so the wrapper can claim only WRAPPED_COLLATERAL for compounding. Requires adding `claim(address token)` to the SP. +- **Gas impact:** the extra storage slot per user (2 slots vs 1) increases gas for every SP interaction. Worth measuring. +- **Migration:** existing users have no `compoundProduct` stored. Default to `currentCompoundProduct` on first checkpoint (equivalent to "just joined, no compound history"). diff --git a/doc/subgraph/setup.md b/doc/subgraph/setup.md new file mode 100644 index 00000000..b6f66019 --- /dev/null +++ b/doc/subgraph/setup.md @@ -0,0 +1,207 @@ +# Subgraph Setup + +## Local Graph Node Setup + +### Prerequisites +- Docker and Docker Compose +- Anvil running on port 8545 + +### Starting Services + +```bash +cd graph-node-local +docker compose up -d +``` + +This starts: +- **Graph Node**: Indexes blockchain events +- **PostgreSQL**: Stores indexed data +- **IPFS**: Stores subgraph manifests + +### Service Endpoints + +| Service | URL | +|---------|-----| +| Graph Node JSON-RPC | http://localhost:8020 | +| GraphQL queries | http://localhost:8000 | +| IPFS | http://localhost:5001 | +| Index status | http://localhost:8030 | + +### Stopping Services (Preserving Data) + +```bash +cd graph-node-local +docker compose down # Keeps volumes (data preserved) +``` + +### Full Reset + +```bash +cd graph-node-local +docker compose down -v # Removes volumes (fresh start) +docker compose up -d +``` + +### Restarting + +```bash +cd graph-node-local +docker compose up -d +``` + +The subgraph will resume from the last indexed block and catch up to current chain state. + +## Deploying the Subgraph + +### Build and Deploy + +```bash +cd subgraph +yarn codegen +yarn build +graph deploy --node http://localhost:8020/ --ipfs http://localhost:5001 harbor-marks-local --version-label v1.0.0 +``` + +### Creating the Subgraph (First Time) + +```bash +graph create --node http://localhost:8020/ harbor-marks-local +``` + +## Checking Sync Status + +```bash +curl -X POST http://localhost:8030/graphql \ + -H "Content-Type: application/json" \ + -d '{"query":"{ indexingStatuses { subgraph chains { latestBlock { number } chainHeadBlock { number } } synced } }"}' +``` + +Check that `latestBlock` is close to `chainHeadBlock` and `synced` is `true`. + +## Troubleshooting Sync Issues + +### Subgraph Behind / Not Indexing + +If the subgraph is behind by many blocks: + +1. **Check the subgraph configuration** -- ensure the contract address and `startBlock` in `subgraph.yaml` match your deployment +2. **Check Graph Node logs**: + ```bash + docker compose logs -f graph-node + ``` +3. **Verify Anvil is reachable**: + ```bash + curl -X POST http://localhost:8545 \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' + ``` + +### Block Ingestor Stuck + +If logs show repeated "Block data unavailable" errors, Graph Node has cached block data from a previous deployment: + +```bash +cd graph-node-local +docker compose down -v +docker compose up -d +``` + +Then redeploy the subgraph. + +### IPFS Issues + +If deployment hangs on "Uploading to IPFS": + +```bash +docker compose restart ipfs +``` + +Then retry the deployment with verbose output: +```bash +graph deploy --node http://localhost:8020/ --ipfs http://localhost:5001 harbor-marks-local -v +``` + +## Multiplier Requirements + +The subgraph tracks marks with configurable multipliers. Key fields stored: + +- `accumulatedMarks`: Marks calculated up to the last event +- `marksPerDay`: Current earning rate (includes multiplier) +- `lastUpdated`: Timestamp of last event + +### How Multipliers Work + +- The subgraph queries the `MarksMultiplier` entity to get the current multiplier for each source +- When calculating `marksPerDay`, it applies: `marksPerDay = balanceUSD * baseRate * multiplier` +- The frontend receives `marksPerDay` with the multiplier already included + +### MarksMultiplier Entity + +```graphql +type MarksMultiplier @entity(immutable: false) { + id: ID! # {sourceType}-{sourceAddress} or "global" + sourceType: String! # "haToken", "sailToken", "stabilityPoolCollateral", etc. + sourceAddress: Bytes + multiplier: BigDecimal! # e.g., 1.0 for ha tokens, 5.0 for sail tokens + effectiveFrom: BigInt! + updatedAt: BigInt! + updatedBy: Bytes +} +``` + +### Frontend Estimation + +The frontend calculates estimated marks in real-time without gas costs: + +```typescript +const estimatedMarks = accumulatedMarks + (marksPerDay * daysSinceLastUpdate); +``` + +No need to apply multipliers on the frontend -- the subgraph handles it. + +## Sail Token Subgraph Integration + +To add sail token marks tracking: + +1. Add `SailTokenBalance` entity to `schema.graphql` +2. Add `SailToken_hsPB` data source to `subgraph.yaml` (after `HaToken_haPB`) +3. Create `src/sailToken.ts` handler (mirrors `haToken.ts` with 5.0x default multiplier) +4. Run `yarn codegen && yarn build` +5. Deploy with new version label + +See [Sail Token Setup](../guides/sail-token-setup.md) for full details. + +## Querying the Subgraph + +### Check Deposits +```bash +curl -X POST http://localhost:8000/subgraphs/name/harbor-marks-local \ + -H "Content-Type: application/json" \ + -d '{"query":"{ deposits { id user amount timestamp blockNumber } }"}' +``` + +### Check User Marks +```bash +curl -X POST http://localhost:8000/subgraphs/name/harbor-marks-local \ + -H "Content-Type: application/json" \ + -d '{"query":"{ userHarborMarks { id totalDeposited currentBalance } }"}' +``` + +### Sail Token Marks +```graphql +query GetSailTokenMarks($userAddress: Bytes!) { + sailTokenBalances(where: { user: $userAddress }) { + id + tokenAddress + balance + balanceUSD + accumulatedMarks + marksPerDay + lastUpdated + } +} +``` + +## Resource Usage + +Running all three Docker services (Graph Node, PostgreSQL, IPFS) uses approximately 3-6 GB RAM. Stopping them and keeping only Anvil reduces usage to ~50-100 MB. diff --git a/doc/deploy-script-testing.md b/doc/tooling/deploy-script-testing.md similarity index 100% rename from doc/deploy-script-testing.md rename to doc/tooling/deploy-script-testing.md From 4883a75311549186aa49bf3792cc3d14b25fc404 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Wed, 1 Apr 2026 19:42:25 +0100 Subject: [PATCH 006/232] StabilityPool_v3 with ERC20 functionality Aliasing for rewards first step analysed dynamic fees/post-rebalance rewards --- .claude/settings.json | 5 +- CLAUDE.md | 4 +- README.md | 2 +- doc/ideas/sp-dynamic-fees.md | 431 ++++++++++++++++++ regression/coverage.txt | 43 +- regression/gas.txt | 2 +- regression/sizes.txt | 2 +- script/src/contracts/StabilityPool.sol | 4 +- src/interfaces/IRewardAlias.sol | 13 + src/interfaces/IStabilityPool_v3.sol | 21 + src/minter/StabilityPool_v3.sol | 371 +++++++-------- ...ultipleRewardCompoundingAccumulator_v3.sol | 78 +++- .../LinearMultipleRewardDistributor_v3.sol | 313 +++++++++++++ test/RebalanceFairness.t.sol | 427 +++++++++++++++++ test/StabilityPoolClaimable.t.sol | 81 ++++ test/StabilityPoolFeatures.t.sol | 98 ++++ test/StabilityPool_v3_ERC20.t.sol | 259 +++++++++++ 17 files changed, 1926 insertions(+), 228 deletions(-) create mode 100644 doc/ideas/sp-dynamic-fees.md create mode 100644 src/interfaces/IRewardAlias.sol create mode 100644 src/interfaces/IStabilityPool_v3.sol create mode 100644 src/reward/distributor/LinearMultipleRewardDistributor_v3.sol create mode 100644 test/RebalanceFairness.t.sol create mode 100644 test/StabilityPool_v3_ERC20.t.sol diff --git a/.claude/settings.json b/.claude/settings.json index c18d4c0b..dfd504ce 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -5,7 +5,10 @@ "Bash(git log:*)", "Bash(2)", "Read(//home/tfras/github/baofinance/harbor/**)", - "Bash(find /home/tfras/github/baofinance/harbor/doc -type f \\\\\\(-name *.md -o -name *.txt \\\\\\) ! -path */.venv/* ! -path */node_modules/* ! -path */lib/*)" + "Bash(find /home/tfras/github/baofinance/harbor/doc -type f \\\\\\(-name *.md -o -name *.txt \\\\\\) ! -path */.venv/* ! -path */node_modules/* ! -path */lib/*)", + "Read(//home/tfras/github/baofinance/harbor-yield.wip-hytoken/**)", + "Bash(ls -la /home/tfras/github/baofinance/harbor-yield.wip-hytoken/*.md)", + "Bash(forge coverage:*)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index df79d168..ffcd80d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,4 +2,6 @@ - Do not create functions that are only called once. Inline the logic instead. - When diagnosing an issue, do not use words like "likely", "probably", or "may" to describe a root cause. Either verify the hypothesis with data (dry run, log, trace) or state explicitly that it is unverified. Never proceed with a fix based on an unverified hypothesis. -- use forge install/remove for managing submodule dependencies \ No newline at end of file +- use forge install/remove for managing submodule dependencies +- In tests, use interface types (e.g. `IStabilityPool_v3(address)`) not concrete contract types (e.g. `StabilityPool_v3(address)`) when calling functions. This verifies the interface matches the implementation. Concrete types are only for initialisation (constructor, deploy). +- in code never use an if or loop statement without curly brackets - I want the code coverage to be visible and that hides some branches from the display \ No newline at end of file diff --git a/README.md b/README.md index 6097d5da..4067934d 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ This provides a gas efficient and general pause mechanism for all UUPS upgradeab ### Reserve pool -Provides discounts for collateral ration beneficial user actions. +Provides discounts for collateral ratio beneficial user actions. The reserve pool is funded by a portion of the fees collected, and can be filled by other mechanisms, e.g. simply transferring the collateral token to it. ## Anchored haTokens - the pegged tokens diff --git a/doc/ideas/sp-dynamic-fees.md b/doc/ideas/sp-dynamic-fees.md new file mode 100644 index 00000000..3c1e4014 --- /dev/null +++ b/doc/ideas/sp-dynamic-fees.md @@ -0,0 +1,431 @@ +# Stability Pool Dynamic Fees and Harvest Fairness + +**Status: Under discussion** + +## 1. The Problem + +A user who anticipates a rebalance can profit by withdrawing pegged tokens beforehand and re-depositing afterwards. This works whether they frontrun a mempool transaction or simply monitor the collateral ratio. The attacker dodges the rebalance loss and re-enters with a larger share of the now-smaller pool, capturing more future harvest rewards. + +The system should be "fire and forget" -- fairness enforced on-chain, no manual intervention, no dependence on private mempools. + +### How Harvests Work + +Harvests come from the yield on wrapped collateral (e.g., fxSAVE) held **by the Minter**. As fxSAVE appreciates, the Minter accumulates excess wrapped collateral above what's needed to back the underlying collateral. This excess is the harvestable amount. + +The StabilityPoolManager distributes harvested fxSAVE to the two stability pools **proportional to their current pegged token balances**. Within each pool, harvest rewards are distributed to depositors proportional to their pegged token holdings. + +### What Happens During Rebalance + +**Collateral SP rebalance**: pegged tokens are redeemed for wrapped collateral. The wrapped collateral is **removed from the Minter** and transferred to the collateral SP. This reduces the Minter's collateral holdings, reducing future harvests for everyone. However, the transferred fxSAVE continues to generate yield independently (fxSAVE is inherently interest-bearing). This yield accrues to the collateral SP depositors who received it -- it was distributed immediately at rebalance time via `_accumulateReward` and belongs to them regardless of whether they claim or withdraw. + +**Leveraged SP rebalance**: pegged tokens are exchanged for leveraged tokens. The collateral backing those leveraged tokens **stays with the Minter**. This means leveraged SP rebalances do not reduce the Minter's collateral and do not directly reduce future harvest generation. The collateral remains, generating harvest that benefits both pools equally according to deposit size. + +### Worked Example + +All figures below are from `test/RebalanceFairness.t.sol`, which deploys the full system via the production deployment scripts (ETH::fxUSD market) and runs scenarios with real contract code. + +#### Setup + +Deployed via production scripts (ETH::fxUSD market) with a mock oracle. + +- Oracle price = 1.0 initially (so 1 fxSAVE collateral = 1 pegged token -- makes balance sheets readable) +- Oracle rate = 1.0 (1 fxSAVE = 1 fxUSD, no yield accrued yet) +- A market maker mints 600 pegged (from 600 fxSAVE) and 200 leveraged (from 200 fxSAVE) +- Minter holds 800 fxSAVE, 600 pegged outstanding, CR = 800/600 = 1.333 (healthy) +- Market maker distributes **100 pegged** to each of 6 actors (keeps leveraged tokens) +- Oracle price drops 10% (1.0 → 0.9): CR = 800 × 0.9 / 600 = **1.20** (below 1.30 threshold) +- Bounty/cut ratios set to 0 for clarity +- Harvest simulated by bumping oracle rate from 1.0 to 1.05 (5% yield accrual) + +**Cast:** + +| Actor | Initial position | Behaviour | +|-------|-----------------|-----------| +| Alice | 100 pegged in Collateral SP | Stays through rebalance | +| Bob | 100 pegged in Collateral SP | Withdraws before, re-deposits after | +| Charlie | 100 pegged in Leveraged SP | Stays through rebalance | +| Dave | 100 pegged in Leveraged SP | Withdraws before, re-deposits after | +| Fred | 100 pegged outside SPs | Deposits into Collateral SP after rebalance | +| George | 100 pegged outside SPs | Deposits into Leveraged SP after rebalance | + +**Pool totals (equal sizes):** +- Collateral SP: 200 pegged (Alice + Bob) +- Leveraged SP: 200 pegged (Charlie + Dave) + +#### Liquidation Split + +The `StabilityPoolManager` weights each pool's contribution to account for the different "CR restoration effectiveness" of collateral vs leveraged redemptions. The weighting formula ensures that the **percentage of pegged tokens liquidated is always equal from both pools**, regardless of pool sizes. + +From the test output with equal pools (Scenario A: 200 per pool): +- Total liquidated: **75 pegged** +- From Collateral SP: **37.5** (18.75% of 200) +- From Leveraged SP: **37.5** (18.75% of 200) + +With half-sized pools (Scenario B after withdrawals, 100 per pool): +- From Collateral SP: **37.5** (37.5% of 100) +- From Leveraged SP: **37.5** (37.5% of 100) + +The percentage is always equal. The absolute amount only differs when pool sizes differ, and even then each pool loses the same fraction of its holdings. + +#### Scenario A: Everyone Stays (Baseline) + +All four depositors stay through the rebalance. Each loses 18.75% of their deposit (100 → 81.25). Harvest: 36.11 fxSAVE total (5% rate increase). + +| Actor | Pool | Deposit after | Rebalance fxSAVE | Rebalance lev tokens | Harvest fxSAVE | +|-------|------|--------------|-----------------|---------------------|---------------| +| Alice | Coll | 81.25 | 20.83 | -- | **9.03** | +| Bob | Coll | 81.25 | 20.83 | -- | **9.03** | +| Charlie | Lev | 81.25 | -- | 31.25 | **9.03** | +| Dave | Lev | 81.25 | -- | 31.25 | **9.03** | +| Fred | -- | -- | -- | -- | 0 | +| George | -- | -- | -- | -- | 0 | +| | | | | **Total** | **36.12** | + +Harvest fxSAVE is **exactly equal** for all four depositors (9.03 each) -- strictly proportional to deposit size. The rebalance compensation differs by pool type (fxSAVE vs leveraged tokens) but that is a user choice, not a fairness issue. + +#### Scenario B: Bob and Dave Withdraw Before Rebalance + +**Step 1 -- Withdrawals:** +- Bob withdraws 100 from Collateral SP +- Dave withdraws 100 from Leveraged SP +- Collateral SP: 100 (Alice only) +- Leveraged SP: 100 (Charlie only) + +**Step 2 -- Rebalance** (75 total, 37.5 from each pool): +- Alice absorbs all Collateral SP loss: 100 → **62.5 pegged + 41.67 fxSAVE** +- Charlie absorbs all Leveraged SP loss: 100 → **62.5 pegged + 62.5 lev tokens** +- Minter fxSAVE: 800 → **758.3** (collateral removed for Alice's fxSAVE) + +**Step 3 -- Re-deposits + new entrants:** +- Bob: 100 pegged → Collateral SP +- Dave: 100 pegged → Leveraged SP +- Fred: 100 pegged → Collateral SP +- George: 100 pegged → Leveraged SP + +**After re-deposits + one harvest** (36.11 fxSAVE total, 5% rate increase): + +| Actor | Behaviour | Deposit | Rebalance fxSAVE | Rebalance lev tokens | Harvest fxSAVE | +|-------|-----------|---------|-----------------|---------------------|---------------| +| **Alice** | Coll, stayed | 62.5 | 41.67 | -- | **4.30** | +| **Bob** | Coll, returned | 100 | -- | -- | **6.88** | +| **Charlie** | Lev, stayed | 62.5 | -- | 62.50 | **4.30** | +| **Dave** | Lev, returned | 100 | -- | -- | **6.88** | +| **Fred** | new → Coll | 100 | -- | -- | **6.88** | +| **George** | new → Lev | 100 | -- | -- | **6.88** | +| | | | | **Total** | **36.12** | + +#### The Harvest Unfairness + +Separating rebalance rewards (static, one-off) from harvest rewards (streamed, ongoing) makes the problem clear: + +1. **Harvest rewards are strictly proportional to current deposit size.** Alice and Charlie each have 62.5 pegged and earn 4.30 fxSAVE harvest. Bob, Dave, Fred, and George each have 100 pegged and earn 6.88. This is mechanically correct -- harvest is per pegged token. But it means **stayers earn less harvest per person than leavers** because their deposit shrunk in the rebalance. + +2. **Bob = Dave = Fred = George** in harvest terms (all 6.88). The system cannot distinguish a leaver who dodged the loss from a new entrant. Both get the same harvest rate on their full deposit. + +3. **Alice vs Bob** -- Alice's total claimable fxSAVE (45.97) exceeds Bob's (6.88), but this is due to the one-off rebalance reward (41.67). Her ongoing harvest rate (4.30) is **less** than Bob's (6.88). Over time, this compounds: Bob earns more harvest per period, which if auto-compounded, grows his base faster. + +4. **Charlie is worst off.** His harvest (4.30) is less than Bob's and Dave's (6.88), despite being loyal. His rebalance compensation was 62.50 leveraged tokens -- not fxSAVE -- so it doesn't show up as fxSAVE claimable. The collateral backing those leveraged tokens stays with the Minter, generating harvest that benefits everyone including Dave who dodged the loss. + +5. **The harvest is identical for both pool types** at equal deposit sizes. Alice and Charlie both have 62.5 pegged and both earn 4.30 fxSAVE harvest. The choice of collateral vs leveraged pool affects the rebalance compensation token (fxSAVE vs leveraged tokens) but that is a user choice based on their risk appetite, not a fairness issue. What matters for this analysis is the harvest redistribution. + +6. **Harvest income flows from stayers to leavers and new entrants.** Alice and Charlie both subsidise Bob, Dave, Fred, and George. Both stayers earn 4.30 harvest vs 6.88 for every leaver/new entrant -- a 37% reduction in ongoing income for staying loyal through the rebalance. + +#### Auto-Compounding Consideration + +A depositor can manually auto-compound: claim fxSAVE reward → mint pegged tokens (via Minter) → deposit back into SP. This converts the fxSAVE reward back into pegged tokens, restoring harvest earning power. + +**Alice's auto-compound opportunity:** +- Claim 41.67 fxSAVE from rebalance reward +- Mint ~41.67 pegged tokens (minus Minter fees, depending on CR) +- Deposit back into Collateral SP +- New pegged balance: 62.5 + 41.67 ≈ **104** (exceeds original 100 -- rebalance reward slightly overcompensates at price=0.9) + +This would restore Alice's harvest share. But: +- Minting incurs fees (CR-dependent, could be significant right after rebalance) +- She gives up the independent fxSAVE yield in exchange for harvest income +- She re-enters the risk of future rebalances with the re-minted pegged tokens +- The compound cycle favours actors with more capital (gas costs are fixed) + +**Compounding rates differ by position.** If Alice and Bob both auto-compound weekly: +- Bob starts with 100 pegged, earns 6.88 fxSAVE/harvest → compounds from a larger base +- Alice starts with 62.5 pegged (before claiming), earns less per harvest → compounds from a smaller base +- Over time, Bob's absolute advantage grows because compounding amplifies the base difference + +If Alice first converts her fxSAVE to pegged (restoring to ~100), then both compound at the same rate. But this requires Alice to act, incur fees, and accept re-entry risk -- while Bob simply re-deposited for free. + +**Charlie cannot auto-compound in the same way.** His leveraged tokens are not fxSAVE -- he cannot mint pegged tokens from them. To restore his harvest share, he would need to sell his leveraged tokens for fxSAVE (or pegged tokens) on the market, which may have slippage and doesn't fully compensate. + +--- + +## 2. Current Mechanism: Withdrawal Window + +### How It Works + +Withdrawals outside a pre-requested time window pay a fixed early-withdrawal fee. The flow: + +1. User calls `requestWithdrawal()` -- opens a window starting at `now + WITHDRAWAL_START_DELAY` lasting `WITHDRAWAL_END_WINDOW` +2. Withdrawals during the window: no fee +3. Withdrawals outside the window: fixed `earlyWithdrawalFee` (configured at initialisation, up to 100%) +4. Depositing cancels any pending withdrawal request +5. `EXEMPT_WITHDRAWAL_FEE_ROLE` bypasses the fee entirely + +### What It Solves + +- **Patience incentive**: discourages impulsive withdrawals +- **Some mempool protection**: an attacker can't open a fee-free window reactively to a rebalance tx already in the mempool (the delay prevents it) +- **Simple**: easy to understand and audit + +### What It Doesn't Solve + +- **Pre-positioned windows**: a user can maintain near-continuous fee-free withdrawal coverage by calling `requestWithdrawal()` every `WITHDRAWAL_END_WINDOW` seconds. Each call resets the delay, but a patient attacker who plans one `WITHDRAWAL_START_DELAY` ahead always has a window open or about to open. Note: depositing cancels the request, so a full sandwich (withdraw + re-deposit) does lose the window -- but the withdrawal half is still fee-free if timed within an existing window +- **No link to system health**: the fee is flat regardless of whether the system is healthy or under stress -- a withdrawal at CR = 2.0 costs the same as at CR = 1.01 +- **No deposit-side protection**: re-depositing after rebalance is free, which is half the sandwich attack +- **Window UX burden**: legitimate users must plan withdrawals days in advance even when the system is perfectly healthy +- **Composability barrier**: the request/window state machine breaks the standard ERC4626 tokenized vault interface (EIP-4626), which defines `withdraw(assets, receiver, owner)` as a single atomic call that burns shares and transfers assets. Contracts built to the ERC4626 spec -- Yearn v3 vaults, ERC4626 autocompounders (e.g., Beefy, Sommelier cellars), yield aggregators (e.g., Yearn routers, DeFi Saver), and any composing vault that wraps another vault -- all expect `withdraw()` to complete in one transaction. The two-step request-then-withdraw flow requires bespoke integration for every wrapper or composing contract, limiting the SP's utility as a building block in DeFi. EIP-7540 (asynchronous vaults) exists specifically to standardise async redemption flows, but adoption is far lower than ERC4626 and most existing infrastructure does not support it + +--- + +## 3. Proposed Mechanism: CR-Based Dynamic Fees + +### How It Works + +Replace the withdrawal window with dynamic fees on both deposits and withdrawals that scale with systemic risk (collateral ratio). When CR is healthy, fees are zero. + +``` +FEE_ACTIVATION_RATIO (immutable, e.g., 1.4e18 if rebalance threshold is 1.3e18) + +if CR >= FEE_ACTIVATION_RATIO: + feeRate = 0 +elif CR >= 1e18: + feeRate = (FEE_ACTIVATION_RATIO - CR) / (FEE_ACTIVATION_RATIO - 1e18) +else: + feeRate = 1e18 (100% -- full depeg, operations effectively blocked) +``` + +At the rebalance threshold (1.3 with activation at 1.4): +`feeRate = (1.4 - 1.3) / (1.4 - 1.0) = 25%` + +The same formula applies to both `withdraw()` and `deposit()`. Both fees go to the protocol `feeAddress`. + +The withdrawal window, request mechanism, and fixed early withdrawal fee are all removed. + +### What It Solves + +- **Scales with risk**: no fee under healthy conditions; steep fee as rebalance approaches +- **Both sides of the sandwich**: withdrawal and deposit are both penalised during stress +- **Address-switching resistant**: attacker withdraws from address A (pays withdrawal fee), deposits from address B (pays deposit fee) -- both sides are captured +- **No UX burden**: no need to plan withdrawal requests in advance; just withdraw (it's free when the system is healthy) +- **Stateless**: computed from `IMinter.collateralRatio()` on each call, no new storage needed +- **Simpler contract**: removes withdrawal window state, request mapping, delay/window immutables + +### What It Doesn't Solve + +- **Post-rebalance gap**: after a rebalance, CR jumps back up to the threshold. The CR-based fee drops immediately -- exactly when an attacker wants to re-enter. An attacker who can deposit in the same block as or shortly after a rebalance faces a low fee. Mitigation: set `FEE_ACTIVATION_RATIO` well above the threshold, or use private mempool for rebalance txs. But this gap is not fully closed on-chain. +- **Withdrawal fee destination**: fees go to the protocol, not to remaining depositors. Redistributing to depositors was considered but rejected: if multiple deposits occur post-rebalance, later depositors' fees partially go to earlier post-rebalance depositors (who already paid their own fee), creating ordering-dependent unfairness. +- **Imprecise calibration**: the linear ramp is a heuristic. The actual rebalance loss fraction at a given CR depends on the rebalance threshold, oracle price, and how much the Minter redeems. The fee may overshoot or undershoot the actual loss. +- **Legitimate stress-period activity penalised**: a user who genuinely wants to deposit during low CR (e.g., to support the pool) pays a fee. This is the trade-off for address-switching resistance. + +--- + +## 4. Mechanism Comparison Under the Worked Example + +### A. Current Mechanism (Withdrawal Window) + +Assume `WITHDRAWAL_START_DELAY = 1 hour`, `WITHDRAWAL_END_WINDOW = 25 hours`, `earlyWithdrawalFee = 1%`. + +**Bob's attack:** +- Bob maintains a standing withdrawal request (re-requests periodically) +- When CR approaches 1.30 threshold, Bob withdraws 100 pegged fee-free during his window +- After rebalance, Bob deposits 100 pegged (depositing cancels his window, but he doesn't need it anymore) +- Net cost to Bob: **0** (fee-free withdrawal within window) + +**Result:** The withdrawal window does not prevent the attack for a patient, pre-positioned attacker. Alice and Charlie bear the same losses as Scenario B: Alice gets 45.97 total (41.67 rebal + 4.30 harvest), Charlie gets 4.30 harvest only, while Bob gets 6.88 for free. + +### B. CR-Based Dynamic Fees + +Assume `FEE_ACTIVATION_RATIO = 1.40`, rebalance threshold = 1.30. + +At the test CR of 1.20 before rebalance: +- `feeRate = (1.40 - 1.20) / (1.40 - 1.00) = 50%` + +**Bob's withdrawal:** +- Withdraws 100 pegged, pays 50% fee = 50 pegged in fees +- Receives 50 pegged +- Can only re-deposit 50 pegged after rebalance + +**But the post-rebalance gap:** After rebalance, CR jumps back to 1.30 → fee drops to `(1.40 - 1.30) / (1.40 - 1.00) = 25%`. Still significant. + +**Fred's deposit (post-rebalance, CR = 1.30):** +- Fred deposits 100 pegged, pays 25% = 25 pegged fee +- Fred credited with 75 pegged + +**Net effect:** Fees capture value on both sides but: +- The withdrawal fee reduces the attacker's capital (50 instead of 100) +- The deposit fee reduces the new entrant's advantage +- **Alice and Charlie still absorb concentrated losses** -- fees don't compensate them +- The gap: if `FEE_ACTIVATION_RATIO` were set at 1.30 (equal to threshold), post-rebalance deposits would face zero fee + +### C. Effective Share: Unclaimed Rebalance Reward Boost + +No fees on withdrawal or deposit. Instead, a depositor's effective harvest share includes the pegged-equivalent value of their unclaimed rebalance reward. + +**Mechanism:** + +``` +effectiveShare = peggedBalance + peggedValueOf(unclaimedRebalanceReward) +``` + +Where `peggedValueOf` converts the unclaimed fxSAVE (for collateral SP) or leveraged tokens (for leveraged SP) to pegged-equivalent using the oracle price. + +Note: SP-held fxSAVE does NOT generate harvest -- only Minter-held fxSAVE does. The unclaimed rebalance reward sitting in the SP appreciates on its own but does not feed into the harvest mechanism. There is no double-counting. + +**In the worked example:** + +Alice has 62.5 pegged + 41.67 fxSAVE unclaimed. At oracle price 0.9, the fxSAVE is worth ~46.3 pegged. Her effective share = 62.5 + 46.3 = **108.8**. Bob has 100 pegged + 0 unclaimed = **100**. Alice's effective share exceeds Bob's, compensating for her smaller pegged balance. + +Charlie has 62.5 pegged + 62.5 lev tokens unclaimed. The leveraged token value converts similarly. His effective share also exceeds Bob's. + +**Natural decay -- no governance parameter needed:** + +When a user claims their rebalance reward, `claimable()` drops to zero and the boost disappears. The mechanism decays automatically via user action rather than a time parameter. + +**Incentive to claim and compound:** + +A user who holds (never claims) has a static effective share. A user who claims, mints pegged, and re-deposits has a growing pegged balance that compounds. Over time, exponential growth always beats a static boost: + +| Period | Alice holds | Alice claims + compounds | +|--------|-----------|------------------------| +| 1 | 62.5 pegged + 46.3 boost = 108.8 effective | Claims 46.3, pays mint fee, deposits ~45. 107.5 pegged, 0 boost | +| 2 | Still 108.8 (static) | 107.5 + harvest reinvested (growing) | +| N | Still 108.8 (static) | 107.5 × (1+r)^N (exponential) | + +The compounding advantage is self-incentivising -- no discount or premium on the unclaimed amount is needed. + +**Interaction with minting fees:** + +The mint fee (CR-dependent) naturally regulates when compounding occurs: + +- **Low CR (high/disallow mint fee)**: minting pegged would push CR lower, risking another rebalance. The fee is prohibitive. User holds → the full-value boost maintains their harvest share → correct behaviour rewarded. +- **High CR (low/zero mint fee)**: minting is safe, system can absorb it. User claims and compounds → exponential growth beats static boost → SP grows with healthy activity. + +The mint fee gates the behaviour without any additional mechanism. No discount on the unclaimed amount is needed at any CR level. + +**What it solves:** +- Pre-rebalance depositors earn harvest proportional to their full position (pegged + compensation) +- Natural decay via claiming -- no governance parameter, no time decay calibration +- Compounding is incentivised when healthy, holding is incentivised when stressed -- mint fee handles both +- No penalty on new depositors -- they have no unclaimed reward, so no boost +- Simpler than the BOLD product approach: no second integral, no per-exponent math changes + +**What it doesn't solve:** +- Oracle dependency: converting fxSAVE/leveraged tokens to pegged-equivalent requires an oracle call in `_getUserPoolShare`. Gas increase + oracle manipulation risk (though oracle is already trusted for CR). +- Does not prevent the withdrawal/re-deposit attack itself -- only adjusts reward distribution +- Leveraged SP token pricing: no direct `mintPegged(leveragedToken)` path. Must use `leveragedTokenPrice()` from Minter for conversion, which is an approximation of market value. + +### D. Harvest Fairness Product (BOLD-Inspired) + +No fees on withdrawal or deposit. Instead, harvest distribution accounts for rebalance history via a second product in the accumulator. + +**Mechanism:** A second product (like Liquity's B sum) that incorporates the loss product P into harvest accumulation: + +``` +harvestGain = initialDeposit * (B_current - B_snapshot) / P_snapshot +``` + +When harvest rewards are accumulated, the integral includes the current loss product: + +``` +B[currentScale] += P * harvestAmount / totalDeposits +``` + +This means harvest is attributed proportional to **original deposit size** (before losses), not current compounded balance. A depositor who absorbed losses via the product still earns harvest as if their deposit were larger. + +**In the worked example:** + +Alice deposited 100 and absorbed losses (product decreased, deposit fell to 62.5). But her harvest is calculated from her initial 100, scaled by the product ratio at each harvest event. Bob deposited 100 after the rebalance with a fresh product snapshot. His harvest is calculated from his 100 at the current (post-loss) product. + +Because Alice's B_snapshot was taken at a higher P, her `(B_current - B_snapshot) / P_snapshot` captures harvest accumulated during the loss period at the pre-loss rate. Bob's snapshot is at the lower P, so he only captures harvest from his deposit time onward. + +**Result for Alice:** her harvest share would be boosted relative to Bob's 6.88, compensating for the product decrease. The boost decays naturally as new harvest events accumulate at the post-loss product -- eventually Alice and Bob converge to equal rates per pegged token. + +**Result for Charlie:** same boost mechanism. Currently Charlie gets 4.30 (less than Bob's 6.88 despite being loyal). With the fairness product, Charlie's harvest would be boosted toward the level implied by his original 100 deposit. + +**What it solves:** +- Pre-rebalance depositors are not permanently disadvantaged in harvest distribution +- No penalty on new depositors -- they simply don't get the boost +- Mathematically precise: uses the existing product/integral system +- Composable with CR fees + +**What it doesn't solve:** +- Complexity: second product, modified accumulator math, interaction with per-exponent tracking +- Does not prevent the withdrawal/re-deposit attack itself -- only adjusts reward distribution +- Decay calibration depends on harvest frequency and collateral type +- Different pool types may need different parameters + +### E. Comparison: Effective Share vs BOLD Product + +| Aspect | Effective Share (C) | BOLD Product (D) | +|--------|-------------------|-----------------| +| **Complexity** | Modifies `_getUserPoolShare` only | New integral, per-exponent tracking | +| **Decay** | Natural (claim to remove) | Time-based (needs calibration) | +| **Governance params** | None | Decay period per pool type | +| **Oracle dependency** | Yes (price conversion) | No | +| **Compounding incentive** | Built-in (exponential beats static) | Requires separate analysis | +| **Mint fee interaction** | Natural gating (hold when expensive, compound when cheap) | Not connected to mint fee | +| **Multiple rebalances** | Additive (each rebalance adds unclaimed) | Multiplicative (products compound) | + +### F. Combined: CR-Based Fees + Effective Share + +Fees deter the movement; the effective share corrects the reward distribution. + +**Bob's attack (combined):** +1. Withdrawal fee: loses 50% of 100 = 50 pegged. Receives 50. +2. Re-deposit fee (post-rebalance, CR = 1.30): loses 25% of 50 = 12.5. Credited 37.5 pegged. +3. Effective share: Bob has 37.5 pegged, 0 unclaimed = 37.5 effective. Alice has 62.5 pegged + 46.3 boost = 108.8 effective. Alice dominates. + +**Net result:** Bob entered with 100, now has 37.5 pegged with no harvest boost. Alice absorbed concentrated losses but earns harvest on 108.8 effective share. Attack is clearly unprofitable. + +**Fred (legitimate new entrant, combined):** +1. No withdrawal (wasn't in pool), no withdrawal fee. +2. Deposit fee: 25% of 100 = 25 fee. Credited 75 pegged. +3. No effective share boost (no unclaimed rewards). + +Fred pays a deposit fee that is arguably unfair to a legitimate new entrant. The effective share mechanism alone (without deposit fees) would handle Fred more fairly: no fee, but no boost either. + +### G. Impact of Auto-Compounding on Each Mechanism + +The claim → mint → deposit cycle amplifies differences over time with compound interest. + +Using weekly compounding over 1 year, with harvest rate `r` per pegged token per year: + +| Mechanism | Alice (1yr compound) | Bob (1yr compound) | Notes | +|-----------|---------------------|-------------------|-------| +| **No protection** | 62.5 × (1+r)^52 | 100 × (1+r)^52 | Bob compounds from 1.6× larger base | +| **CR fees only** | 62.5 × (1+r)^52 | 37.5 × (1+r)^52 | Gap reversed by fees; Bob's capital cut to 37.5% | +| **Effective share only** | 108.8 static then compounds | 100 × (1+r)^52 | Alice starts higher; once she claims + compounds, both grow exponentially | +| **Effective share + CR fees** | 108.8 static then compounds | 37.5 × (1+r)^52 | Strongest protection | + +Note: with the effective share mechanism, Alice is incentivised to claim and compound when CR is healthy (low mint fee). Her static 108.8 effective share is eventually overtaken by Bob's compounding 100 -- but Alice can switch to compounding at any time by claiming. The mint fee naturally gates this: hold when expensive, compound when cheap. + +--- + +## 5. Open Questions + +1. Should the effective share / fairness product affect SAIL/gauge rewards too, or only wrapped collateral harvests? +2. For the effective share mechanism: does `_getUserPoolShare` modification interact correctly with the existing per-exponent integral tracking? +3. For the BOLD product: does the existing reward integral in `MultipleRewardCompoundingAccumulator_v3` already weight by the loss product correctly, or is a separate integral needed? +4. Can the effective share and BOLD product approaches be combined, or are they alternatives? + +--- + +## 6. Summary: Defence Layers + +| Layer | Mechanism | Addresses | +|-------|-----------|-----------| +| **CR-based withdrawal fee** | Dynamic fee scaling with CR | Frontrun withdrawal, general timing | +| **CR-based deposit fee** | Same formula on deposits | Address-switching, post-withdrawal re-entry | +| **Effective share boost** | Unclaimed rebalance reward counts toward harvest share | Harvest unfairness, natural claim-to-decay, compounding incentive | +| **BOLD-inspired fairness product** | Second integral weighted by loss product | Harvest unfairness via accumulator math | +| **Private mempool** (off-chain) | Submit rebalance via Flashbots Protect or similar | Mempool frontrunning specifically | + +The effective share mechanism (C) is the simplest harvest fairness approach: no new integral, no governance parameters, natural decay via claiming, and the mint fee naturally gates when to compound. It can be combined with CR-based fees for belt-and-suspenders protection, or used standalone if the harvest fairness alone provides sufficient deterrence. diff --git a/regression/coverage.txt b/regression/coverage.txt index 47dfb68a..359bd90b 100644 --- a/regression/coverage.txt +++ b/regression/coverage.txt @@ -1,19 +1,19 @@ | File | % Lines | % Statements | % Branches | % Funcs | |--------------------------------------------------------------------|--------------------|--------------------|------------------|-------------------| -| script/config/ConfigBase.sol | X 75% (6/8) | X 75% (6/8) | ✓ 100% (0/0) | X 75% (3/4) | -| script/config/ConfigTokenNames.sol | X 0% (0/24) | X 0% (0/20) | ✓ 100% (0/0) | X 0% (0/11) | -| script/config/chains/ConfigChain_mainnet.sol | X 0% (0/21) | X 0% (0/13) | ✓ 100% (0/0) | X 0% (0/8) | -| script/config/collaterals/ConfigCollateral_fxUSD_mainnet.sol | X 33% (2/6) | X 20% (1/5) | ✓ 100% (0/0) | X 33% (1/3) | +| script/config/ConfigBase.sol | ✓ 100% (8/8) | ✓ 100% (8/8) | ✓ 100% (0/0) | ✓ 100% (4/4) | +| script/config/ConfigTokenNames.sol | X 88% (21/24) | X 90% (18/20) | ✓ 100% (0/0) | X 82% (9/11) | +| script/config/chains/ConfigChain_mainnet.sol | X 5% (1/21) | X 8% (1/13) | ✓ 100% (0/0) | X 0% (0/8) | +| script/config/collaterals/ConfigCollateral_fxUSD_mainnet.sol | X 67% (4/6) | X 60% (3/5) | ✓ 100% (0/0) | X 67% (2/3) | | script/config/collaterals/ConfigCollateral_stETH_mainnet.sol | X 33% (2/6) | X 20% (1/5) | ✓ 100% (0/0) | X 33% (1/3) | -| script/config/pegs/ConfigPeg.sol | X 0% (0/8) | X 0% (0/7) | ✓ 100% (0/0) | X 0% (0/4) | +| script/config/pegs/ConfigPeg.sol | X 75% (6/8) | X 86% (6/7) | ✓ 100% (0/0) | X 75% (3/4) | | script/config/pegs/ConfigPeg_BTC.sol | X 33% (2/6) | X 33% (1/3) | ✓ 100% (0/0) | X 33% (1/3) | -| script/config/pegs/ConfigPeg_ETH.sol | X 33% (2/6) | X 33% (1/3) | ✓ 100% (0/0) | X 33% (1/3) | +| script/config/pegs/ConfigPeg_ETH.sol | X 67% (4/6) | X 67% (2/3) | ✓ 100% (0/0) | X 67% (2/3) | | script/config/pegs/ConfigPeg_EUR.sol | X 33% (2/6) | X 33% (1/3) | ✓ 100% (0/0) | X 33% (1/3) | | script/config/pegs/ConfigPeg_GOLD.sol | X 33% (2/6) | X 33% (1/3) | ✓ 100% (0/0) | X 33% (1/3) | | script/config/pegs/ConfigPeg_MCAP.sol | X 0% (0/6) | X 0% (0/3) | ✓ 100% (0/0) | X 0% (0/3) | | script/config/pegs/ConfigPeg_SILVER.sol | X 0% (0/6) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/3) | -| script/config/stabilitypool/ConfigStabilityPool.sol | X 0% (0/6) | X 0% (0/3) | ✓ 100% (0/0) | X 0% (0/3) | -| script/config/stabilitypool/ConfigStabilityPoolManager.sol | X 0% (0/6) | X 0% (0/3) | ✓ 100% (0/0) | X 0% (0/3) | +| script/config/stabilitypool/ConfigStabilityPool.sol | ✓ 100% (6/6) | ✓ 100% (3/3) | ✓ 100% (0/0) | ✓ 100% (3/3) | +| script/config/stabilitypool/ConfigStabilityPoolManager.sol | ✓ 100% (6/6) | ✓ 100% (3/3) | ✓ 100% (0/0) | ✓ 100% (3/3) | | script/config/stabilitypool/ConfigStabilityPoolManagerCommon.sol | X 0% (0/21) | X 0% (0/17) | ✓ 100% (0/0) | X 0% (0/7) | | script/config/volatility/ConfigPriceVolatility_105.sol | X 0% (0/65) | X 0% (0/71) | ✓ 100% (0/0) | X 0% (0/2) | | script/config/volatility/ConfigPriceVolatility_105_stable.sol | X 0% (0/65) | X 0% (0/71) | ✓ 100% (0/0) | X 0% (0/2) | @@ -22,22 +22,22 @@ | script/config/volatility/ConfigPriceVolatility_125.sol | X 0% (0/65) | X 0% (0/71) | ✓ 100% (0/0) | X 0% (0/2) | | script/config/volatility/ConfigPriceVolatility_125_stable.sol | X 0% (0/65) | X 0% (0/71) | ✓ 100% (0/0) | X 0% (0/2) | | script/config/volatility/ConfigPriceVolatility_130.sol | X 0% (0/65) | X 0% (0/71) | ✓ 100% (0/0) | X 0% (0/2) | -| script/config/volatility/ConfigPriceVolatility_130_stable.sol | X 0% (0/65) | X 0% (0/71) | ✓ 100% (0/0) | X 0% (0/2) | +| script/config/volatility/ConfigPriceVolatility_130_stable.sol | ✓ 100% (65/65) | ✓ 100% (71/71) | ✓ 100% (0/0) | ✓ 100% (2/2) | | script/patch/ForceMigrateAccumulator_v1.sol | X 0% (0/24) | X 0% (0/29) | X 0% (0/2) | X 0% (0/3) | -| script/src/DeployMintersShared.sol | X 0% (0/84) | X 0% (0/102) | X 0% (0/4) | X 0% (0/9) | +| script/src/DeployMintersShared.sol | X 83% (70/84) | X 82% (84/102) | X 25% (1/4) | X 78% (7/9) | | script/src/Deploy_BTC_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | -| script/src/Deploy_ETH_Minter.sol | X 0% (0/4) | X 0% (0/3) | ✓ 100% (0/0) | X 0% (0/1) | +| script/src/Deploy_ETH_Minter.sol | ✓ 100% (4/4) | ✓ 100% (3/3) | ✓ 100% (0/0) | ✓ 100% (1/1) | | script/src/Deploy_EUR_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | | script/src/Deploy_GOLD_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | | script/src/Deploy_MCAP_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | | script/src/Deploy_SILVER_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | -| script/src/HarborFactoryDeployer.sol | X 0% (0/16) | X 0% (0/11) | ✓ 100% (0/0) | X 0% (0/5) | -| script/src/contracts/Genesis.sol | X 0% (0/10) | X 0% (0/12) | ✓ 100% (0/0) | X 0% (0/2) | -| script/src/contracts/LeveragedToken.sol | X 0% (0/15) | X 0% (0/23) | ✓ 100% (0/0) | X 0% (0/1) | -| script/src/contracts/Minter.sol | X 0% (0/42) | X 0% (0/46) | ✓ 100% (0/0) | X 0% (0/8) | -| script/src/contracts/PeggedToken.sol | X 0% (0/24) | X 0% (0/33) | X 0% (0/6) | X 0% (0/1) | -| script/src/contracts/StabilityPool.sol | X 0% (0/26) | X 0% (0/41) | ✓ 100% (0/0) | X 0% (0/3) | -| script/src/contracts/StabilityPoolManager.sol | X 0% (0/26) | X 0% (0/30) | ✓ 100% (0/0) | X 0% (0/4) | +| script/src/HarborFactoryDeployer.sol | X 56% (9/16) | X 73% (8/11) | ✓ 100% (0/0) | X 20% (1/5) | +| script/src/contracts/Genesis.sol | X 70% (7/10) | X 67% (8/12) | ✓ 100% (0/0) | X 50% (1/2) | +| script/src/contracts/LeveragedToken.sol | ✓ 100% (15/15) | ✓ 100% (23/23) | ✓ 100% (0/0) | ✓ 100% (1/1) | +| script/src/contracts/Minter.sol | X 57% (24/42) | X 54% (25/46) | ✓ 100% (0/0) | X 62% (5/8) | +| script/src/contracts/PeggedToken.sol | X 83% (20/24) | X 94% (31/33) | X 50% (3/6) | ✓ 100% (1/1) | +| script/src/contracts/StabilityPool.sol | ✓ 100% (26/26) | ✓ 100% (41/41) | ✓ 100% (0/0) | ✓ 100% (3/3) | +| script/src/contracts/StabilityPoolManager.sol | X 54% (14/26) | X 50% (15/30) | ✓ 100% (0/0) | X 50% (2/4) | | script/test/SPLRemediationTest.t.sol | X 0% (0/3) | X 0% (0/2) | ✓ 100% (0/0) | X 0% (0/1) | | src/../script/src/HarborFactoryDeployer.sol | X 0% (0/16) | X 0% (0/11) | ✓ 100% (0/0) | X 0% (0/5) | | src/math/DecrementalFloatingPoint.sol | ✓ 100% (31/31) | ✓ 100% (33/33) | ✓ 100% (9/9) | ✓ 100% (6/6) | @@ -49,7 +49,7 @@ | src/minter/StabilityPoolManager_v1.sol | X 99% (165/166) | X 99% (176/177) | X 95% (20/21) | ✓ 100% (24/24) | | src/minter/StabilityPool_v1.sol | X 61% (124/203) | X 58% (129/223) | X 18% (6/33) | X 73% (16/22) | | src/minter/StabilityPool_v2.sol | X 68% (136/199) | X 68% (150/219) | X 32% (10/31) | X 64% (14/22) | -| src/minter/StabilityPool_v3.sol | X 76% (210/278) | X 74% (224/303) | X 68% (27/40) | X 71% (25/35) | +| src/minter/StabilityPool_v3.sol | ✓ 100% (289/289) | ✓ 100% (320/320) | ✓ 100% (42/42) | ✓ 100% (37/37) | | src/minter/TokenDistributor_v1.sol | X 96% (94/98) | X 97% (112/116) | X 77% (10/13) | X 93% (14/15) | | src/minter/library/ConfigIncentiveLib.sol | ✓ 100% (24/24) | ✓ 100% (17/17) | ✓ 100% (2/2) | ✓ 100% (9/9) | | src/minter/library/Config_v1.sol | X 96% (76/79) | X 97% (92/95) | X 90% (18/20) | ✓ 100% (6/6) | @@ -57,10 +57,11 @@ | src/price/StakedETHWrappedPriceOracle_v1.sol | X 0% (0/29) | X 0% (0/27) | X 0% (0/5) | X 0% (0/4) | | src/reward/accumulator/MultipleRewardCompoundingAccumulator.sol | X 93% (127/136) | X 94% (161/171) | X 81% (13/16) | X 95% (20/21) | | src/reward/accumulator/MultipleRewardCompoundingAccumulator_v2.sol | X 96% (141/147) | X 96% (177/184) | X 83% (15/18) | ✓ 100% (22/22) | -| src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol | X 77% (106/137) | X 80% (139/173) | X 69% (11/16) | X 68% (15/22) | +| src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol | X 79% (116/147) | X 82% (150/184) | X 72% (13/18) | X 68% (15/22) | | src/reward/distributor/LinearMultipleRewardDistributor.sol | ✓ 100% (77/77) | ✓ 100% (86/86) | ✓ 100% (12/12) | ✓ 100% (14/14) | | src/reward/distributor/LinearMultipleRewardDistributor_v2.sol | ✓ 100% (77/77) | ✓ 100% (86/86) | ✓ 100% (12/12) | ✓ 100% (14/14) | +| src/reward/distributor/LinearMultipleRewardDistributor_v3.sol | X 84% (79/94) | X 85% (90/106) | X 33% (5/15) | X 88% (15/17) | | src/reward/distributor/LinearReward.sol | ✓ 100% (25/25) | ✓ 100% (27/27) | ✓ 100% (6/6) | ✓ 100% (2/2) | | src/util/FmtLib.sol | X 0% (0/19) | X 0% (0/26) | X 0% (0/4) | X 0% (0/1) | | src/util/WordCodec.sol | ✓ 100% (15/15) | ✓ 100% (14/14) | ✓ 100% (0/0) | ✓ 100% (4/4) | -| Total | X 62% (4231/6821) | X 61% (4469/7369) | X 55% (422/764) | X 63% (642/1013) | +| Total | X 68% (4817/7054) | X 67% (5133/7625) | X 57% (448/783) | X 70% (727/1045) | diff --git a/regression/gas.txt b/regression/gas.txt index 8f2a0a13..58d3e6e3 100644 --- a/regression/gas.txt +++ b/regression/gas.txt @@ -222,7 +222,7 @@ src/minter/StabilityPool_v2.sol:StabilityPool_v2 src/minter/StabilityPool_v3.sol:StabilityPool_v3 | function name | max | |-------------------|-----------| -| ASSET_TOKEN | 3.490e+02 | +| ASSET_TOKEN | 2.820e+02 | | LIQUIDATION_TOKEN | 3.500e+02 | | grantRoles | 2.638e+04 | | initialize | 2.041e+05 | diff --git a/regression/sizes.txt b/regression/sizes.txt index 8eec4522..e94e21fb 100644 --- a/regression/sizes.txt +++ b/regression/sizes.txt @@ -47,7 +47,7 @@ | StabilityPoolManager_v1 | 11,209 | 13,367 | 13,057 | 2,372,370 | 237.24 | | StabilityPool_v1 | 21,092 | 3,484 | 23,085 | 4,449,250 | 444.93 | | StabilityPool_v2 | 20,711 | 3,865 | 22,579 | 4,367,990 | 436.80 | -| StabilityPool_v3 | 22,532 | 2,044 | 25,056 | 4,756,960 | 475.70 | +| StabilityPool_v3 | 23,526 | 1,050 | 26,054 | 4,965,740 | 496.57 | | StakedETHWrappedPriceOracle_v1 | 3,695 | 20,881 | 4,653 | 785,530 | 78.55 | | TokenDistributor_v1 | 10,116 | 14,460 | 10,365 | 2,126,850 | 212.69 | | WordCodec | 85 | 24,491 | 135 | 18,350 | 1.84 | diff --git a/script/src/contracts/StabilityPool.sol b/script/src/contracts/StabilityPool.sol index 6c64da14..6581967a 100644 --- a/script/src/contracts/StabilityPool.sol +++ b/script/src/contracts/StabilityPool.sol @@ -7,7 +7,7 @@ import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; import {DeploymentState} from "@bao-script/deployment/DeploymentState.sol"; -import {StabilityPool_v2} from "@harbor/minter/StabilityPool_v2.sol"; +import {StabilityPool_v3} from "@harbor/minter/StabilityPool_v3.sol"; import {StabilityPool_v3} from "@harbor/minter/StabilityPool_v3.sol"; import {Config_MinterMarket, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; import {ConfigTokenNames} from "script/config/ConfigTokenNames.sol"; @@ -104,7 +104,7 @@ abstract contract StabilityPool is HarborFactoryDeployer { address stabilityPoolProxy, address stabilityPoolManager ) internal { - StabilityPool_v2 pool = StabilityPool_v2(stabilityPoolProxy); + StabilityPool_v3 pool = StabilityPool_v3(stabilityPoolProxy); uint256 roles = pool.REBALANCER_ROLE() | pool.REWARD_DEPOSITOR_ROLE(); _grantRoles( stabilityPoolKey, diff --git a/src/interfaces/IRewardAlias.sol b/src/interfaces/IRewardAlias.sol new file mode 100644 index 00000000..ed809567 --- /dev/null +++ b/src/interfaces/IRewardAlias.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT + +pragma solidity >=0.8.28 <0.9.0; + +/// @notice Interface for a reward token alias. +/// @dev If a reward token address implements this interface and returns a non-zero underlying, +/// the reward system treats it as an alias: integrals track under the alias address, +/// but token transfers use the underlying address. +interface IRewardAlias { + /// @notice Returns the underlying token this alias represents. + /// @return The underlying token address. address(0) means not an alias. + function underlying() external view returns (address); +} diff --git a/src/interfaces/IStabilityPool_v3.sol b/src/interfaces/IStabilityPool_v3.sol new file mode 100644 index 00000000..7baed10e --- /dev/null +++ b/src/interfaces/IStabilityPool_v3.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT + +pragma solidity >=0.8.28 <0.9.0; + +import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; + +/// @notice Interface for StabilityPool_v3 additions (selective claim). +/// @dev Extends IStabilityPool with single-token claim functions. +/// Parameter order matches claimable(address account, address token). +interface IStabilityPool_v3 is IStabilityPool { + /// @notice Claim pending rewards of a single token for some user. + /// @param account The address of the user. + /// @param token The reward token address to claim. + function claimSingle(address account, address token) external; + + /// @notice Claim pending rewards of a single token for the user and transfer to others. + /// @param account The address of the user. + /// @param token The reward token address to claim. + /// @param receiver The address of the recipient. + function claimSingle(address account, address token, address receiver) external; +} \ No newline at end of file diff --git a/src/minter/StabilityPool_v3.sol b/src/minter/StabilityPool_v3.sol index 9a130ccd..da0e10e8 100644 --- a/src/minter/StabilityPool_v3.sol +++ b/src/minter/StabilityPool_v3.sol @@ -5,32 +5,32 @@ pragma solidity 0.8.30; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {Token} from "@bao/Token.sol"; import {TokenHolder} from "@bao/TokenHolder.sol"; import {DecrementalFloatingPoint} from "src/math/DecrementalFloatingPoint.sol"; -import {MultipleRewardCompoundingAccumulator} from "src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol"; +import {MultipleRewardCompoundingAccumulator_v3} from "src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol"; import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; import {IMinter} from "src/interfaces/IMinter.sol"; +import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import {IStabilityPool_v3} from "src/interfaces/IStabilityPool_v3.sol"; // solhint-disable not-rely-on-time // slither-disable-start timestamp /// @title StabilityPool_v3 -/// @notice Stability pool with rebasing ERC20 interface and cleaned-up accumulator. -/// `balanceOf` returns the compounded real value (same as `assetBalanceOf`). -/// `totalSupply` returns `totalAssetSupply`. Transfers checkpoint both parties. -/// Rebases downward on liquidation events (like stETH). The autocompounding vault -/// serves as the non-rebasing wrapped version (like wstETH). -/// -/// Uses the v3 accumulator which requires all users to have been force-migrated -/// from the legacy uint192 integral format. No on-demand migration fallback. +/// @notice Stability pool with rebasing ERC20, selective claim, and reward alias support. +/// Uses v3 accumulator and distributor which add alias detection and resolution. +/// @notice This contract hold asset minted as pegged tokens by the Minter contract. +/// Depositing pegged assets here results in: +/// * wrapped collateral being deposited here automatically from the minter when wrapped collateral's value increases +/// In the event of a rebalance, which occurs automatically, when the collateral ratio held by the Minter contract +/// drops below a threshold. In that event some, ro even all, deposited assets are converted to wrapped collatersl +/// or to leveage tokens, depending on what the LIQUIDATION_TOKEN is. /// -/// @author rootminus0x1 +/// @author rootminus0x1 forked from Aladdin's Fx framework and significantly changed /// @dev Uses UUPS proxy, erc7201 storage /// @custom:oz-upgrades /// @custom:oz-upgrades-from src/minter/StabilityPool_v2.sol:StabilityPool_v2 @@ -38,10 +38,11 @@ import {IMinter} from "src/interfaces/IMinter.sol"; contract StabilityPool_v3 is Initializable, UUPSUpgradeable, - MultipleRewardCompoundingAccumulator, + MultipleRewardCompoundingAccumulator_v3, TokenHolder, IStabilityPool, - IERC20Metadata + IERC20Metadata, + IStabilityPool_v3 { using SafeERC20 for IERC20; using DecrementalFloatingPoint for uint128; @@ -74,20 +75,6 @@ contract StabilityPool_v3 is /// @custom:oz-upgrades-unsafe-allow state-variable-immutable address public immutable LIQUIDATION_TOKEN; - /// @dev ERC20 name stored as two bytes32 (up to 64 characters) - /// @custom:oz-upgrades-unsafe-allow state-variable-immutable - bytes32 private immutable _ERC20_NAME_0; - /// @custom:oz-upgrades-unsafe-allow state-variable-immutable - bytes32 private immutable _ERC20_NAME_1; - - /// @dev ERC20 symbol stored as bytes32 (up to 32 characters) - /// @custom:oz-upgrades-unsafe-allow state-variable-immutable - bytes32 private immutable _ERC20_SYMBOL; - - /// @dev ERC20 decimals, matching the ASSET_TOKEN - /// @custom:oz-upgrades-unsafe-allow state-variable-immutable - uint8 private immutable _ERC20_DECIMALS; - /// @dev the pool cannot have less than this supply once it has reached that supply /// @custom:oz-upgrades-unsafe-allow state-variable-immutable uint256 public immutable MIN_TOTAL_ASSET_SUPPLY; @@ -104,6 +91,20 @@ contract StabilityPool_v3 is /// @custom:oz-upgrades-unsafe-allow state-variable-immutable uint64 public immutable WITHDRAWAL_END_WINDOW; + /// @dev ERC20 name stored as two bytes32 (up to 64 characters) + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + bytes32 private immutable _ERC20_NAME_0; + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + bytes32 private immutable _ERC20_NAME_1; + + /// @dev ERC20 symbol stored as bytes32 (up to 32 characters) + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + bytes32 private immutable _ERC20_SYMBOL; + + /// @dev ERC20 decimals, matching the ASSET_TOKEN + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + uint8 private immutable _ERC20_DECIMALS; + /*********** * Structs * ***********/ @@ -159,6 +160,18 @@ contract StabilityPool_v3 is FeePayment feePayment; } + // chisel eval 'keccak256(abi.encode(uint256(keccak256("bao.storage.StabilityPool")) - 1)) & ~bytes32(uint256(0xff))' + bytes32 private constant _STABILITYPOOL_STORAGE = + 0xcb62d703974340239a82baeadff6ad7af3673eb85d9779bde2587fc9e0e3e400; + + // internal as it is used in testing + function _getStabilityPoolStorage() internal pure returns (StabilityPoolStorage storage $) { + // solhint-disable-next-line no-inline-assembly + assembly { + $.slot := _STABILITYPOOL_STORAGE + } + } + /// @custom:storage-location erc7201:bao.storage.StabilityPool_v3 struct StabilityPoolERC20AllowancesStorage { /// @dev ERC20 allowances: owner => spender => amount @@ -168,7 +181,6 @@ contract StabilityPool_v3 is // chisel eval 'keccak256(abi.encode(uint256(keccak256("bao.storage.StabilityPool_v3")) - 1)) & ~bytes32(uint256(0xff))' bytes32 private constant _V3_STORAGE = 0xb4346888fe08dd20fe3aa583577b90a0e39bc6ca623364fcc9a4cf38a1ec7f00; - // internal as it is used in testing function _getERC20Storage() internal pure returns (StabilityPoolERC20AllowancesStorage storage $) { // solhint-disable-next-line no-inline-assembly assembly { @@ -176,17 +188,13 @@ contract StabilityPool_v3 is } } - // chisel eval 'keccak256(abi.encode(uint256(keccak256("bao.storage.StabilityPool")) - 1)) & ~bytes32(uint256(0xff))' - bytes32 private constant _STABILITYPOOL_STORAGE = - 0xcb62d703974340239a82baeadff6ad7af3673eb85d9779bde2587fc9e0e3e400; + /********** + * Errors * + **********/ - // internal as it is used in testing - function _getStabilityPoolStorage() internal pure returns (StabilityPoolStorage storage $) { - // solhint-disable-next-line no-inline-assembly - assembly { - $.slot := _STABILITYPOOL_STORAGE - } - } + error TransferExceedsBalance(address from, uint256 amount, uint256 balance); + error InsufficientAllowance(address spender, uint256 currentAllowance, uint256 needed); + error StringTooLong(); /*************** * Constructor * @@ -221,10 +229,6 @@ contract StabilityPool_v3 is /// @notice In UUPS proxies the constructor is used only to stop the implementation being initialized to any version /// https://forum.openzeppelin.com/t/what-does-disableinitializers-function-mean/28730 /// @custom:oz-upgrades-unsafe-allow constructor - error TransferExceedsBalance(address from, uint256 amount, uint256 balance); - error InsufficientAllowance(address spender, uint256 currentAllowance, uint256 needed); - error StringTooLong(); - constructor( address minter_, address liquidationToken_, @@ -233,9 +237,12 @@ contract StabilityPool_v3 is uint256 minTotalAssetSupply, string memory name_, string memory symbol_ - ) MultipleRewardCompoundingAccumulator(_REWARD_MANAGER_ROLE, _REWARD_DEPOSITOR_ROLE, 1 weeks) { + ) MultipleRewardCompoundingAccumulator_v3(_REWARD_MANAGER_ROLE, _REWARD_DEPOSITOR_ROLE, 1 weeks) { _disableInitializers(); + (_ERC20_NAME_0, _ERC20_NAME_1) = _packString64(name_); + (_ERC20_SYMBOL, ) = _packString64(symbol_); address asset = IMinter(minter_).PEGGED_TOKEN(); + _ERC20_DECIMALS = IERC20Metadata(asset).decimals(); Token.sanityCheckERC20Token(asset); // slither-disable-next-line missing-zero-check ASSET_TOKEN = asset; @@ -248,20 +255,15 @@ contract StabilityPool_v3 is } LIQUIDATION_TOKEN = liquidationToken_; - (_ERC20_NAME_0, _ERC20_NAME_1) = _packString64(name_); - (_ERC20_SYMBOL, ) = _packString64(symbol_); - _ERC20_DECIMALS = IERC20Metadata(asset).decimals(); - - if (withdrawalEndWindow_ == 0) { - revert InvalidWithdrawalWindow(withdrawalStartDelay_, withdrawalEndWindow_); - } - // set these two to the same thing, for public visibility // their purpose is the same thing - preventing a complete emptying of a non-empty pool MIN_TOTAL_ASSET_SUPPLY = minTotalAssetSupply; MIN_DEPOSIT = minTotalAssetSupply; // set immutable withdrawal window params + if (withdrawalStartDelay_ == 0 || withdrawalEndWindow_ == 0) { + revert InvalidWithdrawalWindow(withdrawalStartDelay_, withdrawalEndWindow_); + } WITHDRAWAL_START_DELAY = uint64(withdrawalStartDelay_); WITHDRAWAL_END_WINDOW = uint64(withdrawalEndWindow_); } @@ -333,83 +335,6 @@ contract StabilityPool_v3 is startDelay = WITHDRAWAL_START_DELAY; endWindow = WITHDRAWAL_END_WINDOW; } - /*********************** - * ERC20 View Functions * - ***********************/ - - /// @notice Returns the ERC20 name of this stability pool token. - function name() external view returns (string memory) { - return _unpackString64(_ERC20_NAME_0, _ERC20_NAME_1); - } - - /// @notice Returns the ERC20 symbol of this stability pool token. - function symbol() external view returns (string memory) { - return _unpackString64(_ERC20_SYMBOL, bytes32(0)); - } - - /// @notice Returns the ERC20 decimals, matching the underlying asset token. - function decimals() external view returns (uint8) { - return _ERC20_DECIMALS; - } - - /// @notice Returns the compounded balance of `account` (rebasing ERC20). - /// @dev Same as assetBalanceOf. Rebases downward on liquidation events. - function balanceOf(address account) external view returns (uint256) { - StabilityPoolStorage storage $ = _getStabilityPoolStorage(); - TokenBalance memory balance = $.assetBalances[account]; - return _getCompoundedBalance(balance.amount, balance.product, $.totalAssetSupply.product); - } - - /// @notice Returns the total supply of stability pool tokens. - function totalSupply() external view returns (uint256) { - StabilityPoolStorage storage $ = _getStabilityPoolStorage(); - return $.totalAssetSupply.amount; - } - - /// @notice Returns the ERC20 allowance of `spender` for `owner_`. - function allowance(address owner_, address spender) external view returns (uint256) { - StabilityPoolERC20AllowancesStorage storage $ = _getERC20Storage(); - return $.allowances[owner_][spender]; - } - - /*************************** - * ERC20 Mutator Functions * - ***************************/ - - /// @notice Transfer stability pool tokens to `to`. Checkpoints both parties. - function transfer(address to, uint256 amount) external nonReentrant returns (bool) { - _transferBalance(_msgSender(), to, amount); - return true; - } - - /// @notice Transfer stability pool tokens from `from` to `to` using allowance. - function transferFrom(address from, address to, uint256 amount) external nonReentrant returns (bool) { - StabilityPoolERC20AllowancesStorage storage $ = _getERC20Storage(); - address spender = _msgSender(); - uint256 currentAllowance = $.allowances[from][spender]; - if (currentAllowance != type(uint256).max) { - if (currentAllowance < amount) { - revert InsufficientAllowance(spender, currentAllowance, amount); - } - unchecked { - $.allowances[from][spender] = currentAllowance - amount; - } - } - _transferBalance(from, to, amount); - return true; - } - - /// @notice Approve `spender` to transfer up to `amount` of the caller's tokens. - /// @dev Since balanceOf rebases downward on liquidation, an approval may exceed - /// the owner's balance after a loss event. transferFrom transfers up to - /// min(allowance, balance). Same behavior as stETH. - function approve(address spender, uint256 amount) external returns (bool) { - StabilityPoolERC20AllowancesStorage storage $ = _getERC20Storage(); - $.allowances[_msgSender()][spender] = amount; - emit Approval(_msgSender(), spender, amount); - return true; - } - /**************************** * Public Mutator Functions * ****************************/ @@ -430,6 +355,11 @@ contract StabilityPool_v3 is if (assetsDeposited < minAmount) { revert DepositAmountLessThanMinimum(assetsDeposited, minAmount); } + // although not strictly necessary: it is only needed for the first deposit + // we enforce this limit on all deposits because it is a small amount (1$) + if (assetsDeposited < MIN_TOTAL_ASSET_SUPPLY) { + revert DepositAmountLessThanMinimum(assetsDeposited, MIN_TOTAL_ASSET_SUPPLY); + } // Required for ERC20 compatibility - we're actually minting ourselves StabilityPoolStorage storage $ = _getStabilityPoolStorage(); @@ -454,9 +384,6 @@ contract StabilityPool_v3 is // It should never exceed `type(uint104).max`. TokenBalance memory supply = $.totalAssetSupply; supply.amount += uint104(assetsDeposited); - if (supply.amount < MIN_TOTAL_ASSET_SUPPLY) { - revert DepositAmountLessThanMinimum(assetsDeposited, MIN_TOTAL_ASSET_SUPPLY); - } supply.updatedAt = uint40(block.timestamp); _recordTotalSupply(supply); @@ -504,14 +431,6 @@ contract StabilityPool_v3 is revert WithdrawAmountLessThanMinimum(assetsWithdrawn, minAmount); } - // Floor the total supply at the minimum. - // assetsWithdrawn is the user's requested amount (or balance if max). - // Both assetsWithdrawn and any fee come out of total supply. - TokenBalance memory supply = $.totalAssetSupply; - if (supply.amount - assetsWithdrawn < MIN_TOTAL_ASSET_SUPPLY) { - assetsWithdrawn = supply.amount - MIN_TOTAL_ASSET_SUPPLY; - } - // Determine fee policy // - If no request: fee applies // - If request exists: fee applies outside [start, end]; no fee during window @@ -521,15 +440,21 @@ contract StabilityPool_v3 is // Role-based fee exemption: addresses with EXEMPT_WITHDRAWAL_FEE_ROLE never pay early-withdrawal fees bool isExempt = hasAnyRole(sender, EXEMPT_WITHDRAWAL_FEE_ROLE); if (!inWindow && !isExempt) { - feeAmount = Math.mulDiv( - assetsWithdrawn, - uint256($.feePayment.earlyWithdrawalFee), - 1 ether, - Math.Rounding.Ceil - ); + feeAmount = (assetsWithdrawn * uint256($.feePayment.earlyWithdrawalFee)) / 1 ether; assetsWithdrawn -= feeAmount; } + // floor the total supply at the minimum + TokenBalance memory supply = $.totalAssetSupply; + if (supply.amount - assetsWithdrawn < MIN_TOTAL_ASSET_SUPPLY) { + assetsWithdrawn = supply.amount - MIN_TOTAL_ASSET_SUPPLY; + // if fee pushed us below min, trim fee as well + if (supply.amount - assetsWithdrawn - feeAmount < MIN_TOTAL_ASSET_SUPPLY) { + uint256 maxFee = supply.amount - MIN_TOTAL_ASSET_SUPPLY - assetsWithdrawn; + if (feeAmount > maxFee) feeAmount = maxFee; + } + } + // Close any existing withdrawal request after successful withdrawal if (hasRequest) { $.withdrawalRequests[sender] = WithdrawalRequest({start: 0, end: 0}); @@ -567,10 +492,6 @@ contract StabilityPool_v3 is function requestWithdrawal() external nonReentrant { address sender = _msgSender(); StabilityPoolStorage storage $ = _getStabilityPoolStorage(); - // Guard against unconfigured window in implementation (constructor ensures > 0) - if (WITHDRAWAL_END_WINDOW == 0) { - revert InvalidWithdrawalWindow(WITHDRAWAL_START_DELAY, WITHDRAWAL_END_WINDOW); - } uint64 start = uint64(block.timestamp + WITHDRAWAL_START_DELAY); uint64 end = uint64(start + WITHDRAWAL_END_WINDOW); $.withdrawalRequests[sender] = WithdrawalRequest({start: start, end: end}); @@ -581,7 +502,7 @@ contract StabilityPool_v3 is * Internal Functions * **********************/ - /// @inheritdoc MultipleRewardCompoundingAccumulator + /// @inheritdoc MultipleRewardCompoundingAccumulator_v3 // slither-disable-next-line reentrancy-events,reentrancy-benign,reentrancy-no-eth // function is only called from nonReentrant external functions function _checkpoint(address account) internal virtual override { StabilityPoolStorage storage $ = _getStabilityPoolStorage(); @@ -601,7 +522,7 @@ contract StabilityPool_v3 is } } - /// @inheritdoc MultipleRewardCompoundingAccumulator + /// @inheritdoc MultipleRewardCompoundingAccumulator_v3 function _getTotalPoolShare() internal view virtual override returns (uint128 currentProd, uint256 totalShare) { StabilityPoolStorage storage $ = _getStabilityPoolStorage(); TokenBalance memory supply = $.totalAssetSupply; @@ -609,7 +530,7 @@ contract StabilityPool_v3 is totalShare = supply.amount; } - /// @inheritdoc MultipleRewardCompoundingAccumulator + /// @inheritdoc MultipleRewardCompoundingAccumulator_v3 function _getUserPoolShare( address account ) internal view virtual override returns (uint128 previousProd, uint256 share) { @@ -691,11 +612,116 @@ contract StabilityPool_v3 is $.totalAssetSupply = supply; } - // ERC20 internal helpers + // Rebalancing support // ------------------------------------------------------- + /// @notice function used to control access to the sweep function for extracting harvestable amounts + function _checkSweeper() internal view override(TokenHolder) { + _checkOwnerOrRoles(REBALANCER_ROLE); + } + + /// @inheritdoc IStabilityPool + // slither-disable-next-line reentrancy-no-eth,reentrancy-benign should only ever called from nonReentrant functions + function notifyLiquidation(uint256 liquidated, uint256 returned) external onlyRoles(REBALANCER_ROLE) { + // Emit liquidation event to record loss and conversion details + emit Liquidated(ASSET_TOKEN, liquidated, LIQUIDATION_TOKEN, returned); + // recalculate balances and + // make sure rewards in-flight rewards are distributed on the pre-loss balances + _checkpoint(address(0)); + + // capture the reward, distributed immediately, at the prior-to-loss balances + _accumulateReward(LIQUIDATION_TOKEN, returned); + + // update balances due to loss + _notifyLoss(liquidated); + } + + // ═══════════════════════════════════════════════════════════════════════ + // ERC20 View Functions + // ═══════════════════════════════════════════════════════════════════════ + + function name() external view returns (string memory) { + return _unpackString64(_ERC20_NAME_0, _ERC20_NAME_1); + } + + function symbol() external view returns (string memory) { + return _unpackString64(_ERC20_SYMBOL, bytes32(0)); + } + + function decimals() external view returns (uint8) { + return _ERC20_DECIMALS; + } + + function balanceOf(address account) external view returns (uint256) { + StabilityPoolStorage storage $ = _getStabilityPoolStorage(); + TokenBalance memory balance = $.assetBalances[account]; + return _getCompoundedBalance(balance.amount, balance.product, $.totalAssetSupply.product); + } + + function totalSupply() external view returns (uint256) { + StabilityPoolStorage storage $ = _getStabilityPoolStorage(); + return $.totalAssetSupply.amount; + } + + function allowance(address owner_, address spender) external view returns (uint256) { + StabilityPoolERC20AllowancesStorage storage $ = _getERC20Storage(); + return $.allowances[owner_][spender]; + } + + // ═══════════════════════════════════════════════════════════════════════ + // Selective Claim + // ═══════════════════════════════════════════════════════════════════════ + + /// @inheritdoc IStabilityPool_v3 + function claimSingle(address account, address token) external nonReentrant { + _checkpoint(account); + _claimSingle(account, token, account); + } + + /// @inheritdoc IStabilityPool_v3 + function claimSingle(address account, address token, address receiver) external nonReentrant { + if (account != _msgSender() && receiver != address(0)) { + revert ClaimOthersRewardToAnother(); + } + _checkpoint(account); + _claimSingle(account, token, receiver); + } + + // ═══════════════════════════════════════════════════════════════════════ + // ERC20 Mutator Functions + // ═══════════════════════════════════════════════════════════════════════ + + function transfer(address to, uint256 amount) external nonReentrant returns (bool) { + _transferBalance(_msgSender(), to, amount); + return true; + } + + function transferFrom(address from, address to, uint256 amount) external nonReentrant returns (bool) { + StabilityPoolERC20AllowancesStorage storage $ = _getERC20Storage(); + address spender = _msgSender(); + uint256 currentAllowance = $.allowances[from][spender]; + if (currentAllowance != type(uint256).max) { + if (currentAllowance < amount) { + revert InsufficientAllowance(spender, currentAllowance, amount); + } + unchecked { + $.allowances[from][spender] = currentAllowance - amount; + } + } + _transferBalance(from, to, amount); + return true; + } + + function approve(address spender, uint256 amount) external returns (bool) { + StabilityPoolERC20AllowancesStorage storage $ = _getERC20Storage(); + $.allowances[_msgSender()][spender] = amount; + emit Approval(_msgSender(), spender, amount); + return true; + } + + // ═══════════════════════════════════════════════════════════════════════ + // ERC20 Internal Helpers + // ═══════════════════════════════════════════════════════════════════════ - /// @dev Transfer compounded balance between two accounts. - /// Checkpoints both parties to update rewards at pre-transfer balances, then moves the amount. function _transferBalance(address from, address to, uint256 amount) internal { if (from == address(0) || to == address(0)) { revert InvalidReceiver(address(0)); @@ -727,7 +753,6 @@ contract StabilityPool_v3 is emit Transfer(from, to, amount); } - /// @dev Pack a string (up to 64 chars) into two bytes32 values. function _packString64(string memory s) internal pure returns (bytes32 b0, bytes32 b1) { bytes memory b = bytes(s); if (b.length > 64) { @@ -746,15 +771,18 @@ contract StabilityPool_v3 is } } - /// @dev Unpack two bytes32 values back to a string, trimming trailing zeros. function _unpackString64(bytes32 b0, bytes32 b1) internal pure returns (string memory) { uint256 len0; for (len0 = 32; len0 > 0; len0--) { - if (b0[len0 - 1] != 0) break; + if (b0[len0 - 1] != 0) { + break; + } } uint256 len1; for (len1 = 32; len1 > 0; len1--) { - if (b1[len1 - 1] != 0) break; + if (b1[len1 - 1] != 0) { + break; + } } bytes memory result = new bytes(len0 + len1); for (uint256 i = 0; i < len0; i++) { @@ -765,29 +793,6 @@ contract StabilityPool_v3 is } return string(result); } - - // Rebalancing support - // ------------------------------------------------------- - /// @notice function used to control access to the sweep function for extracting harvestable amounts - function _checkSweeper() internal view override(TokenHolder) { - _checkOwnerOrRoles(REBALANCER_ROLE); - } - - /// @inheritdoc IStabilityPool - // slither-disable-next-line reentrancy-no-eth,reentrancy-benign should only ever called from nonReentrant functions - function notifyLiquidation(uint256 liquidated, uint256 returned) external onlyRoles(REBALANCER_ROLE) { - // Emit liquidation event to record loss and conversion details - emit Liquidated(ASSET_TOKEN, liquidated, LIQUIDATION_TOKEN, returned); - // recalculate balances and - // make sure rewards in-flight rewards are distributed on the pre-loss balances - _checkpoint(address(0)); - - // capture the reward, distributed immediately, at the prior-to-loss balances - _accumulateReward(LIQUIDATION_TOKEN, returned); - - // update balances due to loss - _notifyLoss(liquidated); - } } // slither-disable-end timestamp diff --git a/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol b/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol index 922a5717..50099355 100644 --- a/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol +++ b/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol @@ -10,7 +10,7 @@ import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; import {DecrementalFloatingPoint} from "src/math/DecrementalFloatingPoint.sol"; -import {LinearMultipleRewardDistributor} from "src/reward/distributor/LinearMultipleRewardDistributor_v2.sol"; +import {LinearMultipleRewardDistributor_v3} from "src/reward/distributor/LinearMultipleRewardDistributor_v3.sol"; // solhint-disable not-rely-on-time @@ -112,9 +112,9 @@ import {LinearMultipleRewardDistributor} from "src/reward/distributor/LinearMult /// @dev The method comes from liquity's StabilityPool, the paper is in /// https://github.com/liquity/dev/blob/main/papers/Scalable_Reward_Distribution_with_Compounding_Stakes.pdf -abstract contract MultipleRewardCompoundingAccumulator is +abstract contract MultipleRewardCompoundingAccumulator_v3 is ReentrancyGuardTransientUpgradeable, - LinearMultipleRewardDistributor, + LinearMultipleRewardDistributor_v3, IMultipleRewardAccumulator { using SafeERC20 for IERC20; @@ -127,6 +127,14 @@ abstract contract MultipleRewardCompoundingAccumulator is /// @dev The precision used to calculate accumulated rewards. uint256 internal constant _REWARD_PRECISION = 1e18; + /// @dev Compiler will pack this into single `uint256`. + struct RewardSnapshot { + // The timestamp when the snapshot is updated. + uint64 timestamp; + // The reward integral until now. + uint192 integral; + } + /// @dev Compiler will pack this into single `uint256`. struct ClaimData { // The number of pending rewards. @@ -135,13 +143,21 @@ abstract contract MultipleRewardCompoundingAccumulator is uint128 claimed; } - /// @dev User reward snapshot. Occupies 3 slots. + /// @dev Compiler will pack this into two `uint256`. + struct UserRewardSnapshot { + // The claim data for the user. + ClaimData rewards; + // The reward snapshot for user. + RewardSnapshot checkpoint; + } + + /// @dev V2: widened integral from uint192 to uint256. Occupies 3 slots. struct UserRewardSnapshotV2 { // The claim data for the user. ClaimData rewards; - // The timestamp when the snapshot is updated. + // The timestamp when the snapshot is updated. Non-zero indicates V2 data is populated. uint64 timestamp; - // The reward integral until now. + // The reward integral until now (widened from uint192). uint256 integral; } @@ -159,10 +175,12 @@ abstract contract MultipleRewardCompoundingAccumulator is /// /// @dev The integral is defined as 1e18 * ∫(rate(t) * prod(t) / totalPoolShare(t) dt). mapping(address => mapping(uint8 => uint256)) tokenToExponentToIntegral; - /// @dev V1 mapping slot. No longer read after force migration. Kept for storage layout. - mapping(address => mapping(address => bytes)) legacyUserRewardSnapshot; - /// @notice Mapping from user address to reward token address to user reward snapshot. - mapping(address => mapping(address => UserRewardSnapshotV2)) userRewardSnapshot; + /// @notice V1: Mapping from user address to reward token address to user reward snapshot. + /// @dev Kept for migration fallback. New data is written to userRewardSnapshotV2. + mapping(address => mapping(address => UserRewardSnapshot)) userRewardSnapshot; + /// @notice V2: Mapping from user address to reward token address to user reward snapshot. + /// @dev Uses widened uint256 integral. All new writes go here. + mapping(address => mapping(address => UserRewardSnapshotV2)) userRewardSnapshotV2; } // slither-disable-next-line dead-code @@ -171,15 +189,37 @@ abstract contract MultipleRewardCompoundingAccumulator is globalIntegral = $.tokenToExponentToIntegral[token][exponent]; } + /// @dev Returns the full user reward snapshot with V2-first migration detection. + /// Centralises all migration logic in one place so callers don't need to know about V1/V2. + /// Fast path (migrated, integral > 0): 3 SLOADs from V2. + /// Rare path (migrated, integral = 0): 3 SLOADs from V2. + /// Fallback (unmigrated): 2 SLOADs from V1. function _getUserRewardSnapshot( address account, address token ) internal view returns (uint64 timestamp, uint256 integral, uint128 pending, uint128 claimed_) { MultipleRewardCompoundingAccumulatorStorage storage $ = _getMultipleRewardCompoundingAccumulatorStorage(); - UserRewardSnapshotV2 storage v2 = $.userRewardSnapshot[account][token]; - return (v2.timestamp, v2.integral, v2.rewards.pending, v2.rewards.claimed); + + // Fast path: check V2 integral (1 SLOAD) + uint256 v2Integral = $.userRewardSnapshotV2[account][token].integral; + if (v2Integral != 0) { + UserRewardSnapshotV2 storage v2 = $.userRewardSnapshotV2[account][token]; + return (v2.timestamp, v2Integral, v2.rewards.pending, v2.rewards.claimed); + } + + // Rare path: V2 integral is 0 — check if V2 is populated via timestamp (2 SLOADs) + uint64 v2Timestamp = $.userRewardSnapshotV2[account][token].timestamp; + if (v2Timestamp != 0) { + UserRewardSnapshotV2 storage v2 = $.userRewardSnapshotV2[account][token]; + return (v2Timestamp, 0, v2.rewards.pending, v2.rewards.claimed); + } + + // Not migrated: fall back to V1 (2 SLOADs from different mapping) + UserRewardSnapshot storage v1 = $.userRewardSnapshot[account][token]; + return (v1.checkpoint.timestamp, uint256(v1.checkpoint.integral), v1.rewards.pending, v1.rewards.claimed); } + /// @dev Writes the user reward snapshot to V2 storage. Always writes to V2. function _setUserRewardSnapshot( address account, address token, @@ -189,7 +229,7 @@ abstract contract MultipleRewardCompoundingAccumulator is uint128 claimed_ ) internal { MultipleRewardCompoundingAccumulatorStorage storage $ = _getMultipleRewardCompoundingAccumulatorStorage(); - UserRewardSnapshotV2 storage v2 = $.userRewardSnapshot[account][token]; + UserRewardSnapshotV2 storage v2 = $.userRewardSnapshotV2[account][token]; v2.rewards.pending = pending; v2.rewards.claimed = claimed_; v2.timestamp = timestamp; @@ -231,7 +271,7 @@ abstract contract MultipleRewardCompoundingAccumulator is uint256 rewardManagerRole, uint256 rewardDepositorRole, uint40 periodLength - ) LinearMultipleRewardDistributor(rewardManagerRole, rewardDepositorRole, periodLength) {} + ) LinearMultipleRewardDistributor_v3(rewardManagerRole, rewardDepositorRole, periodLength) {} /************************* * Public View Functions * @@ -488,14 +528,14 @@ abstract contract MultipleRewardCompoundingAccumulator is if (amount > 0) { _setUserRewardSnapshot(account, token, ts, integral, 0, claimed_ + pending); - IERC20(token).safeTransfer(receiver, amount); + IERC20(_resolveUnderlying(token)).safeTransfer(receiver, amount); emit Claim(account, token, receiver, amount); } return amount; } - /// @inheritdoc LinearMultipleRewardDistributor + /// @inheritdoc LinearMultipleRewardDistributor_v3 function _accumulateReward(address token, uint256 amount) internal virtual override { // slither-disable-next-line incorrect-equality if (amount == 0) { @@ -503,6 +543,7 @@ abstract contract MultipleRewardCompoundingAccumulator is } (uint128 currentProd, uint256 totalShare) = _getTotalPoolShare(); + if (totalShare == 0) { // no deposits, queue rewards _getRewardData(token).queued += uint96(amount); @@ -510,10 +551,13 @@ abstract contract MultipleRewardCompoundingAccumulator is } uint8 exponent = currentProd.exponent(); + uint256 magnitude = uint256(currentProd.magnitude()); MultipleRewardCompoundingAccumulatorStorage storage $ = _getMultipleRewardCompoundingAccumulatorStorage(); uint256 integral = $.tokenToExponentToIntegral[token][exponent]; - integral += Math.mulDiv(amount * _REWARD_PRECISION, uint256(currentProd.magnitude()), totalShare); + + uint256 toAdd = Math.mulDiv(amount * _REWARD_PRECISION, magnitude, totalShare); + integral += toAdd; $.tokenToExponentToIntegral[token][exponent] = integral; } diff --git a/src/reward/distributor/LinearMultipleRewardDistributor_v3.sol b/src/reward/distributor/LinearMultipleRewardDistributor_v3.sol new file mode 100644 index 00000000..d20261eb --- /dev/null +++ b/src/reward/distributor/LinearMultipleRewardDistributor_v3.sol @@ -0,0 +1,313 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.30; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {ContextUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ContextUpgradeable.sol"; +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; + +import {BaoOwnableRoles} from "@bao/BaoOwnableRoles.sol"; + +import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; +import {IRewardAlias} from "src/interfaces/IRewardAlias.sol"; +import {LinearReward} from "./LinearReward.sol"; + +// solhint-disable no-empty-blocks +// solhint-disable not-rely-on-time + +/// @title Linear Multiple Reward Distributor +/// @dev A base contract for distributing multiple reward tokens linearly over time. +/// +/// This contract manages the registration, tracking, and linear distribution of +/// multiple reward tokens. It maintains a list of active and historical reward tokens, +/// associates distributors using roles based access, and calculates distribution rates +/// over defined time periods. +/// +/// Key features: +/// - Register and unregister reward tokens +/// - Configure linear reward distribution with customizable period lengths +/// - Track pending and distributed rewards +/// - Manage active and historical reward tokens +/// +/// The contract uses a role-based access control system to manage distributors +/// and supports immediate or time-based reward distribution depending on the +/// configured period length. + +abstract contract LinearMultipleRewardDistributor_v3 is + Initializable, + ContextUpgradeable, + BaoOwnableRoles, + IMultipleRewardDistributor +{ + using EnumerableSet for EnumerableSet.AddressSet; + using SafeERC20 for IERC20; + + using LinearReward for LinearReward.RewardData; + + /************* + * Constants * + *************/ + + /// @notice The role used to manage rewards. + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + uint256 public immutable REWARD_MANAGER_ROLE; + + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + uint256 public immutable REWARD_DEPOSITOR_ROLE; + + /// @notice The length of reward period in seconds. + /// @dev If the value is zero, the reward will be distributed immediately. + /// @dev It is either zero or at least 1 day (which is 86400). + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + uint40 public immutable REWARD_PERIOD_LENGTH; + + /************* + * Variables * + *************/ + + struct LinearMultipleRewardDistributorStorage { + /// @notice Mapping from reward token address to linear distribution reward data. + mapping(address => LinearReward.RewardData) rewardData; + /// @dev The list of active reward tokens. + EnumerableSet.AddressSet activeRewardTokens; + /// @dev The list of historical reward tokens. + EnumerableSet.AddressSet historicalRewardTokens; + /// @dev Alias: token address => underlying token address. address(0) = not an alias. + mapping(address => address) aliasUnderlying; + /// @dev Reverse: underlying token => aliases pointing to it. + mapping(address => EnumerableSet.AddressSet) underlyingAliases; + } + + // chisel eval 'keccak256(abi.encode(uint256(keccak256("bao.storage.LinearMultipleRewardDistributor")) - 1)) & ~bytes32(uint256(0xff))' + bytes32 private constant _LINEARMULTIPLEREWARDDISTRIBUTOR_STORAGE = + 0xe9dd8489e2940f6fb582767a094c112cfce2739b7a5f3357b085cab0a6a7d300; + + function _getLinearMultipleRewardDistributorStorage() + private + pure + returns (LinearMultipleRewardDistributorStorage storage $) + { + // solhint-disable-next-line no-inline-assembly + assembly { + $.slot := _LINEARMULTIPLEREWARDDISTRIBUTOR_STORAGE + } + } + + /*************** + * Constructor * + ***************/ + /// @dev there is no need for an initializer + /// @dev abstract classes should not define role numbers, so pass them in + /// @custom:oz-upgrades-unsafe-allow constructor + constructor(uint256 rewardManagerRole, uint256 rewardDepositorRole, uint40 periodLength_) { + REWARD_MANAGER_ROLE = rewardManagerRole; + + if (periodLength_ != 0 && (periodLength_ < 1 days || periodLength_ > 28 days)) { + revert InvalidPeriodLength(periodLength_); + } + REWARD_PERIOD_LENGTH = periodLength_; + REWARD_DEPOSITOR_ROLE = rewardDepositorRole; + } + + /************************* + * Public View Functions * + *************************/ + + /// @inheritdoc IMultipleRewardDistributor + function rewardData( + address token + ) external view returns (uint256 lastUpdate, uint256 finishAt, uint256 rate, uint256 queued) { + LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); + LinearReward.RewardData memory data = $.rewardData[token]; + return (data.lastUpdate, data.finishAt, data.rate, data.queued); + } + + /// @inheritdoc IMultipleRewardDistributor + // slither-disable-next-line shadowing-local // this isn't shadowing, it's implementing an interface + function activeRewardTokens() public view override returns (address[] memory rewardTokens) { + LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); + rewardTokens = $.activeRewardTokens.values(); + } + + /// @inheritdoc IMultipleRewardDistributor + function isActiveRewardToken(address token) public view returns (bool isActive) { + LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); + isActive = $.activeRewardTokens.contains(token); + } + + /// @inheritdoc IMultipleRewardDistributor + function historicalRewardTokens() public view override returns (address[] memory rewardTokens) { + LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); + rewardTokens = $.historicalRewardTokens.values(); + } + + /// @inheritdoc IMultipleRewardDistributor + function pendingRewards( + address token + ) external view override returns (uint256 distributable, uint256 undistributed) { + (distributable, undistributed) = _pendingRewards(token); + } + + /**************************** + * Public Mutator Functions * + ****************************/ + + /// @inheritdoc IMultipleRewardDistributor + function depositReward(address token, uint256 amount) external override onlyOwnerOrRoles(REWARD_DEPOSITOR_ROLE) { + address _distributor = _msgSender(); + LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); + + if (!$.activeRewardTokens.contains(token)) { + revert NotActiveRewardToken(); + } + if (amount > 0) { + IERC20(_resolveUnderlying(token)).safeTransferFrom(_distributor, address(this), amount); + } + + _distributePendingReward(); + + _notifyReward(token, amount); + + emit DepositReward(token, amount); + } + + /************************ + * Restricted Functions * + ************************/ + + /// @inheritdoc IMultipleRewardDistributor + function registerRewardToken(address token) external onlyOwnerOrRoles(REWARD_MANAGER_ROLE) { + if (token == address(0)) { + revert RewardTokenIsZero(); + } + LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); + + if (!$.activeRewardTokens.add(token)) { + revert DuplicatedRewardToken(); // if value was not added then it already exists + } + // slither-disable-next-line unused-return we don't care if the the token was already in the set + $.historicalRewardTokens.remove(token); // wake-disable-line unchecked-return-value + + // Detect alias: if token implements IRewardAlias.underlying() and returns non-zero + address underlying = _tryGetUnderlying(token); + if (underlying != address(0)) { + $.aliasUnderlying[token] = underlying; + $.underlyingAliases[underlying].add(token); + } + + emit RegisterRewardToken(token); + } + + /// @inheritdoc IMultipleRewardDistributor + function unregisterRewardToken(address token) external onlyOwnerOrRoles(REWARD_MANAGER_ROLE) { + LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); + + if (!$.activeRewardTokens.remove(token)) { + revert NotActiveRewardToken(); + } + LinearReward.RewardData memory _data = $.rewardData[token]; + unchecked { + (uint256 _distributable, uint256 _undistributed) = _data.pending(); + if (_data.queued < REWARD_PERIOD_LENGTH) { + _data.queued = 0; // ignore round error + } + if (_data.queued + _distributable + _undistributed > 0) { + revert RewardDistributionNotFinished(); + } + } + + // slither-disable-next-line unused-return we don't care if the the token was already in the set + $.historicalRewardTokens.add(token); // wake-disable-line unchecked-return-value + emit UnregisterRewardToken(token); + } + + /********************** + * Internal Functions * + **********************/ + + /// @dev Internal function to notify new rewards. + /// + /// @param token The address of token. + /// @param amount The amount of new rewards. + function _notifyReward(address token, uint256 amount) internal { + LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); + + if (REWARD_PERIOD_LENGTH == 0) { + _accumulateReward(token, amount); + } else { + LinearReward.RewardData memory data = $.rewardData[token]; + data.increase(REWARD_PERIOD_LENGTH, amount); + $.rewardData[token] = data; + } + } + + /// @dev Internal function to distribute all pending reward tokens. + function _distributePendingReward() internal { + LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); + + // If the reward period length is zero, we distribute rewards immediately. + // If there are no active reward tokens, we do nothing. + if (REWARD_PERIOD_LENGTH == 0 || $.activeRewardTokens.length() == 0) { + return; + } + address[] memory activeRewardTokens_ = $.activeRewardTokens.values(); + for (uint256 i = 0; i < activeRewardTokens_.length; i++) { + address token = activeRewardTokens_[i]; + + // slither-disable-next-line unused-return + (uint256 pending, ) = $.rewardData[token].pending(); + + $.rewardData[token].lastUpdate = uint40(block.timestamp); + + if (pending > 0) { + _accumulateReward(token, pending); + } + } + } + + function _getRewardData(address token) internal view returns (LinearReward.RewardData storage) { + LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); + return $.rewardData[token]; + } + + /// @dev Internal function to accumulate distributed rewards. + /// @dev derived contracts should implement this + /// @param token The address of token. + /// @param amount The amount of rewards to accumulate. + function _accumulateReward(address token, uint256 amount) internal virtual; + + function _pendingRewards(address token) internal view returns (uint256 distributable, uint256 undistributed) { + LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); + (distributable, undistributed) = $.rewardData[token].pending(); + } + + // ═══════════════════════════════════════════════════════════════════════ + // Alias support + // ═══════════════════════════════════════════════════════════════════════ + + /// @dev Try to read underlying() from a token. Returns address(0) if not an alias. + function _tryGetUnderlying(address token) internal view returns (address underlying) { + (bool success, bytes memory data) = token.staticcall(abi.encodeCall(IRewardAlias.underlying, ())); + if (success && data.length >= 32) { + underlying = abi.decode(data, (address)); + } + } + + /// @dev Returns the underlying token for transfers. If not an alias, returns the token itself. + function _resolveUnderlying(address token) internal view returns (address) { + LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); + address underlying = $.aliasUnderlying[token]; + if (underlying != address(0)) { + return underlying; + } + return token; + } + + /// @dev Returns all aliases registered for an underlying token. + function _getAliases(address underlying) internal view returns (address[] memory) { + LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); + return $.underlyingAliases[underlying].values(); + } +} diff --git a/test/RebalanceFairness.t.sol b/test/RebalanceFairness.t.sol new file mode 100644 index 00000000..12f897cd --- /dev/null +++ b/test/RebalanceFairness.t.sol @@ -0,0 +1,427 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.28 <0.9.0; + +import {BaoTest} from "@bao-test/BaoTest.sol"; +import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; +import {Deploy_ETH_Minter} from "script/src/Deploy_ETH_Minter.sol"; +import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; +import {Config_MinterMarket, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IMinter} from "src/interfaces/IMinter.sol"; +import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; +import {IStabilityPoolManager} from "src/interfaces/IStabilityPoolManager.sol"; +import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; +import {IWrappedPriceOracle} from "src/interfaces/IWrappedPriceOracle.sol"; +import {Minter_v2} from "src/minter/Minter_v2.sol"; +import {StabilityPoolManager_v1} from "src/minter/StabilityPoolManager_v1.sol"; +import {MockWrappedPriceOracle} from "test/mocks/MockWrappedPriceOracle.sol"; + +import {console2} from "forge-std/console2.sol"; + +/// @title RebalanceFairnessTest +/// @notice Worked example from doc/ideas/sp-dynamic-fees.md using real contract code +/// deployed via the production deployment scripts. Simulates all actors through +/// rebalance scenarios to measure the exact income redistribution. +contract RebalanceFairnessSetUp is BaoTest, Deploy_ETH_Minter { + using MinterMarketConfigLib for Config_MinterMarket; + + // Deployed contract addresses + address minter; + address stabilityPoolCollateral; + address stabilityPoolLeveraged; + address stabilityPoolManager; + address pegged; + address leveraged; + address wrappedCollateral; + + // Mock oracle for price/rate control + MockWrappedPriceOracle mockOracle; + uint256 oraclePrice; + uint256 oracleRate; + + // Cast — 6 actors, equal amounts, 2 per pool initially (equal pool sizes) + address alice; // Stays in Collateral SP + address bob; // Withdraws from Coll SP before rebalance, re-deposits after + address charlie; // Stays in Leveraged SP + address dave; // Withdraws from Lev SP before rebalance, re-deposits after + address fred; // Outside SPs, deposits into Coll SP after rebalance + address george; // Outside SPs, deposits into Lev SP after rebalance + + function _shouldPersistState() internal pure override returns (bool) { + return false; + } + + function setUp() public virtual { + // Deploy BaoFactory locally + address factory = _ensureBaoFactory(); + + // Fork mainnet so real token contracts (fxSAVE, fxUSD, etc.) exist + uint256 forkId = vm.createSelectFork(vm.rpcUrl("mainnet")); + vm.selectFork(forkId); + + // Register as factory operator + vm.prank(IBaoFactory(factory).owner()); + IBaoFactory(factory).setOperator(address(this), 365 days); + + // Deploy a fresh ETH::fxUSD market via the production deployment scripts + (ConfigPeg peg, Config_MinterMarket[] memory mktConfigs) = createETHMintersConfig(); + // Deploy only the fxUSD market (index 0) + Config_MinterMarket[] memory toDeploy = new Config_MinterMarket[](1); + toDeploy[0] = mktConfigs[0]; + deployForPeg("fairness_test", peg, mktConfigs, "mainnet", true, toDeploy); + + // Resolve deployed addresses + _setSaltPrefix("fairness_test"); + minter = _predictAddress("ETH", "fxUSD", "minter"); + stabilityPoolCollateral = _predictAddress("ETH", "fxUSD", "stabilityPoolCollateral"); + stabilityPoolLeveraged = _predictAddress("ETH", "fxUSD", "stabilityPoolLeveraged"); + stabilityPoolManager = _predictAddress("ETH", "fxUSD", "stabilityPoolManager"); + pegged = _predictAddress("ETH", "pegged"); + leveraged = _predictAddress("ETH", "fxUSD", "leveraged"); + wrappedCollateral = IMinter(minter).WRAPPED_COLLATERAL_TOKEN(); + + // Install mock oracle so we can control price/rate + // The deployment script sets the oracle to a predicted address that doesn't exist yet + // (oracles are deployed separately). Override it with our mock. + mockOracle = new MockWrappedPriceOracle(); + // Price = 1 so collateral and pegged amounts are in the same units (simplifies balance sheets) + // Rate = 1 means 1 fxSAVE = 1 fxUSD (no yield accrued yet) + oraclePrice = 1 ether; + oracleRate = 1 ether; + mockOracle.setLatestAnswer(oraclePrice, oracleRate); + + vm.prank(Minter_v2(minter).owner()); + Minter_v2(minter).updatePriceOracle(address(mockOracle)); + + // Override harvest config: set cut to 0 so harvest goes to pools, not treasury + vm.startPrank(StabilityPoolManager_v1(stabilityPoolManager).owner()); + StabilityPoolManager_v1(stabilityPoolManager).updateHarvestCutRatio(0); + StabilityPoolManager_v1(stabilityPoolManager).updateHarvestBountyRatio(0); + StabilityPoolManager_v1(stabilityPoolManager).updateRebalanceBountyRatio(0); + vm.stopPrank(); + + // Create actors + alice = makeAddr("alice"); + bob = makeAddr("bob"); + charlie = makeAddr("charlie"); + dave = makeAddr("dave"); + fred = makeAddr("fred"); + george = makeAddr("george"); + + // Approve both pools for all actors + address[6] memory actors = [alice, bob, charlie, dave, fred, george]; + for (uint256 i = 0; i < actors.length; i++) { + vm.startPrank(actors[i]); + IERC20(pegged).approve(stabilityPoolCollateral, type(uint256).max); + IERC20(pegged).approve(stabilityPoolLeveraged, type(uint256).max); + vm.stopPrank(); + } + } + + // ═══════════════════════════════════════════════════════════════ + // Minting helpers — use the zero-fee role to mint pegged/leveraged + // ═══════════════════════════════════════════════════════════════ + + function _mintPegged(address to, uint256 collateralAmount) internal returns (uint256 peggedMinted) { + deal(wrappedCollateral, address(this), collateralAmount); + IERC20(wrappedCollateral).approve(minter, collateralAmount); + + // Mint via zero-fee — this test contract has owner privileges from deployment + uint256 zeroFeeRole = IMinter(minter).ZERO_FEE_ROLE(); + vm.prank(Minter_v2(minter).owner()); + Minter_v2(minter).grantRoles(address(this), zeroFeeRole); + + peggedMinted = IMinter(minter).freeMintPeggedToken(collateralAmount, to); + } + + function _mintLeveraged(address to, uint256 collateralAmount) internal returns (uint256 levMinted) { + deal(wrappedCollateral, address(this), collateralAmount); + IERC20(wrappedCollateral).approve(minter, collateralAmount); + + uint256 zeroFeeRole = IMinter(minter).ZERO_FEE_ROLE(); + vm.prank(Minter_v2(minter).owner()); + Minter_v2(minter).grantRoles(address(this), zeroFeeRole); + + levMinted = IMinter(minter).freeMintLeveragedToken(collateralAmount, to); + } + + // ═══════════════════════════════════════════════════════════════ + // SP helpers + // ═══════════════════════════════════════════════════════════════ + + function _deposit(address pool, address who, uint256 amount) internal { + vm.prank(who); + IStabilityPool(pool).deposit(amount, who, 0); + } + + function _withdrawAll(address pool, address who) internal { + // Use request + window to avoid early withdrawal fee + vm.prank(who); + IStabilityPool(pool).requestWithdrawal(); + (uint64 start, ) = IStabilityPool(pool).getWithdrawalRequest(who); + vm.warp(uint256(start) + 1); + vm.prank(who); + IStabilityPool(pool).withdraw(type(uint256).max, who, 0); + } + + function _triggerHarvest() internal returns (uint256 harvested) { + // Increase rate by 5% to simulate yield accrual + oracleRate = oracleRate * 105 / 100; + mockOracle.setLatestAnswer(oraclePrice, oracleRate); + harvested = IStabilityPoolManager(stabilityPoolManager).harvest(makeAddr("bountyReceiver"), 0); + } + + // ═══════════════════════════════════════════════════════════════ + // Logging + // ═══════════════════════════════════════════════════════════════ + + function _logState(string memory label) internal view { + console2.log(""); + console2.log("=== %s ===", label); + console2.log("Minter CR: %e", IMinter(minter).collateralRatio()); + console2.log("Minter harvestable: %e", IMinter(minter).harvestable()); + console2.log("Minter wstETH: %e", IERC20(wrappedCollateral).balanceOf(minter)); + console2.log("Coll SP pegged bal: %e", IERC20(pegged).balanceOf(stabilityPoolCollateral)); + console2.log("Lev SP pegged bal: %e", IERC20(pegged).balanceOf(stabilityPoolLeveraged)); + console2.log("Coll SP wstETH bal: %e", IERC20(wrappedCollateral).balanceOf(stabilityPoolCollateral)); + console2.log("Lev SP lev token bal: %e", IERC20(leveraged).balanceOf(stabilityPoolLeveraged)); + console2.log("Rebalance threshold: %e", IStabilityPoolManager(stabilityPoolManager).rebalanceThreshold()); + } + + // ═══════════════════════════════════════════════════════════════ + // Claimable snapshots — to separate rebalance from harvest + // ═══════════════════════════════════════════════════════════════ + + struct ClaimableSnapshot { + uint256 fxSAVE_collSP; // fxSAVE claimable from Coll SP (rebal + harvest combined) + uint256 fxSAVE_levSP; // fxSAVE claimable from Lev SP (harvest only — rebal pays lev tokens) + uint256 levToken_levSP; // leveraged token claimable from Lev SP (rebal only) + } + + function _snapshotClaimable(address who) internal view returns (ClaimableSnapshot memory s) { + s.fxSAVE_collSP = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(who, wrappedCollateral); + s.fxSAVE_levSP = IMultipleRewardAccumulator(stabilityPoolLeveraged).claimable(who, wrappedCollateral); + s.levToken_levSP = IMultipleRewardAccumulator(stabilityPoolLeveraged).claimable(who, leveraged); + } + + function _logActor(string memory name, address who) internal view { + console2.log("--- %s ---", name); + console2.log(" pegged (wallet): %e", IERC20(pegged).balanceOf(who)); + console2.log(" Coll SP deposit: %e", IStabilityPool(stabilityPoolCollateral).assetBalanceOf(who)); + console2.log(" Lev SP deposit: %e", IStabilityPool(stabilityPoolLeveraged).assetBalanceOf(who)); + console2.log(" fxSAVE (wallet): %e", IERC20(wrappedCollateral).balanceOf(who)); + console2.log(" leveraged (wallet): %e", IERC20(leveraged).balanceOf(who)); + ClaimableSnapshot memory c = _snapshotClaimable(who); + console2.log(" claimable fxSAVE (coll SP): %e", c.fxSAVE_collSP); + console2.log(" claimable fxSAVE (lev SP): %e", c.fxSAVE_levSP); + console2.log(" claimable lev tokens (lev SP): %e", c.levToken_levSP); + } + + function _logAllActors() internal view { + _logActor("Alice (Coll, stays)", alice); + _logActor("Bob (Coll, leaves+returns)", bob); + _logActor("Charlie (Lev, stays)", charlie); + _logActor("Dave (Lev, leaves+returns)", dave); + _logActor("Fred (new->Coll)", fred); + _logActor("George (new->Lev)", george); + } + + /// @notice Log the separated rebalance vs harvest breakdown for all actors. + /// @param label Description of the snapshot point + /// @param preRebal Claimable snapshots taken before the rebalance (or before harvest) + /// @param current Claimable snapshots taken now + /// @param isHarvestDelta If true, subtracts preRebal to show harvest-only delta + function _logBreakdown( + string memory label, + ClaimableSnapshot[6] memory preRebal, + ClaimableSnapshot[6] memory current, + bool isHarvestDelta + ) internal pure { + console2.log(""); + console2.log("=== %s ===", label); + string[6] memory names = [ + "Alice (Coll, stays)", + "Bob (Coll, returns)", + "Charlie (Lev, stays)", + "Dave (Lev, returns)", + "Fred (new->Coll)", + "George (new->Lev)" + ]; + + for (uint256 i = 0; i < 6; i++) { + uint256 fxSAVE_coll = isHarvestDelta + ? current[i].fxSAVE_collSP - preRebal[i].fxSAVE_collSP + : current[i].fxSAVE_collSP; + uint256 fxSAVE_lev = isHarvestDelta + ? current[i].fxSAVE_levSP - preRebal[i].fxSAVE_levSP + : current[i].fxSAVE_levSP; + uint256 levToken = isHarvestDelta + ? current[i].levToken_levSP - preRebal[i].levToken_levSP + : current[i].levToken_levSP; + console2.log(" %s", names[i]); + console2.log(" fxSAVE (coll SP): %e | fxSAVE (lev SP): %e | lev tokens: %e", fxSAVE_coll, fxSAVE_lev, levToken); + } + } + + function _snapshotAll() internal view returns (ClaimableSnapshot[6] memory snaps) { + address[6] memory actors = [alice, bob, charlie, dave, fred, george]; + for (uint256 i = 0; i < 6; i++) { + snaps[i] = _snapshotClaimable(actors[i]); + } + } +} + +contract RebalanceFairnessScenarios is RebalanceFairnessSetUp { + address marketMaker; + + /// @notice Bootstrap the system: mint pegged + leveraged at healthy CR, distribute to actors, + /// then drop price to push CR below threshold. + /// Each actor gets 100 pegged. Market maker keeps the leveraged tokens. + function _bootstrap() internal returns (uint256 each) { + marketMaker = makeAddr("marketMaker"); + each = 100 ether; + + // Mint 600 pegged (for 6 actors × 100) and 200 leveraged (for market maker) + // At price=1: 600 fxSAVE → 600 pegged, 200 fxSAVE → 200 leveraged + // Total collateral = 800, pegged = 600, CR = 800/600 = 1.333 (healthy) + _mintPegged(marketMaker, 600 ether); + _mintLeveraged(marketMaker, 200 ether); + + // Market maker distributes pegged to actors + vm.startPrank(marketMaker); + IERC20(pegged).transfer(alice, each); + IERC20(pegged).transfer(bob, each); + IERC20(pegged).transfer(charlie, each); + IERC20(pegged).transfer(dave, each); + IERC20(pegged).transfer(fred, each); + IERC20(pegged).transfer(george, each); + vm.stopPrank(); + + // Drop price by 10%: CR = 800 * 0.9 / 600 = 1.20 (below 1.30 threshold) + oraclePrice = 0.9 ether; + mockOracle.setLatestAnswer(oraclePrice, oracleRate); + } + + /// @notice Scenario A: Everyone stays through the rebalance (baseline). + function test_scenarioA_everyoneStays() public { + uint256 each = _bootstrap(); + + // Deposit into pools: Coll SP = Alice + Bob, Lev SP = Charlie + Dave (equal pool sizes) + _deposit(stabilityPoolCollateral, alice, each); + _deposit(stabilityPoolCollateral, bob, each); + _deposit(stabilityPoolLeveraged, charlie, each); + _deposit(stabilityPoolLeveraged, dave, each); + // Fred and George hold pegged outside SPs + + _logState("BEFORE REBALANCE - Scenario A"); + + // Snapshot before rebalance + ClaimableSnapshot[6] memory preRebal = _snapshotAll(); + uint256 collPeggedBefore = IERC20(pegged).balanceOf(stabilityPoolCollateral); + uint256 levPeggedBefore = IERC20(pegged).balanceOf(stabilityPoolLeveraged); + + // Rebalance + uint256 liquidated = IStabilityPoolManager(stabilityPoolManager).rebalance(makeAddr("bounty"), 0); + + uint256 collLiquidated = collPeggedBefore - IERC20(pegged).balanceOf(stabilityPoolCollateral); + uint256 levLiquidated = levPeggedBefore - IERC20(pegged).balanceOf(stabilityPoolLeveraged); + console2.log(""); + console2.log("--- LIQUIDATION SPLIT ---"); + console2.log("Total liquidated: %e", liquidated); + console2.log("From Coll SP: %e", collLiquidated); + console2.log("From Lev SP: %e", levLiquidated); + + // Snapshot after rebalance — delta from preRebal = rebalance rewards only + ClaimableSnapshot[6] memory postRebal = _snapshotAll(); + _logBreakdown("REBALANCE REWARDS (static, one-off)", preRebal, postRebal, true); + + _logState("AFTER REBALANCE - Scenario A"); + _logAllActors(); + + // Trigger harvest + skip(1 days); + uint256 harvested = _triggerHarvest(); + console2.log("Harvested: %e", harvested); + + // Wait for full reward distribution period + skip(8 days); + + // Snapshot after harvest — delta from postRebal = harvest rewards only + ClaimableSnapshot[6] memory postHarvest = _snapshotAll(); + _logBreakdown("HARVEST REWARDS (streamed, ongoing)", postRebal, postHarvest, true); + _logBreakdown("TOTAL CLAIMABLE (rebalance + harvest)", preRebal, postHarvest, false); + + _logState("AFTER HARVEST - Scenario A"); + _logAllActors(); + } + + /// @notice Scenario B: Bob and Dave withdraw before rebalance, then re-deposit after. + /// Fred and George also deposit after rebalance. + function test_scenarioB_leaversReturn() public { + uint256 each = _bootstrap(); + + // Everyone deposits initially: 2 per pool (equal pool sizes) + _deposit(stabilityPoolCollateral, alice, each); + _deposit(stabilityPoolCollateral, bob, each); + _deposit(stabilityPoolLeveraged, charlie, each); + _deposit(stabilityPoolLeveraged, dave, each); + + _logState("BEFORE WITHDRAWALS - Scenario B"); + _logAllActors(); + + // Step 1: Bob and Dave withdraw before rebalance + _withdrawAll(stabilityPoolCollateral, bob); + _withdrawAll(stabilityPoolLeveraged, dave); + + _logState("AFTER WITHDRAWALS - Scenario B"); + _logAllActors(); + + // Step 2: Rebalance (Alice and Charlie absorb all losses) + ClaimableSnapshot[6] memory preRebal = _snapshotAll(); + uint256 collPeggedBefore = IERC20(pegged).balanceOf(stabilityPoolCollateral); + uint256 levPeggedBefore = IERC20(pegged).balanceOf(stabilityPoolLeveraged); + + uint256 liquidated = IStabilityPoolManager(stabilityPoolManager).rebalance(makeAddr("bounty"), 0); + + uint256 collLiquidated = collPeggedBefore - IERC20(pegged).balanceOf(stabilityPoolCollateral); + uint256 levLiquidated = levPeggedBefore - IERC20(pegged).balanceOf(stabilityPoolLeveraged); + console2.log(""); + console2.log("--- LIQUIDATION SPLIT ---"); + console2.log("Total liquidated: %e", liquidated); + console2.log("From Coll SP: %e", collLiquidated); + console2.log("From Lev SP: %e", levLiquidated); + + ClaimableSnapshot[6] memory postRebal = _snapshotAll(); + _logBreakdown("REBALANCE REWARDS (static, one-off)", preRebal, postRebal, true); + + _logState("AFTER REBALANCE - Scenario B"); + + // Step 3: Re-deposits + new entrants + uint256 bobPegged = IERC20(pegged).balanceOf(bob); + _deposit(stabilityPoolCollateral, bob, bobPegged); + + uint256 davePegged = IERC20(pegged).balanceOf(dave); + _deposit(stabilityPoolLeveraged, dave, davePegged); + + _deposit(stabilityPoolCollateral, fred, each); + _deposit(stabilityPoolLeveraged, george, each); + + _logState("AFTER RE-DEPOSITS - Scenario B"); + + // Step 4: Harvest + skip(1 days); + uint256 harvested = _triggerHarvest(); + console2.log("Harvested: %e", harvested); + + // Wait for full distribution + skip(8 days); + + ClaimableSnapshot[6] memory postHarvest = _snapshotAll(); + _logBreakdown("REBALANCE REWARDS (static, one-off)", preRebal, postRebal, true); + _logBreakdown("HARVEST REWARDS (streamed, ongoing)", postRebal, postHarvest, true); + _logBreakdown("TOTAL CLAIMABLE (rebalance + harvest)", preRebal, postHarvest, false); + + _logState("AFTER HARVEST - Scenario B"); + } +} diff --git a/test/StabilityPoolClaimable.t.sol b/test/StabilityPoolClaimable.t.sol index ab9a0a94..8707358f 100644 --- a/test/StabilityPoolClaimable.t.sol +++ b/test/StabilityPoolClaimable.t.sol @@ -8,6 +8,7 @@ import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistribu import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; import {MockERC20} from "@bao-test/mocks/MockERC20.sol"; +import {IStabilityPool_v3} from "src/interfaces/IStabilityPool_v3.sol"; import {TestStabilityPoolRebalanceSetUp} from "test/StabilityPoolRebalance.t.sol"; contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { @@ -618,4 +619,84 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { "User claimable after full liquidation: %s" ); } + + // ═══════════════════════════════════════════════════════════════════════ + // claimSingle tests + // ═══════════════════════════════════════════════════════════════════════ + + function testClaimSingle_claimsOnlySpecifiedToken() public { + _depositForUsers(); + _depositRewardAndWait(address(rewardToken1), 100 ether); + _depositRewardAndWait(address(rewardToken2), 200 ether); + + uint256 claimable1 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(rewardToken1)); + uint256 claimable2 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(rewardToken2)); + assertGt(claimable1, 0, "should have claimable rewardToken1"); + assertGt(claimable2, 0, "should have claimable rewardToken2"); + + // Claim only rewardToken1 + uint256 bal1Before = rewardToken1.balanceOf(user1); + uint256 bal2Before = rewardToken2.balanceOf(user1); + vm.prank(user1); + IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, address(rewardToken1)); + + // rewardToken1 claimed + assertEq(rewardToken1.balanceOf(user1) - bal1Before, claimable1, "rewardToken1 claimed"); + // rewardToken2 NOT claimed + assertEq(rewardToken2.balanceOf(user1), bal2Before, "rewardToken2 untouched"); + + // rewardToken2 still claimable + assertGt( + IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(rewardToken2)), + 0, + "rewardToken2 still claimable" + ); + } + + function testClaimSingle_withReceiver() public { + _depositForUsers(); + _depositRewardAndWait(address(rewardToken1), 100 ether); + + uint256 claimable1 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(rewardToken1)); + address receiver = makeAddr("receiver"); + + vm.prank(user1); + IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, address(rewardToken1), receiver); + + assertEq(rewardToken1.balanceOf(receiver), claimable1, "receiver got tokens"); + assertEq(rewardToken1.balanceOf(user1), 0, "user1 got nothing"); + } + + function testClaimSingle_forOtherUser() public { + _depositForUsers(); + _depositRewardAndWait(address(rewardToken1), 100 ether); + + uint256 claimable1 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(rewardToken1)); + + // Anyone can trigger claim for user1 — tokens go to user1 + vm.prank(user2); + IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, address(rewardToken1)); + + assertEq(rewardToken1.balanceOf(user1), claimable1, "user1 received tokens"); + } + + function testClaimSingle_cannotRedirectOthersReward() public { + _depositForUsers(); + _depositRewardAndWait(address(rewardToken1), 100 ether); + + address receiver = makeAddr("receiver"); + + // user2 cannot redirect user1's rewards to receiver + vm.prank(user2); + vm.expectRevert(IMultipleRewardAccumulator.ClaimOthersRewardToAnother.selector); + IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, address(rewardToken1), receiver); + } + + function testClaimSingle_zeroClaimable() public { + _depositForUsers(); + // No rewards deposited — claimSingle should not revert + vm.prank(user1); + IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, address(rewardToken1)); + assertEq(rewardToken1.balanceOf(user1), 0, "nothing claimed"); + } } diff --git a/test/StabilityPoolFeatures.t.sol b/test/StabilityPoolFeatures.t.sol index 13c865e4..4a7cb553 100644 --- a/test/StabilityPoolFeatures.t.sol +++ b/test/StabilityPoolFeatures.t.sol @@ -298,4 +298,102 @@ contract StabilityPoolFeatures is TestStabilityPoolSetUp { assertEq(withdrawn, amount - expectedFee); assertEq(IERC20(peggedToken).balanceOf(FEE_ADDRESS), feeReceiverBefore + expectedFee); } + + // ═══════════════════════════════════════════════════════════════════════ + // Constructor revert coverage + // ═══════════════════════════════════════════════════════════════════════ + + function test_constructor_invalidLiquidationToken_reverts() public { + // Use the pegged token — it's a valid ERC20 but not wrapped collateral or leveraged + address invalidLiq = peggedToken; + vm.expectRevert(abi.encodeWithSelector(IStabilityPool.InvalidLiquidationToken.selector, invalidLiq)); + new StabilityPool_v3(minter, invalidLiq, 3600, 90000, 1 ether, "Test", "T"); + } + + function test_constructor_zeroWithdrawalDelay_reverts() public { + vm.expectRevert(abi.encodeWithSelector(IStabilityPool.InvalidWithdrawalWindow.selector, 0, 90000)); + new StabilityPool_v3(minter, wrappedCollateralToken, 0, 90000, 1 ether, "Test", "T"); + } + + function test_constructor_zeroWithdrawalWindow_reverts() public { + vm.expectRevert(abi.encodeWithSelector(IStabilityPool.InvalidWithdrawalWindow.selector, 3600, 0)); + new StabilityPool_v3(minter, wrappedCollateralToken, 3600, 0, 1 ether, "Test", "T"); + } + + // ═══════════════════════════════════════════════════════════════════════ + // Withdraw fee trim when fee + withdrawal would breach MIN_TOTAL_ASSET_SUPPLY + // + // BUG (inherited from v2, fix deferred to v4): The fee trim logic is nested inside + // the supply floor clamp (line 449). It only runs when supply - assetsWithdrawn < MIN. + // But assetsWithdrawn has already been reduced by the fee (line 444), so + // supply - assetsWithdrawn looks larger than the actual remaining supply. + // + // This means there's a window where: + // supply - assetsWithdrawn >= MIN (first check passes, no clamp) + // supply - assetsWithdrawn - fee < MIN (actual supply breaches MIN) + // The fee trim never runs because it's inside the skipped block. + // + // Example: supply=2, withdraw=1.01, fee=2.5% + // fee=0.02525, assetsWithdrawn=0.98475 + // Check: 2-0.98475=1.01525 >= MIN(1) → skip block + // Actual: 2 - 0.98475 - 0.02525 = 0.99 < MIN → supply breached! + // + // These tests verify the CURRENT (buggy) behaviour. They will need updating + // when the v4 fix reorders to clamp-then-fee. + // ═══════════════════════════════════════════════════════════════════════ + + function test_withdraw_feeTrimmedWhenBreachingMin() public { + // Single depositor, deposit = MIN + 0.01. Withdraw all without request. + // The first clamp triggers (assetsWithdrawn after fee > supply - MIN). + // Fee trim also triggers but maxFee = 0, so fee is trimmed to zero. + // + // NOTE: This test will fail when the v4 fee fix is applied — the fee + // calculation will change from fee-then-clamp to clamp-then-fee. + + setUp_collateral(1 ether, 0 ether, user1); + uint256 depositAmount = 1.01 ether; + deal(peggedToken, user1, depositAmount); + vm.prank(user1); + IStabilityPool(stabilityPoolCollateral).deposit(depositAmount, user1, 0); + + uint256 feeReceiverBefore = IERC20(peggedToken).balanceOf(FEE_ADDRESS); + vm.prank(user1); + uint256 withdrawn = IStabilityPool(stabilityPoolCollateral).withdraw(type(uint256).max, user1, 0); + + uint256 supplyAfter = IStabilityPool(stabilityPoolCollateral).totalAssetSupply(); + assertEq(supplyAfter, 1 ether, "supply at MIN"); + + // Fee should have been trimmed to 0 + uint256 feeCollected = IERC20(peggedToken).balanceOf(FEE_ADDRESS) - feeReceiverBefore; + assertEq(feeCollected, 0, "fee trimmed to zero"); + + // User got 0.01 ether (the delta above MIN) + assertEq(withdrawn, 0.01 ether, "user got delta above MIN"); + } + + function test_withdraw_feeTrimmedWithTwoDepositors() public { + // Single depositor with 1.05 ether. Withdraw all without request. + // Same pattern as above but with a larger delta above MIN. + // After clamp: assetsWithdrawn = supply - MIN = 0.05. + // Fee was calculated on the original 1.05 = 0.02625, but maxFee = 0 after clamp. + // + // NOTE: This test will fail when the v4 fee fix is applied. + + setUp_collateral(1 ether, 0 ether, user1); + deal(peggedToken, user1, 1.05 ether); + vm.prank(user1); + IStabilityPool(stabilityPoolCollateral).deposit(1.05 ether, user1, 0); + + // Withdraw all without request — triggers clamp AND fee trim + uint256 feeReceiverBefore = IERC20(peggedToken).balanceOf(FEE_ADDRESS); + vm.prank(user1); + uint256 withdrawn = IStabilityPool(stabilityPoolCollateral).withdraw(type(uint256).max, user1, 0); + + uint256 supplyAfter = IStabilityPool(stabilityPoolCollateral).totalAssetSupply(); + assertEq(supplyAfter, 1 ether, "supply at MIN"); + + uint256 feeCollected = IERC20(peggedToken).balanceOf(FEE_ADDRESS) - feeReceiverBefore; + assertEq(feeCollected, 0, "fee trimmed to zero after clamp"); + assertEq(withdrawn, 0.05 ether, "user got delta above MIN"); + } } diff --git a/test/StabilityPool_v3_ERC20.t.sol b/test/StabilityPool_v3_ERC20.t.sol new file mode 100644 index 00000000..ddcbd906 --- /dev/null +++ b/test/StabilityPool_v3_ERC20.t.sol @@ -0,0 +1,259 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.28 <0.9.0; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; + +import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; +import {StabilityPool_v3} from "src/minter/StabilityPool_v3.sol"; + +import {TestStabilityPoolSetUp} from "test/StabilityPool.t.sol"; + +/// @title TestStabilityPool_v3_ERC20 +/// @notice Coverage tests for StabilityPool_v3 ERC20 functions. +/// Uses IERC20/IERC20Metadata interfaces per CLAUDE.md. +contract TestStabilityPool_v3_ERC20 is TestStabilityPoolSetUp { + function _deposit(address user, uint256 amount) internal { + deal(peggedToken, user, amount); + vm.prank(user); + IStabilityPool(stabilityPoolCollateral).deposit(amount, user, 0); + } + + // ═══════════════════════════════════════════════════════════════════════ + // Metadata: name, symbol, decimals + // ═══════════════════════════════════════════════════════════════════════ + + function test_name() public view { + string memory n = IERC20Metadata(stabilityPoolCollateral).name(); + assertGt(bytes(n).length, 0, "name not empty"); + } + + function test_symbol() public view { + string memory s = IERC20Metadata(stabilityPoolCollateral).symbol(); + assertGt(bytes(s).length, 0, "symbol not empty"); + } + + function test_decimals() public view { + uint8 d = IERC20Metadata(stabilityPoolCollateral).decimals(); + assertEq(d, 18, "decimals"); + } + + // ═══════════════════════════════════════════════════════════════════════ + // String packing: StringTooLong, short strings, medium strings + // ═══════════════════════════════════════════════════════════════════════ + + function test_stringTooLong_name_reverts() public { + // 65-char string exceeds 64-char limit + string memory longName = "12345678901234567890123456789012345678901234567890123456789012345"; + vm.expectRevert(StabilityPool_v3.StringTooLong.selector); + new StabilityPool_v3(minter, wrappedCollateralToken, 3600, 90000, 1 ether, longName, "s"); + } + + function test_stringTooLong_symbol_reverts() public { + string memory longSymbol = "12345678901234567890123456789012345678901234567890123456789012345"; + vm.expectRevert(StabilityPool_v3.StringTooLong.selector); + new StabilityPool_v3(minter, wrappedCollateralToken, 3600, 90000, 1 ether, "n", longSymbol); + } + + function test_name_shortString() public { + // < 32 chars + StabilityPool_v3 sp = new StabilityPool_v3( + minter, wrappedCollateralToken, 3600, 90000, 1 ether, "Short", "S" + ); + assertEq(sp.name(), "Short", "short name"); + assertEq(sp.symbol(), "S", "short symbol"); + } + + function test_name_exactly32chars() public { + // Exactly 32 chars + string memory name32 = "12345678901234567890123456789012"; + assertEq(bytes(name32).length, 32, "sanity"); + StabilityPool_v3 sp = new StabilityPool_v3( + minter, wrappedCollateralToken, 3600, 90000, 1 ether, name32, "S" + ); + assertEq(sp.name(), name32, "32-char name"); + } + + function test_name_between32and64chars() public { + // 40 chars (between 32 and 64) + string memory name40 = "1234567890123456789012345678901234567890"; + assertEq(bytes(name40).length, 40, "sanity"); + StabilityPool_v3 sp = new StabilityPool_v3( + minter, wrappedCollateralToken, 3600, 90000, 1 ether, name40, "S" + ); + assertEq(sp.name(), name40, "40-char name"); + } + + function test_name_exactly64chars() public { + string memory name64 = "1234567890123456789012345678901234567890123456789012345678901234"; + assertEq(bytes(name64).length, 64, "sanity"); + StabilityPool_v3 sp = new StabilityPool_v3( + minter, wrappedCollateralToken, 3600, 90000, 1 ether, name64, "S" + ); + assertEq(sp.name(), name64, "64-char name"); + } + + // ═══════════════════════════════════════════════════════════════════════ + // balanceOf / totalSupply + // ═══════════════════════════════════════════════════════════════════════ + + function test_balanceOf_matchesAssetBalanceOf() public { + _deposit(user1, 10 ether); + assertEq( + IERC20(stabilityPoolCollateral).balanceOf(user1), + IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1), + "balanceOf == assetBalanceOf" + ); + } + + function test_balanceOf_zeroForNewUser() public view { + assertEq(IERC20(stabilityPoolCollateral).balanceOf(user1), 0, "zero for new user"); + } + + function test_totalSupply_matchesTotalAssetSupply() public { + _deposit(user1, 10 ether); + assertEq( + IERC20(stabilityPoolCollateral).totalSupply(), + IStabilityPool(stabilityPoolCollateral).totalAssetSupply(), + "totalSupply == totalAssetSupply" + ); + } + + // ═══════════════════════════════════════════════════════════════════════ + // transfer + // ═══════════════════════════════════════════════════════════════════════ + + function test_transfer() public { + _deposit(user1, 10 ether); + _deposit(user2, 5 ether); + + vm.prank(user1); + bool success = IERC20(stabilityPoolCollateral).transfer(user2, 3 ether); + + assertTrue(success, "returns true"); + assertEq(IERC20(stabilityPoolCollateral).balanceOf(user1), 7 ether, "sender"); + assertEq(IERC20(stabilityPoolCollateral).balanceOf(user2), 8 ether, "receiver"); + } + + function test_transfer_entireBalance() public { + _deposit(user1, 10 ether); + _deposit(user2, 5 ether); + + vm.prank(user1); + IERC20(stabilityPoolCollateral).transfer(user2, 10 ether); + + assertEq(IERC20(stabilityPoolCollateral).balanceOf(user1), 0, "sender zero"); + } + + function test_transfer_exceedsBalance_reverts() public { + _deposit(user1, 10 ether); + + vm.prank(user1); + vm.expectRevert( + abi.encodeWithSelector(StabilityPool_v3.TransferExceedsBalance.selector, user1, 11 ether, 10 ether) + ); + IERC20(stabilityPoolCollateral).transfer(user2, 11 ether); + } + + function test_transfer_toZeroAddress_reverts() public { + _deposit(user1, 10 ether); + + vm.prank(user1); + vm.expectRevert(abi.encodeWithSelector(IStabilityPool.InvalidReceiver.selector, address(0))); + IERC20(stabilityPoolCollateral).transfer(address(0), 1 ether); + } + + function test_transfer_toSelf_reverts() public { + _deposit(user1, 10 ether); + + vm.prank(user1); + vm.expectRevert(abi.encodeWithSelector(IStabilityPool.InvalidReceiver.selector, user1)); + IERC20(stabilityPoolCollateral).transfer(user1, 1 ether); + } + + function test_transfer_emitsEvent() public { + _deposit(user1, 10 ether); + + vm.expectEmit(true, true, false, true); + emit IERC20.Transfer(user1, user2, 3 ether); + + vm.prank(user1); + IERC20(stabilityPoolCollateral).transfer(user2, 3 ether); + } + + function test_transfer_fromZeroAddress_reverts() public { + _deposit(user1, 10 ether); + + vm.prank(address(0)); + vm.expectRevert(abi.encodeWithSelector(IStabilityPool.InvalidReceiver.selector, address(0))); + IERC20(stabilityPoolCollateral).transfer(user1, 1 ether); + } + + // ═══════════════════════════════════════════════════════════════════════ + // approve / allowance + // ═══════════════════════════════════════════════════════════════════════ + + function test_approve_and_allowance() public { + vm.prank(user1); + bool success = IERC20(stabilityPoolCollateral).approve(user2, 5 ether); + + assertTrue(success, "returns true"); + assertEq(IERC20(stabilityPoolCollateral).allowance(user1, user2), 5 ether, "allowance"); + } + + function test_approve_emitsEvent() public { + vm.expectEmit(true, true, false, true); + emit IERC20.Approval(user1, user2, 5 ether); + + vm.prank(user1); + IERC20(stabilityPoolCollateral).approve(user2, 5 ether); + } + + // ═══════════════════════════════════════════════════════════════════════ + // transferFrom + // ═══════════════════════════════════════════════════════════════════════ + + function test_transferFrom() public { + _deposit(user1, 10 ether); + + vm.prank(user1); + IERC20(stabilityPoolCollateral).approve(user2, 5 ether); + + vm.prank(user2); + bool success = IERC20(stabilityPoolCollateral).transferFrom(user1, user2, 3 ether); + + assertTrue(success, "returns true"); + assertEq(IERC20(stabilityPoolCollateral).balanceOf(user1), 7 ether, "sender"); + assertEq(IERC20(stabilityPoolCollateral).balanceOf(user2), 3 ether, "receiver"); + assertEq(IERC20(stabilityPoolCollateral).allowance(user1, user2), 2 ether, "allowance decreased"); + } + + function test_transferFrom_infiniteAllowance() public { + _deposit(user1, 10 ether); + + vm.prank(user1); + IERC20(stabilityPoolCollateral).approve(user2, type(uint256).max); + + vm.prank(user2); + IERC20(stabilityPoolCollateral).transferFrom(user1, user2, 3 ether); + + assertEq( + IERC20(stabilityPoolCollateral).allowance(user1, user2), + type(uint256).max, + "infinite not deducted" + ); + } + + function test_transferFrom_insufficientAllowance_reverts() public { + _deposit(user1, 10 ether); + + vm.prank(user1); + IERC20(stabilityPoolCollateral).approve(user2, 2 ether); + + vm.prank(user2); + vm.expectRevert( + abi.encodeWithSelector(StabilityPool_v3.InsufficientAllowance.selector, user2, 2 ether, 3 ether) + ); + IERC20(stabilityPoolCollateral).transferFrom(user1, user2, 3 ether); + } +} From 34e94a1a1f0fadbe4b76382ab7628a7f8568c4a9 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Thu, 2 Apr 2026 13:53:30 +0100 Subject: [PATCH 007/232] salt string generation improvements reward aliases investigation into removing current withdrawal fees workflow --- .solhintignore | 2 +- CLAUDE.md | 10 +- doc/fixes/remediation-ETH-fxUSD-SPL.md | 2 +- lib/bao-base | 2 +- package.json | 2 +- script/Deploy_Minter_v2_mainnet.s.sol | 4 +- script/Deploy_StabilityPool_v3_mainnet.s.sol | 8 +- .../Grant_Minter_ZeroFeeRoles_mainnet.s.sol | 6 +- script/Pause_SPL_ETH_fxUSD.s.sol | 2 +- script/Remediate_Accumulators.s.sol | 2 +- script/Remediate_SPL_ETH_fxUSD.s.sol | 6 +- script/UpdateVolatility_OGPlus.s.sol | 16 +- script/UpdateVolatility_test3_SILVER.s.sol | 6 +- ...ebalanceRemediationForStabilityPool_v2.sol | 4 +- script/safe/SafeBatch.s.sol | 2 +- script/src/DeployMintersShared.sol | 65 +++-- script/src/contracts/Genesis.sol | 2 +- script/src/contracts/LeveragedToken.sol | 4 +- script/src/contracts/Minter.sol | 6 +- script/src/contracts/PeggedToken.sol | 4 +- script/src/contracts/StabilityPool.sol | 51 +++- script/src/contracts/StabilityPoolManager.sol | 4 +- script/test/MainnetRoles.t.sol | 12 +- script/test/MinterUpgradeTest.t.sol | 2 +- script/test/SPLRemediationTest.t.sol | 14 +- script/test/SPv3MigrationTest.t.sol | 6 +- script/test/V2ReplaySimulation.t.sol | 12 +- src/minter/StabilityPool_v3.sol | 14 + src/reward/RewardAlias.sol | 50 ++++ test/RebalanceCheck.t.sol | 12 +- test/RebalanceFairness.t.sol | 12 +- test/StabilityPoolAliasDeployment.t.sol | 259 ++++++++++++++++++ test/StabilityPoolClaimable.t.sol | 234 ++++++++++++++++ 33 files changed, 737 insertions(+), 100 deletions(-) rename {src/minter => script/patch}/PostRebalanceRemediationForStabilityPool_v2.sol (98%) create mode 100644 src/reward/RewardAlias.sol create mode 100644 test/StabilityPoolAliasDeployment.t.sol diff --git a/.solhintignore b/.solhintignore index dffc2f65..ead61e6b 100644 --- a/.solhintignore +++ b/.solhintignore @@ -1,3 +1,3 @@ src/util/WordCodec.sol *_v1.sol -src/minter/PostRebalanceRemediationForStabilityPool_v2.sol \ No newline at end of file +script/patch/PostRebalanceRemediationForStabilityPool_v2.sol \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index ffcd80d6..72ecaacf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,4 +4,12 @@ - When diagnosing an issue, do not use words like "likely", "probably", or "may" to describe a root cause. Either verify the hypothesis with data (dry run, log, trace) or state explicitly that it is unverified. Never proceed with a fix based on an unverified hypothesis. - use forge install/remove for managing submodule dependencies - In tests, use interface types (e.g. `IStabilityPool_v3(address)`) not concrete contract types (e.g. `StabilityPool_v3(address)`) when calling functions. This verifies the interface matches the implementation. Concrete types are only for initialisation (constructor, deploy). -- in code never use an if or loop statement without curly brackets - I want the code coverage to be visible and that hides some branches from the display \ No newline at end of file +- in code never use an if or loop statement without curly brackets - I want the code coverage to be visible and that hides some branches from the display +- In deployment scripts, use salt keys and `_predictAddress(key)` to reference contracts — not deployed addresses. BaoFactory CREATE3 gives deterministic addresses from salts, so contracts can reference each other before deployment. For example, `registerRewardToken(_predictAddress(aliasKey))` works even if the alias hasn't been deployed yet. This decouples deployment order from contract dependencies. +- In deployment scripts, build salt strings using `_saltString()` / `_predictAddress()` library functions from FactoryDeployer — never manually concat salt strings with `string.concat`. +- Three ownership patterns for UUPS contracts: + - **BaoOwnable** (legacy): `_initializeOwner(finalOwner)` uses `msg.sender` as temp owner. Deploy via `_deployProxyViaStubAndRecord` (needs UUPSProxyDeployStub so msg.sender = FactoryDeployer, not BaoFactory). Used by: Minter_v2, StabilityPool_v3, SPM, Genesis, LeveragedToken, PeggedToken. + - **HarborOwnable** (modern): `_initializeOwner(deployerOwner, pendingOwner)` takes explicit deployer. Deploy via `_deployProxyAndRecord` (direct, no stub). Used by: RewardAlias, all new contracts. + - **HarborFixedOwnable** (hardcoded): Owner is immutable constructor param (Harbor multisig). Deploy via `_deployProxyAndRecord` with empty initData. Used by: HarborPauser_v1. +- Each UUPS contract composes Initializable + UUPSUpgradeable + ownership mixin directly — don't create "Upgradeable" base contracts that bundle these, as each contract has different init needs (roles, reentrancy, custom state). The "Upgradeable" suffix means something different in OZ (storage-safe proxy variant) and combining meanings causes confusion. +- In tests, never create and then remove files or directories — forge runs tests in parallel so you can create a race condition. Write test output to `./results` and leave it there. \ No newline at end of file diff --git a/doc/fixes/remediation-ETH-fxUSD-SPL.md b/doc/fixes/remediation-ETH-fxUSD-SPL.md index e3fe7e3a..1386621d 100644 --- a/doc/fixes/remediation-ETH-fxUSD-SPL.md +++ b/doc/fixes/remediation-ETH-fxUSD-SPL.md @@ -129,7 +129,7 @@ Post-remediation (`results/post_remediation.csv`): - Remaining dilution: ~$8 from bounty receiver 2 (0.0025 excess sailETH, not ours) - Treasury cost: ~$82 of fxSAVE -**Contract**: `src/minter/PostRebalanceRemediationForStabilityPool_v2.sol` +**Contract**: `script/patch/PostRebalanceRemediationForStabilityPool_v2.sol` **Script**: `script/Remediate_SPL_ETH_fxUSD.s.sol` ## Value Accounting diff --git a/lib/bao-base b/lib/bao-base index c2d4fbfd..12c10141 160000 --- a/lib/bao-base +++ b/lib/bao-base @@ -1 +1 @@ -Subproject commit c2d4fbfddf1b671a354f296e515483b673d585b8 +Subproject commit 12c101413afb8001729dce5b411aff21640ad7ce diff --git a/package.json b/package.json index c84f9a35..8e981efc 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "gas": "./lib/bao-base/run regression-of gas", "coverage": "./lib/bao-base/run regression-of coverage", "wake": "wake detect all", - "slither": "./lib/bao-base/run slither --filter-paths 'PostRebalanceRemediationForStabilityPool_v2'", + "slither": "./lib/bao-base/run slither --filter-paths 'script/patch'", "verify-audit": "lib/bao-base/run verify-audit", "validate": "./lib/bao-base/run validate", "script": "forge script --force --ffi", diff --git a/script/Deploy_Minter_v2_mainnet.s.sol b/script/Deploy_Minter_v2_mainnet.s.sol index 111f4c2e..ce963535 100644 --- a/script/Deploy_Minter_v2_mainnet.s.sol +++ b/script/Deploy_Minter_v2_mainnet.s.sol @@ -45,8 +45,8 @@ contract Deploy_Minter_v2_mainnet is string memory marketKey = MinterMarketConfigLib.salt(markets[i]); IFullMinterConfig cfg = IFullMinterConfig(address(markets[i])); address wrappedCollateral = cfg.wrappedCollateralToken(); - address peggedToken = _predictAddress(cfg.peg(), "pegged"); - address leveragedToken = _predictAddress(marketKey, "leveraged"); + address peggedToken = _predictAddress(_key(cfg.peg(), "pegged")); + address leveragedToken = _predictAddress(_key(marketKey, "leveraged")); (address impl, string memory key) = deployMinterImplementation( state, diff --git a/script/Deploy_StabilityPool_v3_mainnet.s.sol b/script/Deploy_StabilityPool_v3_mainnet.s.sol index 162b990b..8f1d7cf9 100644 --- a/script/Deploy_StabilityPool_v3_mainnet.s.sol +++ b/script/Deploy_StabilityPool_v3_mainnet.s.sol @@ -42,8 +42,8 @@ contract Deploy_StabilityPool_v3_mainnet is function _doOneMinter(DeploymentTypes.State memory state, Config_MinterMarket[] memory markets) internal { for (uint i = 0; i < markets.length; i++) { string memory marketKey = MinterMarketConfigLib.salt(markets[i]); - address minter = _predictAddress(marketKey, "minter"); - address leveragedToken = _predictAddress(marketKey, "leveraged"); + address minter = _predictAddress(_key(marketKey, "minter")); + address leveragedToken = _predictAddress(_key(marketKey, "leveraged")); address collateralToken = IFullMinterConfig(address(markets[i])).wrappedCollateralToken(); address implLeveraged = deployStabilityPoolImplementation( @@ -64,12 +64,12 @@ contract Deploy_StabilityPool_v3_mainnet is // Queue Safe upgrade transactions queue( - _saltString(marketKey, StabilityPoolLeveraged), + _saltString(_key(marketKey, StabilityPoolLeveraged)), abi.encodeCall(UUPSUpgradeable.upgradeToAndCall, (implLeveraged, "")), string.concat("upgrade to StabilityPool_v3 ", implLeveraged.toHexString()) ); queue( - _saltString(marketKey, StabilityPoolCollateral), + _saltString(_key(marketKey, StabilityPoolCollateral)), abi.encodeCall(UUPSUpgradeable.upgradeToAndCall, (implCollateral, "")), string.concat("upgrade to StabilityPool_v3 ", implCollateral.toHexString()) ); diff --git a/script/Grant_Minter_ZeroFeeRoles_mainnet.s.sol b/script/Grant_Minter_ZeroFeeRoles_mainnet.s.sol index 8cbc5e85..288a8780 100644 --- a/script/Grant_Minter_ZeroFeeRoles_mainnet.s.sol +++ b/script/Grant_Minter_ZeroFeeRoles_mainnet.s.sol @@ -34,15 +34,15 @@ contract Grant_Minter_ZeroFeeRoles_mainnet is function _grantForMarkets(Config_MinterMarket[] memory markets) internal { for (uint i = 0; i < markets.length; i++) { string memory marketKey = MinterMarketConfigLib.salt(markets[i]); - address minter = _predictAddress(marketKey, "minter"); - address spm = _predictAddress(marketKey, "stabilityPoolManager"); + address minter = _predictAddress(_key(marketKey, "minter")); + address spm = _predictAddress(_key(marketKey, "stabilityPoolManager")); uint256 zeroFeeRole = IMinter(minter).ZERO_FEE_ROLE(); console.log(" %s: grant ZERO_FEE_ROLE to SPM %s", marketKey, spm.toHexString()); queue( - _saltString(marketKey, "minter"), + _saltString(_key(marketKey, "minter")), abi.encodeCall(IBaoRoles.grantRoles, (spm, zeroFeeRole)), string.concat("grant ZERO_FEE_ROLE to SPM on ", marketKey, "::minter") ); diff --git a/script/Pause_SPL_ETH_fxUSD.s.sol b/script/Pause_SPL_ETH_fxUSD.s.sol index 6a023080..393f9068 100644 --- a/script/Pause_SPL_ETH_fxUSD.s.sol +++ b/script/Pause_SPL_ETH_fxUSD.s.sol @@ -12,7 +12,7 @@ contract Pause_SPL_ETH_fxUSD is SafeBatch { function build() internal override { queue( - _saltString("ETH", "fxUSD", "stabilityPoolLeveraged"), + _saltString(_key("ETH", "fxUSD", "stabilityPoolLeveraged")), abi.encodeCall(UUPSUpgradeable.upgradeToAndCall, (BAO_PAUSER, "")), "pause: upgrade to BaoPauser_v1" ); diff --git a/script/Remediate_Accumulators.s.sol b/script/Remediate_Accumulators.s.sol index 766e023b..75132839 100644 --- a/script/Remediate_Accumulators.s.sol +++ b/script/Remediate_Accumulators.s.sol @@ -61,7 +61,7 @@ contract Remediate_Accumulators is } function _remediatePool(string memory marketKey, string memory spType) internal { - string memory fullSalt = _saltString(marketKey, spType); + string memory fullSalt = _saltString(_key(marketKey, spType)); address pool = _predictAddressFromFullSalt(fullSalt); // Read current implementation (to restore after remediation) diff --git a/script/Remediate_SPL_ETH_fxUSD.s.sol b/script/Remediate_SPL_ETH_fxUSD.s.sol index 6513c410..3c2505fd 100644 --- a/script/Remediate_SPL_ETH_fxUSD.s.sol +++ b/script/Remediate_SPL_ETH_fxUSD.s.sol @@ -3,7 +3,7 @@ pragma solidity >=0.8.28 <0.9.0; import {LibString} from "@solady/utils/LibString.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; -import {PostRebalanceRemediationForStabilityPool_v2} from "src/minter/PostRebalanceRemediationForStabilityPool_v2.sol"; +import {PostRebalanceRemediationForStabilityPool_v2} from "script/patch/PostRebalanceRemediationForStabilityPool_v2.sol"; import {SafeBatch} from "script/safe/SafeBatch.s.sol"; import {WellKnownAddress} from "@bao-script/deployment/FactoryDeployer.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; @@ -69,11 +69,11 @@ contract Remediate_SPL_ETH_fxUSD is SafeBatch { } function build() internal override { - string memory splSalt = _saltString("ETH", "fxUSD", "stabilityPoolLeveraged"); + string memory splSalt = _saltString(_key("ETH", "fxUSD", "stabilityPoolLeveraged")); address spl = _predictAddressFromFullSalt(splSalt); require( - LEVERAGED == _predictAddressFromFullSalt(_saltString("ETH", "fxUSD", "leveraged")), + LEVERAGED == _predictAddressFromFullSalt(_saltString(_key("ETH", "fxUSD", "leveraged"))), "LEVERAGED is not the correct address" ); diff --git a/script/UpdateVolatility_OGPlus.s.sol b/script/UpdateVolatility_OGPlus.s.sol index dc9940eb..e177bc70 100644 --- a/script/UpdateVolatility_OGPlus.s.sol +++ b/script/UpdateVolatility_OGPlus.s.sol @@ -21,49 +21,49 @@ contract UpdateVolatility_OGPlus is SafeBatch { function build() internal override { // BTC-fxUSD queue( - _saltString("BTC", "fxUSD", "minter"), + _saltString(_key("BTC", "fxUSD", "minter")), abi.encodeCall(IMinter.updateConfig, (new ConfigPriceVolatility_130_stable().minterConfig())), "updateConfig(130)" ); // BTC-stETH queue( - _saltString("BTC", "stETH", "minter"), + _saltString(_key("BTC", "stETH", "minter")), abi.encodeCall(IMinter.updateConfig, (new ConfigPriceVolatility_125_stable().minterConfig())), "updateConfig(125)" ); queue( - _saltString("BTC", "stETH", "stabilityPoolManager"), + _saltString(_key("BTC", "stETH", "stabilityPoolManager")), abi.encodeCall(IStabilityPoolManager.updateRebalanceThreshold, (125e16)), "updateRebalanceThreshold(125)" ); // ETH-fxUSD queue( - _saltString("ETH", "fxUSD", "minter"), + _saltString(_key("ETH", "fxUSD", "minter")), abi.encodeCall(IMinter.updateConfig, (new ConfigPriceVolatility_130_stable().minterConfig())), "updateConfig(130)" ); // EUR-fxUSD queue( - _saltString("EUR", "fxUSD", "minter"), + _saltString(_key("EUR", "fxUSD", "minter")), abi.encodeCall(IMinter.updateConfig, (new ConfigPriceVolatility_105().minterConfig())), "updateConfig(105 month1)" ); queue( - _saltString("EUR", "fxUSD", "stabilityPoolManager"), + _saltString(_key("EUR", "fxUSD", "stabilityPoolManager")), abi.encodeCall(IStabilityPoolManager.updateRebalanceThreshold, (105e16)), "updateRebalanceThreshold(105)" ); // GOLD-fxUSD queue( - _saltString("GOLD", "fxUSD", "minter"), + _saltString(_key("GOLD", "fxUSD", "minter")), abi.encodeCall(IMinter.updateConfig, (new ConfigPriceVolatility_115().minterConfig())), "updateConfig(105 month1)" ); queue( - _saltString("GOLD", "fxUSD", "stabilityPoolManager"), + _saltString(_key("GOLD", "fxUSD", "stabilityPoolManager")), abi.encodeCall(IStabilityPoolManager.updateRebalanceThreshold, (115e16)), "updateRebalanceThreshold(115)" ); diff --git a/script/UpdateVolatility_test3_SILVER.s.sol b/script/UpdateVolatility_test3_SILVER.s.sol index f3fb005c..1c631432 100644 --- a/script/UpdateVolatility_test3_SILVER.s.sol +++ b/script/UpdateVolatility_test3_SILVER.s.sol @@ -14,19 +14,19 @@ import {ConfigPriceVolatility_130} from "script/config/volatility/ConfigPriceVol contract UpdateVolatility_test3_SILVER is SafeBatch { function build() internal override { queue( - _saltString("SILVER", "fxUSD", "minter"), + _saltString(_key("SILVER", "fxUSD", "minter")), abi.encodeCall(IMinter.updateConfig, (new ConfigPriceVolatility_125().minterConfig())), "updateConfig(125_month1)" ); queue( - _saltString("SILVER", "fxUSD", "stabilityPoolManager"), + _saltString(_key("SILVER", "fxUSD", "stabilityPoolManager")), abi.encodeCall(IStabilityPoolManager.updateRebalanceThreshold, (125e16)), "updateRebalanceThreshold(125)" ); queue( - _saltString("SILVER", "stETH", "minter"), + _saltString(_key("SILVER", "stETH", "minter")), abi.encodeCall(IMinter.updateConfig, (new ConfigPriceVolatility_130().minterConfig())), "updateConfig(130_month1)" ); diff --git a/src/minter/PostRebalanceRemediationForStabilityPool_v2.sol b/script/patch/PostRebalanceRemediationForStabilityPool_v2.sol similarity index 98% rename from src/minter/PostRebalanceRemediationForStabilityPool_v2.sol rename to script/patch/PostRebalanceRemediationForStabilityPool_v2.sol index cf6bfb90..6807d507 100644 --- a/src/minter/PostRebalanceRemediationForStabilityPool_v2.sol +++ b/script/patch/PostRebalanceRemediationForStabilityPool_v2.sol @@ -4,9 +4,9 @@ pragma solidity >=0.8.28 <0.9.0; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IBurnable} from "@bao/interfaces/IBurnable.sol"; import {IBurnableFrom} from "@bao/interfaces/IBurnableFrom.sol"; -import {IMinter} from "../interfaces/IMinter.sol"; +import {IMinter} from "src/interfaces/IMinter.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; -import {DecrementalFloatingPoint} from "../math/DecrementalFloatingPoint.sol"; +import {DecrementalFloatingPoint} from "src/math/DecrementalFloatingPoint.sol"; /// @title Post-Rebalance Remediation for StabilityPool_v2 /// @notice One-shot upgrade that corrects the reward integral inflated by the diff --git a/script/safe/SafeBatch.s.sol b/script/safe/SafeBatch.s.sol index 2434a2aa..ec7aa2dc 100644 --- a/script/safe/SafeBatch.s.sol +++ b/script/safe/SafeBatch.s.sol @@ -21,7 +21,7 @@ import {DeploymentState} from "@bao-script/deployment/DeploymentState.sol"; /// ```solidity /// contract MyBatch is SafeBatch { /// function build() internal override { -/// string memory salt = _saltString("BTC", "fxUSD", "minter"); +/// string memory salt = _saltString(_key("BTC", "fxUSD", "minter")); /// queue(salt, abi.encodeCall(IMinter.updateConfig, (cfg)), "updateConfig(130)"); /// } /// } diff --git a/script/src/DeployMintersShared.sol b/script/src/DeployMintersShared.sol index 4b976a91..957d3d6a 100644 --- a/script/src/DeployMintersShared.sol +++ b/script/src/DeployMintersShared.sol @@ -15,8 +15,8 @@ import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; import {Config_MinterMarket, IMarketConfig, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; import {Minter_v2} from "@harbor/minter/Minter_v2.sol"; -import {StabilityPool_v2} from "@harbor/minter/StabilityPool_v2.sol"; import {IMinter} from "src/interfaces/IMinter.sol"; +import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; /// @notice Extended market config interface with methods from collateral and chain configs. interface IFullMinterConfig { @@ -166,6 +166,9 @@ abstract contract DeployMintersShared is // Deploy Stability Pools _deployStabilityPools(state, cfg, marketKey); + // Deploy reward aliases and register on SPs + _deployRewardAliases(state, cfg, marketKey); + // Deploy StabilityPoolManager _deployStabilityPoolManager(state, cfg, marketKey); @@ -184,8 +187,8 @@ abstract contract DeployMintersShared is string memory marketKey ) internal { address wrappedCollateral = cfg.wrappedCollateralToken(); - address peggedToken = _predictAddress(cfg.peg(), "pegged"); - address leveragedToken = _predictAddress(marketKey, "leveraged"); + address peggedToken = _predictAddress(_key(cfg.peg(), "pegged")); + address leveragedToken = _predictAddress(_key(marketKey, "leveraged")); deployMinter(stateData, marketKey, wrappedCollateral, peggedToken, leveragedToken); } @@ -195,7 +198,7 @@ abstract contract DeployMintersShared is IFullMinterConfig cfg, string memory marketKey ) internal { - address minter = _predictAddress(marketKey, "minter"); + address minter = _predictAddress(_key(marketKey, "minter")); deployStabilityPool( StabilityPoolCollateral, @@ -210,18 +213,41 @@ abstract contract DeployMintersShared is stateData, Config_MinterMarket(address(cfg)), minter, - _predictAddress(marketKey, "leveraged") + _predictAddress(_key(marketKey, "leveraged")) ); } + function _deployRewardAliases( + DeploymentTypes.State memory state, + IFullMinterConfig cfg, + string memory marketKey + ) internal { + string memory spCollKey = _key(marketKey, StabilityPoolCollateral); + string memory spLevKey = _key(marketKey, StabilityPoolLeveraged); + address wrappedCollateral = cfg.wrappedCollateralToken(); + address leveragedToken = _predictAddress(_key(marketKey, "leveraged")); + + // Collateral SP: harvest + rebalance aliases (both underlying = wrappedCollateral) + deployRewardAlias(state, spCollKey, "harvest", wrappedCollateral); + deployRewardAlias(state, spCollKey, "rebalance", wrappedCollateral); + registerRewardAlias(spCollKey, "harvest"); + registerRewardAlias(spCollKey, "rebalance"); + + // Leveraged SP: harvest alias (underlying = wrappedCollateral), rebalance alias (underlying = leveragedToken) + deployRewardAlias(state, spLevKey, "harvest", wrappedCollateral); + deployRewardAlias(state, spLevKey, "rebalance", leveragedToken); + registerRewardAlias(spLevKey, "harvest"); + registerRewardAlias(spLevKey, "rebalance"); + } + function _deployStabilityPoolManager( DeploymentTypes.State memory stateData, IFullMinterConfig, string memory marketKey ) internal { - address minter = _predictAddress(marketKey, "minter"); - address spCollateral = _predictAddress(marketKey, "stabilityPoolCollateral"); - address spLeveraged = _predictAddress(marketKey, "stabilityPoolLeveraged"); + address minter = _predictAddress(_key(marketKey, "minter")); + address spCollateral = _predictAddress(_key(marketKey, "stabilityPoolCollateral")); + address spLeveraged = _predictAddress(_key(marketKey, "stabilityPoolLeveraged")); deployStabilityPoolManager(stateData, marketKey, minter, treasury(), spCollateral, spLeveraged); } @@ -232,19 +258,18 @@ abstract contract DeployMintersShared is string memory marketKey ) internal { cfg; - address minter = _predictAddress(marketKey, "minter"); + address minter = _predictAddress(_key(marketKey, "minter")); deployGenesis(stateData, marketKey, minter); } function _configureMinter(Config_MinterMarket market, string memory marketKey) internal { IFullMinterConfig cfg = IFullMinterConfig(address(market)); - address minter = _predictAddress(marketKey, "minter"); - address reservePool = _predictAddress(marketKey, "reservePool"); - address spCollateral = _predictAddress(marketKey, "stabilityPoolCollateral"); - address spLeveraged = _predictAddress(marketKey, "stabilityPoolLeveraged"); - address spm = _predictAddress(marketKey, "stabilityPoolManager"); - address genesis = _predictAddress(marketKey, "genesis"); - address leveragedToken = _predictAddress(marketKey, "leveraged"); + address minter = _predictAddress(_key(marketKey, "minter")); + address reservePool = _predictAddress(_key(marketKey, "reservePool")); + address spCollateral = _predictAddress(_key(marketKey, "stabilityPoolCollateral")); + address spLeveraged = _predictAddress(_key(marketKey, "stabilityPoolLeveraged")); + address spm = _predictAddress(_key(marketKey, "stabilityPoolManager")); + address genesis = _predictAddress(_key(marketKey, "genesis")); address priceOracle = _predictAddress(MinterMarketConfigLib.priceOracleKey(market)); // Update minter configuration (incentive ratios) @@ -259,10 +284,10 @@ abstract contract DeployMintersShared is grantStabilityPoolRoles(string.concat(marketKey, "::stabilityPoolCollateral"), spCollateral, spm); grantStabilityPoolRoles(string.concat(marketKey, "::stabilityPoolLeveraged"), spLeveraged, spm); - // Register reward tokens - StabilityPool_v2(spCollateral).registerRewardToken(cfg.wrappedCollateralToken()); - StabilityPool_v2(spLeveraged).registerRewardToken(cfg.wrappedCollateralToken()); - StabilityPool_v2(spLeveraged).registerRewardToken(leveragedToken); + // Register raw reward tokens (needed for SPM's depositReward calls) + IMultipleRewardDistributor(spCollateral).registerRewardToken(cfg.wrappedCollateralToken()); + IMultipleRewardDistributor(spLeveraged).registerRewardToken(cfg.wrappedCollateralToken()); + IMultipleRewardDistributor(spLeveraged).registerRewardToken(_predictAddress(_key(marketKey, "leveraged"))); // Configure StabilityPoolManager configureStabilityPoolManager( diff --git a/script/src/contracts/Genesis.sol b/script/src/contracts/Genesis.sol index 9e24363a..e35d76bd 100644 --- a/script/src/contracts/Genesis.sol +++ b/script/src/contracts/Genesis.sol @@ -34,7 +34,7 @@ abstract contract Genesis is HarborFactoryDeployer { bytes memory initData = abi.encodeCall(Genesis_v1.initialize, (owner())); - proxy = _deployProxyAndRecord( + proxy = _deployProxyViaStubAndRecord( stateData, genesisKey, impl, diff --git a/script/src/contracts/LeveragedToken.sol b/script/src/contracts/LeveragedToken.sol index d230981d..c0f27b73 100644 --- a/script/src/contracts/LeveragedToken.sol +++ b/script/src/contracts/LeveragedToken.sol @@ -34,7 +34,7 @@ abstract contract LeveragedToken is HarborFactoryDeployer { bytes memory initData = abi.encodeCall(MintableBurnableERC20_v1.initialize, (owner(), tokenName, tokenSymbol)); - leveragedToken = _deployProxyAndRecord( + leveragedToken = _deployProxyViaStubAndRecord( stateData, leveragedKey, impl, @@ -44,7 +44,7 @@ abstract contract LeveragedToken is HarborFactoryDeployer { ); // Grant minter roles - address minter = _predictAddress(marketKey, "minter"); + address minter = _predictAddress(_key(marketKey, "minter")); uint256 roles = IMintableRole(leveragedToken).MINTER_ROLE() | IBurnableRole(leveragedToken).BURNER_ROLE(); _grantRoles(leveragedKey, leveragedToken, minter, marketKey, roles, "MINTER | BURNER"); } diff --git a/script/src/contracts/Minter.sol b/script/src/contracts/Minter.sol index f4042ed5..73daa790 100644 --- a/script/src/contracts/Minter.sol +++ b/script/src/contracts/Minter.sol @@ -68,7 +68,7 @@ abstract contract Minter is HarborFactoryDeployer { bytes memory initData = abi.encodeCall(Minter_v2.initialize, (owner())); - proxy = _deployProxyAndRecord(stateData, minterKey, impl, initData); + proxy = _deployProxyViaStubAndRecord(stateData, minterKey, impl, initData); } /// @notice Configure a deployed Minter with its operational parameters. @@ -120,7 +120,7 @@ abstract contract Minter is HarborFactoryDeployer { bytes memory initData = abi.encodeCall(ReservePool_v1.initialize, (owner())); - proxy = _deployProxyAndRecord( + proxy = _deployProxyViaStubAndRecord( stateData, reservePoolKey, impl, @@ -152,7 +152,7 @@ abstract contract Minter is HarborFactoryDeployer { bytes memory initData = abi.encodeCall(TokenDistributor_v1.initialize, (owner(), name)); - proxy = _deployProxyAndRecord( + proxy = _deployProxyViaStubAndRecord( stateData, feeReceiverKey, impl, diff --git a/script/src/contracts/PeggedToken.sol b/script/src/contracts/PeggedToken.sol index 1f78b72d..ff5749c2 100644 --- a/script/src/contracts/PeggedToken.sol +++ b/script/src/contracts/PeggedToken.sol @@ -49,7 +49,7 @@ abstract contract PeggedToken is HarborFactoryDeployer { (owner(), pegConfig.name(), pegConfig.symbol()) ); - peggedToken = _deployProxyAndRecord( + peggedToken = _deployProxyViaStubAndRecord( stateData, tokenKey, impl, @@ -69,7 +69,7 @@ abstract contract PeggedToken is HarborFactoryDeployer { ); string memory marketKey = MinterMarketConfigLib.salt(marketConfigs[i]); - address minter = _predictAddress(marketKey, "minter"); + address minter = _predictAddress(_key(marketKey, "minter")); uint256 roles = IMintableRole(peggedToken).MINTER_ROLE() | IBurnableRole(peggedToken).BURNER_ROLE(); if (alreadyDeployed) { diff --git a/script/src/contracts/StabilityPool.sol b/script/src/contracts/StabilityPool.sol index 6581967a..1848f123 100644 --- a/script/src/contracts/StabilityPool.sol +++ b/script/src/contracts/StabilityPool.sol @@ -8,7 +8,8 @@ import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; import {DeploymentState} from "@bao-script/deployment/DeploymentState.sol"; import {StabilityPool_v3} from "@harbor/minter/StabilityPool_v3.sol"; -import {StabilityPool_v3} from "@harbor/minter/StabilityPool_v3.sol"; +import {RewardAlias} from "@harbor/reward/RewardAlias.sol"; +import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; import {Config_MinterMarket, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; import {ConfigTokenNames} from "script/config/ConfigTokenNames.sol"; @@ -95,7 +96,7 @@ abstract contract StabilityPool is HarborFactoryDeployer { (owner(), cfg.stabilityPoolEarlyWithdrawalFeeRatio(), treasury()) ); - proxy = _deployProxyAndRecord(stateData, spKey, impl, initData); + proxy = _deployProxyViaStubAndRecord(stateData, spKey, impl, initData); } /// @notice Grant StabilityPool roles to StabilityPoolManager. @@ -115,4 +116,50 @@ abstract contract StabilityPool is HarborFactoryDeployer { "REBALANCER | REWARD_DEPOSITOR" ); } + + // ========== REWARD ALIAS DEPLOYMENT ========== + + /// @notice Deploy a reward alias at a predictable address. + /// @param stateData Deployment state for recording. + /// @param spKey The stability pool local key (e.g. _key(marketKey, StabilityPoolCollateral)). + /// @param aliasName Alias purpose suffix (e.g. "harvest", "rebalance"). + /// @param underlying The underlying reward token address. + function deployRewardAlias( + DeploymentTypes.State memory stateData, + string memory spKey, + string memory aliasName, + address underlying + ) internal returns (address aliasProxy) { + string memory aliasKey = _key(spKey, aliasName); + console.log(" > %s", aliasKey); + + address impl = address(new RewardAlias(underlying)); + console.log(" Impl: %s", impl); + console.log(" Underlying: %s", underlying); + + bytes memory initData = abi.encodeCall( + RewardAlias.initialize, + (address(this), owner()) + ); + + aliasProxy = _deployProxyAndRecord( + stateData, + aliasKey, + impl, + "@harbor/reward/RewardAlias.sol", + "RewardAlias", + initData + ); + } + + /// @notice Register a reward alias on a stability pool. + /// @dev The SP must be deployed. The alias address is predicted — doesn't need to be deployed yet. + /// @param spKey The stability pool local key. + /// @param aliasName Alias purpose suffix. + function registerRewardAlias(string memory spKey, string memory aliasName) internal { + address sp = _predictAddress(spKey); + address aliasAddr = _predictAddress(_key(spKey, aliasName)); + IMultipleRewardDistributor(sp).registerRewardToken(aliasAddr); + console.log(" > Registered %s on %s", aliasName, spKey); + } } diff --git a/script/src/contracts/StabilityPoolManager.sol b/script/src/contracts/StabilityPoolManager.sol index 07be9425..155d204c 100644 --- a/script/src/contracts/StabilityPoolManager.sol +++ b/script/src/contracts/StabilityPoolManager.sol @@ -44,7 +44,7 @@ abstract contract StabilityPoolManager is HarborFactoryDeployer { bytes memory initData = abi.encodeCall(StabilityPoolManager_v1.initialize, (owner())); - proxy = _deployProxyAndRecord( + proxy = _deployProxyViaStubAndRecord( stateData, spmKey, impl, @@ -80,7 +80,7 @@ abstract contract StabilityPoolManager is HarborFactoryDeployer { bytes memory initData = abi.encodeCall(TokenDistributor_v1.initialize, (owner(), name)); - proxy = _deployProxyAndRecord( + proxy = _deployProxyViaStubAndRecord( stateData, feeReceiverKey, impl, diff --git a/script/test/MainnetRoles.t.sol b/script/test/MainnetRoles.t.sol index 0d0f7bd7..50a2cbd3 100644 --- a/script/test/MainnetRoles.t.sol +++ b/script/test/MainnetRoles.t.sol @@ -44,8 +44,8 @@ contract MainnetRoles is Test, HarborFactoryDeployer { function test_allSPMs_haveZeroFeeRole() public { for (uint256 i = 0; i < markets.length; i++) { string memory marketKey = string.concat(markets[i].peg, "::", markets[i].collateral); - address minter = _predictAddressFromFullSalt(_saltString(marketKey, "minter")); - address spm = _predictAddressFromFullSalt(_saltString(marketKey, "stabilityPoolManager")); + address minter = _predictAddressFromFullSalt(_saltString(_key(marketKey, "minter"))); + address spm = _predictAddressFromFullSalt(_saltString(_key(marketKey, "stabilityPoolManager"))); uint256 zeroFeeRole = IMinter(minter).ZERO_FEE_ROLE(); assertTrue( @@ -58,8 +58,8 @@ contract MainnetRoles is Test, HarborFactoryDeployer { function test_allSPMs_haveHarvesterRole() public { for (uint256 i = 0; i < markets.length; i++) { string memory marketKey = string.concat(markets[i].peg, "::", markets[i].collateral); - address minter = _predictAddressFromFullSalt(_saltString(marketKey, "minter")); - address spm = _predictAddressFromFullSalt(_saltString(marketKey, "stabilityPoolManager")); + address minter = _predictAddressFromFullSalt(_saltString(_key(marketKey, "minter"))); + address spm = _predictAddressFromFullSalt(_saltString(_key(marketKey, "stabilityPoolManager"))); uint256 harvesterRole = IMinter(minter).HARVESTER_ROLE(); assertTrue( @@ -72,8 +72,8 @@ contract MainnetRoles is Test, HarborFactoryDeployer { function test_allGenesis_haveZeroFeeRole() public { for (uint256 i = 0; i < markets.length; i++) { string memory marketKey = string.concat(markets[i].peg, "::", markets[i].collateral); - address minter = _predictAddressFromFullSalt(_saltString(marketKey, "minter")); - address genesis = _predictAddressFromFullSalt(_saltString(marketKey, "genesis")); + address minter = _predictAddressFromFullSalt(_saltString(_key(marketKey, "minter"))); + address genesis = _predictAddressFromFullSalt(_saltString(_key(marketKey, "genesis"))); uint256 zeroFeeRole = IMinter(minter).ZERO_FEE_ROLE(); assertTrue( diff --git a/script/test/MinterUpgradeTest.t.sol b/script/test/MinterUpgradeTest.t.sol index 6901c599..c0f61ca7 100644 --- a/script/test/MinterUpgradeTest.t.sol +++ b/script/test/MinterUpgradeTest.t.sol @@ -25,7 +25,7 @@ contract MinterUpgradeTest is BaoTest, HarborFactoryDeployer { } function _predict(string memory marketKey, string memory suffix) internal returns (address) { - return _predictAddressFromFullSalt(_saltString(marketKey, suffix)); + return _predictAddressFromFullSalt(_saltString(_key(marketKey, suffix))); } // ---- ETH::fxUSD rebalance tests (the market with known sub-threshold CR) ---- diff --git a/script/test/SPLRemediationTest.t.sol b/script/test/SPLRemediationTest.t.sol index 416ddc1f..367e1086 100644 --- a/script/test/SPLRemediationTest.t.sol +++ b/script/test/SPLRemediationTest.t.sol @@ -10,7 +10,7 @@ import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumula import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IMinter} from "src/interfaces/IMinter.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; -import {PostRebalanceRemediationForStabilityPool_v2} from "src/minter/PostRebalanceRemediationForStabilityPool_v2.sol"; +import {PostRebalanceRemediationForStabilityPool_v2} from "script/patch/PostRebalanceRemediationForStabilityPool_v2.sol"; import {MockWrappedPriceOracle} from "test/mocks/MockWrappedPriceOracle.sol"; import {IWrappedPriceOracle} from "src/interfaces/IWrappedPriceOracle.sol"; import {IStabilityPoolManager} from "src/interfaces/IStabilityPoolManager.sol"; @@ -48,12 +48,12 @@ abstract contract SPLTestBase is BaoTest, HarborFactoryDeployer { function _initAddresses() internal { _setSaltPrefix("harbor_v1"); - spl = _predictAddress("ETH", "fxUSD", "stabilityPoolLeveraged"); - spc = _predictAddress("ETH", "fxUSD", "stabilityPoolCollateral"); - minter = _predictAddress("ETH", "fxUSD", "minter"); - lev = _predictAddress("ETH", "fxUSD", "leveraged"); - peg = _predictAddress("ETH", "pegged"); - spm = _predictAddress("ETH", "fxUSD", "stabilityPoolManager"); + spl = _predictAddress(_key("ETH", "fxUSD", "stabilityPoolLeveraged")); + spc = _predictAddress(_key("ETH", "fxUSD", "stabilityPoolCollateral")); + minter = _predictAddress(_key("ETH", "fxUSD", "minter")); + lev = _predictAddress(_key("ETH", "fxUSD", "leveraged")); + peg = _predictAddress(_key("ETH", "pegged")); + spm = _predictAddress(_key("ETH", "fxUSD", "stabilityPoolManager")); wrappedCollateral = IMinter(minter).WRAPPED_COLLATERAL_TOKEN(); proxyOwner = IBaoOwnable(spl).owner(); } diff --git a/script/test/SPv3MigrationTest.t.sol b/script/test/SPv3MigrationTest.t.sol index 62ff829e..5879a483 100644 --- a/script/test/SPv3MigrationTest.t.sol +++ b/script/test/SPv3MigrationTest.t.sol @@ -76,9 +76,9 @@ contract SPv3MigrationTest is BaoTest, HarborFactoryDeployer { function setUp() public { vm.createSelectFork(vm.rpcUrl("mainnet")); _setSaltPrefix("harbor_v1"); - spc = _predictAddress("ETH", "fxUSD", "stabilityPoolCollateral"); - minter = _predictAddress("ETH", "fxUSD", "minter"); - peg = _predictAddress("ETH", "pegged"); + spc = _predictAddress(_key("ETH", "fxUSD", "stabilityPoolCollateral")); + minter = _predictAddress(_key("ETH", "fxUSD", "minter")); + peg = _predictAddress(_key("ETH", "pegged")); wrappedCollateral = IMinter(minter).WRAPPED_COLLATERAL_TOKEN(); proxyOwner = IBaoOwnable(spc).owner(); diff --git a/script/test/V2ReplaySimulation.t.sol b/script/test/V2ReplaySimulation.t.sol index 9d38fe87..3efc6e06 100644 --- a/script/test/V2ReplaySimulation.t.sol +++ b/script/test/V2ReplaySimulation.t.sol @@ -247,12 +247,12 @@ contract V2ReplaySimulation is BaoTest, HarborFactoryDeployer { vm.createSelectFork(vm.rpcUrl("mainnet"), FORK_BLOCK); _setSaltPrefix("harbor_v1"); - spm = _predictAddress("ETH", "fxUSD", "stabilityPoolManager"); - minterAddr = _predictAddress("ETH", "fxUSD", "minter"); - spl = _predictAddress("ETH", "fxUSD", "stabilityPoolLeveraged"); - spc = _predictAddress("ETH", "fxUSD", "stabilityPoolCollateral"); - lev = _predictAddress("ETH", "fxUSD", "leveraged"); - peg = _predictAddress("ETH", "pegged"); + spm = _predictAddress(_key("ETH", "fxUSD", "stabilityPoolManager")); + minterAddr = _predictAddress(_key("ETH", "fxUSD", "minter")); + spl = _predictAddress(_key("ETH", "fxUSD", "stabilityPoolLeveraged")); + spc = _predictAddress(_key("ETH", "fxUSD", "stabilityPoolCollateral")); + lev = _predictAddress(_key("ETH", "fxUSD", "leveraged")); + peg = _predictAddress(_key("ETH", "pegged")); proxyOwner = IBaoOwnable(minterAddr).owner(); realOracle = IMinter(minterAddr).priceOracle(); diff --git a/src/minter/StabilityPool_v3.sol b/src/minter/StabilityPool_v3.sol index da0e10e8..e7669ea4 100644 --- a/src/minter/StabilityPool_v3.sol +++ b/src/minter/StabilityPool_v3.sol @@ -667,6 +667,20 @@ contract StabilityPool_v3 is return $.allowances[owner_][spender]; } + // ═══════════════════════════════════════════════════════════════════════ + // Alias-Aware Claimable + // ═══════════════════════════════════════════════════════════════════════ + + /// @notice Returns claimable for a token. If the token has aliases, sums all aliases' claimable. + /// @dev Overrides the accumulator's claimable to aggregate across aliases. + function claimable(address account, address token) external view override returns (uint256 total) { + total = _claimable(account, token, true); + address[] memory aliases = _getAliases(token); + for (uint256 i = 0; i < aliases.length; i++) { + total += _claimable(account, aliases[i], true); + } + } + // ═══════════════════════════════════════════════════════════════════════ // Selective Claim // ═══════════════════════════════════════════════════════════════════════ diff --git a/src/reward/RewardAlias.sol b/src/reward/RewardAlias.sol new file mode 100644 index 00000000..38b3a792 --- /dev/null +++ b/src/reward/RewardAlias.sol @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.30; + +import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; +import {IERC5313} from "@openzeppelin/contracts/interfaces/IERC5313.sol"; +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; + +import {HarborOwnable} from "@bao/HarborOwnable.sol"; +import {IRewardAlias} from "src/interfaces/IRewardAlias.sol"; + +/// @title RewardAlias +/// @notice A minimal UUPS-upgradeable contract that identifies itself as an alias for an underlying reward token. +/// @dev Deploy via BaoFactory (CREATE3) at a predictable address. +/// The reward system detects aliases via IRewardAlias.underlying() during registration. +/// The alias address is used for integral tracking; the underlying is used for token transfers. +// solhint-disable-next-line contract-name-capwords +contract RewardAlias is Initializable, UUPSUpgradeable, HarborOwnable, IERC5313, IRewardAlias { + /// @notice The underlying reward token this alias represents. + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + address public immutable override underlying; + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor(address underlying_) { + _disableInitializers(); + underlying = underlying_; + } + + /// @notice Initialize ownership. + /// @param deployerOwner_ The initial (temporary) owner — typically the FactoryDeployer contract. + /// @param pendingOwner_ The final owner — typically the Harbor multisig. + function initialize(address deployerOwner_, address pendingOwner_) external initializer { + _initializeOwner(deployerOwner_, pendingOwner_); + __UUPSUpgradeable_init(); + } + + /// @inheritdoc IERC5313 + function owner() public view override(HarborOwnable, IERC5313) returns (address owner_) { + owner_ = HarborOwnable.owner(); + } + + /// @inheritdoc IERC165 + function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + return interfaceId == type(IERC5313).interfaceId || super.supportsInterface(interfaceId); + } + + /// @notice Authorize upgrades — only owner can upgrade. + function _authorizeUpgrade(address) internal override onlyOwner {} // solhint-disable-line no-empty-blocks +} diff --git a/test/RebalanceCheck.t.sol b/test/RebalanceCheck.t.sol index 6727c580..a5addea7 100644 --- a/test/RebalanceCheck.t.sol +++ b/test/RebalanceCheck.t.sol @@ -30,12 +30,12 @@ abstract contract RebalanceCheckBase is BaoTest, HarborFactoryDeployer { vm.createSelectFork(mainnet, FORK_BLOCK); _setSaltPrefix("harbor_v1"); - stabilityPoolManager = _predictAddress("ETH", "fxUSD", "stabilityPoolManager"); - stabilityPoolCollateral = _predictAddress("ETH", "fxUSD", "stabilityPoolCollateral"); - stabilityPoolLeveraged = _predictAddress("ETH", "fxUSD", "stabilityPoolLeveraged"); - minter = _predictAddress("ETH", "fxUSD", "minter"); - pegged = _predictAddress("ETH", "pegged"); - leveraged = _predictAddress("ETH", "fxUSD", "leveraged"); + stabilityPoolManager = _predictAddress(_key("ETH", "fxUSD", "stabilityPoolManager")); + stabilityPoolCollateral = _predictAddress(_key("ETH", "fxUSD", "stabilityPoolCollateral")); + stabilityPoolLeveraged = _predictAddress(_key("ETH", "fxUSD", "stabilityPoolLeveraged")); + minter = _predictAddress(_key("ETH", "fxUSD", "minter")); + pegged = _predictAddress(_key("ETH", "pegged")); + leveraged = _predictAddress(_key("ETH", "fxUSD", "leveraged")); } function _upgradeSpm() internal { diff --git a/test/RebalanceFairness.t.sol b/test/RebalanceFairness.t.sol index 12f897cd..b9a61595 100644 --- a/test/RebalanceFairness.t.sol +++ b/test/RebalanceFairness.t.sol @@ -73,12 +73,12 @@ contract RebalanceFairnessSetUp is BaoTest, Deploy_ETH_Minter { // Resolve deployed addresses _setSaltPrefix("fairness_test"); - minter = _predictAddress("ETH", "fxUSD", "minter"); - stabilityPoolCollateral = _predictAddress("ETH", "fxUSD", "stabilityPoolCollateral"); - stabilityPoolLeveraged = _predictAddress("ETH", "fxUSD", "stabilityPoolLeveraged"); - stabilityPoolManager = _predictAddress("ETH", "fxUSD", "stabilityPoolManager"); - pegged = _predictAddress("ETH", "pegged"); - leveraged = _predictAddress("ETH", "fxUSD", "leveraged"); + minter = _predictAddress(_key("ETH", "fxUSD", "minter")); + stabilityPoolCollateral = _predictAddress(_key("ETH", "fxUSD", "stabilityPoolCollateral")); + stabilityPoolLeveraged = _predictAddress(_key("ETH", "fxUSD", "stabilityPoolLeveraged")); + stabilityPoolManager = _predictAddress(_key("ETH", "fxUSD", "stabilityPoolManager")); + pegged = _predictAddress(_key("ETH", "pegged")); + leveraged = _predictAddress(_key("ETH", "fxUSD", "leveraged")); wrappedCollateral = IMinter(minter).WRAPPED_COLLATERAL_TOKEN(); // Install mock oracle so we can control price/rate diff --git a/test/StabilityPoolAliasDeployment.t.sol b/test/StabilityPoolAliasDeployment.t.sol new file mode 100644 index 00000000..afeff528 --- /dev/null +++ b/test/StabilityPoolAliasDeployment.t.sol @@ -0,0 +1,259 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.28 <0.9.0; + +import {BaoTest} from "@bao-test/BaoTest.sol"; +import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; +import {Deploy_ETH_Minter} from "script/src/Deploy_ETH_Minter.sol"; +import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; +import {Config_MinterMarket, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IMinter} from "src/interfaces/IMinter.sol"; +import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; +import {IStabilityPool_v3} from "src/interfaces/IStabilityPool_v3.sol"; +import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; +import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; +import {IRewardAlias} from "src/interfaces/IRewardAlias.sol"; +import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; +import {Minter_v2} from "src/minter/Minter_v2.sol"; +import {StabilityPoolManager_v1} from "src/minter/StabilityPoolManager_v1.sol"; +import {MockWrappedPriceOracle} from "test/mocks/MockWrappedPriceOracle.sol"; + +/// @title StabilityPoolAliasDeploymentTest +/// @notice Tests that v3 stability pools deployed via the production deployment scripts +/// have reward aliases correctly registered and functional. +contract StabilityPoolAliasDeploymentSetUp is BaoTest, Deploy_ETH_Minter { + using MinterMarketConfigLib for Config_MinterMarket; + + // Deployed contract addresses + address minter; + address stabilityPoolCollateral; + address stabilityPoolLeveraged; + address stabilityPoolManager; + address pegged; + address leveraged; + address wrappedCollateral; + + // Alias addresses + address collHarvestAlias; + address collRebalanceAlias; + address levHarvestAlias; + address levRebalanceAlias; + + // Mock oracle + MockWrappedPriceOracle mockOracle; + + function _shouldPersistState() internal pure override returns (bool) { + return false; + } + + function setUp() public virtual { + // Deploy BaoFactory locally + address factory = _ensureBaoFactory(); + + // Fork mainnet so real token contracts (fxSAVE, fxUSD, etc.) exist + vm.createSelectFork(vm.rpcUrl("mainnet")); + + // Register as factory operator + vm.prank(IBaoFactory(factory).owner()); + IBaoFactory(factory).setOperator(address(this), 365 days); + + // Deploy ETH::fxUSD market via production deployment scripts + (ConfigPeg peg, Config_MinterMarket[] memory mktConfigs) = createETHMintersConfig(); + Config_MinterMarket[] memory toDeploy = new Config_MinterMarket[](1); + toDeploy[0] = mktConfigs[0]; + deployForPeg("alias_test", peg, mktConfigs, "mainnet", true, toDeploy); + + // Resolve deployed addresses + _setSaltPrefix("alias_test"); + string memory marketKey = "ETH::fxUSD"; + minter = _predictAddress(_key(marketKey, "minter")); + stabilityPoolCollateral = _predictAddress(_key(marketKey, "stabilityPoolCollateral")); + stabilityPoolLeveraged = _predictAddress(_key(marketKey, "stabilityPoolLeveraged")); + stabilityPoolManager = _predictAddress(_key(marketKey, "stabilityPoolManager")); + pegged = _predictAddress(_key("ETH", "pegged")); + leveraged = _predictAddress(_key(marketKey, "leveraged")); + wrappedCollateral = IMinter(minter).WRAPPED_COLLATERAL_TOKEN(); + + // Resolve alias addresses + collHarvestAlias = _predictAddress(_key(marketKey, "stabilityPoolCollateral", "harvest")); + collRebalanceAlias = _predictAddress(_key(marketKey, "stabilityPoolCollateral", "rebalance")); + levHarvestAlias = _predictAddress(_key(marketKey, "stabilityPoolLeveraged", "harvest")); + levRebalanceAlias = _predictAddress(_key(marketKey, "stabilityPoolLeveraged", "rebalance")); + + // Install mock oracle and grant roles for test operations + mockOracle = new MockWrappedPriceOracle(); + mockOracle.setLatestAnswer(1 ether, 1 ether); + + vm.startPrank(HARBOR_MULTISIG); + Minter_v2(minter).updatePriceOracle(address(mockOracle)); + IBaoRoles(minter).grantRoles(address(this), IMinter(minter).ZERO_FEE_ROLE()); + IBaoRoles(stabilityPoolCollateral).grantRoles( + address(this), + IMultipleRewardDistributor(stabilityPoolCollateral).REWARD_DEPOSITOR_ROLE() + ); + vm.stopPrank(); + } + + function _mintPegged(address to, uint256 collateralAmount) internal returns (uint256 peggedMinted) { + deal(wrappedCollateral, address(this), collateralAmount); + IERC20(wrappedCollateral).approve(minter, collateralAmount); + peggedMinted = IMinter(minter).freeMintPeggedToken(collateralAmount, to); + } +} + +contract StabilityPoolAliasDeploymentTest is StabilityPoolAliasDeploymentSetUp { + // ═══════════════════════════════════════════════════════════════ + // Alias deployment verification + // ═══════════════════════════════════════════════════════════════ + + function test_aliasesDeployed() public view { + assertGt(collHarvestAlias.code.length, 0, "collHarvestAlias deployed"); + assertGt(collRebalanceAlias.code.length, 0, "collRebalanceAlias deployed"); + assertGt(levHarvestAlias.code.length, 0, "levHarvestAlias deployed"); + assertGt(levRebalanceAlias.code.length, 0, "levRebalanceAlias deployed"); + } + + function test_aliasUnderlyings() public view { + // Collateral SP aliases both point to wrappedCollateral + assertEq(IRewardAlias(collHarvestAlias).underlying(), wrappedCollateral, "coll harvest underlying"); + assertEq(IRewardAlias(collRebalanceAlias).underlying(), wrappedCollateral, "coll rebalance underlying"); + + // Leveraged SP: harvest → wrappedCollateral, rebalance → leveraged token + assertEq(IRewardAlias(levHarvestAlias).underlying(), wrappedCollateral, "lev harvest underlying"); + assertEq(IRewardAlias(levRebalanceAlias).underlying(), leveraged, "lev rebalance underlying"); + } + + // ═══════════════════════════════════════════════════════════════ + // Alias registration on SPs + // ═══════════════════════════════════════════════════════════════ + + function test_aliasesRegisteredOnCollateralSP() public view { + address[] memory tokens = IMultipleRewardDistributor(stabilityPoolCollateral).activeRewardTokens(); + bool foundHarvest; + bool foundRebalance; + for (uint256 i = 0; i < tokens.length; i++) { + if (tokens[i] == collHarvestAlias) { foundHarvest = true; } + if (tokens[i] == collRebalanceAlias) { foundRebalance = true; } + } + assertTrue(foundHarvest, "harvest alias registered on coll SP"); + assertTrue(foundRebalance, "rebalance alias registered on coll SP"); + } + + function test_aliasesRegisteredOnLeveragedSP() public view { + address[] memory tokens = IMultipleRewardDistributor(stabilityPoolLeveraged).activeRewardTokens(); + bool foundHarvest; + bool foundRebalance; + for (uint256 i = 0; i < tokens.length; i++) { + if (tokens[i] == levHarvestAlias) { foundHarvest = true; } + if (tokens[i] == levRebalanceAlias) { foundRebalance = true; } + } + assertTrue(foundHarvest, "harvest alias registered on lev SP"); + assertTrue(foundRebalance, "rebalance alias registered on lev SP"); + } + + // ═══════════════════════════════════════════════════════════════ + // Deposit via alias → claim resolves to underlying + // ═══════════════════════════════════════════════════════════════ + + function test_depositViaAlias_claimReturnsUnderlying() public { + address alice = makeAddr("alice"); + uint256 depositAmount = 100 ether; + uint256 rewardAmount = 5 ether; + + // Mint pegged and deposit into collateral SP + _mintPegged(alice, depositAmount); + vm.prank(alice); + IERC20(pegged).approve(stabilityPoolCollateral, depositAmount); + vm.prank(alice); + IStabilityPool(stabilityPoolCollateral).deposit(depositAmount, alice, 0); + + // Deposit reward via harvest alias + deal(wrappedCollateral, address(this), rewardAmount); + IERC20(wrappedCollateral).approve(stabilityPoolCollateral, rewardAmount); + IMultipleRewardDistributor(stabilityPoolCollateral).depositReward(collHarvestAlias, rewardAmount); + + // Wait for full distribution + skip(8 days); + + // Claimable should show under the alias address + uint256 claimableAlias = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(alice, collHarvestAlias); + assertApprox(claimableAlias, rewardAmount, 604800, "claimable via alias"); + + // Claim via alias — should receive wrappedCollateral (the underlying) + uint256 balBefore = IERC20(wrappedCollateral).balanceOf(alice); + vm.prank(alice); + IStabilityPool_v3(stabilityPoolCollateral).claimSingle(alice, collHarvestAlias); + uint256 received = IERC20(wrappedCollateral).balanceOf(alice) - balBefore; + + assertApprox(received, rewardAmount, 604800, "claimed underlying amount"); + } + + function test_separateTracking_harvestVsRebalance() public { + address alice = makeAddr("alice"); + uint256 depositAmount = 100 ether; + uint256 harvestReward = 3 ether; + uint256 rebalanceReward = 7 ether; + + // Mint and deposit + _mintPegged(alice, depositAmount); + vm.prank(alice); + IERC20(pegged).approve(stabilityPoolCollateral, depositAmount); + vm.prank(alice); + IStabilityPool(stabilityPoolCollateral).deposit(depositAmount, alice, 0); + + // Deposit harvest reward via harvest alias + deal(wrappedCollateral, address(this), harvestReward); + IERC20(wrappedCollateral).approve(stabilityPoolCollateral, harvestReward); + IMultipleRewardDistributor(stabilityPoolCollateral).depositReward(collHarvestAlias, harvestReward); + + // Deposit rebalance reward via rebalance alias + deal(wrappedCollateral, address(this), rebalanceReward); + IERC20(wrappedCollateral).approve(stabilityPoolCollateral, rebalanceReward); + IMultipleRewardDistributor(stabilityPoolCollateral).depositReward(collRebalanceAlias, rebalanceReward); + + // Wait for distribution + skip(8 days); + + // Each alias tracks separately + uint256 claimableHarvest = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(alice, collHarvestAlias); + uint256 claimableRebalance = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(alice, collRebalanceAlias); + + assertApprox(claimableHarvest, harvestReward, 604800, "harvest alias tracked separately"); + assertApprox(claimableRebalance, rebalanceReward, 604800, "rebalance alias tracked separately"); + + // Aggregated claimable for underlying should be sum of aliases + uint256 claimableTotal = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(alice, wrappedCollateral); + assertApprox(claimableTotal, harvestReward + rebalanceReward, 2 * 604800, "aggregated claimable"); + } + + function test_claimAll_collectsBothAliases() public { + address alice = makeAddr("alice"); + uint256 depositAmount = 100 ether; + uint256 harvestReward = 4 ether; + uint256 rebalanceReward = 6 ether; + + // Mint and deposit + _mintPegged(alice, depositAmount); + vm.prank(alice); + IERC20(pegged).approve(stabilityPoolCollateral, depositAmount); + vm.prank(alice); + IStabilityPool(stabilityPoolCollateral).deposit(depositAmount, alice, 0); + + // Deposit via both aliases + deal(wrappedCollateral, address(this), harvestReward + rebalanceReward); + IERC20(wrappedCollateral).approve(stabilityPoolCollateral, harvestReward + rebalanceReward); + IMultipleRewardDistributor(stabilityPoolCollateral).depositReward(collHarvestAlias, harvestReward); + IMultipleRewardDistributor(stabilityPoolCollateral).depositReward(collRebalanceAlias, rebalanceReward); + + skip(8 days); + + // Claim all — should receive total from both aliases + uint256 balBefore = IERC20(wrappedCollateral).balanceOf(alice); + vm.prank(alice); + IMultipleRewardAccumulator(stabilityPoolCollateral).claim(alice); + uint256 received = IERC20(wrappedCollateral).balanceOf(alice) - balBefore; + + assertApprox(received, harvestReward + rebalanceReward, 2 * 604800, "claim all collects both aliases"); + } +} diff --git a/test/StabilityPoolClaimable.t.sol b/test/StabilityPoolClaimable.t.sol index 8707358f..91d37da9 100644 --- a/test/StabilityPoolClaimable.t.sol +++ b/test/StabilityPoolClaimable.t.sol @@ -700,3 +700,237 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { assertEq(rewardToken1.balanceOf(user1), 0, "nothing claimed"); } } + +// ═══════════════════════════════════════════════════════════════════════════ +// Reward Alias Tests +// ═══════════════════════════════════════════════════════════════════════════ + +import {RewardAlias} from "src/reward/RewardAlias.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IStabilityPool_v3} from "src/interfaces/IStabilityPool_v3.sol"; + +contract TestRewardAlias is TestStabilityPoolRebalanceSetUp { + MockERC20 aliasUnderlying; + RewardAlias harvestAlias; + RewardAlias boostAlias; + + uint256 constant DEPOSIT_AMOUNT = 10 ether; + + function setUp() public override { + super.setUp(); + + // Create a reward token and two aliases for it + aliasUnderlying = new MockERC20("Reward", "RWD", 18); + vm.label(address(aliasUnderlying), "AliasUnderlying"); + harvestAlias = new RewardAlias(address(aliasUnderlying)); + vm.label(address(harvestAlias), "HARVEST_ALIAS"); + boostAlias = new RewardAlias(address(aliasUnderlying)); + vm.label(address(boostAlias), "BOOST_ALIAS"); + + // Register both aliases as reward tokens + vm.startPrank(rewardManager); + IMultipleRewardDistributor(stabilityPoolCollateral).registerRewardToken(address(harvestAlias)); + IMultipleRewardDistributor(stabilityPoolCollateral).registerRewardToken(address(boostAlias)); + vm.stopPrank(); + + // Fund the depositor with the underlying reward token + aliasUnderlying.mint(rewardDepositor, 1000 ether); + vm.prank(rewardDepositor); + aliasUnderlying.approve(stabilityPoolCollateral, type(uint256).max); + + // Deposit for users + deal(peggedToken, user1, DEPOSIT_AMOUNT * 10); + deal(peggedToken, user2, DEPOSIT_AMOUNT * 10); + setUp_collateral(100 ether, 100 ether); + + vm.prank(user1); + IStabilityPool(stabilityPoolCollateral).deposit(DEPOSIT_AMOUNT, user1, 0); + vm.prank(user2); + IStabilityPool(stabilityPoolCollateral).deposit(DEPOSIT_AMOUNT, user2, 0); + } + + function _depositRewardAndWait(address alias_, uint256 amount) internal { + vm.prank(rewardDepositor); + IMultipleRewardDistributor(stabilityPoolCollateral).depositReward(alias_, amount); + skip(8 days); + } + + // ── Registration ──────────────────────────────────────────────────── + + function testAlias_registeredAsActiveToken() public view { + address[] memory active = IMultipleRewardDistributor(stabilityPoolCollateral).activeRewardTokens(); + bool foundHarvest; + bool foundBoost; + for (uint256 i = 0; i < active.length; i++) { + if (active[i] == address(harvestAlias)) { + foundHarvest = true; + } + if (active[i] == address(boostAlias)) { + foundBoost = true; + } + } + assertTrue(foundHarvest, "harvest alias registered"); + assertTrue(foundBoost, "boost alias registered"); + } + + // ── Deposit via alias ─────────────────────────────────────────────── + + function testAlias_depositTransfersUnderlying() public { + uint256 spBalBefore = aliasUnderlying.balanceOf(stabilityPoolCollateral); + uint256 depositorBalBefore = aliasUnderlying.balanceOf(rewardDepositor); + + vm.prank(rewardDepositor); + IMultipleRewardDistributor(stabilityPoolCollateral).depositReward(address(harvestAlias), 100 ether); + + // The underlying token was transferred, not the alias + assertEq(aliasUnderlying.balanceOf(stabilityPoolCollateral) - spBalBefore, 100 ether, "SP received underlying"); + assertEq(depositorBalBefore - aliasUnderlying.balanceOf(rewardDepositor), 100 ether, "depositor sent underlying"); + } + + // ── Claimable per alias ───────────────────────────────────────────── + + function testAlias_claimableTrackedSeparately() public { + _depositRewardAndWait(address(harvestAlias), 100 ether); + _depositRewardAndWait(address(boostAlias), 200 ether); + + uint256 claimHarvest = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( + user1, address(harvestAlias) + ); + uint256 claimBoost = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( + user1, address(boostAlias) + ); + + // user1 has 50% of the pool → gets 50% of each alias's reward + assertApproxEqAbs(claimHarvest, 50 ether, 2 * 604800, "harvest claimable ~50 (tolerance: 2 periods of rate truncation)"); + assertApproxEqAbs(claimBoost, 100 ether, 2 * 604800, "boost claimable ~100 (tolerance: 2 periods of rate truncation)"); + } + + // ── Claim via alias → transfers underlying ────────────────────────── + + function testAlias_claimSingleTransfersUnderlying() public { + _depositRewardAndWait(address(harvestAlias), 100 ether); + + uint256 claimable = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( + user1, address(harvestAlias) + ); + assertGt(claimable, 0, "has claimable"); + + uint256 rwdBefore = aliasUnderlying.balanceOf(user1); + vm.prank(user1); + IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, address(harvestAlias)); + + // User received the underlying token, not the alias + assertEq(aliasUnderlying.balanceOf(user1) - rwdBefore, claimable, "received underlying"); + } + + // ── Claim one alias doesn't affect another ────────────────────────── + + function testAlias_claimOneDoesNotAffectOther() public { + _depositRewardAndWait(address(harvestAlias), 100 ether); + _depositRewardAndWait(address(boostAlias), 200 ether); + + uint256 boostBefore = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( + user1, address(boostAlias) + ); + + // Claim only harvest + vm.prank(user1); + IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, address(harvestAlias)); + + // Boost should be unchanged + uint256 boostAfter = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( + user1, address(boostAlias) + ); + assertEq(boostAfter, boostBefore, "boost unaffected by harvest claim"); + } + + // ── claim() claims all aliases, transferring underlying ───────────── + + function testAlias_claimAllTransfersUnderlying() public { + _depositRewardAndWait(address(harvestAlias), 100 ether); + _depositRewardAndWait(address(boostAlias), 200 ether); + + uint256 rwdBefore = aliasUnderlying.balanceOf(user1); + vm.prank(user1); + IMultipleRewardAccumulator(stabilityPoolCollateral).claim(user1); + + uint256 received = aliasUnderlying.balanceOf(user1) - rwdBefore; + // Should have received harvest + boost combined (~150 ether for 50% of pool) + assertApproxEqAbs(received, 150 ether, 4 * 604800, "received total from both aliases (tolerance: 4 periods of rate truncation)"); + + // Both should be zero after claim + assertEq( + IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(harvestAlias)), + 0, + "harvest zeroed" + ); + assertEq( + IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(boostAlias)), + 0, + "boost zeroed" + ); + } + + // ── RewardAlias contract ──────────────────────────────────────────── + + function testAlias_underlyingReturnsCorrectToken() public view { + assertEq(harvestAlias.underlying(), address(aliasUnderlying), "harvest underlying"); + assertEq(boostAlias.underlying(), address(aliasUnderlying), "boost underlying"); + } + + function testAlias_differentAliasesDifferentAddresses() public view { + assertTrue(address(harvestAlias) != address(boostAlias), "different addresses"); + } + + // ── Aggregation: claimable(raw token) sums aliases ────────────────── + + function testAlias_claimableAggregatesAliases() public { + _depositRewardAndWait(address(harvestAlias), 100 ether); + _depositRewardAndWait(address(boostAlias), 200 ether); + + // Claimable for each alias individually + uint256 harvestOnly = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( + user1, address(harvestAlias) + ); + uint256 boostOnly = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( + user1, address(boostAlias) + ); + + // Claimable for the raw underlying — should sum both aliases + uint256 aggregated = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( + user1, address(aliasUnderlying) + ); + + assertEq(aggregated, harvestOnly + boostOnly, "aggregated = harvest + boost"); + assertGt(aggregated, 0, "non-zero aggregated"); + } + + function testAlias_claimableRawTokenWithNoAliases() public { + // Register a plain token (no alias) and deposit to it + MockERC20 plainToken = new MockERC20("Plain", "PLN", 18); + vm.prank(rewardManager); + IMultipleRewardDistributor(stabilityPoolCollateral).registerRewardToken(address(plainToken)); + plainToken.mint(rewardDepositor, 100 ether); + vm.prank(rewardDepositor); + plainToken.approve(stabilityPoolCollateral, 100 ether); + vm.prank(rewardDepositor); + IMultipleRewardDistributor(stabilityPoolCollateral).depositReward(address(plainToken), 100 ether); + skip(8 days); + + // Claimable for a plain token with no aliases — should return its own claimable only + uint256 claimable = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( + user1, address(plainToken) + ); + assertGt(claimable, 0, "plain token has claimable"); + + // No aliases exist for this token, so aggregation adds nothing + uint256 aliasCount = 0; + address[] memory active = IMultipleRewardDistributor(stabilityPoolCollateral).activeRewardTokens(); + for (uint256 i = 0; i < active.length; i++) { + if (active[i] == address(plainToken)) { + aliasCount++; + } + } + assertEq(aliasCount, 1, "plain token registered once"); + } +} From 6b0ddc4b7102c806e8f4cb4c0dd9e9bb1c866606 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Thu, 2 Apr 2026 19:26:27 +0100 Subject: [PATCH 008/232] Minter_v3 copied Minter_v2 and made prive functions internal making Minter easier to extend (and same contract size) --- .claude/settings.json | 3 +- CLAUDE.md | 3 +- regression/gas.txt | 364 ++- regression/sizes.txt | 4 +- script/{test => verify}/README.md | 0 .../minter-v2-upgrade}/DeployMinters.t.sol | 0 .../MainnetForkUpgradeTest.t.sol | 0 .../MinterUpgradeTest.t.sol | 0 .../run-upgrade-test-Minter_v2 | 0 .../minter-v2-upgrade}/test-deploy | 0 .../minter-v2-upgrade}/test-deploy.md | 0 .../minter-v2-upgrade}/upgrade-Minter_v2.md | 0 .../{test => verify/roles}/MainnetRoles.t.sol | 0 .../sp-v2-upgrade}/epoch-removal-summary.md | 0 .../verify/sp-v2-upgrade}/finishat-zero.md | 0 .../verify/sp-v2-upgrade}/genesis-end.md | 0 .../sp-v2-upgrade}/linear-reward-underflow.md | 0 .../run-upgrade-test-StabilityPool_v2 | 0 .../verify/sp-v2-upgrade}/sp-overflow.md | 0 .../upgrade-StabilityPool_v2.md | 0 .../sp-v3-migration}/SPv3MigrationTest.t.sol | 0 .../run-upgrade-test-remediate-accumulators | 0 .../verify/sp-v3-migration}/sp-v3-upgrade.md | 0 .../spl-remediation}/SPLRemediationTest.t.sol | 0 .../spl-remediation}/V2ReplaySimulation.t.sol | 0 .../spl-remediation}/collect-holders | 0 .../rebalance-bug-remediation.md | 0 .../spl-remediation}/rebalance-remediation.md | 0 .../remediation-ETH-fxUSD-SPL.md | 0 .../run-upgrade-test-remediate-ETH_fxUSD_SPL | 0 src/minter/Minter_v3.sol | 1966 +++++++++++++++++ test/{ => deployment}/RebalanceFairness.t.sol | 0 .../StabilityPoolAliasDeployment.t.sol | 0 33 files changed, 2283 insertions(+), 57 deletions(-) rename script/{test => verify}/README.md (100%) rename script/{test => verify/minter-v2-upgrade}/DeployMinters.t.sol (100%) rename script/{test => verify/minter-v2-upgrade}/MainnetForkUpgradeTest.t.sol (100%) rename script/{test => verify/minter-v2-upgrade}/MinterUpgradeTest.t.sol (100%) rename script/{test => verify/minter-v2-upgrade}/run-upgrade-test-Minter_v2 (100%) rename script/{test => verify/minter-v2-upgrade}/test-deploy (100%) rename script/{test => verify/minter-v2-upgrade}/test-deploy.md (100%) rename script/{test => verify/minter-v2-upgrade}/upgrade-Minter_v2.md (100%) rename script/{test => verify/roles}/MainnetRoles.t.sol (100%) rename {doc/fixes => script/verify/sp-v2-upgrade}/epoch-removal-summary.md (100%) rename {doc/fixes => script/verify/sp-v2-upgrade}/finishat-zero.md (100%) rename {doc/fixes => script/verify/sp-v2-upgrade}/genesis-end.md (100%) rename {doc/fixes => script/verify/sp-v2-upgrade}/linear-reward-underflow.md (100%) rename script/{test => verify/sp-v2-upgrade}/run-upgrade-test-StabilityPool_v2 (100%) rename {doc/fixes => script/verify/sp-v2-upgrade}/sp-overflow.md (100%) rename script/{test => verify/sp-v2-upgrade}/upgrade-StabilityPool_v2.md (100%) rename script/{test => verify/sp-v3-migration}/SPv3MigrationTest.t.sol (100%) rename script/{test => verify/sp-v3-migration}/run-upgrade-test-remediate-accumulators (100%) rename {doc/fixes => script/verify/sp-v3-migration}/sp-v3-upgrade.md (100%) rename script/{test => verify/spl-remediation}/SPLRemediationTest.t.sol (100%) rename script/{test => verify/spl-remediation}/V2ReplaySimulation.t.sol (100%) rename script/{test => verify/spl-remediation}/collect-holders (100%) rename script/{test => verify/spl-remediation}/rebalance-bug-remediation.md (100%) rename {doc/fixes => script/verify/spl-remediation}/rebalance-remediation.md (100%) rename {doc/fixes => script/verify/spl-remediation}/remediation-ETH-fxUSD-SPL.md (100%) rename script/{test => verify/spl-remediation}/run-upgrade-test-remediate-ETH_fxUSD_SPL (100%) create mode 100644 src/minter/Minter_v3.sol rename test/{ => deployment}/RebalanceFairness.t.sol (100%) rename test/{ => deployment}/StabilityPoolAliasDeployment.t.sol (100%) diff --git a/.claude/settings.json b/.claude/settings.json index dfd504ce..5ff68069 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -8,7 +8,8 @@ "Bash(find /home/tfras/github/baofinance/harbor/doc -type f \\\\\\(-name *.md -o -name *.txt \\\\\\) ! -path */.venv/* ! -path */node_modules/* ! -path */lib/*)", "Read(//home/tfras/github/baofinance/harbor-yield.wip-hytoken/**)", "Bash(ls -la /home/tfras/github/baofinance/harbor-yield.wip-hytoken/*.md)", - "Bash(forge coverage:*)" + "Bash(forge coverage:*)", + "Bash(yarn sizes:*)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index 72ecaacf..91919930 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,4 +12,5 @@ - **HarborOwnable** (modern): `_initializeOwner(deployerOwner, pendingOwner)` takes explicit deployer. Deploy via `_deployProxyAndRecord` (direct, no stub). Used by: RewardAlias, all new contracts. - **HarborFixedOwnable** (hardcoded): Owner is immutable constructor param (Harbor multisig). Deploy via `_deployProxyAndRecord` with empty initData. Used by: HarborPauser_v1. - Each UUPS contract composes Initializable + UUPSUpgradeable + ownership mixin directly — don't create "Upgradeable" base contracts that bundle these, as each contract has different init needs (roles, reentrancy, custom state). The "Upgradeable" suffix means something different in OZ (storage-safe proxy variant) and combining meanings causes confusion. -- In tests, never create and then remove files or directories — forge runs tests in parallel so you can create a race condition. Write test output to `./results` and leave it there. \ No newline at end of file +- In tests, never create and then remove files or directories — forge runs tests in parallel so you can create a race condition. Write test output to `./results` and leave it there. +- Never modify a deployed contract's source file. Check `deployments/*.state.json` for deployed contracts. To add functionality: inherit from the deployed version (e.g. `Minter_v3 is Minter_v2`) if the changes are additive, or clone and modify if the inheritance chain doesn't work. The proxy upgrade mechanism allows swapping implementations, but the old source must remain unchanged for audit traceability. \ No newline at end of file diff --git a/regression/gas.txt b/regression/gas.txt index 58d3e6e3..bbd30507 100644 --- a/regression/gas.txt +++ b/regression/gas.txt @@ -1,62 +1,236 @@ script/config/markets/ConfigMarket_BTC_fxUSD_mainnet.sol:ConfigMarket_BTC_fxUSD_mainnet +| function name | max | +|--------------------------------------|-----------| +| collateral | 4.990e+02 | +| getWellKnownAddresses | 3.698e+03 | +| harvestBountyRatio | 2.810e+02 | +| harvestCutRatio | 2.490e+02 | +| leveragedName | 5.704e+03 | +| leveragedSymbol | 6.807e+03 | +| minTotalSupply | 2.810e+02 | +| minterConfig | 1.169e+04 | +| peg | 5.010e+02 | +| rebalanceBountyRatio | 2.360e+02 | +| rebalanceThreshold | 2.590e+02 | +| spCollateralName | 8.085e+03 | +| spCollateralSymbol | 8.061e+03 | +| spLeveragedName | 1.070e+04 | +| spLeveragedSymbol | 1.074e+04 | +| stabilityPoolEarlyWithdrawalFeeRatio | 2.800e+02 | +| stabilityPoolWithdrawalDelay | 3.010e+02 | +| stabilityPoolWithdrawalPeriod | 2.820e+02 | +| wrappedCollateralToken | 2.790e+02 | + +script/config/markets/ConfigMarket_BTC_stETH_mainnet.sol:ConfigMarket_BTC_stETH_mainnet +| function name | max | +|--------------------------------------|-----------| +| collateral | 4.990e+02 | +| harvestBountyRatio | 2.810e+02 | +| harvestCutRatio | 2.490e+02 | +| leveragedName | 5.704e+03 | +| leveragedSymbol | 6.807e+03 | +| minTotalSupply | 2.810e+02 | +| minterConfig | 1.169e+04 | +| peg | 5.010e+02 | +| rebalanceBountyRatio | 2.360e+02 | +| rebalanceThreshold | 2.590e+02 | +| spCollateralName | 8.085e+03 | +| spCollateralSymbol | 8.061e+03 | +| spLeveragedName | 1.070e+04 | +| spLeveragedSymbol | 1.074e+04 | +| stabilityPoolEarlyWithdrawalFeeRatio | 2.800e+02 | +| stabilityPoolWithdrawalDelay | 3.010e+02 | +| stabilityPoolWithdrawalPeriod | 2.820e+02 | +| wrappedCollateralToken | 2.790e+02 | + +script/config/markets/ConfigMarket_ETH_fxUSD_mainnet.sol:ConfigMarket_ETH_fxUSD_mainnet +| function name | max | +|--------------------------------------|-----------| +| collateral | 4.990e+02 | +| getWellKnownAddresses | 3.698e+03 | +| harvestBountyRatio | 2.810e+02 | +| harvestCutRatio | 2.490e+02 | +| leveragedName | 5.704e+03 | +| leveragedSymbol | 6.807e+03 | +| minTotalSupply | 2.810e+02 | +| minterConfig | 1.169e+04 | +| peg | 5.010e+02 | +| rebalanceBountyRatio | 2.360e+02 | +| rebalanceThreshold | 2.590e+02 | +| spCollateralName | 8.085e+03 | +| spCollateralSymbol | 8.061e+03 | +| spLeveragedName | 1.070e+04 | +| spLeveragedSymbol | 1.074e+04 | +| stabilityPoolEarlyWithdrawalFeeRatio | 2.800e+02 | +| stabilityPoolWithdrawalDelay | 3.010e+02 | +| stabilityPoolWithdrawalPeriod | 2.820e+02 | +| wrappedCollateralToken | 2.790e+02 | + +script/config/markets/ConfigMarket_EUR_fxUSD_mainnet.sol:ConfigMarket_EUR_fxUSD_mainnet +| function name | max | +|--------------------------------------|-----------| +| collateral | 4.990e+02 | +| getWellKnownAddresses | 3.698e+03 | +| harvestBountyRatio | 2.810e+02 | +| harvestCutRatio | 2.490e+02 | +| leveragedName | 5.704e+03 | +| leveragedSymbol | 6.807e+03 | +| minTotalSupply | 2.700e+02 | +| minterConfig | 1.169e+04 | +| peg | 5.010e+02 | +| rebalanceBountyRatio | 2.360e+02 | +| rebalanceThreshold | 2.590e+02 | +| spCollateralName | 8.085e+03 | +| spCollateralSymbol | 8.061e+03 | +| spLeveragedName | 1.070e+04 | +| spLeveragedSymbol | 1.074e+04 | +| stabilityPoolEarlyWithdrawalFeeRatio | 2.800e+02 | +| stabilityPoolWithdrawalDelay | 3.010e+02 | +| stabilityPoolWithdrawalPeriod | 2.820e+02 | +| wrappedCollateralToken | 2.790e+02 | + +script/config/markets/ConfigMarket_EUR_stETH_mainnet.sol:ConfigMarket_EUR_stETH_mainnet | function name | max | |-----------------|-----------| | collateral | 4.990e+02 | | peg | 5.010e+02 | -script/config/markets/ConfigMarket_BTC_stETH_mainnet.sol:ConfigMarket_BTC_stETH_mainnet +script/config/markets/ConfigMarket_GOLD_fxUSD_mainnet.sol:ConfigMarket_GOLD_fxUSD_mainnet +| function name | max | +|--------------------------------------|-----------| +| collateral | 4.990e+02 | +| getWellKnownAddresses | 3.698e+03 | +| harvestBountyRatio | 2.810e+02 | +| harvestCutRatio | 2.490e+02 | +| leveragedName | 5.704e+03 | +| leveragedSymbol | 6.893e+03 | +| minTotalSupply | 2.810e+02 | +| minterConfig | 1.169e+04 | +| peg | 5.010e+02 | +| rebalanceBountyRatio | 2.360e+02 | +| rebalanceThreshold | 2.590e+02 | +| spCollateralName | 8.171e+03 | +| spCollateralSymbol | 8.144e+03 | +| spLeveragedName | 1.087e+04 | +| spLeveragedSymbol | 1.090e+04 | +| stabilityPoolEarlyWithdrawalFeeRatio | 2.800e+02 | +| stabilityPoolWithdrawalDelay | 3.010e+02 | +| stabilityPoolWithdrawalPeriod | 2.820e+02 | +| wrappedCollateralToken | 2.790e+02 | + +script/config/markets/ConfigMarket_GOLD_stETH_mainnet.sol:ConfigMarket_GOLD_stETH_mainnet | function name | max | |-----------------|-----------| | collateral | 4.990e+02 | | peg | 5.010e+02 | -script/config/markets/ConfigMarket_ETH_fxUSD_mainnet.sol:ConfigMarket_ETH_fxUSD_mainnet +script/config/markets/ConfigMarket_SILVER_fxUSD_mainnet.sol:ConfigMarket_SILVER_fxUSD_mainnet +| function name | max | +|--------------------------------------|-----------| +| collateral | 4.990e+02 | +| getWellKnownAddresses | 3.698e+03 | +| harvestBountyRatio | 2.810e+02 | +| harvestCutRatio | 2.490e+02 | +| leveragedName | 5.704e+03 | +| leveragedSymbol | 7.059e+03 | +| minTotalSupply | 2.810e+02 | +| minterConfig | 1.169e+04 | +| peg | 5.010e+02 | +| rebalanceBountyRatio | 2.360e+02 | +| rebalanceThreshold | 2.590e+02 | +| spCollateralName | 8.337e+03 | +| spCollateralSymbol | 8.310e+03 | +| spLeveragedName | 1.120e+04 | +| spLeveragedSymbol | 1.124e+04 | +| stabilityPoolEarlyWithdrawalFeeRatio | 2.800e+02 | +| stabilityPoolWithdrawalDelay | 3.010e+02 | +| stabilityPoolWithdrawalPeriod | 2.820e+02 | +| wrappedCollateralToken | 2.790e+02 | + +script/config/markets/ConfigMarket_SILVER_stETH_mainnet.sol:ConfigMarket_SILVER_stETH_mainnet +| function name | max | +|--------------------------------------|-----------| +| collateral | 4.990e+02 | +| getWellKnownAddresses | 3.698e+03 | +| harvestBountyRatio | 2.810e+02 | +| harvestCutRatio | 2.490e+02 | +| leveragedName | 5.704e+03 | +| leveragedSymbol | 7.059e+03 | +| minTotalSupply | 2.810e+02 | +| minterConfig | 1.169e+04 | +| peg | 5.010e+02 | +| rebalanceBountyRatio | 2.360e+02 | +| rebalanceThreshold | 2.590e+02 | +| spCollateralName | 8.337e+03 | +| spCollateralSymbol | 8.310e+03 | +| spLeveragedName | 1.120e+04 | +| spLeveragedSymbol | 1.124e+04 | +| stabilityPoolEarlyWithdrawalFeeRatio | 2.800e+02 | +| stabilityPoolWithdrawalDelay | 3.010e+02 | +| stabilityPoolWithdrawalPeriod | 2.820e+02 | +| wrappedCollateralToken | 2.790e+02 | + +script/config/pegs/ConfigPeg_BTC.sol:ConfigPeg_BTC | function name | max | |-----------------|-----------| -| collateral | 4.990e+02 | -| peg | 5.010e+02 | +| key | 4.270e+02 | +| name | 6.300e+02 | +| peg | 4.340e+02 | +| symbol | 1.155e+03 | -script/config/markets/ConfigMarket_EUR_fxUSD_mainnet.sol:ConfigMarket_EUR_fxUSD_mainnet +script/config/pegs/ConfigPeg_ETH.sol:ConfigPeg_ETH | function name | max | |-----------------|-----------| -| collateral | 4.990e+02 | -| peg | 5.010e+02 | +| key | 4.270e+02 | +| name | 6.300e+02 | +| peg | 4.340e+02 | +| symbol | 1.155e+03 | -script/config/markets/ConfigMarket_EUR_stETH_mainnet.sol:ConfigMarket_EUR_stETH_mainnet +script/config/pegs/ConfigPeg_EUR.sol:ConfigPeg_EUR | function name | max | |-----------------|-----------| -| collateral | 4.990e+02 | -| peg | 5.010e+02 | +| key | 4.270e+02 | +| name | 6.300e+02 | +| peg | 4.340e+02 | +| symbol | 1.155e+03 | -script/config/markets/ConfigMarket_GOLD_fxUSD_mainnet.sol:ConfigMarket_GOLD_fxUSD_mainnet +script/config/pegs/ConfigPeg_GOLD.sol:ConfigPeg_GOLD | function name | max | |-----------------|-----------| -| collateral | 4.990e+02 | -| peg | 5.010e+02 | +| key | 4.270e+02 | +| name | 6.300e+02 | +| peg | 4.340e+02 | +| symbol | 1.238e+03 | -script/config/markets/ConfigMarket_GOLD_stETH_mainnet.sol:ConfigMarket_GOLD_stETH_mainnet +script/config/pegs/ConfigPeg_SILVER.sol:ConfigPeg_SILVER | function name | max | |-----------------|-----------| -| collateral | 4.990e+02 | -| peg | 5.010e+02 | +| key | 4.270e+02 | +| name | 6.300e+02 | +| peg | 4.340e+02 | +| symbol | 1.404e+03 | src/minter/Genesis_v1.sol:Genesis_v1 -| function name | max | -|--------------------------|-----------| -| LEVERAGED_TOKEN | 2.820e+02 | -| MINTER | 3.030e+02 | -| PEGGED_TOKEN | 2.830e+02 | -| WRAPPED_COLLATERAL_TOKEN | 2.820e+02 | -| balanceOf | 2.591e+03 | -| claim | 8.751e+04 | -| claimable | 1.161e+04 | -| deposit | 6.747e+04 | -| endGenesis | 3.490e+05 | -| genesisIsEnded | 2.349e+03 | -| initialize | 7.146e+04 | -| owner | 2.401e+03 | -| transferOwnership | 1.202e+04 | -| withdraw | 4.335e+04 | +| function name | max | +|---------------------------|-----------| +| LEVERAGED_TOKEN | 2.820e+02 | +| MINTER | 3.030e+02 | +| PEGGED_TOKEN | 2.830e+02 | +| STABILITY_POOL_COLLATERAL | 3.040e+02 | +| STABILITY_POOL_LEVERAGED | 2.610e+02 | +| UPGRADE_INTERFACE_VERSION | 4.360e+02 | +| WRAPPED_COLLATERAL_TOKEN | 2.820e+02 | +| balanceOf | 2.591e+03 | +| claim | 8.751e+04 | +| claimable | 1.161e+04 | +| deposit | 6.747e+04 | +| endGenesis | 3.490e+05 | +| genesisIsEnded | 2.349e+03 | +| initialize | 7.146e+04 | +| owner | 2.401e+03 | +| proxiableUUID | 3.300e+02 | +| transferOwnership | 1.202e+04 | +| withdraw | 4.335e+04 | src/minter/Minter_v1.sol:Minter_v1 | function name | max | @@ -111,7 +285,7 @@ src/minter/Minter_v2.sol:Minter_v2 | mintLeveragedTokenDryRun | 7.337e+04 | | mintLeveragedTokenIncentiveRatio | 3.108e+04 | | mintPeggedToken | 1.909e+05 | -| mintPeggedTokenDryRun | 6.337e+04 | +| mintPeggedTokenDryRun | 6.335e+04 | | mintPeggedTokenIncentiveRatio | 3.012e+04 | | owner | 2.380e+03 | | peggedTokenBalance | 2.409e+03 | @@ -135,24 +309,64 @@ src/minter/Minter_v2.sol:Minter_v2 | updatePriceOracle | 2.636e+04 | | updateReservePool | 2.631e+04 | +src/minter/Minter_v3.sol:Minter_v3 +| function name | max | +|--------------------------------|-----------| +| HARVESTER_ROLE | 2.830e+02 | +| LEVERAGED_TOKEN | 2.830e+02 | +| PEGGED_TOKEN | 3.050e+02 | +| UPGRADE_INTERFACE_VERSION | 4.590e+02 | +| WRAPPED_COLLATERAL_TOKEN | 2.830e+02 | +| ZERO_FEE_ROLE | 2.850e+02 | +| collateralRatio | 1.912e+04 | +| config | 7.187e+04 | +| feeReceiver | 2.442e+03 | +| freeMintLeveragedToken | 1.150e+05 | +| freeMintPeggedToken | 1.674e+05 | +| freeRedeemPeggedToken | 1.346e+05 | +| grantRoles | 2.633e+04 | +| harvestable | 2.981e+04 | +| initialize | 1.844e+05 | +| owner | 2.402e+03 | +| peggedTokenPrice | 2.449e+03 | +| proxiableUUID | 3.530e+02 | +| redeemPeggedForCollateralRatio | 3.107e+03 | +| reservePool | 2.411e+03 | +| rolesOf | 2.609e+03 | +| sweep | 3.645e+04 | +| transferOwnership | 1.207e+04 | +| updateConfig | 2.740e+05 | +| updateFeeReceiver | 2.635e+04 | +| updatePriceOracle | 2.636e+04 | +| updateReservePool | 2.631e+04 | + src/minter/ReservePool_v1.sol:ReservePool_v1 -| function name | max | -|-------------------|-----------| -| REQUESTER_ROLE | 2.390e+02 | -| grantRoles | 2.633e+04 | -| hasAnyRole | 2.569e+03 | -| initialize | 7.031e+04 | -| owner | 2.389e+03 | -| requestBonus | 3.934e+04 | -| supportsInterface | 8.420e+02 | -| sweep | 2.642e+03 | -| transferOwnership | 1.204e+04 | +| function name | max | +|---------------------------|-----------| +| REQUESTER_ROLE | 2.390e+02 | +| UPGRADE_INTERFACE_VERSION | 4.130e+02 | +| grantRoles | 2.633e+04 | +| hasAnyRole | 2.569e+03 | +| initialize | 7.031e+04 | +| owner | 2.389e+03 | +| proxiableUUID | 2.860e+02 | +| requestBonus | 3.934e+04 | +| rolesOf | 2.542e+03 | +| supportsInterface | 8.420e+02 | +| sweep | 2.642e+03 | +| transferOwnership | 1.204e+04 | src/minter/StabilityPoolManager_v1.sol:StabilityPoolManager_v1 | function name | max | |----------------------------|-----------| +| LEVERAGED_TOKEN | 3.050e+02 | +| MINTER | 3.250e+02 | +| PEGGED_TOKEN | 2.610e+02 | +| TREASURY | 2.830e+02 | +| UPGRADE_INTERFACE_VERSION | 4.800e+02 | +| WRAPPED_COLLATERAL_TOKEN | 3.040e+02 | | feeReceiver | 2.441e+03 | -| harvest | 4.442e+05 | +| harvest | 4.488e+05 | | harvestBountyRatio | 2.369e+03 | | harvestCutRatio | 2.371e+03 | | harvestable | 3.052e+04 | @@ -164,6 +378,7 @@ src/minter/StabilityPoolManager_v1.sol:StabilityPoolManager_v1 | rebalanceBountyRatio | 2.354e+03 | | rebalanceThreshold | 2.347e+03 | | rebalanceable | 2.926e+04 | +| rolesOf | 2.598e+03 | | stabilityPools | 9.030e+02 | | supportsInterface | 5.690e+02 | | transferOwnership | 1.204e+04 | @@ -220,15 +435,48 @@ src/minter/StabilityPool_v2.sol:StabilityPool_v2 | withdraw | 3.013e+05 | src/minter/StabilityPool_v3.sol:StabilityPool_v3 -| function name | max | -|-------------------|-----------| -| ASSET_TOKEN | 2.820e+02 | -| LIQUIDATION_TOKEN | 3.500e+02 | -| grantRoles | 2.638e+04 | -| initialize | 2.041e+05 | -| owner | 2.424e+03 | -| totalAssetSupply | 2.489e+03 | -| transferOwnership | 1.207e+04 | +| function name | max | +|----------------------------|-----------| +| ASSET_TOKEN | 2.820e+02 | +| EXEMPT_WITHDRAWAL_FEE_ROLE | 2.860e+02 | +| LIQUIDATION_TOKEN | 3.500e+02 | +| MIN_DEPOSIT | 3.270e+02 | +| MIN_TOTAL_ASSET_SUPPLY | 2.620e+02 | +| REBALANCER_ROLE | 2.840e+02 | +| REWARD_DEPOSITOR_ROLE | 2.840e+02 | +| REWARD_MANAGER_ROLE | 3.050e+02 | +| REWARD_PERIOD_LENGTH | 2.710e+02 | +| UPGRADE_INTERFACE_VERSION | 4.800e+02 | +| WITHDRAWAL_END_WINDOW | 3.150e+02 | +| WITHDRAWAL_START_DELAY | 2.930e+02 | +| activeRewardTokens | 1.195e+04 | +| assetBalanceOf | 8.043e+03 | +| claim | 2.762e+05 | +| claimSingle | 1.987e+05 | +| claimable | 5.993e+04 | +| deposit | 4.061e+05 | +| depositReward | 8.041e+04 | +| getEarlyWithdrawalFee | 2.395e+03 | +| getFeeAddress | 2.398e+03 | +| getWithdrawalRequest | 2.739e+03 | +| getWithdrawalWindow | 3.530e+02 | +| grantRoles | 2.637e+04 | +| historicalRewardTokens | 2.877e+03 | +| initialize | 2.041e+05 | +| isActiveRewardToken | 2.760e+03 | +| lastAssetLossError | 2.379e+03 | +| name | 1.563e+04 | +| notifyLiquidation | 1.198e+05 | +| owner | 2.424e+03 | +| proxiableUUID | 3.310e+02 | +| registerRewardToken | 1.704e+05 | +| requestWithdrawal | 2.503e+04 | +| rolesOf | 2.604e+03 | +| sweep | 3.605e+04 | +| symbol | 1.584e+04 | +| totalAssetSupply | 2.489e+03 | +| transferOwnership | 1.206e+04 | +| withdraw | 2.275e+05 | src/minter/TokenDistributor_v1.sol:TokenDistributor_v1 | function name | max | @@ -251,6 +499,14 @@ src/minter/TokenDistributor_v1.sol:TokenDistributor_v1 | tokens | 7.448e+03 | | transferOwnership | 1.200e+04 | +src/reward/RewardAlias.sol:RewardAlias +| function name | max | +|-------------------|-----------| +| initialize | 7.040e+04 | +| owner | 2.371e+03 | +| transferOwnership | 1.202e+04 | +| underlying | 2.150e+02 | + test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator.sol:MockMultipleRewardCompoundingAccumulator | function name | max | |---------------------------|-----------| diff --git a/regression/sizes.txt b/regression/sizes.txt index e94e21fb..bcd08199 100644 --- a/regression/sizes.txt +++ b/regression/sizes.txt @@ -40,14 +40,16 @@ | MinterMarketConfigLib | 85 | 24,491 | 135 | 18,350 | 1.84 | | Minter_v1 | 23,681 | 895 | 25,515 | 4,991,350 | 499.14 | | Minter_v2 | 23,675 | 901 | 25,509 | 4,990,090 | 499.01 | +| Minter_v3 | 24,218 | 358 | 26,045 | 5,104,050 | 510.41 | | MockWrappedPriceOracle | 373 | 24,203 | 435 | 78,950 | 7.90 | | PostRebalanceRemediationForStabilityPool_v2 | 3,852 | 20,724 | 4,350 | 813,900 | 81.39 | | PriceOracle_v1 | 1,958 | 22,618 | 2,010 | 411,700 | 41.17 | | ReservePool_v1 | 4,760 | 19,816 | 5,009 | 1,002,090 | 100.21 | +| RewardAlias | 2,979 | 21,597 | 3,324 | 629,040 | 62.90 | | StabilityPoolManager_v1 | 11,209 | 13,367 | 13,057 | 2,372,370 | 237.24 | | StabilityPool_v1 | 21,092 | 3,484 | 23,085 | 4,449,250 | 444.93 | | StabilityPool_v2 | 20,711 | 3,865 | 22,579 | 4,367,990 | 436.80 | -| StabilityPool_v3 | 23,526 | 1,050 | 26,054 | 4,965,740 | 496.57 | +| StabilityPool_v3 | 23,697 | 879 | 26,225 | 5,001,650 | 500.17 | | StakedETHWrappedPriceOracle_v1 | 3,695 | 20,881 | 4,653 | 785,530 | 78.55 | | TokenDistributor_v1 | 10,116 | 14,460 | 10,365 | 2,126,850 | 212.69 | | WordCodec | 85 | 24,491 | 135 | 18,350 | 1.84 | diff --git a/script/test/README.md b/script/verify/README.md similarity index 100% rename from script/test/README.md rename to script/verify/README.md diff --git a/script/test/DeployMinters.t.sol b/script/verify/minter-v2-upgrade/DeployMinters.t.sol similarity index 100% rename from script/test/DeployMinters.t.sol rename to script/verify/minter-v2-upgrade/DeployMinters.t.sol diff --git a/script/test/MainnetForkUpgradeTest.t.sol b/script/verify/minter-v2-upgrade/MainnetForkUpgradeTest.t.sol similarity index 100% rename from script/test/MainnetForkUpgradeTest.t.sol rename to script/verify/minter-v2-upgrade/MainnetForkUpgradeTest.t.sol diff --git a/script/test/MinterUpgradeTest.t.sol b/script/verify/minter-v2-upgrade/MinterUpgradeTest.t.sol similarity index 100% rename from script/test/MinterUpgradeTest.t.sol rename to script/verify/minter-v2-upgrade/MinterUpgradeTest.t.sol diff --git a/script/test/run-upgrade-test-Minter_v2 b/script/verify/minter-v2-upgrade/run-upgrade-test-Minter_v2 similarity index 100% rename from script/test/run-upgrade-test-Minter_v2 rename to script/verify/minter-v2-upgrade/run-upgrade-test-Minter_v2 diff --git a/script/test/test-deploy b/script/verify/minter-v2-upgrade/test-deploy similarity index 100% rename from script/test/test-deploy rename to script/verify/minter-v2-upgrade/test-deploy diff --git a/script/test/test-deploy.md b/script/verify/minter-v2-upgrade/test-deploy.md similarity index 100% rename from script/test/test-deploy.md rename to script/verify/minter-v2-upgrade/test-deploy.md diff --git a/script/test/upgrade-Minter_v2.md b/script/verify/minter-v2-upgrade/upgrade-Minter_v2.md similarity index 100% rename from script/test/upgrade-Minter_v2.md rename to script/verify/minter-v2-upgrade/upgrade-Minter_v2.md diff --git a/script/test/MainnetRoles.t.sol b/script/verify/roles/MainnetRoles.t.sol similarity index 100% rename from script/test/MainnetRoles.t.sol rename to script/verify/roles/MainnetRoles.t.sol diff --git a/doc/fixes/epoch-removal-summary.md b/script/verify/sp-v2-upgrade/epoch-removal-summary.md similarity index 100% rename from doc/fixes/epoch-removal-summary.md rename to script/verify/sp-v2-upgrade/epoch-removal-summary.md diff --git a/doc/fixes/finishat-zero.md b/script/verify/sp-v2-upgrade/finishat-zero.md similarity index 100% rename from doc/fixes/finishat-zero.md rename to script/verify/sp-v2-upgrade/finishat-zero.md diff --git a/doc/fixes/genesis-end.md b/script/verify/sp-v2-upgrade/genesis-end.md similarity index 100% rename from doc/fixes/genesis-end.md rename to script/verify/sp-v2-upgrade/genesis-end.md diff --git a/doc/fixes/linear-reward-underflow.md b/script/verify/sp-v2-upgrade/linear-reward-underflow.md similarity index 100% rename from doc/fixes/linear-reward-underflow.md rename to script/verify/sp-v2-upgrade/linear-reward-underflow.md diff --git a/script/test/run-upgrade-test-StabilityPool_v2 b/script/verify/sp-v2-upgrade/run-upgrade-test-StabilityPool_v2 similarity index 100% rename from script/test/run-upgrade-test-StabilityPool_v2 rename to script/verify/sp-v2-upgrade/run-upgrade-test-StabilityPool_v2 diff --git a/doc/fixes/sp-overflow.md b/script/verify/sp-v2-upgrade/sp-overflow.md similarity index 100% rename from doc/fixes/sp-overflow.md rename to script/verify/sp-v2-upgrade/sp-overflow.md diff --git a/script/test/upgrade-StabilityPool_v2.md b/script/verify/sp-v2-upgrade/upgrade-StabilityPool_v2.md similarity index 100% rename from script/test/upgrade-StabilityPool_v2.md rename to script/verify/sp-v2-upgrade/upgrade-StabilityPool_v2.md diff --git a/script/test/SPv3MigrationTest.t.sol b/script/verify/sp-v3-migration/SPv3MigrationTest.t.sol similarity index 100% rename from script/test/SPv3MigrationTest.t.sol rename to script/verify/sp-v3-migration/SPv3MigrationTest.t.sol diff --git a/script/test/run-upgrade-test-remediate-accumulators b/script/verify/sp-v3-migration/run-upgrade-test-remediate-accumulators similarity index 100% rename from script/test/run-upgrade-test-remediate-accumulators rename to script/verify/sp-v3-migration/run-upgrade-test-remediate-accumulators diff --git a/doc/fixes/sp-v3-upgrade.md b/script/verify/sp-v3-migration/sp-v3-upgrade.md similarity index 100% rename from doc/fixes/sp-v3-upgrade.md rename to script/verify/sp-v3-migration/sp-v3-upgrade.md diff --git a/script/test/SPLRemediationTest.t.sol b/script/verify/spl-remediation/SPLRemediationTest.t.sol similarity index 100% rename from script/test/SPLRemediationTest.t.sol rename to script/verify/spl-remediation/SPLRemediationTest.t.sol diff --git a/script/test/V2ReplaySimulation.t.sol b/script/verify/spl-remediation/V2ReplaySimulation.t.sol similarity index 100% rename from script/test/V2ReplaySimulation.t.sol rename to script/verify/spl-remediation/V2ReplaySimulation.t.sol diff --git a/script/test/collect-holders b/script/verify/spl-remediation/collect-holders similarity index 100% rename from script/test/collect-holders rename to script/verify/spl-remediation/collect-holders diff --git a/script/test/rebalance-bug-remediation.md b/script/verify/spl-remediation/rebalance-bug-remediation.md similarity index 100% rename from script/test/rebalance-bug-remediation.md rename to script/verify/spl-remediation/rebalance-bug-remediation.md diff --git a/doc/fixes/rebalance-remediation.md b/script/verify/spl-remediation/rebalance-remediation.md similarity index 100% rename from doc/fixes/rebalance-remediation.md rename to script/verify/spl-remediation/rebalance-remediation.md diff --git a/doc/fixes/remediation-ETH-fxUSD-SPL.md b/script/verify/spl-remediation/remediation-ETH-fxUSD-SPL.md similarity index 100% rename from doc/fixes/remediation-ETH-fxUSD-SPL.md rename to script/verify/spl-remediation/remediation-ETH-fxUSD-SPL.md diff --git a/script/test/run-upgrade-test-remediate-ETH_fxUSD_SPL b/script/verify/spl-remediation/run-upgrade-test-remediate-ETH_fxUSD_SPL similarity index 100% rename from script/test/run-upgrade-test-remediate-ETH_fxUSD_SPL rename to script/verify/spl-remediation/run-upgrade-test-remediate-ETH_fxUSD_SPL diff --git a/src/minter/Minter_v3.sol b/src/minter/Minter_v3.sol new file mode 100644 index 00000000..4890b0bd --- /dev/null +++ b/src/minter/Minter_v3.sol @@ -0,0 +1,1966 @@ +// SPDX-License-Identifier: MIT +// coding standards by https://www.rareskills.io/post/solidity-style-guide +// and https://docs.soliditylang.org/en/latest/style-guide.html +pragma solidity 0.8.30; + +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {ContextUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ContextUpgradeable.sol"; +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {ReentrancyGuardTransientUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardTransientUpgradeable.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; + +import {Token} from "@bao/Token.sol"; +import {TokenHolder, ITokenHolder} from "@bao/TokenHolder.sol"; + +import {BaoOwnableRoles} from "@bao/BaoOwnableRoles.sol"; +import {IMinter} from "src/interfaces/IMinter.sol"; + +// different ERC20 mint/burn interfaces +import {IMintable} from "@bao/interfaces/IMintable.sol"; +import {IBurnable} from "@bao/interfaces/IBurnable.sol"; +import {IBurnableFrom} from "@bao/interfaces/IBurnableFrom.sol"; +import {IBurnable2Arg} from "@bao/interfaces/IBurnable2Arg.sol"; + +import {IWrappedPriceOracle} from "src/interfaces/IWrappedPriceOracle.sol"; +import {IReservePool} from "src/interfaces/IReservePool.sol"; + +import {ConfigIncentiveLib} from "src/minter/library/ConfigIncentiveLib.sol"; +import {Config_v1} from "src/minter/library/Config_v1.sol"; + +/// @title Bao Minter +/// @author rootminus0x1 based on (albeit significantly modified) Aladdin's FX system +/// @notice Provides a gas-efficient, feature-rich implementation for the `IMinter` interface. +/// Functions are provided for users to mint (for wrapped collateral) and redeem (for wrapped collateral) pegged and leveraged tokens +/// ### Pegged tokens +/// Pegged tokens are ERC20 tokens that are pegged to some price provided by the `priceOracle`. +/// Pegged tokens have value, not just because they provide exposure to a price, for example, a real world asset, +/// but they can also be deposited into one of the stability pools for a reward. +///
+/// Note: +/// * This contract must be given access to mint the pegged tokens by the owners of that pegged token. +/// * This contract does not assume it is the only minter of the pegged tokens. Instead it tracks how many it has +/// minted and +/// ensures that it will not redeem more than it has minted. Pegged tokened minted elsewhere can be used here. +/// * This contract provides the pegging mechanism. +/// #### Price Stability +/// The price stability is provided by a set of stability pools which utilise protected functionality provided by this +/// contract to do so. +/// ### Leveraged Tokens +/// Leveraged tokens are ERC20 tokens that are minted only by this contract. These tokens have value in that they can be +/// redeemed for wrapped collateral at a leveraged ratio, hence the name 'leveraged token'. +/// The leverage mechanism is provided by this contract and is designed such that the leverage ratio increases as the +/// underlying collateral ratio decreases. The leveraged ratio is capped at 100. +/// ### Collateral Ratio +/// The collateral ratio value returned by the this contract is the value of the underlying collateral tokens divided by the value +/// of the pegged tokens, not assuming one pegged token's value is 1 - if the underlying collateral value is less than the +/// value of the underlying collateral, then the pegged token is valued as it's share of the underlying collateral. This effectively +/// places a lower limit on the collateral ratio of 1. +/// The collateral ratio used internally assumes the pegged token value is 1. This allows the collateral ratio to reach 0. +/// and consequently allows the configuration of fees/discounts to be applied in the event of a depeg. +/// ### Fees, discounts and disallows +/// Fees, discounts and disallows are defined by the config. Two arrays, one defining fee/discount/disallow values +/// between -1 and 1, and the other defining the collateral ratio levels at which those values apply. +///
    +///
  • positive values refer to fees as a ratio of the input tokens, e.g. a fee for minting pegged/leverage tokens would +/// be levied as a portion of the collateral tokens supplied, and a fee for redeeming a token would be a portion of +/// the pegged or leveraged tokens supplied and revalued at their actual price (i.e. pegged tokens can have a price less than 1) +/// at the given collateral ratio level. +///
  • negative values refer to discounts. The collateral needed to make up the discount is retrieved from the reserve +/// pool. If the reserve pool does not have sufficient collateral to provide the full discount, the discount it can provide is. +///
  • values == 1 ether are treated as a 'disallow', i.e. the action being requested is disallowed at that collateral +/// ratio level. The interpretation is that the fee is 100% and so we don't apply that. Fees are expected to be much lower than 100% +///
+/// The collateral ratio levels are defined by an array of upper bounds, each strictly increasing from the previous one. +/// Some actions - minting pegged tokens and redeemin leveraged tokens - tend to lower the collateral ratio and other +/// actions - redeeming pegged tokens and minting leveraged tokens - tend to increase the collateral ratio. This means +/// two things: +/// 1. if an action results in the collateral ratio crossing one or more of the bounds then the fee and discount +/// (and both may apply) are each applied to the portion of collateral that is processed within each bound. This +/// means that the fees and discounts applied net and are also a definite integral of the collateral-fee/discount +/// function, i.e. the same fees/discounts apply whether the action is performed one dollat at a time or in much +/// larger chunks. This is, of course, within the precision of the uint256 datatype. +/// 'disallow' applies then the action is not permitted at the collateral ratio and effectively limits the amount of +/// collateral that can be processed. +/// 2. Disallows ony apply to actions that tend to lower collateral ratio, and must only be in the first element of the +/// array. The author also cannot envisage a situation where a discount is applied to an action that lowers +/// collateral ratio and so configs that contain them are rejected. +/// ### Rebalancing +/// Stability pools know about the minter contract they are offering a rebalance service to and set themselves up to use +/// The collateral ratio stored in this contract's config to allow or disallow liquidation calls to them. +/// ### Harvesting +/// Harvesting becomes available to be executed, transferring to the stability pools the value accrued by holding wrapped collateral +/// instead of underlying collateral. A portion of that is handed to the caller of the harvest function as a reward. +/// @dev Uses UUPS proxy, erc7201 storage +/// @dev As openzeppelin's validator doesn't currently support external libraries +/// (see issue: https://github.com/OpenZeppelin/openzeppelin-upgrades/issues/52) +/// we add this: +/// @custom:oz-upgrades-unsafe-allow external-library-linking +/// @custom:oz-upgrades-from src/minter/Minter_v2.sol:Minter_v2 +// solhint-disable-next-line contract-name-capwords +contract Minter_v3 is + Initializable, + UUPSUpgradeable, + ContextUpgradeable, + ReentrancyGuardTransientUpgradeable, + BaoOwnableRoles, + TokenHolder, + IMinter +{ + using SafeERC20 for IERC20; + + /// @notice raised when the signature for the pegged token's burn function is not known + error UnrecognisedBurnSignature(string signature); + + /////////////// + // Constants // + /////////////// + + /// @notice The role that allows access to the zero fee versions of the functions. + uint256 public constant ZERO_FEE_ROLE = _ROLE_0; + + /// @notice The role that allows access to the sweep function. + uint256 public constant HARVESTER_ROLE = _ROLE_1; + + /// @dev the maximum leverage ratio - used to calculate the leverage return on redeeming pegged tokens for leveraged + uint256 private constant _LEVERAGE_RATIO_CAP = 20 ether; + + //////////////// + // Immutables // + //////////////// + + // these variables are set in the constructor, not the initializer, to improve contract size and gas usage + // to change them the contract must be upgraded + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + address public immutable WRAPPED_COLLATERAL_TOKEN; // this is the wrapped token + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + address public immutable PEGGED_TOKEN; + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + address public immutable LEVERAGED_TOKEN; + // the type of burn signature for burning pegged tokens + enum BurnSignature { + Burn1Arg, + Burn2Arg, + BurnFrom + } + /// @notice The burn signature for the pegged token. + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + BurnSignature private immutable _BURN_SIGNATURE; + + ///////////// + // Storage // + ///////////// + + // Share-with-proxy Storage + // ------------------------ + /// @custom:storage-location erc7201:bao.storage.Minter + /// @notice The state of this Minter contract. + ///
+ /// It contains: + /// * the addresses of the pegged, leveraged and collateral tokens + /// * the pegged token balance - the total number of pegged tokens minted by this contract. + /// Other contracts may also mint these tokens and so we cannot just use the totalSupply of them + /// * the addresses of the reserve pool (where discounts come from) and fee receiver (where fees go to) + /// * the address of the price oracle, which provides the price of the collateral and also, if the collateral is + /// wrapped, the rate at which the token represents the token it wraps. + /// * the rebalance and harvest collateral ratio trigger points + /// * the fee/discount/disallow configurations for minting/redeeming pegged/leveraged tokens + /// @dev The entire state of the contract is in this struct so that changing the layout during an upgrade is + /// simplified. See ERC 7201. + /// @dev As most of the content is addresses and structs, solidity lays it out in memory efficiently. + /// Where it doesn't we use structs containing bytes32, each representing a slot of storage. + struct MinterStorage { + // slot + // we keep track of pegged tokens as they can be minted through other rmeans + uint256 peggedTokenBalance; // 256 + // we keep track of underlying collateal tokens as they are the collateral, not the wrapped collateral tokens + uint256 underlyingCollateral; // 256 + // slot + // @custom:security non-reentrant + address reservePool; // 160 + // slot + // @custom:security non-reentrant + address feeReceiver; // 160 + // slot + address priceOracle; // 160 + // slot*2*4 + ConfigIncentiveLib.ActionIncentive[4] incentiveConfig; + } + + //////////////////// + // Initialisation // + //////////////////// + + // UUPSUpgradeable functions + // ------------------------- + + function initialize(address owner_) external initializer { + // initialise all the state variables + _initializeOwner(owner_); + __UUPSUpgradeable_init(); + __Context_init(); + __ReentrancyGuardTransient_init(); + MinterStorage storage $ = _getMinterStorage(); + $.peggedTokenBalance = 0; + $.underlyingCollateral = 0; + + // initialise the config to something that works + Config_v1.defaultIncentive($.incentiveConfig); + } + /// @notice In UUPS proxies the constructor is used only to stop the implementation being initialized to any version + /// https://forum.openzeppelin.com/t/what-does-disableinitializers-function-mean/28730 + /// @custom:oz-upgrades-unsafe-allow constructor + // slither-disable-next-line missing-zero-check // sanityCheckERC20Token is called + constructor( + address collateralToken_, + address peggedToken_, + address leveragedToken_, + string memory peggedBurnSignature + ) { + _disableInitializers(); + + Token.sanityCheckERC20Token(collateralToken_); + // slither-disable-next-line missing-zero-check + WRAPPED_COLLATERAL_TOKEN = collateralToken_; + Token.sanityCheckERC20Token(leveragedToken_); + // slither-disable-next-line missing-zero-check + LEVERAGED_TOKEN = leveragedToken_; + Token.sanityCheckERC20Token(peggedToken_); + // slither-disable-next-line missing-zero-check + PEGGED_TOKEN = peggedToken_; + + // get the type of burn model used by the pegged token + bytes4 burnSelector = bytes4(keccak256(bytes(peggedBurnSignature))); + if (burnSelector == bytes4(keccak256("burn(address,uint256)"))) { + _BURN_SIGNATURE = BurnSignature.Burn2Arg; + } else if (burnSelector == bytes4(keccak256("burn(uint256)"))) { + _BURN_SIGNATURE = BurnSignature.Burn1Arg; + } else if (burnSelector == bytes4(keccak256("burnFrom(address,uint256)"))) { + _BURN_SIGNATURE = BurnSignature.BurnFrom; + } else { + revert UnrecognisedBurnSignature(peggedBurnSignature); + } + } + + /// @notice The check that allow this contract to be upgraded: + /// In UUPS proxies the implementation is responsible for upgrading itself + /// only owners can upgrade this contract. + function _authorizeUpgrade(address) internal override onlyOwner {} // solhint-disable-line no-empty-blocks + + /// @notice Returns true if a given interface is supported. + /// @dev See {IERC165-supportsInterface}. + function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + return + interfaceId == type(IMinter).interfaceId || + interfaceId == type(ITokenHolder).interfaceId || + super.supportsInterface(interfaceId); + } + + /////////////////////////// + // Public View Functions // + /////////////////////////// + + /// @inheritdoc IMinter + function priceOracle() external view override returns (address) { + MinterStorage storage $ = _getMinterStorage(); + return $.priceOracle; + } + + /// @inheritdoc IMinter + function feeReceiver() external view override returns (address) { + MinterStorage storage $ = _getMinterStorage(); + return $.feeReceiver; + } + + /// @inheritdoc IMinter + function reservePool() external view returns (address) { + MinterStorage storage $ = _getMinterStorage(); + return $.reservePool; + } + + /// @inheritdoc IMinter + function peggedTokenBalance() external view override returns (uint256) { + MinterStorage storage $ = _getMinterStorage(); + return $.peggedTokenBalance; + } + + /// @inheritdoc IMinter + function leveragedTokenBalance() external view override returns (uint256) { + return _leveragedTokenBalance(); + } + + /// @inheritdoc IMinter + function collateralTokenBalance() external view override returns (uint256) { + MinterStorage storage $ = _getMinterStorage(); + return $.underlyingCollateral; + } + + /// @inheritdoc IMinter + function config() external view returns (Config memory config_) { + MinterStorage storage $ = _getMinterStorage(); + config_ = Config_v1.copyIncentivesBack($.incentiveConfig); + } + + /// @inheritdoc IMinter + function collateralRatio() external view override returns (uint256 collateralRatio_) { + MinterStorage storage $ = _getMinterStorage(); + collateralRatio_ = _collateralRatio( + $.underlyingCollateral, + _fetchMid($.priceOracle).price, + $.peggedTokenBalance + ); + } + + /// @inheritdoc IMinter + function leverageRatio() external view override returns (uint256 ratio) { + MinterStorage storage $ = _getMinterStorage(); + + // slither-disable-next-line unused-return we don't need the leveraged value here + OracleData memory oracle = _fetchMid($.priceOracle); + ratio = _leverageRatio($.peggedTokenBalance, $.underlyingCollateral, oracle.price); + } + + /// @inheritdoc IMinter + function leveragedTokenPrice() external view override returns (uint256 nav) { + MinterStorage storage $ = _getMinterStorage(); + OracleData memory oracle = _fetchMid($.priceOracle); + (uint256 collateralValueE36, uint256 peggedValueE36) = _tokenValuesE36( + $.peggedTokenBalance, + $.underlyingCollateral, + oracle.price + ); + nav = _leveragedTokenPriceE36(collateralValueE36, peggedValueE36, _leveragedTokenBalance()) / 1 ether; + } + + function _leveragedTokenPriceE36( + uint256 collateralValueE36, + uint256 peggedValueE36, + uint256 leveragedTokenBalance_ + ) internal pure returns (uint256 navE36) { + if (leveragedTokenBalance_ == 0) { + navE36 = 1e36; + } else { + // by definition the leveraged token value is the difference between the collateral value and pegged value + navE36 = Math.mulDiv(collateralValueE36 - peggedValueE36, 1e18, leveragedTokenBalance_); + } + } + + /// @inheritdoc IMinter + function peggedTokenPrice() external view override returns (uint256 nav) { + MinterStorage storage $ = _getMinterStorage(); + uint256 peggedTokenBalance_ = $.peggedTokenBalance; + if (peggedTokenBalance_ == 0) { + nav = 1 ether; + } else { + OracleData memory oracle = _fetchMid($.priceOracle); + (, uint256 peggedValueE36) = _tokenValuesE36(peggedTokenBalance_, $.underlyingCollateral, oracle.price); + nav = peggedValueE36 / peggedTokenBalance_; + } + } + + /// @inheritdoc IMinter + function redeemPeggedForCollateralRatio( + uint256 targetCollateralRatio + ) external view returns (uint256 peggedForCollateral, uint256 peggedForLeveraged) { + // TODO: add a check for no pegged tokens + MinterStorage storage $ = _getMinterStorage(); + OracleData memory oracle = _fetchMax($.priceOracle); + uint256 collateralTokenBalance_ = $.underlyingCollateral; + uint256 peggedTokenBalance_ = $.peggedTokenBalance; + uint256 currentCollateralRatio = _collateralRatio(collateralTokenBalance_, oracle.price, peggedTokenBalance_); + if (targetCollateralRatio > currentCollateralRatio) { + if (currentCollateralRatio < 1 ether) { + // we're depegged, so all we can do is redeem them all + peggedForCollateral = peggedTokenBalance_; + } else { + peggedForCollateral = + (targetCollateralRatio * peggedTokenBalance_ - collateralTokenBalance_ * oracle.price) / + (targetCollateralRatio - 1 ether); + } + peggedForLeveraged = + peggedTokenBalance_ - Math.mulDiv(collateralTokenBalance_, oracle.price, targetCollateralRatio); + } else { + peggedForCollateral = 0; + peggedForLeveraged = 0; + } + } + + // incentive ratios + // ---------------- + + // solhint-disable-next-line explicit-types + function _lookupIncentiveRatio(uint action) internal view returns (int256 incentiveRatio) { + MinterStorage storage $ = _getMinterStorage(); + OracleData memory oracle = _fetchMid($.priceOracle); + uint256 collateralTokenBalance_ = $.underlyingCollateral; + uint256 peggedTokenBalance_ = $.peggedTokenBalance; + + ConfigIncentiveLib.ActionIncentive memory config_ = $.incentiveConfig[action]; + // solhint-disable-next-line explicit-types + uint band = _findBand(config_, collateralTokenBalance_, oracle.price, peggedTokenBalance_, false); + incentiveRatio = ConfigIncentiveLib._incentiveRatio(config_, band); + } + + /// @inheritdoc IMinter + function mintPeggedTokenIncentiveRatio() external view override returns (int256 incentiveRatio) { + incentiveRatio = _lookupIncentiveRatio(Config_v1.MINT_PEGGED); + } + + /// @inheritdoc IMinter + function redeemPeggedTokenIncentiveRatio() external view override returns (int256 incentiveRatio) { + incentiveRatio = _lookupIncentiveRatio(Config_v1.REDEEM_PEGGED); + } + + /// @inheritdoc IMinter + function mintLeveragedTokenIncentiveRatio() external view override returns (int256 incentiveRatio) { + incentiveRatio = _lookupIncentiveRatio(Config_v1.MINT_LEVERAGED); + } + + /// @inheritdoc IMinter + function redeemLeveragedTokenIncentiveRatio() external view override returns (int256 incentiveRatio) { + incentiveRatio = _lookupIncentiveRatio(Config_v1.REDEEM_LEVERAGED); + } + + // dry run functions + // here we simulate a mint or redeem taking into account who is making the call for balance. + // we don't take into account the allowance the Minter contract has for the msgSender because + // most user interfaces, where the dry run functions are expected to be called will leave changing + // allowance until the actual mint or redeem function is called. + // in other words we don't require all conditions to be met for the dry run to succeed if those conditions + // require gas to be spent on a transaction. + + /// @inheritdoc IMinter + function mintPeggedTokenDryRun( + uint256 wrappedCollateralIn + ) + external + view + returns ( + int256 incentiveRatio, + uint256 wrappedFee, + uint256 wrappedCollateralUsed, + uint256 peggedMinted, + uint256 price, + uint256 rate + ) + { + wrappedCollateralIn = Token.allOfQuiet(_msgSender(), WRAPPED_COLLATERAL_TOKEN, wrappedCollateralIn); + MinterStorage storage $ = _getMinterStorage(); + OracleData memory oracle = _fetchMid($.priceOracle); + price = oracle.price; + rate = oracle.rate; + uint256 underlyingCollateralAdded; + (wrappedFee, peggedMinted, wrappedCollateralUsed, underlyingCollateralAdded) = _mintPeggedAdjustments( + $.incentiveConfig[Config_v1.MINT_PEGGED], + wrappedCollateralIn, + CollateralRatioData($.underlyingCollateral, oracle.price, oracle.rate, $.peggedTokenBalance) + ); + // slither-disable-next-line incorrect-equality + incentiveRatio = wrappedCollateralUsed == 0 + ? _lookupIncentiveRatio(Config_v1.MINT_PEGGED) + : int256(Math.mulDiv(wrappedFee, 1 ether, wrappedCollateralUsed)); + } + + /// @inheritdoc IMinter + function redeemPeggedTokenDryRun( + uint256 peggedIn + ) + external + view + returns ( + int256 incentiveRatio, + uint256 wrappedFee, + uint256 wrappedDiscount, + uint256 peggedRedeemed, + uint256 wrappedCollateralReturned, + uint256 price, + uint256 rate + ) + { + peggedIn = Token.allOfQuiet(_msgSender(), PEGGED_TOKEN, peggedIn); + MinterStorage storage $ = _getMinterStorage(); + uint256 peggedTokenBalance_ = $.peggedTokenBalance; + peggedIn = _redeemableQuiet(peggedIn, peggedTokenBalance_); + OracleData memory oracle = _fetchMid($.priceOracle); + price = oracle.price; + rate = oracle.rate; + peggedRedeemed = peggedIn; + uint256 peggedPriceE36; + (wrappedFee, wrappedDiscount, wrappedCollateralReturned, , peggedPriceE36) = _redeemPeggedAdjustments( + $.incentiveConfig[Config_v1.REDEEM_PEGGED], + peggedIn, + CollateralRatioData($.underlyingCollateral, price, rate, peggedTokenBalance_), + IERC20(WRAPPED_COLLATERAL_TOKEN).balanceOf($.reservePool) + ); + // slither-disable-next-line incorrect-equality + if (peggedRedeemed == 0) { + incentiveRatio = _lookupIncentiveRatio(Config_v1.REDEEM_PEGGED); + } else { + uint256 incentive; + int256 sign; + if (wrappedFee > wrappedDiscount) { + incentive = wrappedFee - wrappedDiscount; + sign = 1; + } else { + incentive = wrappedDiscount - wrappedFee; + sign = -1; + } + incentiveRatio = + sign * int256(Math.mulDiv(incentive * 1e18, price * rate, peggedRedeemed * peggedPriceE36)); + } + } + + /// @inheritdoc IMinter + function mintLeveragedTokenDryRun( + uint256 wrappedCollateralIn + ) + external + view + returns ( + int256 incentiveRatio, + uint256 wrappedFee, + uint256 wrappedDiscount, + uint256 wrappedCollateralUsed, + uint256 leveragedMinted, + uint256 price, + uint256 rate + ) + { + wrappedCollateralIn = Token.allOfQuiet(_msgSender(), WRAPPED_COLLATERAL_TOKEN, wrappedCollateralIn); + MinterStorage storage $ = _getMinterStorage(); + OracleData memory oracle = _fetchMid($.priceOracle); + price = oracle.price; + rate = oracle.rate; + (wrappedFee, wrappedDiscount, leveragedMinted, wrappedCollateralUsed, ) = _mintLeveragedAdjustments( + $.incentiveConfig[Config_v1.MINT_LEVERAGED], + wrappedCollateralIn, + CollateralRatioData($.underlyingCollateral, oracle.price, oracle.rate, $.peggedTokenBalance), + IERC20(WRAPPED_COLLATERAL_TOKEN).balanceOf($.reservePool) + ); + // slither-disable-next-line incorrect-equality + if (wrappedCollateralUsed == 0) { + incentiveRatio = _lookupIncentiveRatio(Config_v1.MINT_LEVERAGED); + } else { + uint256 incentive; + int256 sign; + if (wrappedFee > wrappedDiscount) { + incentive = wrappedFee - wrappedDiscount; + sign = 1; + } else { + incentive = wrappedDiscount - wrappedFee; + sign = -1; + } + incentiveRatio = sign * int256(Math.mulDiv(incentive, 1 ether, wrappedCollateralUsed)); + } + } + + /// @inheritdoc IMinter + function redeemLeveragedTokenDryRun( + uint256 leveragedIn + ) + external + view + returns ( + int256 incentiveRatio, + uint256 wrappedFee, + uint256 leveragedRedeemed, + uint256 wrappedCollateralReturned, + uint256 price, + uint256 rate + ) + { + leveragedIn = Token.allOfQuiet(_msgSender(), LEVERAGED_TOKEN, leveragedIn); + MinterStorage storage $ = _getMinterStorage(); + uint256 leveragedTokenBalance_ = _leveragedTokenBalance(); + leveragedIn = _redeemableQuiet(leveragedIn, leveragedTokenBalance_); + OracleData memory oracle = _fetchMid($.priceOracle); + price = oracle.price; + rate = oracle.rate; + (wrappedFee, leveragedRedeemed, wrappedCollateralReturned, ) = _redeemLeveragedAdjustments( + $.incentiveConfig[Config_v1.REDEEM_LEVERAGED], + leveragedIn, + CollateralRatioData($.underlyingCollateral, price, rate, $.peggedTokenBalance), + leveragedTokenBalance_ + ); + // slither-disable-next-line incorrect-equality + incentiveRatio = wrappedCollateralReturned == 0 + ? _lookupIncentiveRatio(Config_v1.REDEEM_LEVERAGED) + : int256(Math.mulDiv(wrappedFee, 1 ether, wrappedCollateralReturned + wrappedFee)); + } + + /// @inheritdoc IMinter + function harvestable() external view returns (uint256 wrappedAmount) { + MinterStorage storage $ = _getMinterStorage(); + uint256 rate = _fetchMid($.priceOracle).rate; + wrappedAmount = 0; + if (rate > 0) { + uint256 balance = IERC20(WRAPPED_COLLATERAL_TOKEN).balanceOf(address(this)); + uint256 value = Math.mulDiv($.underlyingCollateral, 1 ether, rate); + wrappedAmount = (balance > value) ? balance - value : 0; + } + } + + ////////////////////////////// + // Public Mutator Functions // + ////////////////////////////// + + /// @inheritdoc IMinter + function reset() external onlyOwner { + MinterStorage storage $ = _getMinterStorage(); + uint256 underlying = $.underlyingCollateral; + uint256 wrapped = IERC20(WRAPPED_COLLATERAL_TOKEN).balanceOf(address(this)); + OracleData memory oracle = _fetchMid($.priceOracle); + wrapped = Math.mulDiv(wrapped, oracle.rate, 1 ether); + emit Reset(underlying, wrapped); + $.underlyingCollateral = wrapped; + } + + /// @inheritdoc IMinter + function updateConfig(Config calldata config_) external override onlyOwner { + // or is this handled by the fact that the CR for discount is much lower than the rebalance CR + emit UpdateConfig(config_); // the code below may alter the config so emit it soon + + MinterStorage storage $ = _getMinterStorage(); + + // incentive config + + Config_v1.checkAndCopyIncentives(config_, $.incentiveConfig); + } + + /// @inheritdoc IMinter + function updatePriceOracle(address priceOracle_) external onlyOwner { + _updatePriceOracle(priceOracle_); + } + + /// @inheritdoc IMinter + function updateFeeReceiver(address feeReceiver_) external override onlyOwner { + _updateFeeReceiver(feeReceiver_); + } + + /// @inheritdoc IMinter + function updateReservePool(address reservePool_) external override onlyOwner { + _updateReservePool(reservePool_); + } + + // minting/redeeming pegged/leveraged tokens + // ----------------------------------------- + + /// @inheritdoc IMinter + function mintPeggedToken( + uint256 wrappedCollateralIn, + address receiver, + uint256 minPeggedOut + ) external override nonReentrant returns (uint256 peggedOut) { + MinterStorage storage $ = _getMinterStorage(); + // work out how much collateral to use + OracleData memory oracle = _fetchMid($.priceOracle); + wrappedCollateralIn = Token.allOf(_msgSender(), WRAPPED_COLLATERAL_TOKEN, wrappedCollateralIn); + + uint256 peggedTokenBalance_ = $.peggedTokenBalance; + uint256 underlyingCollateral_ = $.underlyingCollateral; + + // fee, etc. calculation + uint256 wrappedFee; + uint256 underlyingCollateralAdded; + (wrappedFee, peggedOut, wrappedCollateralIn, underlyingCollateralAdded) = _mintPeggedAdjustments( + $.incentiveConfig[Config_v1.MINT_PEGGED], + wrappedCollateralIn, + CollateralRatioData(underlyingCollateral_, oracle.price, oracle.rate, peggedTokenBalance_) + ); + + // slither-disable-next-line incorrect-equality + if (wrappedCollateralIn == 0) { + revert MintZeroAmount(PEGGED_TOKEN); + } + + // check the amounts involved + // slither-disable-next-line incorrect-equality + if (peggedOut < minPeggedOut) { + revert MintInsufficientAmount(PEGGED_TOKEN, peggedOut, minPeggedOut); + } + + // do the mint for collateral + _mintPeggedToken(wrappedCollateralIn, peggedOut, receiver); + + // take the fee + if (wrappedFee > 0) { + IERC20(WRAPPED_COLLATERAL_TOKEN).safeTransfer($.feeReceiver, wrappedFee); + } + + // update our records + $.underlyingCollateral = underlyingCollateral_ + underlyingCollateralAdded; + $.peggedTokenBalance = peggedTokenBalance_ + peggedOut; + } + + /// @inheritdoc IMinter + + function redeemPeggedToken( + uint256 peggedIn, + address receiver, + uint256 minWrappedCollateralOut + ) + external + override + nonReentrant + returns ( + uint256 wrappedCollateralOut // wake-disable-line reentrancy + ) + { + MinterStorage storage $ = _getMinterStorage(); + uint256 peggedTokenBalance_ = $.peggedTokenBalance; + peggedIn = Token.allOf(_msgSender(), PEGGED_TOKEN, peggedIn); + peggedIn = _redeemable(PEGGED_TOKEN, peggedIn, peggedTokenBalance_); + + OracleData memory oracle = _fetchMax($.priceOracle); + uint256 underlyingCollateral_ = $.underlyingCollateral; + address reservePool_ = $.reservePool; + + uint256 wrappedFee; + uint256 wrappedDiscount; + uint256 underlyingCollateralRemoved; + (wrappedFee, wrappedDiscount, wrappedCollateralOut, underlyingCollateralRemoved, ) = _redeemPeggedAdjustments( + $.incentiveConfig[Config_v1.REDEEM_PEGGED], + peggedIn, + CollateralRatioData(underlyingCollateral_, oracle.price, oracle.rate, peggedTokenBalance_), + IERC20(WRAPPED_COLLATERAL_TOKEN).balanceOf(reservePool_) + ); + // make sure it meets the minimum requirements + if (wrappedCollateralOut < minWrappedCollateralOut) { + revert ReturnInsufficientAmount(WRAPPED_COLLATERAL_TOKEN, wrappedCollateralOut, minWrappedCollateralOut); + } + // slither-disable-next-line incorrect-equality + if (wrappedCollateralOut == 0) { + revert ReturnZeroAmount(WRAPPED_COLLATERAL_TOKEN); + } + + // do the fee (feeReceiver) / discount (reservePool) + if (wrappedFee > 0) { + // send the fee + IERC20(WRAPPED_COLLATERAL_TOKEN).safeTransfer($.feeReceiver, wrappedFee); + } + if (wrappedDiscount > 0) { + // it's a discount, so collect the extra collateral, if available + // wake-disable-next-line reentrancy // reservePool is trusted and reentrancy guard + uint256 actualBonus = IReservePool($.reservePool).requestBonus( + WRAPPED_COLLATERAL_TOKEN, + address(this), + wrappedDiscount + ); + if (actualBonus != wrappedDiscount) { + revert RequestedBonusNotGiven(wrappedDiscount, actualBonus); + } + } + + // redeem pegged tokens and send the remainder of the collateral + _redeemPeggedToken(peggedIn, wrappedCollateralOut, receiver); + + // update our records + $.peggedTokenBalance = peggedTokenBalance_ - peggedIn; + $.underlyingCollateral = underlyingCollateral_ - underlyingCollateralRemoved; + } + + /// @inheritdoc IMinter + function mintLeveragedToken( + uint256 wrappedCollateralIn, + address receiver, + uint256 minLeveragedOut + ) external override nonReentrant returns (uint256 leveragedOut) { + MinterStorage storage $ = _getMinterStorage(); + wrappedCollateralIn = Token.allOf(_msgSender(), WRAPPED_COLLATERAL_TOKEN, wrappedCollateralIn); + + OracleData memory oracle = _fetchMid($.priceOracle); + uint256 wrappedFee; + uint256 wrappedDiscount; + uint256 underlyingCollateral_ = $.underlyingCollateral; + uint256 underlyingCollateralAdded; + address reservePool_ = $.reservePool; + ( + wrappedFee, + wrappedDiscount, + leveragedOut, + wrappedCollateralIn, + underlyingCollateralAdded + ) = _mintLeveragedAdjustments( + $.incentiveConfig[Config_v1.MINT_LEVERAGED], + wrappedCollateralIn, + CollateralRatioData(underlyingCollateral_, oracle.price, oracle.rate, $.peggedTokenBalance), + IERC20(WRAPPED_COLLATERAL_TOKEN).balanceOf(reservePool_) + ); + if (wrappedDiscount > 0) { + // it's a discount, so collect the extra collateral, if available + // wake-disable-next-line reentrancy // reservePool is trusted + uint256 actualBonus = IReservePool(reservePool_).requestBonus( + WRAPPED_COLLATERAL_TOKEN, + address(this), + wrappedDiscount + ); + if (actualBonus != wrappedDiscount) { + revert RequestedBonusNotGiven(wrappedDiscount, actualBonus); + } + } + // make sure it meets the minimum requirements + if (leveragedOut < minLeveragedOut) { + revert MintInsufficientAmount(LEVERAGED_TOKEN, leveragedOut, minLeveragedOut); + } + // mint the leveraged tokens and take wrappedCollateralIn + _mintLeveragedToken(wrappedCollateralIn, leveragedOut, receiver); + // take the fee + if (wrappedFee > 0) { + IERC20(WRAPPED_COLLATERAL_TOKEN).safeTransfer($.feeReceiver, wrappedFee); + } + // update our records + $.underlyingCollateral = underlyingCollateral_ + underlyingCollateralAdded; + } + + /// @inheritdoc IMinter + function redeemLeveragedToken( + uint256 leveragedIn, + address receiver, + uint256 minWrappedCollateralOut + ) external override returns (uint256 wrappedCollateralOut) { + MinterStorage storage $ = _getMinterStorage(); + leveragedIn = Token.allOf(_msgSender(), LEVERAGED_TOKEN, leveragedIn); + + uint256 leveragedTokenBalance_ = _leveragedTokenBalance(); + leveragedIn = _redeemable(LEVERAGED_TOKEN, leveragedIn, leveragedTokenBalance_); + OracleData memory oracle = _fetchMin($.priceOracle); + + uint256 underlyingCollateral_ = $.underlyingCollateral; + + uint256 wrappedFee; + uint256 underlyingCollateralOut; + (wrappedFee, leveragedIn, wrappedCollateralOut, underlyingCollateralOut) = _redeemLeveragedAdjustments( + $.incentiveConfig[Config_v1.REDEEM_LEVERAGED], + leveragedIn, + CollateralRatioData(underlyingCollateral_, oracle.price, oracle.rate, $.peggedTokenBalance), + leveragedTokenBalance_ + ); + // slither-disable-next-line incorrect-equality + if (wrappedCollateralOut == 0) { + revert ReturnZeroAmount(WRAPPED_COLLATERAL_TOKEN); + } + if (wrappedCollateralOut < minWrappedCollateralOut) { + revert ReturnInsufficientAmount(WRAPPED_COLLATERAL_TOKEN, wrappedCollateralOut, minWrappedCollateralOut); + } + + _redeemLeveragedToken(leveragedIn, wrappedCollateralOut, receiver); + + if (wrappedFee > 0) { + // send the fee + IERC20(WRAPPED_COLLATERAL_TOKEN).safeTransfer($.feeReceiver, wrappedFee); + } + + // update our records + $.underlyingCollateral = underlyingCollateral_ - underlyingCollateralOut; + } + + ////////////////////////////////// + // Restricted Mutator Functions // + ////////////////////////////////// + + // fee-free minting/redeeming pegged/leveraged tokens + // -------------------------------------------------- + + /// @inheritdoc IMinter + function freeMintPeggedToken( + uint256 wrappedCollateralIn, + address receiver + ) external override onlyRoles(ZERO_FEE_ROLE) nonReentrant returns (uint256 peggedOut) { + MinterStorage storage $ = _getMinterStorage(); + OracleData memory oracle = _fetchMid($.priceOracle); + uint256 underlyingCollateralInE36 = wrappedCollateralIn * oracle.rate; + + uint256 peggedTokenBalance_ = $.peggedTokenBalance; + uint256 underlyingCollateral_ = $.underlyingCollateral; + + // transfer and mint + peggedOut = Math.mulDiv( + underlyingCollateralInE36, + oracle.price, + _peggedTokenPriceE36(peggedTokenBalance_, underlyingCollateral_, oracle.price) + ); + + _mintPeggedToken(wrappedCollateralIn, peggedOut, receiver); + + // update our records + $.peggedTokenBalance = peggedTokenBalance_ + peggedOut; + $.underlyingCollateral = underlyingCollateral_ + underlyingCollateralInE36 / 1 ether; + } + + // @inheritdoc IMinter + function freeRedeemPeggedToken( + uint256 peggedForCollateral, + uint256 peggedForLeveraged, + address receiver + ) external nonReentrant onlyRoles(ZERO_FEE_ROLE) returns (uint256 wrappedCollateralOut, uint256 leveragedOut) { + if (peggedForCollateral + peggedForLeveraged > 0) { + MinterStorage storage $ = _getMinterStorage(); + uint256 peggedTokenBalance_ = $.peggedTokenBalance; + + if ((peggedForCollateral + peggedForLeveraged) > peggedTokenBalance_) { + revert InsufficientRedeemableTokens( + PEGGED_TOKEN, + peggedTokenBalance_, + peggedForCollateral + peggedForLeveraged + ); + } + + OracleData memory oracle = _fetchMax($.priceOracle); + // Snapshot original state so both paths price against the same pre-burn balances, + // consistent with how redeemPeggedForCollateralRatio computed the amounts. + uint256 underlyingCollateral_ = $.underlyingCollateral; + + if (peggedForCollateral > 0) { + uint256 underlyingCollateralOutE36 = Math.mulDiv( + peggedForCollateral, + _peggedTokenPriceE36(peggedTokenBalance_, underlyingCollateral_, oracle.price), + oracle.price + ); + wrappedCollateralOut = underlyingCollateralOutE36 / oracle.rate; + // return the collateral + IERC20(WRAPPED_COLLATERAL_TOKEN).safeTransfer(receiver, wrappedCollateralOut); + $.underlyingCollateral = underlyingCollateral_ - underlyingCollateralOutE36 / 1 ether; + } + + if (peggedForLeveraged > 0) { + leveragedOut = _leveragedTokensForPegged( + peggedForLeveraged, + _leveragedTokenBalance(), + peggedTokenBalance_, + underlyingCollateral_, + oracle.price + ); + // mint the tokens to the receiver + // wake-disable-next-line reentrancy + IMintable(LEVERAGED_TOKEN).mint(receiver, leveragedOut); + } + + emit RedeemPeggedToken( + _msgSender(), + receiver, + peggedForLeveraged + peggedForCollateral, + wrappedCollateralOut, + leveragedOut + ); + + // burn the tokens from the sender - deal with the different burn signatures for ERC20 contracts + _burnPeggedToken(peggedForCollateral + peggedForLeveraged); + // update our records + $.peggedTokenBalance = peggedTokenBalance_ - (peggedForCollateral + peggedForLeveraged); + } + } + + // @inheritdoc IMinter + function freeMintLeveragedToken( + uint256 wrappedCollateralIn, + address receiver + ) external override onlyRoles(ZERO_FEE_ROLE) nonReentrant returns (uint256 leveragedOut) { + MinterStorage storage $ = _getMinterStorage(); + // how much collateral to use + OracleData memory oracle = _fetchMid($.priceOracle); + (uint256 collateralValueE36, uint256 peggedValueE36) = _tokenValuesE36( + $.peggedTokenBalance, + $.underlyingCollateral, + oracle.price + ); + uint256 underlyingCollateralInE36 = wrappedCollateralIn * oracle.rate; + uint256 leveragedTokenBalance_ = _leveragedTokenBalance(); + if (leveragedTokenBalance_ > 0) { + leveragedOut = + (underlyingCollateralInE36 * oracle.price) / + _leveragedTokenPriceE36(collateralValueE36, peggedValueE36, leveragedTokenBalance_); + } else { + leveragedOut = collateralValueE36; // First term + leveragedOut += Math.mulDiv(underlyingCollateralInE36, oracle.price, 1e18); // Second term + leveragedOut -= $.peggedTokenBalance * 1e18; + leveragedOut /= 1e18; + } + + // mint the tokens to the receiver + _mintLeveragedToken(wrappedCollateralIn, leveragedOut, receiver); + + // update our records + $.underlyingCollateral += underlyingCollateralInE36 / 1e18; + } + + // @inheritdoc IMinter + function freeRedeemLeveragedToken( + uint256 leveragedIn, + address receiver + ) external override onlyRoles(ZERO_FEE_ROLE) returns (uint256 collateralOut) { + MinterStorage storage $ = _getMinterStorage(); + + uint256 leveragedTokenBalance_ = _leveragedTokenBalance(); + leveragedIn = _redeemable(LEVERAGED_TOKEN, leveragedIn, leveragedTokenBalance_); + + OracleData memory oracle = _fetchMin($.priceOracle); + + (uint256 collateralValueE36, uint256 peggedValueE36) = _tokenValuesE36( + $.peggedTokenBalance, + $.underlyingCollateral, + oracle.price + ); + if (collateralValueE36 <= peggedValueE36) { + collateralOut = 0; + } else { + uint256 underlyingCollateralOutE36; + if (leveragedTokenBalance_ == 0) { + underlyingCollateralOutE36 = leveragedIn * oracle.price; + } else { + underlyingCollateralOutE36 = Math.mulDiv( + leveragedIn * 1 ether, + collateralValueE36 - peggedValueE36, + oracle.price * leveragedTokenBalance_ + ); + } + collateralOut = underlyingCollateralOutE36 / oracle.rate; + + _redeemLeveragedToken(leveragedIn, collateralOut, receiver); + + // update our records + $.underlyingCollateral -= underlyingCollateralOutE36 / 1 ether; + } + } + + /////////////////////// + // Private functions // + /////////////////////// + + /// @notice The storage hash for the shared-with-proxy storage + // chisel eval 'keccak256(abi.encode(uint256(keccak256("bao.storage.Minter")) - 1)) & ~bytes32(uint256(0xff))' + bytes32 private constant _MINTER_STORAGE = 0x92e73fe9557052b4a0b810a38eb7ef595ff750f166ca39d63b3f4c74937fef00; + + /// @notice Returns a reference to the contract state + function _getMinterStorage() internal pure returns (MinterStorage storage $) { + // solhint-disable-next-line no-inline-assembly + assembly { + $.slot := _MINTER_STORAGE + } + } + + // Price/Rate Oracle + // ----------------- + + /// @notice Updates the price oracle address. + function _updatePriceOracle(address priceOracle_) internal { + MinterStorage storage $ = _getMinterStorage(); + address old = $.priceOracle; + $.priceOracle = priceOracle_; + emit UpdatePriceOracle(old, priceOracle_); + } + + // Fee Receiver + // ------------ + + /// @notice Updates the fee receiver address. + function _updateFeeReceiver(address feeReceiver_) internal { + MinterStorage storage $ = _getMinterStorage(); + address old = $.feeReceiver; + $.feeReceiver = feeReceiver_; + emit UpdateFeeReceiver(old, feeReceiver_); + } + + // ReservePool + // ----------- + + /// @notice Updates the reserve pool address. + function _updateReservePool(address reservePool_) internal { + MinterStorage storage $ = _getMinterStorage(); + address old = $.reservePool; + $.reservePool = reservePool_; + emit UpdateReservePool(old, reservePool_); + } + + // Mint/Redeem Pegged/Leveraged + // ---------------------------- + + /// @notice Perform the transfers and event emissions for minting pegged tokens. + /// Fees and discounts transfers and event emissions are not handled here. + /// @dev no checks for zeros values are performed. + /// @param wrappedCollateralIn The amount of collateral to be taken from the sender. + /// @param peggedOut The amount of pegged to be transferred to the `receiver`. + /// @param receiver The address of the receiver. + + function _mintPeggedToken(uint256 wrappedCollateralIn, uint256 peggedOut, address receiver) internal { + emit MintPeggedToken(_msgSender(), receiver, wrappedCollateralIn, peggedOut); + + // mint the tokens to the receiver + // wake-disable-next-line reentrancy // all callers to this function have nonReentrant guard + IMintable(PEGGED_TOKEN).mint(receiver, peggedOut); + + // take the collateral + IERC20(WRAPPED_COLLATERAL_TOKEN).safeTransferFrom(_msgSender(), address(this), wrappedCollateralIn); + } + + /// @notice burn pegged tokens in the way the like to burn + function _burnPeggedToken(uint256 amount) internal { + if (_BURN_SIGNATURE == BurnSignature.Burn2Arg) { + IBurnable2Arg(PEGGED_TOKEN).burn(_msgSender(), amount); + } else if (_BURN_SIGNATURE == BurnSignature.BurnFrom) { + IBurnableFrom(PEGGED_TOKEN).burnFrom(_msgSender(), amount); + } else if (_BURN_SIGNATURE == BurnSignature.Burn1Arg) { + // get the tokens here first + IERC20(PEGGED_TOKEN).safeTransferFrom(_msgSender(), address(this), amount); + IBurnable(PEGGED_TOKEN).burn(amount); + } // no need to check for others because the constructor does this + } + + /// @notice Perform the transfers and event emissions for redeeming pegged tokens + /// Fees and discounts transfers and event emissions are not handled here. + /// @dev no checks for zeros values are performed. + /// @param peggedIn The amount of pegged tokens to be taken from the sender. + /// @param wrappedCollateralOut The amount of collateral to be transferred to the `receiver`. + /// @param receiver The address of the receiver. + + function _redeemPeggedToken(uint256 peggedIn, uint256 wrappedCollateralOut, address receiver) internal { + // tell the world + emit RedeemPeggedToken(_msgSender(), receiver, peggedIn, wrappedCollateralOut, 0); + + // burn the tokens from the sender - deal with the different burn signatures for ERC20 contracts + _burnPeggedToken(peggedIn); + + // return the collateral + IERC20(WRAPPED_COLLATERAL_TOKEN).safeTransfer(receiver, wrappedCollateralOut); + } + + /// @notice Perform the transfers and event emissions for minting leveraged tokens + /// Fees and discounts transfers and event emissions are not handled here. + /// @dev no checks for zeros values are performed. + /// @param wrappedCollateralIn The amount of collateral to be taken from the sender. + /// @param leveragedOut The amount of leveraged to be transferred to the `receiver`. + /// @param receiver The address of the receiver. + + function _mintLeveragedToken(uint256 wrappedCollateralIn, uint256 leveragedOut, address receiver) internal { + // slither-disable-next-line incorrect-equality + if (leveragedOut == 0) { + revert ReturnZeroAmount(LEVERAGED_TOKEN); + } + // tell the world + emit MintLeveragedToken(_msgSender(), receiver, wrappedCollateralIn, leveragedOut); + // mint the tokens to the receiver + // wake-disable-next-line reentrancy + IMintable(LEVERAGED_TOKEN).mint(receiver, leveragedOut); + // take the collateral + IERC20(WRAPPED_COLLATERAL_TOKEN).safeTransferFrom(_msgSender(), address(this), wrappedCollateralIn); + } + + /// @notice Perform the transfers and event emissions for redeeming leveraged tokens. + /// Fees and discounts transfers and event emissions are not handled here. + /// @dev no checks for zeros values are performed. + /// @param leveragedIn The amount of leveraged tokens to be taken from the sender. + /// @param collateralOut The amount of collateral to be transferred to the `receiver`. + /// @param receiver The address of the receiver. + + function _redeemLeveragedToken(uint256 leveragedIn, uint256 collateralOut, address receiver) internal { + // tell the world + emit RedeemLeveragedToken(_msgSender(), receiver, leveragedIn, collateralOut); + // burn the leveraged + // wake-disable-next-line reentrancy // leveragedToken is trusted + IBurnableFrom(LEVERAGED_TOKEN).burnFrom(_msgSender(), leveragedIn); + // return the collateral + IERC20(WRAPPED_COLLATERAL_TOKEN /* */).safeTransfer(receiver, collateralOut); + } + + /// @notice Checks and returns whether a token can be redeemed. + /// @param token_ The token being checked. + /// @param amountIn The proposed amount to redeem. + /// @param tokenBalance_ The amount of the `token_` managed. + /// @return amountOut the amountIn or tokenBalance whatever is the smaller + /// @dev never returns a non-positive amountOut. reverts instead + + function _redeemable( + address token_, + uint256 amountIn, + uint256 tokenBalance_ + ) internal pure returns (uint256 amountOut) { + amountOut = _redeemableQuiet(amountIn, tokenBalance_); + // slither-disable-next-line incorrect-equality + if (amountOut == 0) { + revert NoRedeemableTokens(token_); + } + } + + function _redeemableQuiet(uint256 amountIn, uint256 tokenBalance_) internal pure returns (uint256 amountOut) { + amountOut = Math.min(amountIn, tokenBalance_); + } + + // Adjustments - fees, bonuses and disallows + // ----------------------------------------- + // Each of the algorithms simulates the operation {mint/redeem}/{Pegged/Leveraged} in a loop covering each fee band + // Much of the operations are performed and some results are returned at 1e36 precision. + // This is because, particularly for collateral based results, the result is transformed into a wrapped collateral basis, + // which can reduce precision through dividing before multiplying across function call boundaries. + // The fee calculation also takes into account truncations due to divisions such that each iteration of the loop + // adds back truncations from previous iterations to the current iteration. This is an adaption of the Kahan–Babuška summation + // algorithm, which is used to reduce numerical errors in floating point arithmetic, to integer arithmetic in solidity. + // Although it is anticipated that few fee calculations will cross more than one boundary, we should still handle the case well, + // and fairly, where, say a large deposit is made in the face of a relatively small collateral balance or when fee boundaries + // are placed closely together to create the correct incentives for investors. + + struct CollateralRatioData { + uint256 underlyingCollateral; + uint256 price; + uint256 rate; + uint256 peggedTokenBalance; + } + + struct MintPeggedWorkspace { + uint band; // solhint-disable-line explicit-types + uint256 underlyingCollateralInLeftE36; + uint256 underlyingCollateralHeldE36; + uint256 underlyingCollateralAddedE36; + uint256 peggedTokenHeldE36; + uint256 underlyingFeeE36; + uint256 mintedE36; + int256 feeErrorE54; + } + + /// @notice Perform a dry run of a mint pegged to calculate the various transfers of tokens. + /// Fees, discounts and disallows relating to the different incentiveRatios values are calculated as sum, weighted + /// in proportion, in collateral space, to the amount spent within each collateral ratio boundary. + /// It essentially performs a definite integral of the fee function. + /// @param config_ The collateral ratio boundaries and the incentive ratios within each boundary, + /// for minting pegged tokens. + /// @param wrappedCollateralIn The proposed amount of wrapped collateral being posted in exchange for pegged tokens. + /// @param cr contains: + /// UnderlyingCollateral The amount of collateral held. This is used to calculate collateral ratios. + /// The price value of a collateral token in terms of the pegged token, and the rate of wrapped collateral to underlying collateral. + /// peggedTokenBalance The amount of pegged tokens issued. This is used to calculate collateral ratios. + /// @return wrappedFee The pro-rated fee, in wrapped collateral terms. + /// @return peggedMinted the amount of pegged tokens minted after fees are taken into account + /// @return maxWrappedCollateralIn the amount of wrapped collateral that is allowed, according to the config + /// @return underlyingCollateralAdded the amount of underlying collateral added to the backing of the pegged tokens + + function _mintPeggedAdjustments( + ConfigIncentiveLib.ActionIncentive memory config_, + uint256 wrappedCollateralIn, + CollateralRatioData memory cr + ) + internal + pure + returns ( + uint256 wrappedFee, + uint256 peggedMinted, + uint256 maxWrappedCollateralIn, + uint256 underlyingCollateralAdded + ) + { + // we cannot calculate collateral ratio when there are no pegged tokens as it's infinite i.e. (/0) + // slither-disable-next-line incorrect-equality + // if (cr.peggedTokenBalance == 0) { + // revert ActionPaused(); + // } + // find the band and it's lower bound where the current collateral ratio is + // (note we treat the disallow band as any other here, except that it is the terminal band) + MintPeggedWorkspace memory w; + w.band = _findBand(config_, cr.underlyingCollateral, cr.price, cr.peggedTokenBalance, false); + uint256 peggedTokenPriceE36 = _peggedTokenPriceE36(cr.peggedTokenBalance, cr.underlyingCollateral, cr.price); + + w.underlyingCollateralInLeftE36 = wrappedCollateralIn * cr.rate; // scaled to 1e36 + w.underlyingCollateralHeldE36 = cr.underlyingCollateral * 1 ether; // scaled to 1e36 + w.peggedTokenHeldE36 = cr.peggedTokenBalance * 1 ether; + w.underlyingFeeE36 = 0; + w.mintedE36 = 0; + // simulate minting until we run out of collateral, adding the fee & collateral as we go + while (true) { + uint256 bandFeeRatio = uint256(ConfigIncentiveLib._incentiveRatio(config_, w.band)); // no discounts for this action + // slither-disable-next-line incorrect-equality, the vaule 1 ether corresponds to a specific meaning + if (bandFeeRatio == 1 ether) { + // fee ratio of 100% means the action is disallowed, and in the lowest band + break; + } + + uint256 collateralInBandE36; // includes the fee + uint256 bandLowerBound = ConfigIncentiveLib._collateralRatioLowerBounds(config_, w.band); + if (bandLowerBound <= 1 ether) { + // We can never mint enough pegged tokens such that we de-peg and + // if we have already de-pegged, we can use all the collateral given + collateralInBandE36 = w.underlyingCollateralInLeftE36; + } else { + // here we can assume pegged tokens are not de-pegged + // we have collateral ratio R = C.p / Z + // where p = price of collateral in pegged tokens, C = collateral balance and Z = pegged token balance + // adding fee ratio, f, change in collateral, dC, and change in pegged, dZ, we have + // R = ((C + dC - dC * f) * p) / (Z + dZ - dZ * f) + // captures the changes in pegged and collateral in order for R to be the lower bound, for a given constant fee ratio, f + // now, dZ = dC * p and solving for dC gives us + // dC = (C * p - R * Z) / (p * phi) + // where phi = R * (1 - f) - 1 + f = (R - 1) * (1 - f) + uint256 phiE36 = (bandLowerBound - 1e18) * (1e18 - bandFeeRatio); + collateralInBandE36 = Math.mulDiv( + w.underlyingCollateralHeldE36 * cr.price - bandLowerBound * w.peggedTokenHeldE36, + 1e36, + cr.price * phiE36 + ); + collateralInBandE36 = Math.min(w.underlyingCollateralInLeftE36, collateralInBandE36); + } + uint256 bandFeeE36; + (bandFeeE36, w.feeErrorE54) = _divAccumulateError(collateralInBandE36 * bandFeeRatio, w.feeErrorE54); + w.underlyingFeeE36 += bandFeeE36; + uint256 collateralAddedInBandE36 = collateralInBandE36 - bandFeeE36; + w.underlyingCollateralAddedE36 += collateralAddedInBandE36; + + w.underlyingCollateralInLeftE36 -= collateralInBandE36; + + uint256 peggedMintedInBandE36 = Math.mulDiv( + collateralAddedInBandE36, + cr.price * 1 ether, + peggedTokenPriceE36 + ); + + w.mintedE36 += peggedMintedInBandE36; + + // slither-disable-next-line incorrect-equality + if (w.underlyingCollateralInLeftE36 == 0 || w.band == 0) { + // we have run out of collateral for the simulation + // or we are in the lowest band, so no more, so exit + break; + } + // still some collateral left and we're allowed to mint, so simulate + w.underlyingCollateralHeldE36 += collateralAddedInBandE36; + w.peggedTokenHeldE36 += peggedMintedInBandE36; + w.band--; + } + // return the results + peggedMinted = w.mintedE36 / 1 ether; + // first do calculations in underlying collateral + underlyingCollateralAdded = _round(w.underlyingCollateralAddedE36, 1 ether); + // then wrapped collateral based on the underlying collateral numbers + wrappedFee = w.underlyingFeeE36 / cr.rate; + maxWrappedCollateralIn = (w.underlyingCollateralAddedE36 + w.underlyingFeeE36) / cr.rate; + } + + struct RedeemPeggedWorkspace { + uint256 peggedInLeftE36; + uint256 underlyingCollateralHeldE36; + uint256 peggedTokenHeldE36; + uint256 underlyingFeeE36; + uint256 underlyingDiscountE36; + uint256 redeemedE36; + int256 feeErrorE54; + int256 discountErrorE54; + int256 collateralHeldErrorE54; + } + + /// @notice Perform a dry run of a redeem pegged to calculate the various transfers of tokens + /// Fees and discounts relating to the different incentiveRatios values are calculated as sum, weighted + /// in proportion, in collateral space, to the amount spent within each collateral ratio boundary. + /// It essentially performs a definite integral of the fee function. + /// @param config_ The collateral ratio boundaries and the incentive ratios within each boundary, + /// for redeeming pegged tokens. + /// @param peggedIn The given amount of pegged tokens. + /// @param cr contains: + /// UnderlyingCollateral The amount of collateral held. This is used to calculate collateral ratios. + /// The price value of a collateral token in terms of the pegged token, and the rate of wrapped collateral to underlying collateral. + /// peggedTokenBalance The amount of pegged tokens issued. This is used to calculate collateral ratios. + /// @param reserveWrappedCapacity The current balance of the reserve pool (scaled to 1e36). + /// @return wrappedFee the fee charged in wrapped collateral tokens. + /// @return wrappedDiscount the discount given in wrapped collateral tokens. + /// @return wrappedCollateralReturned the wrapped collateral returned to the receiver in exchange for the 'peggedRedeemed' + /// @return underlyingCollateralRemoved the collateral removed from the balance to return the peggedIn. + + function _redeemPeggedAdjustments( + ConfigIncentiveLib.ActionIncentive memory config_, + uint256 peggedIn, + CollateralRatioData memory cr, + uint256 reserveWrappedCapacity + ) + internal + pure + returns ( + uint256 wrappedFee, + uint256 wrappedDiscount, // amount requested from reserve pool + uint256 wrappedCollateralReturned, // this includes the discount + uint256 underlyingCollateralRemoved, + uint256 peggedPriceE36 + ) + { + RedeemPeggedWorkspace memory w; + // solhint-disable-next-line explicit-types + uint band = _findBand(config_, cr.underlyingCollateral, cr.price, cr.peggedTokenBalance, true); + // simulate redeeming until we run out of pegged tokens, adding the fee & bonus as we go + // We do this band at a time, pro-rating the resulting fee according to how much collateral was needed in + // each band entered. We use collateral to pro-rate, rather than collateral ratio which would be simpler, because + // we multiply the resulting ratios by the collateral for the final fee + + // we capture the pegged price now as it doesn't change throughout the process, even if depegged + peggedPriceE36 = _peggedTokenPriceE36(cr.peggedTokenBalance, cr.underlyingCollateral, cr.price); + + w.peggedInLeftE36 = peggedIn * 1 ether; // scaled to 1e36 + w.underlyingCollateralHeldE36 = cr.underlyingCollateral * 1 ether; // scaled to 1e36 + w.peggedTokenHeldE36 = cr.peggedTokenBalance * 1 ether; + w.underlyingFeeE36 = 0; + w.underlyingDiscountE36 = 0; + w.redeemedE36 = 0; + + while (w.peggedInLeftE36 > 0) { + uint256 peggedInBandE36; + { + if (band + 1 == ConfigIncentiveLib._collateralRatioBandCount(config_)) { + // the last band goes on forever and there must be more than 1 band + peggedInBandE36 = w.peggedInLeftE36; + } else { + uint256 bandUpperBound = ConfigIncentiveLib._collateralRatioUpperBounds(config_, band); + if (bandUpperBound <= 1 ether) { + // given the price of the pegged is a proportionate share of the collateral and leveraged tokens are worthless + // we redeem all of it (at the depegged rate) in this band, at the rate for the band + peggedInBandE36 = w.peggedInLeftE36; + } else { + // note the bandUpperBound cannot be == 1 ether so this is safe below + peggedInBandE36 = + (bandUpperBound * w.peggedTokenHeldE36 - w.underlyingCollateralHeldE36 * cr.price) / + (bandUpperBound - 1 ether); + peggedInBandE36 = Math.min(w.peggedInLeftE36, peggedInBandE36); + } + } + } + // account for pegged being removed + w.peggedInLeftE36 -= peggedInBandE36; + w.redeemedE36 += peggedInBandE36; + w.peggedTokenHeldE36 -= peggedInBandE36; + + { + uint256 collateralInBandE36; + (collateralInBandE36, w.collateralHeldErrorE54) = _divAccumulateError( + Math.mulDiv(peggedInBandE36, peggedPriceE36, cr.price), + w.collateralHeldErrorE54 + ); + + // tally the fee or discount - these values have no effect at the moment: + // fees have already been accounted for and discounts come from the reserve pool + int256 bandIncentiveRatio = ConfigIncentiveLib._incentiveRatio(config_, band); + if (bandIncentiveRatio < 0) { + uint256 bandDiscountE36; + (bandDiscountE36, w.discountErrorE54) = _divAccumulateError( + collateralInBandE36 * uint256(-bandIncentiveRatio), + w.discountErrorE54 + ); + w.underlyingDiscountE36 += bandDiscountE36; + } else { + uint256 bandFeeE36; + (bandFeeE36, w.feeErrorE54) = _divAccumulateError( + collateralInBandE36 * uint256(bandIncentiveRatio), + w.feeErrorE54 + ); + w.underlyingFeeE36 += bandFeeE36; + } + w.underlyingCollateralHeldE36 -= collateralInBandE36; + } + // still some pegged tokens left so continue redeeing them + band++; + } + wrappedFee = w.underlyingFeeE36 / cr.rate; + wrappedDiscount = Math.min(reserveWrappedCapacity, w.underlyingDiscountE36 / cr.rate); // amount requested from reserve pool + uint256 underlyingCollateralRemovedE36 = cr.underlyingCollateral * 1 ether - w.underlyingCollateralHeldE36; + underlyingCollateralRemoved = underlyingCollateralRemovedE36 / 1 ether; // don't round this as it may push CR the wrong way + wrappedCollateralReturned = underlyingCollateralRemovedE36 / cr.rate + wrappedDiscount - wrappedFee; + } + + struct MintLeveragedWorkspace { + uint band; // solhint-disable-line explicit-types + uint256 underlyingCollateralInLeftE36; + uint256 underlyingReserveCapacityE36; + uint256 underlyingCollateralHeldE36; + uint256 underlyingCollateralAddedE36; + uint256 peggedTokenHeldE36; + uint256 underlyingFeeE36; + uint256 underlyingDiscountE36; + uint256 bandFeeRatio; + uint256 bandDiscountRatio; + uint256 leveragedPriceE36; + uint256 leveragedTokenBalance; + uint256 collateralValueE36; + uint256 peggedValueE36; + } + + /// @notice Perform a dry run of a mint pegged to calculate the various transfers of tokens. + /// Fees, discounts and disallows relating to the different incentiveRatios values are calculated as sum, weighted + /// in proportion, in collateral space, to the amount spent within each collateral ratio boundary. + /// It essentially performs a definite integral of the fee function. + /// @param config_ The collateral ratio boundaries and the incentive ratios within each boundary, + /// for minting leveraged tokens. + /// @param wrappedCollateralIn The proposed amount of wrapped collateral being posted in exchange for leveraged tokens + /// @param cr contains: + /// UnderlyingCollateral The amount of collateral held. This is used to calculate collateral ratios. + /// The price value of a collateral token in terms of the pegged token, and the rate of wrapped collateral to underlying collateral. + /// peggedTokenBalance The amount of pegged tokens issued. This is used to calculate collateral ratios. + /// @param reserveWrappedCapacity The current balance of the reserve pool. + /// @return wrappedFee The pro-rated fee, in wrapped collateral terms. + /// @return wrappedDiscount the discount given in wrapped collateral tokens. + /// @return leveragedMinted The amount of leveraged tokens minted, after fees and discounts are taken into account. + /// @return maxWrappedCollateralIn the collateral used from the wrappedCollateralIn. + /// @return underlyingCollateralAdded the collateral added to the balance to return the wrappedCollateralIn. + + // slither-disable-next-line cyclomatic-complexity + function _mintLeveragedAdjustments( + ConfigIncentiveLib.ActionIncentive memory config_, + uint256 wrappedCollateralIn, + CollateralRatioData memory cr, + uint256 reserveWrappedCapacity + ) + internal + view + returns ( + uint256 wrappedFee, + uint256 wrappedDiscount, + uint256 leveragedMinted, + uint256 maxWrappedCollateralIn, + uint256 underlyingCollateralAdded + ) + { + MintLeveragedWorkspace memory w; + (w.collateralValueE36, w.peggedValueE36) = _tokenValuesE36( + cr.peggedTokenBalance, + cr.underlyingCollateral, + cr.price + ); + // leveraged tokens have no value (we may not have quite depegged, though) + if (w.collateralValueE36 <= w.peggedValueE36) { + return (0, 0, 0, 0, 0); + } + maxWrappedCollateralIn = wrappedCollateralIn; + w.leveragedTokenBalance = _leveragedTokenBalance(); + + // simulate minting leveaged tokens from current collateral ratio upwards, + // applying the incentive at the correct ratio as we go. + // We do this band at a time, pro-rating the resulting fee according to how much collateral was needed in + // each band entered. We use collateral to pro-rate, rather than collateral ratio which would be simpler, because + // we multiply the resulting ratios by the collateral for the final fee + // solhint-disable-next-line explicit-types + w.band = _findBand(config_, cr.underlyingCollateral, cr.price, cr.peggedTokenBalance, true); + w.underlyingCollateralInLeftE36 = wrappedCollateralIn * cr.rate; // scaled to 1e36 + w.underlyingReserveCapacityE36 = reserveWrappedCapacity * cr.rate; + w.underlyingCollateralHeldE36 = cr.underlyingCollateral * 1e18; + w.underlyingCollateralAddedE36 = 0; + w.peggedTokenHeldE36 = cr.peggedTokenBalance * 1e18; + + while (w.underlyingCollateralInLeftE36 > 0) { + // we calculate the collateral and discount for the current band + uint256 collateralInBandE36; + uint256 bandDiscountE36 = 0; + { + int256 incentiveRatio = ConfigIncentiveLib._incentiveRatio(config_, w.band); + // get the fee and discount ratios + w.bandFeeRatio = incentiveRatio > 0 ? uint256(incentiveRatio) : 0; + w.bandDiscountRatio = incentiveRatio < 0 ? uint256(-incentiveRatio) : 0; + } + // now get: + // the collateral in the band, + // the corresponding discount + // This is complex because both are dependent on the reservePool capacity which limits the discount which, in turn, inflences the collateral + + // slither-disable-next-line incorrect-equality + if (w.band + 1 == ConfigIncentiveLib._collateralRatioBandCount(config_)) { + // the last band has no upper bound and there are at least 2 bands + // gross collateral includes fees and discounts + collateralInBandE36 = w.underlyingCollateralInLeftE36; + if (w.bandDiscountRatio > 0) { + // theoretical + bandDiscountE36 = Math.mulDiv(collateralInBandE36, w.bandDiscountRatio, 1e18); + // actual + bandDiscountE36 = Math.min(bandDiscountE36, w.underlyingReserveCapacityE36); + } + } else if (w.bandDiscountRatio > 0) { + // discount + // we calculate the collateralInBand assuming there is no reserve pool capacity limit (for this band) + collateralInBandE36 = Math.mulDiv( + ConfigIncentiveLib._collateralRatioUpperBounds(config_, w.band) * w.peggedTokenHeldE36 - + w.underlyingCollateralHeldE36 * cr.price, + 1e18, + cr.price * (1e18 + w.bandDiscountRatio) + ); + // user limits how much of the band collateral is used (and the discount) + collateralInBandE36 = Math.min(collateralInBandE36, w.underlyingCollateralInLeftE36); + + // now check that the reserve pool can do it's corresponding bit + bandDiscountE36 = Math.mulDiv(collateralInBandE36, w.bandDiscountRatio, 1e18); + if (bandDiscountE36 > w.underlyingReserveCapacityE36) { + // Reserve pool has a capacity limit and wont be able to supply it's part of the collateralInBand, + // so we shift the onus on reaching the upper bound to the supplied collateral + collateralInBandE36 += bandDiscountE36 - w.underlyingReserveCapacityE36; + collateralInBandE36 = Math.min(collateralInBandE36, w.underlyingCollateralInLeftE36); + bandDiscountE36 = w.underlyingReserveCapacityE36; + } + } else { + // no discount + collateralInBandE36 = Math.mulDiv( + ConfigIncentiveLib._collateralRatioUpperBounds(config_, w.band) * w.peggedTokenHeldE36 - + w.underlyingCollateralHeldE36 * cr.price, + 1e18, + cr.price * (1e18 - w.bandFeeRatio) + ); + collateralInBandE36 = Math.min(collateralInBandE36, w.underlyingCollateralInLeftE36); + } + + // we have, for the band the user collateral needed, and the band discount + + w.underlyingCollateralHeldE36 += collateralInBandE36; + w.underlyingCollateralInLeftE36 -= collateralInBandE36; + w.underlyingCollateralAddedE36 += collateralInBandE36; + + if (w.bandFeeRatio > 0) { + uint256 bandFeeE36 = Math.mulDiv(collateralInBandE36, w.bandFeeRatio, 1e18); + w.underlyingFeeE36 += bandFeeE36; + w.underlyingCollateralHeldE36 -= bandFeeE36; + w.underlyingCollateralAddedE36 -= bandFeeE36; + } else if (bandDiscountE36 > 0) { + w.underlyingDiscountE36 += bandDiscountE36; + w.underlyingReserveCapacityE36 -= bandDiscountE36; + w.underlyingCollateralHeldE36 += bandDiscountE36; + w.underlyingCollateralAddedE36 += bandDiscountE36; + } + + w.band++; + } + wrappedDiscount = w.underlyingDiscountE36 / cr.rate; // we don't round this as it may overflow the reserve pool + wrappedFee = _round(w.underlyingFeeE36, cr.rate); + if (w.leveragedTokenBalance > 0) { + leveragedMinted = Math.mulDiv( + w.underlyingCollateralAddedE36, + cr.price * w.leveragedTokenBalance, + w.collateralValueE36 - w.peggedValueE36 + ); + } else if (w.underlyingCollateralAddedE36 > 0) { + leveragedMinted = Math.mulDiv(w.underlyingCollateralHeldE36, cr.price, 1e18) - w.peggedValueE36; + } else { + leveragedMinted = 0; + } + leveragedMinted = _round(leveragedMinted, 1e18); + underlyingCollateralAdded = _round(w.underlyingCollateralAddedE36, 1e18); + } + + struct RedeemLeveragedWorkspace { + uint256 underlyingCollateralInE36; + uint256 underlyingCollateralInLeftE36; // remaining underlying collateral to process (underlying * 1e18) + uint256 underlyingFeeE54; // Σ(collateralInBandE36 * feeRatio) + uint256 underlyingCollateralRemovedE36; // Σ(collateralInBandE36) (underlying * 1e18, pre-fee) + uint256 underlyingCollateralHeldE36; // provisional collateral balance (underlying * 1e18) + } + + /// @notice Perform a dry run of a redeem leveraged to calculate the various transfers of tokens + /// Fees and disallows relating to the different incentiveRatios values are calculated as sum, weighted + /// in proportion, in collateral space, to the amount spent within each collateral ratio boundary. + /// It essentially performs a definite integral of the fee function. + /// @param config_ The collateral ratio boundaries and the incentive ratios within each boundary, + /// for redeeming leveraged tokens. + /// @param leveragedIn The given amount of leveraged tokens. + /// @param cr contains: + /// UnderlyingCollateral The amount of collateral held. This is used to calculate collateral ratios. + /// The price value of a collateral token in terms of the pegged token, and the rate of wrapped collateral to underlying collateral. + /// peggedTokenBalance The amount of pegged tokens issued. This is used to calculate collateral ratios. + /// @param leveragedTokenBalance_ the current supply of leveraged tokens, assumed to be > 0. + /// @return wrappedFee the fee charged in collateral tokens. + /// @return leveragedRedeemed the leveraged tokens to be burned. + /// @return wrappedCollateralOut the collateral returned to the receiver in exchange for the `leveragedRedeemed` + /// @return underlyingCollateralRemoved the collateral removed from the system + + function _redeemLeveragedAdjustments( + ConfigIncentiveLib.ActionIncentive memory config_, + uint256 leveragedIn, + CollateralRatioData memory cr, + uint256 leveragedTokenBalance_ + ) + internal + pure + returns ( + uint256 wrappedFee, + uint256 leveragedRedeemed, + uint256 wrappedCollateralOut, + uint256 underlyingCollateralRemoved + ) + { + RedeemLeveragedWorkspace memory w; + + // we can't meaningfully do anything with leveraged tokens as their value is zero + // and we an do this once, here, and not in the loop below, because redeeming leveraged tokens, will never cause a re-peg. + { + (uint256 collateralValueE36, uint256 peggedValueE36) = _tokenValuesE36( + cr.peggedTokenBalance, + cr.underlyingCollateral, + cr.price + ); + if (collateralValueE36 <= peggedValueE36 || leveragedTokenBalance_ == 0 || leveragedIn == 0) { + // there is no value in the leveraged being offered + return (0, 0, 0, 0); + } + + // we know leveraged token balance is > 0 + w.underlyingCollateralInE36 = Math.mulDiv( + collateralValueE36 - peggedValueE36, + leveragedIn * 1e18, + cr.price * leveragedTokenBalance_ + ); + w.underlyingCollateralInLeftE36 = w.underlyingCollateralInE36; + } + // solhint-disable-next-line explicit-types + uint band = _findBand(config_, cr.underlyingCollateral, cr.price, cr.peggedTokenBalance, false); + w.underlyingCollateralHeldE36 = cr.underlyingCollateral * 1e18; + + while (true) { + uint256 bandFeeRatio = uint256(ConfigIncentiveLib._incentiveRatio(config_, band)); // no discounts for this action + if (bandFeeRatio == 1 ether) { + // fee ratio of 100% means the action is disallowed, and in the lowest band + break; + } + uint256 bandLowerBound = ConfigIncentiveLib._collateralRatioLowerBounds(config_, band); + if (bandLowerBound < 1 ether) { + // depegged (as there is always a CR = 1 boundary) means we disallow redeeming leveraged + // because the price has become 0 + break; + } + uint256 collateralInBandE36; + { + // segment pre-fee underlying (1e18 scale): + // netValue = (segment - fee)*price = segment*(1 - f/1e18)*price/1e18 + // => segment = valueToLowerBoundE36 * 1e18 / (price * (1 - f)) + // the fee is taken from the returned collateral not the input + collateralInBandE36 = + w.underlyingCollateralHeldE36 - Math.mulDiv(bandLowerBound * 1e18, cr.peggedTokenBalance, cr.price); + collateralInBandE36 = Math.min(collateralInBandE36, w.underlyingCollateralInLeftE36); + } + w.underlyingFeeE54 += collateralInBandE36 * bandFeeRatio; + w.underlyingCollateralRemovedE36 += collateralInBandE36; + w.underlyingCollateralInLeftE36 -= collateralInBandE36; + + // If we fully traversed this band's remaining distance (collateralInBandE36 == segmentTargetE36) descend one band. + // slither-disable-next-line incorrect-equality + if (w.underlyingCollateralInLeftE36 == 0 || band == 0 || bandLowerBound == 1 ether) { + break; + } + w.underlyingCollateralHeldE36 -= collateralInBandE36; + band--; + } + underlyingCollateralRemoved = _round(w.underlyingCollateralRemovedE36, 1e18); + // calculate the leveraged for the collateral assuming constant leveraged price. + leveragedRedeemed = Math.mulDiv(leveragedIn, w.underlyingCollateralRemovedE36, w.underlyingCollateralInE36); + + wrappedFee = w.underlyingFeeE54 / (cr.rate * 1e18); + wrappedCollateralOut = w.underlyingCollateralRemovedE36 / cr.rate - wrappedFee; + } + + /// @notice Returns the collateral ratio band given `collateralTokenBalance_`, `collateralPrice`, and + /// `peggedTokenBalance_`. + /// @param config_ Contains the collateral ratio boundaries to be searched. + /// for redeeming leveraged tokens. + /// @param collateralTokenBalance_ The amount of collateral managed. Used to calculate the modified collateral ratio. + /// @param collateralPrice The price of the collateral. Used to calculate the modified collateral ratio. + /// @param peggedTokenBalance_ The amount of pegged tokens managed. Used to calculate the modified collateral ratio. + /// @param atLower {bool} Indicates the starting point for the search, i.e. if it true the search will go toward + /// increasing collateral ratio. + + function _findBand( + ConfigIncentiveLib.ActionIncentive memory config_, + uint256 collateralTokenBalance_, + uint256 collateralPrice, + uint256 peggedTokenBalance_, + bool atLower + ) + internal + pure + returns ( + uint band // solhint-disable-line explicit-types + ) + { + uint256 collateralRatio_ = _collateralRatio(collateralTokenBalance_, collateralPrice, peggedTokenBalance_); + for (band = 0; band < ConfigIncentiveLib._collateralRatioBandCount(config_) - 1; band++) { + uint256 bandUpperBound = ConfigIncentiveLib._collateralRatioUpperBounds(config_, band); + if (atLower) { + if (collateralRatio_ < bandUpperBound) { + break; + } + } else { + if (collateralRatio_ <= bandUpperBound) { + break; + } + } + } + } + + // other calculations + // ------------------ + + // the price of a pegged token taking into account de-peg rate + function _peggedTokenPriceE36( + uint256 peggedTokenBalance_, + uint256 collateralTokenBalance_, + uint256 collateralPrice + ) internal pure returns (uint256 navE36) { + if (peggedTokenBalance_ > 0) { + (, navE36) = _tokenValuesE36(peggedTokenBalance_, collateralTokenBalance_, collateralPrice); + navE36 = Math.mulDiv(navE36, 1 ether, peggedTokenBalance_); + } else { + navE36 = 1 ether * 1 ether; + } + } + + function _tokenValuesE36( + uint256 peggedTokenBalance_, + uint256 collateralTokenBalance_, + uint256 collateralPrice + ) internal pure returns (uint256 collateralValueE36, uint256 peggedValueE36) { + collateralValueE36 = collateralTokenBalance_ * collateralPrice; + peggedValueE36 = peggedTokenBalance_ * 1 ether; + // the value of the pegged cannot be greater than the value of the collateral + if (peggedValueE36 > collateralValueE36) { + peggedValueE36 = collateralValueE36; + } + } + + function _leverageRatio( + uint256 peggedTokenBalance_, + uint256 underlyingCollateral_, + uint256 price + ) internal pure returns (uint256 ratio) { + (uint256 collateralValueE36, uint256 peggedValueE36) = _tokenValuesE36( + peggedTokenBalance_, + underlyingCollateral_, + price + ); + if (peggedValueE36 >= collateralValueE36) { + // it divides by 0 or goes negative! + ratio = _LEVERAGE_RATIO_CAP; + } else { + // we have collateral and it's worth something + ratio = Math.mulDiv(collateralValueE36, 1 ether, collateralValueE36 - peggedValueE36); + if (ratio > _LEVERAGE_RATIO_CAP) { + ratio = _LEVERAGE_RATIO_CAP; + } + } + } + + function _round(uint256 numerator, uint256 denominator) internal pure returns (uint256 result) { + unchecked { + result = numerator / denominator; + uint256 remainder = numerator % denominator; + + uint256 halfDenominator = denominator >> 1; + + if (remainder >= halfDenominator) result += 1; + } + } + + /// @dev function to accumulate an error term from a divide by 1 ether + function _divAccumulateError( + uint256 preDivideE54, + int256 errorE54 + ) internal pure returns (uint256 postDivideE36, int256 newErrorE54) { + unchecked { + postDivideE36 = preDivideE54 / 1 ether; // scaled to 1e36 + newErrorE54 = errorE54 + (int256(preDivideE54) % 1 ether); + // perform a rounding to nearest + if (newErrorE54 >= 0.5 ether) { + postDivideE36 += 1; // rounding up, which is the nearest in this case + newErrorE54 -= 1 ether; // remove the above correction + } + } + } + + function _leveragedTokensForPegged( + uint256 peggedIn, + uint256 leveragedTokenBalance_, + uint256 peggedTokenBalance_, + uint256 collateralTokenBalance_, + uint256 collateralPrice + ) internal pure returns (uint256 leveragedTokens) { + // we use leverage ratio for this calculation as it is capped + if (leveragedTokenBalance_ > 0) { + uint256 leverageRatio_ = _leverageRatio(peggedTokenBalance_, collateralTokenBalance_, collateralPrice); + // slither-disable-next-line incorrect-equality + if (leverageRatio_ == _LEVERAGE_RATIO_CAP) { + // cap the amount returned + leveragedTokens = Math.mulDiv(peggedIn, _LEVERAGE_RATIO_CAP, 1 ether); + } else { + // Convert using leverage ratio approach as this is only called in a rebalance context + leveragedTokens = Math.mulDiv( + peggedIn * leveragedTokenBalance_, + leverageRatio_, + collateralTokenBalance_ * collateralPrice + ); + } + } else { + leveragedTokens = peggedIn; // TODO: the third place initial price of 1 ether is assumed + } + } + + /// @notice Calculates the raw collateral ratio without any flooring. + /// @dev This returns the actual mathematical ratio (collateralValue / peggedValue) which may be < 1 in depegged scenarios. + /// Semantics: + /// - Hot path (pegged > 0): single branch then mulDiv; zero collateral/price naturally yields 0. + /// - If pegged == 0: + /// - If price == 0 => 0 (collateral has zero value; limit as Z→0+ is 0) + /// - Else if collateral == 0 => 1e18 (define 0/0 as 1.0) + /// - Else => +infinity encoded as 1e36 + /// @param collateralTokenBalance_ The amount of collateral tokens + /// @param collateralPrice The price of collateral in terms of the pegged token + /// @param peggedTokenBalance_ The amount of pegged tokens + /// @return collateralRatio_ The raw collateral ratio with 18 decimals + function _collateralRatio( + uint256 collateralTokenBalance_, + uint256 collateralPrice, + uint256 peggedTokenBalance_ + ) internal pure returns (uint256 collateralRatio_) { + // Hot path: pegged > 0 → just compute the ratio (covers collateral==0 or price==0 as 0). + // slither-disable-next-line incorrect-equality + if (peggedTokenBalance_ != 0) { + return Math.mulDiv(collateralTokenBalance_, collateralPrice, peggedTokenBalance_); + } + + // Cold path: pegged == 0 → handle edge semantics without doing mulDiv. + // slither-disable-next-line incorrect-equality + if (collateralPrice == 0) { + return 0; // zero value collateral implies CR→0 in the Z→0+ limit + } + // slither-disable-next-line incorrect-equality + if (collateralTokenBalance_ == 0) { + return 1 ether; // define 0/0 as 1.0 + } + return 1 ether * 1 ether; // encode +infinity as 1e36 + } + + /// @notice Returns the amount of leveraged tokens being managed + function _leveragedTokenBalance() internal view returns (uint256) { + return IERC20(LEVERAGED_TOKEN).totalSupply(); + } + + // fetching collateral price in terms of the pegged tokens + // ------------------------------------------------------- + + struct OracleData { + uint256 price; + uint256 rate; + } + + /// @notice Returns the safe price for the collateral token. + /// @dev Checks safe price non-zero. + function _fetchMid(address priceOracle_) internal view returns (OracleData memory) { + (uint256 minPrice, uint256 maxPrice, uint256 minRate, uint256 maxRate) = IWrappedPriceOracle(priceOracle_) + .latestAnswer(); + return OracleData(_round(minPrice + maxPrice, 2), _round(minRate + maxRate, 2)); + } + + /// @notice Returns the min price for the collateral token. + /// If the safe price is valid it is returned, else the min price. + /// @dev Checks the returned price is non-zero. + function _fetchMin(address priceOracle_) internal view returns (OracleData memory) { + // slither-disable-next-line unused-return + (uint256 minPrice, , uint256 minRate, ) = IWrappedPriceOracle(priceOracle_).latestAnswer(); + return OracleData(minPrice, minRate); + } + + /// @notice Returns the max price for the collateral token. + /// If the safe price is valid it is returned, else the max price. + /// @dev Checks the returned price is non-zero. + function _fetchMax(address priceOracle_) internal view returns (OracleData memory) { + // slither-disable-next-line unused-return + (, uint256 maxPrice, , uint256 maxRate) = IWrappedPriceOracle(priceOracle_).latestAnswer(); + return OracleData(maxPrice, maxRate); + } + + // Harvesting support + // ------------------------------------------------------- + /// @notice function used to control access to the sweep function for extracting harvestable amounts + function _checkSweeper() internal view override(TokenHolder) { + _checkOwnerOrRoles(HARVESTER_ROLE); + } +} diff --git a/test/RebalanceFairness.t.sol b/test/deployment/RebalanceFairness.t.sol similarity index 100% rename from test/RebalanceFairness.t.sol rename to test/deployment/RebalanceFairness.t.sol diff --git a/test/StabilityPoolAliasDeployment.t.sol b/test/deployment/StabilityPoolAliasDeployment.t.sol similarity index 100% rename from test/StabilityPoolAliasDeployment.t.sol rename to test/deployment/StabilityPoolAliasDeployment.t.sol From 8aa4af105c60e76fd97a4d7d8b04ab22f3f0ea09 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Thu, 2 Apr 2026 19:28:55 +0100 Subject: [PATCH 009/232] minter v3 capped mint pegged --- script/src/contracts/Minter.sol | 26 ++++++++++------------ src/interfaces/IMinter_v3.sol | 39 +++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 14 deletions(-) create mode 100644 src/interfaces/IMinter_v3.sol diff --git a/script/src/contracts/Minter.sol b/script/src/contracts/Minter.sol index 73daa790..cdf4c0db 100644 --- a/script/src/contracts/Minter.sol +++ b/script/src/contracts/Minter.sol @@ -8,12 +8,12 @@ import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; import {Config_MinterMarket, IMarketConfig, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; -import {Minter_v2} from "@harbor/minter/Minter_v2.sol"; +import {Minter_v3} from "@harbor/minter/Minter_v3.sol"; import {ReservePool_v1} from "@harbor/minter/ReservePool_v1.sol"; import {TokenDistributor_v1} from "@harbor/minter/TokenDistributor_v1.sol"; import {IMinter} from "@harbor/interfaces/IMinter.sol"; -/// @notice Harbor Minter_v2 deployment logic (including ReservePool and FeeReceiver). +/// @notice Harbor Minter_v3 deployment logic (including ReservePool and FeeReceiver). /// @dev File Organization Pattern (see deployment2-design.md Section 3.3.2): /// @dev - This file: contract-specific deployment for Minter, ReservePool, MinterFeeReceiver /// @dev - Uses DeploymentOwnership pattern: register deployed contracts, transfer at end @@ -35,15 +35,15 @@ abstract contract Minter is HarborFactoryDeployer { minterKey = string.concat(marketKey, "::minter"); console.log(" > %s", minterKey); - impl = address(new Minter_v2(wrappedCollateral, peggedToken, leveragedToken, "burn(uint256)")); + impl = address(new Minter_v3(wrappedCollateral, peggedToken, leveragedToken, "burn(uint256)")); console.log(" Impl: %s", impl); DeploymentState.recordImplementation( stateData, DeploymentTypes.ImplementationRecord({ proxy: minterKey, - contractSource: "@harbor/minter/Minter_v2.sol", - contractType: "Minter_v2", + contractSource: "@harbor/minter/Minter_v3.sol", + contractType: "Minter_v3", implementation: impl, deploymentTime: uint64(block.timestamp) }) @@ -66,7 +66,7 @@ abstract contract Minter is HarborFactoryDeployer { leveragedToken ); - bytes memory initData = abi.encodeCall(Minter_v2.initialize, (owner())); + bytes memory initData = abi.encodeCall(Minter_v3.initialize, (owner())); proxy = _deployProxyViaStubAndRecord(stateData, minterKey, impl, initData); } @@ -79,11 +79,10 @@ abstract contract Minter is HarborFactoryDeployer { address priceOracle, address reservePool ) internal { - Minter_v2 minter = Minter_v2(minterProxy); - minter.updateConfig(config); - minter.updateFeeReceiver(feeReceiver); - minter.updatePriceOracle(priceOracle); - minter.updateReservePool(reservePool); + IMinter(minterProxy).updateConfig(config); + IMinter(minterProxy).updateFeeReceiver(feeReceiver); + IMinter(minterProxy).updatePriceOracle(priceOracle); + IMinter(minterProxy).updateReservePool(reservePool); } /// @notice Grant Minter roles to downstream contracts. @@ -93,16 +92,15 @@ abstract contract Minter is HarborFactoryDeployer { address stabilityPoolManager, address genesis ) internal { - Minter_v2 minter = Minter_v2(minterProxy); _grantRoles( minterKey, minterProxy, stabilityPoolManager, "stabilityPoolManager", - minter.HARVESTER_ROLE() | minter.ZERO_FEE_ROLE(), + IMinter(minterProxy).HARVESTER_ROLE() | IMinter(minterProxy).ZERO_FEE_ROLE(), "HARVESTER | ZERO_FEE" ); - _grantRoles(minterKey, minterProxy, genesis, "genesis", minter.ZERO_FEE_ROLE(), "ZERO_FEE"); + _grantRoles(minterKey, minterProxy, genesis, "genesis", IMinter(minterProxy).ZERO_FEE_ROLE(), "ZERO_FEE"); } // ========== RESERVE POOL DEPLOYMENT ========== diff --git a/src/interfaces/IMinter_v3.sol b/src/interfaces/IMinter_v3.sol new file mode 100644 index 00000000..0cff28ea --- /dev/null +++ b/src/interfaces/IMinter_v3.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MIT + +pragma solidity >=0.8.28 <0.9.0; + +/// @notice Minter v3 extensions: fee-capped minting. +interface IMinter_v3 { + /// @notice Mint pegged tokens with a fee cap. Stops minting when cumulative fee would exceed maxFeeRatio. + /// Returns (0, 0) gracefully if the fee exceeds the cap from the start (does not revert). + /// @param collateralIn The amount of wrapped collateral to post. Use type(uint256).max for all. + /// @param receiver The address to receive minted pegged tokens. + /// @param minPeggedOut Minimum acceptable pegged output. 0 means no check. + /// @param maxFeeRatio Maximum overall fee ratio (18 decimals). e.g. 0.05 ether = 5%. + /// @return peggedOut The amount of pegged tokens minted. + /// @return collateralUsed The amount of wrapped collateral actually consumed (collateral added + fee). + function mintPeggedToken( + uint256 collateralIn, + address receiver, + uint256 minPeggedOut, + uint256 maxFeeRatio + ) external returns (uint256 peggedOut, uint256 collateralUsed); + + /// @notice Dry run of a capped mint: computes outcome if total fee is capped at maxFeeRatio. + /// @param collateralIn The proposed amount of wrapped collateral. + /// @param maxFeeRatio The maximum overall fee ratio (18 decimals). e.g. 0.05 ether = 5%. + function mintPeggedTokenDryRun( + uint256 collateralIn, + uint256 maxFeeRatio + ) + external + view + returns ( + int256 incentiveRatio, + uint256 fee, + uint256 collateralTaken, + uint256 peggedMinted, + uint256 price, + uint256 rate + ); +} From d5e76b1a98d138ffc96d737dcac78f86bb5c0180 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Thu, 2 Apr 2026 19:50:48 +0100 Subject: [PATCH 010/232] reorganised script and test deployment scripts --- .solhintignore | 2 +- CLAUDE.md | 5 +- lib/bao-base | 2 +- package.json | 2 +- regression/coverage.txt | 20 +- script/Remediate_Accumulators.s.sol | 2 +- script/Remediate_SPL_ETH_fxUSD.s.sol | 2 +- script/src/DeployMintersShared.sol | 9 +- script/verify/README.md | 78 +++-- .../minter-v2-upgrade/MinterUpgradeTest.t.sol | 2 +- script/verify/roles/MainnetRoles.t.sol | 2 +- .../ForceMigrateAccumulator_v1.sol | 0 .../sp-v3-migration/SPv3MigrationTest.t.sol | 4 +- ...ebalanceRemediationForStabilityPool_v2.sol | 0 .../spl-remediation/SPLRemediationTest.t.sol | 4 +- .../spl-remediation/V2ReplaySimulation.t.sol | 2 +- .../remediation-ETH-fxUSD-SPL.md | 2 +- src/minter/Minter_v3.sol | 111 +++++-- test/RebalanceCheck.t.sol | 2 +- test/deployment/MinterCappedMint.t.sol | 300 ++++++++++++++++++ test/deployment/RebalanceFairness.t.sol | 3 +- .../StabilityPoolAliasDeployment.t.sol | 3 +- 22 files changed, 469 insertions(+), 88 deletions(-) rename script/{patch => verify/sp-v3-migration}/ForceMigrateAccumulator_v1.sol (100%) rename script/{patch => verify/spl-remediation}/PostRebalanceRemediationForStabilityPool_v2.sol (100%) create mode 100644 test/deployment/MinterCappedMint.t.sol diff --git a/.solhintignore b/.solhintignore index ead61e6b..bd08fb11 100644 --- a/.solhintignore +++ b/.solhintignore @@ -1,3 +1,3 @@ src/util/WordCodec.sol *_v1.sol -script/patch/PostRebalanceRemediationForStabilityPool_v2.sol \ No newline at end of file +script/verify/spl-remediation/PostRebalanceRemediationForStabilityPool_v2.sol \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 91919930..dd34114f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,8 +3,8 @@ - Do not create functions that are only called once. Inline the logic instead. - When diagnosing an issue, do not use words like "likely", "probably", or "may" to describe a root cause. Either verify the hypothesis with data (dry run, log, trace) or state explicitly that it is unverified. Never proceed with a fix based on an unverified hypothesis. - use forge install/remove for managing submodule dependencies -- In tests, use interface types (e.g. `IStabilityPool_v3(address)`) not concrete contract types (e.g. `StabilityPool_v3(address)`) when calling functions. This verifies the interface matches the implementation. Concrete types are only for initialisation (constructor, deploy). -- in code never use an if or loop statement without curly brackets - I want the code coverage to be visible and that hides some branches from the display +- In tests and scripts, use interface types (e.g. `IStabilityPool_v3(address)`) not concrete contract types (e.g. `StabilityPool_v3(address)`) when calling functions. This verifies the interface matches the implementation. Concrete types are only for initialisation (constructor, deploy). +- Every branch must have each path on a separate line so coverage tools can distinguish them. Use curly brackets on all if/for/while statements (no single-line bodies). Ternary expressions are fine — formatters already split the branches across lines. - In deployment scripts, use salt keys and `_predictAddress(key)` to reference contracts — not deployed addresses. BaoFactory CREATE3 gives deterministic addresses from salts, so contracts can reference each other before deployment. For example, `registerRewardToken(_predictAddress(aliasKey))` works even if the alias hasn't been deployed yet. This decouples deployment order from contract dependencies. - In deployment scripts, build salt strings using `_saltString()` / `_predictAddress()` library functions from FactoryDeployer — never manually concat salt strings with `string.concat`. - Three ownership patterns for UUPS contracts: @@ -13,4 +13,5 @@ - **HarborFixedOwnable** (hardcoded): Owner is immutable constructor param (Harbor multisig). Deploy via `_deployProxyAndRecord` with empty initData. Used by: HarborPauser_v1. - Each UUPS contract composes Initializable + UUPSUpgradeable + ownership mixin directly — don't create "Upgradeable" base contracts that bundle these, as each contract has different init needs (roles, reentrancy, custom state). The "Upgradeable" suffix means something different in OZ (storage-safe proxy variant) and combining meanings causes confusion. - In tests, never create and then remove files or directories — forge runs tests in parallel so you can create a race condition. Write test output to `./results` and leave it there. +- In tests, prefer `console2.log` over `emit` for debug logging — it shows in `forge test -vvv` output without cluttering the event log. Use the `Fmt` library with `string.concat` for readable formatted messages. - Never modify a deployed contract's source file. Check `deployments/*.state.json` for deployed contracts. To add functionality: inherit from the deployed version (e.g. `Minter_v3 is Minter_v2`) if the changes are additive, or clone and modify if the inheritance chain doesn't work. The proxy upgrade mechanism allows swapping implementations, but the old source must remain unchanged for audit traceability. \ No newline at end of file diff --git a/lib/bao-base b/lib/bao-base index 12c10141..6e68da70 160000 --- a/lib/bao-base +++ b/lib/bao-base @@ -1 +1 @@ -Subproject commit 12c101413afb8001729dce5b411aff21640ad7ce +Subproject commit 6e68da702919ba380bf23f0721b25a3e903d46b4 diff --git a/package.json b/package.json index 8e981efc..2ebf8f2e 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "gas": "./lib/bao-base/run regression-of gas", "coverage": "./lib/bao-base/run regression-of coverage", "wake": "wake detect all", - "slither": "./lib/bao-base/run slither --filter-paths 'script/patch'", + "slither": "./lib/bao-base/run slither --filter-paths 'script/verify'", "verify-audit": "lib/bao-base/run verify-audit", "validate": "./lib/bao-base/run validate", "script": "forge script --force --ffi", diff --git a/regression/coverage.txt b/regression/coverage.txt index 359bd90b..fb783353 100644 --- a/regression/coverage.txt +++ b/regression/coverage.txt @@ -23,8 +23,7 @@ | script/config/volatility/ConfigPriceVolatility_125_stable.sol | X 0% (0/65) | X 0% (0/71) | ✓ 100% (0/0) | X 0% (0/2) | | script/config/volatility/ConfigPriceVolatility_130.sol | X 0% (0/65) | X 0% (0/71) | ✓ 100% (0/0) | X 0% (0/2) | | script/config/volatility/ConfigPriceVolatility_130_stable.sol | ✓ 100% (65/65) | ✓ 100% (71/71) | ✓ 100% (0/0) | ✓ 100% (2/2) | -| script/patch/ForceMigrateAccumulator_v1.sol | X 0% (0/24) | X 0% (0/29) | X 0% (0/2) | X 0% (0/3) | -| script/src/DeployMintersShared.sol | X 83% (70/84) | X 82% (84/102) | X 25% (1/4) | X 78% (7/9) | +| script/src/DeployMintersShared.sol | X 86% (83/97) | X 85% (99/117) | X 25% (1/4) | X 80% (8/10) | | script/src/Deploy_BTC_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | | script/src/Deploy_ETH_Minter.sol | ✓ 100% (4/4) | ✓ 100% (3/3) | ✓ 100% (0/0) | ✓ 100% (1/1) | | script/src/Deploy_EUR_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | @@ -34,34 +33,33 @@ | script/src/HarborFactoryDeployer.sol | X 56% (9/16) | X 73% (8/11) | ✓ 100% (0/0) | X 20% (1/5) | | script/src/contracts/Genesis.sol | X 70% (7/10) | X 67% (8/12) | ✓ 100% (0/0) | X 50% (1/2) | | script/src/contracts/LeveragedToken.sol | ✓ 100% (15/15) | ✓ 100% (23/23) | ✓ 100% (0/0) | ✓ 100% (1/1) | -| script/src/contracts/Minter.sol | X 57% (24/42) | X 54% (25/46) | ✓ 100% (0/0) | X 62% (5/8) | +| script/src/contracts/Minter.sol | X 58% (23/40) | X 55% (23/42) | ✓ 100% (0/0) | X 62% (5/8) | | script/src/contracts/PeggedToken.sol | X 83% (20/24) | X 94% (31/33) | X 50% (3/6) | ✓ 100% (1/1) | -| script/src/contracts/StabilityPool.sol | ✓ 100% (26/26) | ✓ 100% (41/41) | ✓ 100% (0/0) | ✓ 100% (3/3) | +| script/src/contracts/StabilityPool.sol | ✓ 100% (39/39) | ✓ 100% (56/56) | ✓ 100% (0/0) | ✓ 100% (5/5) | | script/src/contracts/StabilityPoolManager.sol | X 54% (14/26) | X 50% (15/30) | ✓ 100% (0/0) | X 50% (2/4) | -| script/test/SPLRemediationTest.t.sol | X 0% (0/3) | X 0% (0/2) | ✓ 100% (0/0) | X 0% (0/1) | -| src/../script/src/HarborFactoryDeployer.sol | X 0% (0/16) | X 0% (0/11) | ✓ 100% (0/0) | X 0% (0/5) | | src/math/DecrementalFloatingPoint.sol | ✓ 100% (31/31) | ✓ 100% (33/33) | ✓ 100% (9/9) | ✓ 100% (6/6) | | src/minter/Genesis_v1.sol | X 99% (88/89) | ✓ 100% (88/88) | ✓ 100% (14/14) | X 92% (11/12) | | src/minter/Minter_v1.sol | X 31% (188/601) | X 29% (191/649) | X 16% (16/102) | X 56% (38/68) | | src/minter/Minter_v2.sol | X 99% (593/601) | X 99% (642/649) | X 93% (95/102) | X 99% (67/68) | -| src/minter/PostRebalanceRemediationForStabilityPool_v2.sol | X 0% (0/38) | X 0% (0/45) | X 0% (0/9) | X 0% (0/6) | +| src/minter/Minter_v3.sol | X 41% (251/617) | X 40% (270/667) | X 26% (27/105) | X 54% (38/71) | | src/minter/ReservePool_v1.sol | X 94% (15/16) | ✓ 100% (15/15) | ✓ 100% (3/3) | X 80% (4/5) | | src/minter/StabilityPoolManager_v1.sol | X 99% (165/166) | X 99% (176/177) | X 95% (20/21) | ✓ 100% (24/24) | | src/minter/StabilityPool_v1.sol | X 61% (124/203) | X 58% (129/223) | X 18% (6/33) | X 73% (16/22) | | src/minter/StabilityPool_v2.sol | X 68% (136/199) | X 68% (150/219) | X 32% (10/31) | X 64% (14/22) | -| src/minter/StabilityPool_v3.sol | ✓ 100% (289/289) | ✓ 100% (320/320) | ✓ 100% (42/42) | ✓ 100% (37/37) | +| src/minter/StabilityPool_v3.sol | ✓ 100% (294/294) | ✓ 100% (327/327) | ✓ 100% (42/42) | ✓ 100% (38/38) | | src/minter/TokenDistributor_v1.sol | X 96% (94/98) | X 97% (112/116) | X 77% (10/13) | X 93% (14/15) | | src/minter/library/ConfigIncentiveLib.sol | ✓ 100% (24/24) | ✓ 100% (17/17) | ✓ 100% (2/2) | ✓ 100% (9/9) | | src/minter/library/Config_v1.sol | X 96% (76/79) | X 97% (92/95) | X 90% (18/20) | ✓ 100% (6/6) | | src/price/PriceOracle_v1.sol | X 97% (31/32) | X 97% (36/37) | X 85% (11/13) | ✓ 100% (3/3) | | src/price/StakedETHWrappedPriceOracle_v1.sol | X 0% (0/29) | X 0% (0/27) | X 0% (0/5) | X 0% (0/4) | +| src/reward/RewardAlias.sol | X 73% (8/11) | X 56% (5/9) | ✓ 100% (0/0) | X 60% (3/5) | | src/reward/accumulator/MultipleRewardCompoundingAccumulator.sol | X 93% (127/136) | X 94% (161/171) | X 81% (13/16) | X 95% (20/21) | | src/reward/accumulator/MultipleRewardCompoundingAccumulator_v2.sol | X 96% (141/147) | X 96% (177/184) | X 83% (15/18) | ✓ 100% (22/22) | -| src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol | X 79% (116/147) | X 82% (150/184) | X 72% (13/18) | X 68% (15/22) | +| src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol | X 79% (116/147) | X 81% (149/184) | X 72% (13/18) | X 68% (15/22) | | src/reward/distributor/LinearMultipleRewardDistributor.sol | ✓ 100% (77/77) | ✓ 100% (86/86) | ✓ 100% (12/12) | ✓ 100% (14/14) | | src/reward/distributor/LinearMultipleRewardDistributor_v2.sol | ✓ 100% (77/77) | ✓ 100% (86/86) | ✓ 100% (12/12) | ✓ 100% (14/14) | -| src/reward/distributor/LinearMultipleRewardDistributor_v3.sol | X 84% (79/94) | X 85% (90/106) | X 33% (5/15) | X 88% (15/17) | +| src/reward/distributor/LinearMultipleRewardDistributor_v3.sol | X 91% (86/94) | X 93% (99/106) | X 53% (8/15) | X 94% (16/17) | | src/reward/distributor/LinearReward.sol | ✓ 100% (25/25) | ✓ 100% (27/27) | ✓ 100% (6/6) | ✓ 100% (2/2) | | src/util/FmtLib.sol | X 0% (0/19) | X 0% (0/26) | X 0% (0/4) | X 0% (0/1) | | src/util/WordCodec.sol | ✓ 100% (15/15) | ✓ 100% (14/14) | ✓ 100% (0/0) | ✓ 100% (4/4) | -| Total | X 68% (4817/7054) | X 67% (5133/7625) | X 57% (448/783) | X 70% (727/1045) | +| Total | X 67% (5177/7759) | X 66% (5516/8388) | X 54% (478/888) | X 69% (779/1126) | diff --git a/script/Remediate_Accumulators.s.sol b/script/Remediate_Accumulators.s.sol index 75132839..0f659702 100644 --- a/script/Remediate_Accumulators.s.sol +++ b/script/Remediate_Accumulators.s.sol @@ -6,7 +6,7 @@ import {LibString} from "@solady/utils/LibString.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import {Config_MinterMarket, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; -import {ForceMigrateAccumulator_v1} from "script/patch/ForceMigrateAccumulator_v1.sol"; +import {ForceMigrateAccumulator_v1} from "script/verify/sp-v3-migration/ForceMigrateAccumulator_v1.sol"; import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; import {Deploy_BTC_Minter} from "script/src/Deploy_BTC_Minter.sol"; diff --git a/script/Remediate_SPL_ETH_fxUSD.s.sol b/script/Remediate_SPL_ETH_fxUSD.s.sol index 3c2505fd..de7366f3 100644 --- a/script/Remediate_SPL_ETH_fxUSD.s.sol +++ b/script/Remediate_SPL_ETH_fxUSD.s.sol @@ -3,7 +3,7 @@ pragma solidity >=0.8.28 <0.9.0; import {LibString} from "@solady/utils/LibString.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; -import {PostRebalanceRemediationForStabilityPool_v2} from "script/patch/PostRebalanceRemediationForStabilityPool_v2.sol"; +import {PostRebalanceRemediationForStabilityPool_v2} from "script/verify/spl-remediation/PostRebalanceRemediationForStabilityPool_v2.sol"; import {SafeBatch} from "script/safe/SafeBatch.s.sol"; import {WellKnownAddress} from "@bao-script/deployment/FactoryDeployer.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; diff --git a/script/src/DeployMintersShared.sol b/script/src/DeployMintersShared.sol index 957d3d6a..1e2c3b6d 100644 --- a/script/src/DeployMintersShared.sol +++ b/script/src/DeployMintersShared.sol @@ -14,7 +14,6 @@ import {DeploymentState} from "@bao-script/deployment/DeploymentState.sol"; import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; import {Config_MinterMarket, IMarketConfig, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; -import {Minter_v2} from "@harbor/minter/Minter_v2.sol"; import {IMinter} from "src/interfaces/IMinter.sol"; import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; @@ -273,10 +272,10 @@ abstract contract DeployMintersShared is address priceOracle = _predictAddress(MinterMarketConfigLib.priceOracleKey(market)); // Update minter configuration (incentive ratios) - Minter_v2(minter).updateConfig(cfg.minterConfig()); - Minter_v2(minter).updateReservePool(reservePool); - Minter_v2(minter).updateFeeReceiver(treasury()); - Minter_v2(minter).updatePriceOracle(priceOracle); + IMinter(minter).updateConfig(cfg.minterConfig()); + IMinter(minter).updateReservePool(reservePool); + IMinter(minter).updateFeeReceiver(treasury()); + IMinter(minter).updatePriceOracle(priceOracle); // Grant roles grantReservePoolRoles(string.concat(marketKey, "::reservePool"), reservePool, minter); diff --git a/script/verify/README.md b/script/verify/README.md index 2bf3bb05..609c44d9 100644 --- a/script/verify/README.md +++ b/script/verify/README.md @@ -1,50 +1,60 @@ -# Manual Test Scripts +# Verification Scripts -These tests run against local anvil forks and are NOT part of CI. Run them -manually during deployment and upgrade workflows. +One-shot verification tests for deployments, upgrades, and remediations. +These are NOT regression tests — they validate specific operations and are run +manually before executing the corresponding deployment/upgrade scripts. -## Role checks +Each subdirectory corresponds to a campaign (deployment or upgrade operation). +Documentation that was previously in `doc/fixes/` lives alongside the +verification scripts it relates to. -Verify that all deployed contracts have the expected roles. Can run against -mainnet or a local anvil fork: +## Campaigns -```bash -# Against mainnet -forge test --mp script/test/MainnetRoles.t.sol --fork-url mainnet -vv +### [minter-v2-upgrade/](minter-v2-upgrade/) -# Against local anvil (e.g. after a deploy) -forge test --mp script/test/MainnetRoles.t.sol --fork-url local -vv -``` +Minter v1→v2 upgrade verification. Compares fresh deployment against mainnet +reference, validates upgrade preserves state. -- **Test contract**: `script/test/MainnetRoles.t.sol` +- `DeployMinters.t.sol` — compare view function outputs between reference and candidate +- `MinterUpgradeTest.t.sol` — upgrade against local anvil fork +- `MainnetForkUpgradeTest.t.sol` — upgrade against mainnet fork +- `test-deploy` — deployment dry-run script +- [test-deploy.md](minter-v2-upgrade/test-deploy.md), [upgrade-Minter_v2.md](minter-v2-upgrade/upgrade-Minter_v2.md) -## Deploy tests +### [sp-v2-upgrade/](sp-v2-upgrade/) -Verify fresh deployments produce correct contract state. +StabilityPool v1→v2 upgrade verification and bug fix documentation. -```bash -script/test/test-deploy BTC -``` +- [upgrade-StabilityPool_v2.md](sp-v2-upgrade/upgrade-StabilityPool_v2.md) — upgrade runbook +- [sp-overflow.md](sp-v2-upgrade/sp-overflow.md) — reward integral overflow analysis +- [linear-reward-underflow.md](sp-v2-upgrade/linear-reward-underflow.md) — rate truncation fix +- [finishat-zero.md](sp-v2-upgrade/finishat-zero.md) — reward period end fix +- [epoch-removal-summary.md](sp-v2-upgrade/epoch-removal-summary.md) — epoch removal +- [genesis-end.md](sp-v2-upgrade/genesis-end.md) — genesis end condition fix + +### [spl-remediation/](spl-remediation/) -- **Docs**: [test-deploy.md](test-deploy.md) -- **Script**: `script/test/test-deploy` -- **Test contract**: `script/test/DeployMinters.t.sol` +ETH::fxUSD SPL over-minting bug remediation (post-rebalance integral correction). -## Upgrade verification tests +- `SPLRemediationTest.t.sol` — mainnet fork remediation test +- `V2ReplaySimulation.t.sol` — v1 vs v2 replay comparison +- `collect-holders/` — holder data collection scripts +- [remediation-ETH-fxUSD-SPL.md](spl-remediation/remediation-ETH-fxUSD-SPL.md) — full remediation writeup +- [rebalance-remediation.md](spl-remediation/rebalance-remediation.md) — rebalance fix documentation -Verify that UUPS upgrades preserve on-chain state and fix targeted issues. +### [sp-v3-migration/](sp-v3-migration/) -### StabilityPool v2 +StabilityPool v3 upgrade and accumulator force-migration. -- **Docs**: [upgrade-StabilityPool_v2.md](upgrade-StabilityPool_v2.md) -- **Script**: `script/test/run-upgrade-test-StabilityPool_v2` -- **Test contract**: `script/test/MainnetForkUpgradeTest.t.sol` -- **Deploy script**: `script/Deploy_StabilityPool_v2_mainnet.s.sol` +- `SPv3MigrationTest.t.sol` — mainnet fork migration test +- [sp-v3-upgrade.md](sp-v3-migration/sp-v3-upgrade.md) — upgrade documentation -### Minter v2 +### [roles/](roles/) -- **Docs**: [upgrade-Minter_v2.md](upgrade-Minter_v2.md) -- **Script**: `script/test/run-upgrade-test-Minter_v2` -- **Test contracts**: `script/test/MinterUpgradeTest.t.sol`, `script/test/MainnetRoles.t.sol` -- **Deploy scripts**: `script/Deploy_Minter_v2_mainnet.s.sol`, `script/Grant_Minter_ZeroFeeRoles_mainnet.s.sol` -- **Unit tests**: `test/RebalanceCheck.t.sol`, `test/MinterUpgradeMigration.t.sol` +Post-deployment role verification. Run after any deployment to verify roles are correct. + +- `MainnetRoles.t.sol` — checks all deployed contracts have expected roles + +```bash +forge test --mp script/verify/roles/MainnetRoles.t.sol --fork-url mainnet -vv +``` diff --git a/script/verify/minter-v2-upgrade/MinterUpgradeTest.t.sol b/script/verify/minter-v2-upgrade/MinterUpgradeTest.t.sol index c0f61ca7..2cc3af70 100644 --- a/script/verify/minter-v2-upgrade/MinterUpgradeTest.t.sol +++ b/script/verify/minter-v2-upgrade/MinterUpgradeTest.t.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {BaoTest} from "@bao-test/BaoTest.sol"; -import {HarborFactoryDeployer} from "@harbor/../script/src/HarborFactoryDeployer.sol"; +import {HarborFactoryDeployer} from "script/src/HarborFactoryDeployer.sol"; import {IMinter} from "src/interfaces/IMinter.sol"; import {IStabilityPoolManager} from "src/interfaces/IStabilityPoolManager.sol"; import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; diff --git a/script/verify/roles/MainnetRoles.t.sol b/script/verify/roles/MainnetRoles.t.sol index 50a2cbd3..8d5d9a8a 100644 --- a/script/verify/roles/MainnetRoles.t.sol +++ b/script/verify/roles/MainnetRoles.t.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {Test} from "forge-std/Test.sol"; -import {HarborFactoryDeployer} from "@harbor/../script/src/HarborFactoryDeployer.sol"; +import {HarborFactoryDeployer} from "script/src/HarborFactoryDeployer.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; import {IMinter} from "src/interfaces/IMinter.sol"; diff --git a/script/patch/ForceMigrateAccumulator_v1.sol b/script/verify/sp-v3-migration/ForceMigrateAccumulator_v1.sol similarity index 100% rename from script/patch/ForceMigrateAccumulator_v1.sol rename to script/verify/sp-v3-migration/ForceMigrateAccumulator_v1.sol diff --git a/script/verify/sp-v3-migration/SPv3MigrationTest.t.sol b/script/verify/sp-v3-migration/SPv3MigrationTest.t.sol index 5879a483..88c1e84a 100644 --- a/script/verify/sp-v3-migration/SPv3MigrationTest.t.sol +++ b/script/verify/sp-v3-migration/SPv3MigrationTest.t.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {BaoTest} from "@bao-test/BaoTest.sol"; -import {HarborFactoryDeployer} from "@harbor/../script/src/HarborFactoryDeployer.sol"; +import {HarborFactoryDeployer} from "script/src/HarborFactoryDeployer.sol"; import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; @@ -10,7 +10,7 @@ import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistribu import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IMinter} from "src/interfaces/IMinter.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; -import {ForceMigrateAccumulator_v1} from "script/patch/ForceMigrateAccumulator_v1.sol"; +import {ForceMigrateAccumulator_v1} from "script/verify/sp-v3-migration/ForceMigrateAccumulator_v1.sol"; import {StabilityPool_v3} from "src/minter/StabilityPool_v3.sol"; import {console2 as console} from "forge-std/console2.sol"; diff --git a/script/patch/PostRebalanceRemediationForStabilityPool_v2.sol b/script/verify/spl-remediation/PostRebalanceRemediationForStabilityPool_v2.sol similarity index 100% rename from script/patch/PostRebalanceRemediationForStabilityPool_v2.sol rename to script/verify/spl-remediation/PostRebalanceRemediationForStabilityPool_v2.sol diff --git a/script/verify/spl-remediation/SPLRemediationTest.t.sol b/script/verify/spl-remediation/SPLRemediationTest.t.sol index 367e1086..f42ea7e2 100644 --- a/script/verify/spl-remediation/SPLRemediationTest.t.sol +++ b/script/verify/spl-remediation/SPLRemediationTest.t.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {BaoTest} from "@bao-test/BaoTest.sol"; -import {HarborFactoryDeployer} from "@harbor/../script/src/HarborFactoryDeployer.sol"; +import {HarborFactoryDeployer} from "script/src/HarborFactoryDeployer.sol"; import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; @@ -10,7 +10,7 @@ import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumula import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IMinter} from "src/interfaces/IMinter.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; -import {PostRebalanceRemediationForStabilityPool_v2} from "script/patch/PostRebalanceRemediationForStabilityPool_v2.sol"; +import {PostRebalanceRemediationForStabilityPool_v2} from "script/verify/spl-remediation/PostRebalanceRemediationForStabilityPool_v2.sol"; import {MockWrappedPriceOracle} from "test/mocks/MockWrappedPriceOracle.sol"; import {IWrappedPriceOracle} from "src/interfaces/IWrappedPriceOracle.sol"; import {IStabilityPoolManager} from "src/interfaces/IStabilityPoolManager.sol"; diff --git a/script/verify/spl-remediation/V2ReplaySimulation.t.sol b/script/verify/spl-remediation/V2ReplaySimulation.t.sol index 3efc6e06..969d9599 100644 --- a/script/verify/spl-remediation/V2ReplaySimulation.t.sol +++ b/script/verify/spl-remediation/V2ReplaySimulation.t.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {BaoTest} from "@bao-test/BaoTest.sol"; -import {HarborFactoryDeployer} from "@harbor/../script/src/HarborFactoryDeployer.sol"; +import {HarborFactoryDeployer} from "script/src/HarborFactoryDeployer.sol"; import {StabilityPoolManager_v1} from "src/minter/StabilityPoolManager_v1.sol"; import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; diff --git a/script/verify/spl-remediation/remediation-ETH-fxUSD-SPL.md b/script/verify/spl-remediation/remediation-ETH-fxUSD-SPL.md index 1386621d..8f20fd10 100644 --- a/script/verify/spl-remediation/remediation-ETH-fxUSD-SPL.md +++ b/script/verify/spl-remediation/remediation-ETH-fxUSD-SPL.md @@ -129,7 +129,7 @@ Post-remediation (`results/post_remediation.csv`): - Remaining dilution: ~$8 from bounty receiver 2 (0.0025 excess sailETH, not ours) - Treasury cost: ~$82 of fxSAVE -**Contract**: `script/patch/PostRebalanceRemediationForStabilityPool_v2.sol` +**Contract**: `script/verify/spl-remediation/PostRebalanceRemediationForStabilityPool_v2.sol` **Script**: `script/Remediate_SPL_ETH_fxUSD.s.sol` ## Value Accounting diff --git a/src/minter/Minter_v3.sol b/src/minter/Minter_v3.sol index 4890b0bd..6ac1cf8f 100644 --- a/src/minter/Minter_v3.sol +++ b/src/minter/Minter_v3.sol @@ -16,6 +16,7 @@ import {TokenHolder, ITokenHolder} from "@bao/TokenHolder.sol"; import {BaoOwnableRoles} from "@bao/BaoOwnableRoles.sol"; import {IMinter} from "src/interfaces/IMinter.sol"; +import {IMinter_v3} from "src/interfaces/IMinter_v3.sol"; // different ERC20 mint/burn interfaces import {IMintable} from "@bao/interfaces/IMintable.sol"; @@ -106,7 +107,8 @@ contract Minter_v3 is ReentrancyGuardTransientUpgradeable, BaoOwnableRoles, TokenHolder, - IMinter + IMinter, + IMinter_v3 { using SafeERC20 for IERC20; @@ -444,17 +446,42 @@ contract Minter_v3 is uint256 price, uint256 rate ) + { + return mintPeggedTokenDryRun(wrappedCollateralIn, type(uint256).max); + } + + /// @notice Dry run of a capped mint: computes outcome if total fee is capped at maxFeeRatio of collateral used. + /// @param wrappedCollateralIn The proposed amount of wrapped collateral. + /// @param maxFeeRatio The maximum overall fee ratio (18 decimals). e.g. 0.05 ether = 5%. + function mintPeggedTokenDryRun( + uint256 wrappedCollateralIn, + uint256 maxFeeRatio + ) + public + view + returns ( + int256 incentiveRatio, + uint256 wrappedFee, + uint256 wrappedCollateralUsed, + uint256 peggedMinted, + uint256 price, + uint256 rate + ) { wrappedCollateralIn = Token.allOfQuiet(_msgSender(), WRAPPED_COLLATERAL_TOKEN, wrappedCollateralIn); MinterStorage storage $ = _getMinterStorage(); OracleData memory oracle = _fetchMid($.priceOracle); price = oracle.price; rate = oracle.rate; + uint256 maxFeeE36 = maxFeeRatio == type(uint256).max + ? type(uint256).max + : Math.mulDiv(wrappedCollateralIn, maxFeeRatio, 1 ether) * oracle.rate; uint256 underlyingCollateralAdded; (wrappedFee, peggedMinted, wrappedCollateralUsed, underlyingCollateralAdded) = _mintPeggedAdjustments( $.incentiveConfig[Config_v1.MINT_PEGGED], wrappedCollateralIn, - CollateralRatioData($.underlyingCollateral, oracle.price, oracle.rate, $.peggedTokenBalance) + CollateralRatioData($.underlyingCollateral, oracle.price, oracle.rate, $.peggedTokenBalance), + maxFeeE36 ); // slither-disable-next-line incorrect-equality incentiveRatio = wrappedCollateralUsed == 0 @@ -652,43 +679,75 @@ contract Minter_v3 is address receiver, uint256 minPeggedOut ) external override nonReentrant returns (uint256 peggedOut) { + (peggedOut, ) = _mintPeggedTokenCapped(wrappedCollateralIn, receiver, minPeggedOut, type(uint256).max); + } + + /// @notice Mint pegged tokens with a fee cap. Stops minting when cumulative fee would exceed maxFeeRatio. + /// Returns (0, 0) gracefully if the fee exceeds the cap from the start (does not revert). + /// @param wrappedCollateralIn The amount of wrapped collateral to post. Use type(uint256).max for all. + /// @param receiver The address to receive minted pegged tokens. + /// @param minPeggedOut Minimum acceptable pegged output. 0 means no check. + /// @param maxFeeRatio Maximum overall fee ratio (18 decimals). e.g. 0.05 ether = 5%. + /// @return peggedOut The amount of pegged tokens minted. + /// @return wrappedCollateralUsed The amount of wrapped collateral actually consumed (collateral added + fee). + function mintPeggedToken( + uint256 wrappedCollateralIn, + address receiver, + uint256 minPeggedOut, + uint256 maxFeeRatio + ) external nonReentrant returns (uint256 peggedOut, uint256 wrappedCollateralUsed) { + (peggedOut, wrappedCollateralUsed) = _mintPeggedTokenCapped( + wrappedCollateralIn, receiver, minPeggedOut, maxFeeRatio + ); + } + + function _mintPeggedTokenCapped( + uint256 wrappedCollateralIn, + address receiver, + uint256 minPeggedOut, + uint256 maxFeeRatio + ) internal returns (uint256 peggedOut, uint256 wrappedCollateralUsed) { MinterStorage storage $ = _getMinterStorage(); - // work out how much collateral to use OracleData memory oracle = _fetchMid($.priceOracle); wrappedCollateralIn = Token.allOf(_msgSender(), WRAPPED_COLLATERAL_TOKEN, wrappedCollateralIn); uint256 peggedTokenBalance_ = $.peggedTokenBalance; uint256 underlyingCollateral_ = $.underlyingCollateral; - // fee, etc. calculation + uint256 maxFeeE36 = maxFeeRatio == type(uint256).max + ? type(uint256).max + : Math.mulDiv(wrappedCollateralIn, maxFeeRatio, 1 ether) * oracle.rate; + uint256 wrappedFee; uint256 underlyingCollateralAdded; - (wrappedFee, peggedOut, wrappedCollateralIn, underlyingCollateralAdded) = _mintPeggedAdjustments( + (wrappedFee, peggedOut, wrappedCollateralUsed, underlyingCollateralAdded) = _mintPeggedAdjustments( $.incentiveConfig[Config_v1.MINT_PEGGED], wrappedCollateralIn, - CollateralRatioData(underlyingCollateral_, oracle.price, oracle.rate, peggedTokenBalance_) + CollateralRatioData(underlyingCollateral_, oracle.price, oracle.rate, peggedTokenBalance_), + maxFeeE36 ); // slither-disable-next-line incorrect-equality - if (wrappedCollateralIn == 0) { - revert MintZeroAmount(PEGGED_TOKEN); + if (wrappedCollateralUsed == 0) { + if (maxFeeRatio == type(uint256).max) { + // Uncapped: zero means minting is disallowed by config + revert MintZeroAmount(PEGGED_TOKEN); + } + // Capped: fee exceeds cap from the start — return (0, 0) gracefully + return (0, 0); } - // check the amounts involved - // slither-disable-next-line incorrect-equality if (peggedOut < minPeggedOut) { revert MintInsufficientAmount(PEGGED_TOKEN, peggedOut, minPeggedOut); } - // do the mint for collateral - _mintPeggedToken(wrappedCollateralIn, peggedOut, receiver); + // _mintPeggedToken pulls only wrappedCollateralUsed from sender via safeTransferFrom + _mintPeggedToken(wrappedCollateralUsed, peggedOut, receiver); - // take the fee if (wrappedFee > 0) { IERC20(WRAPPED_COLLATERAL_TOKEN).safeTransfer($.feeReceiver, wrappedFee); } - // update our records $.underlyingCollateral = underlyingCollateral_ + underlyingCollateralAdded; $.peggedTokenBalance = peggedTokenBalance_ + peggedOut; } @@ -1214,6 +1273,8 @@ contract Minter_v3 is uint256 underlyingFeeE36; uint256 mintedE36; int256 feeErrorE54; + bool feeCapped; + uint256 peggedTokenPriceE36; } /// @notice Perform a dry run of a mint pegged to calculate the various transfers of tokens. @@ -1235,7 +1296,8 @@ contract Minter_v3 is function _mintPeggedAdjustments( ConfigIncentiveLib.ActionIncentive memory config_, uint256 wrappedCollateralIn, - CollateralRatioData memory cr + CollateralRatioData memory cr, + uint256 maxFeeE36 ) internal pure @@ -1255,7 +1317,7 @@ contract Minter_v3 is // (note we treat the disallow band as any other here, except that it is the terminal band) MintPeggedWorkspace memory w; w.band = _findBand(config_, cr.underlyingCollateral, cr.price, cr.peggedTokenBalance, false); - uint256 peggedTokenPriceE36 = _peggedTokenPriceE36(cr.peggedTokenBalance, cr.underlyingCollateral, cr.price); + w.peggedTokenPriceE36 = _peggedTokenPriceE36(cr.peggedTokenBalance, cr.underlyingCollateral, cr.price); w.underlyingCollateralInLeftE36 = wrappedCollateralIn * cr.rate; // scaled to 1e36 w.underlyingCollateralHeldE36 = cr.underlyingCollateral * 1 ether; // scaled to 1e36 @@ -1295,6 +1357,16 @@ contract Minter_v3 is ); collateralInBandE36 = Math.min(w.underlyingCollateralInLeftE36, collateralInBandE36); } + // Cap collateral to stay within fee budget (skip when uncapped) + if (maxFeeE36 != type(uint256).max) { + uint256 remainingFeeE36 = maxFeeE36 - w.underlyingFeeE36; + uint256 maxCollateralForFeeE36 = Math.mulDiv(remainingFeeE36, 1 ether, bandFeeRatio); + if (collateralInBandE36 > maxCollateralForFeeE36) { + collateralInBandE36 = maxCollateralForFeeE36; + w.feeCapped = true; + } + } + uint256 bandFeeE36; (bandFeeE36, w.feeErrorE54) = _divAccumulateError(collateralInBandE36 * bandFeeRatio, w.feeErrorE54); w.underlyingFeeE36 += bandFeeE36; @@ -1306,15 +1378,14 @@ contract Minter_v3 is uint256 peggedMintedInBandE36 = Math.mulDiv( collateralAddedInBandE36, cr.price * 1 ether, - peggedTokenPriceE36 + w.peggedTokenPriceE36 ); w.mintedE36 += peggedMintedInBandE36; // slither-disable-next-line incorrect-equality - if (w.underlyingCollateralInLeftE36 == 0 || w.band == 0) { - // we have run out of collateral for the simulation - // or we are in the lowest band, so no more, so exit + if (w.feeCapped || w.underlyingCollateralInLeftE36 == 0 || w.band == 0) { + // we have hit the fee cap, run out of collateral, or are in the lowest band break; } // still some collateral left and we're allowed to mint, so simulate diff --git a/test/RebalanceCheck.t.sol b/test/RebalanceCheck.t.sol index a5addea7..2336e3d9 100644 --- a/test/RebalanceCheck.t.sol +++ b/test/RebalanceCheck.t.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {BaoTest} from "@bao-test/BaoTest.sol"; -import {HarborFactoryDeployer} from "@harbor/../script/src/HarborFactoryDeployer.sol"; +import {HarborFactoryDeployer} from "script/src/HarborFactoryDeployer.sol"; import {StabilityPoolManager_v1} from "src/minter/StabilityPoolManager_v1.sol"; import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; diff --git a/test/deployment/MinterCappedMint.t.sol b/test/deployment/MinterCappedMint.t.sol new file mode 100644 index 00000000..ad96ec0b --- /dev/null +++ b/test/deployment/MinterCappedMint.t.sol @@ -0,0 +1,300 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.28 <0.9.0; + +import {BaoTest} from "@bao-test/BaoTest.sol"; +import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; +import {Deploy_ETH_Minter} from "script/src/Deploy_ETH_Minter.sol"; +import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; +import {Config_MinterMarket, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IMinter} from "src/interfaces/IMinter.sol"; +import {IMinter_v3} from "src/interfaces/IMinter_v3.sol"; +import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; +import {MockWrappedPriceOracle} from "test/mocks/MockWrappedPriceOracle.sol"; + + +/// @title MinterCappedMintTest +/// @notice Tests for Minter_v3 fee-capped minting, deployed via production deployment scripts. +contract MinterCappedMintSetUp is BaoTest, Deploy_ETH_Minter { + address minter; + address pegged; + address wrappedCollateral; + + MockWrappedPriceOracle mockOracle; + + function _shouldPersistState() internal pure override returns (bool) { + return false; + } + + function setUp() public virtual { + address factory = _ensureBaoFactory(); + // Pinned after latest Harbor deployment (SPL remediation, 2026-03-25) for caching + vm.createSelectFork(vm.rpcUrl("mainnet"), 24699497); + + vm.prank(IBaoFactory(factory).owner()); + IBaoFactory(factory).setOperator(address(this), 365 days); + + // Deploy ETH::fxUSD market via production deployment scripts (now deploys Minter_v3) + (ConfigPeg peg, Config_MinterMarket[] memory mktConfigs) = createETHMintersConfig(); + Config_MinterMarket[] memory toDeploy = new Config_MinterMarket[](1); + toDeploy[0] = mktConfigs[0]; + deployForPeg("capped_test", peg, mktConfigs, "mainnet", true, toDeploy); + + // Resolve addresses + _setSaltPrefix("capped_test"); + minter = _predictAddress(_key("ETH", "fxUSD", "minter")); + pegged = _predictAddress(_key("ETH", "pegged")); + wrappedCollateral = IMinter(minter).WRAPPED_COLLATERAL_TOKEN(); + + // Install mock oracle (price=1, rate=1 for simplicity) + mockOracle = new MockWrappedPriceOracle(); + mockOracle.setLatestAnswer(1 ether, 1 ether); + vm.prank(HARBOR_MULTISIG); + IMinter(minter).updatePriceOracle(address(mockOracle)); + + // Grant zero-fee role for free minting in bootstrap + uint256 zeroFeeRole = IMinter(minter).ZERO_FEE_ROLE(); + vm.prank(HARBOR_MULTISIG); + IBaoRoles(minter).grantRoles(address(this), zeroFeeRole); + } + + /// @dev Mint pegged + leveraged to create a collateral ratio where minting is allowed with fees. + /// Target: CR around 1.5-2.0 where fee bands are active. + function _bootstrapCollateralRatio() internal { + // Mint 500 pegged → CR starts very high (all collateral, small pegged supply) + deal(wrappedCollateral, address(this), 500 ether); + IERC20(wrappedCollateral).approve(minter, 500 ether); + IMinter(minter).freeMintPeggedToken(500 ether, address(this)); + + // Mint 500 leveraged → absorbs collateral, lowering effective CR + deal(wrappedCollateral, address(this), 500 ether); + IERC20(wrappedCollateral).approve(minter, 500 ether); + IMinter(minter).freeMintLeveragedToken(500 ether, address(this)); + + // CR = totalCollateral * price / peggedBalance = 1000 * 1 / 500 = 2.0 + } +} + +contract MinterCappedMintTest is MinterCappedMintSetUp { + // ═══════════════════════════════════════════════════════════════ + // Uncapped mint (3-arg) behavior unchanged + // ═══════════════════════════════════════════════════════════════ + + function test_uncappedMint_matchesV2Behavior() public { + _bootstrapCollateralRatio(); + + address alice = makeAddr("alice"); + uint256 collateralIn = 10 ether; + deal(wrappedCollateral, alice, collateralIn); + + // Dry run + (,, uint256 dryCollUsed, uint256 dryPegged,,) = + IMinter(minter).mintPeggedTokenDryRun(collateralIn); + + // Actual mint + vm.startPrank(alice); + IERC20(wrappedCollateral).approve(minter, collateralIn); + uint256 peggedOut = IMinter(minter).mintPeggedToken(collateralIn, alice, 0); + vm.stopPrank(); + + assertEq(peggedOut, dryPegged, "pegged matches dry run"); + // Collateral used = what was taken from alice + uint256 aliceRemaining = IERC20(wrappedCollateral).balanceOf(alice); + assertEq(collateralIn - aliceRemaining, dryCollUsed, "collateral used matches dry run"); + } + + // ═══════════════════════════════════════════════════════════════ + // Capped mint (4-arg) — full mint when fee below cap + // ═══════════════════════════════════════════════════════════════ + + function test_cappedMint_fullMint_feeBelowCap() public { + _bootstrapCollateralRatio(); + + address alice = makeAddr("alice"); + uint256 collateralIn = 10 ether; + deal(wrappedCollateral, alice, collateralIn); + + // Get current fee ratio + (int256 incentiveRatio,,,,,) = IMinter(minter).mintPeggedTokenDryRun(collateralIn); + + // Set cap well above current fee + uint256 maxFeeRatio = uint256(incentiveRatio) * 2; + + // Capped dry run + (,, uint256 dryCollUsed, uint256 dryPegged,,) = + IMinter_v3(minter).mintPeggedTokenDryRun(collateralIn, maxFeeRatio); + + // Actual capped mint + vm.startPrank(alice); + IERC20(wrappedCollateral).approve(minter, collateralIn); + (uint256 peggedOut, uint256 collUsed) = IMinter_v3(minter).mintPeggedToken(collateralIn, alice, 0, maxFeeRatio); + vm.stopPrank(); + + assertEq(peggedOut, dryPegged, "pegged matches capped dry run"); + assertEq(collUsed, dryCollUsed, "collateral used matches capped dry run"); + // Full mint — all collateral used (fee is below cap) + assertEq(collUsed, collateralIn, "all collateral used when fee below cap"); + } + + // ═══════════════════════════════════════════════════════════════ + // Capped mint — no mint when fee exceeds cap from start + // ═══════════════════════════════════════════════════════════════ + + function test_cappedMint_noMint_feeExceedsCap() public { + _bootstrapCollateralRatio(); + + address alice = makeAddr("alice"); + uint256 collateralIn = 10 ether; + deal(wrappedCollateral, alice, collateralIn); + + // Set cap to 0 — any fee exceeds it + vm.startPrank(alice); + IERC20(wrappedCollateral).approve(minter, collateralIn); + (uint256 peggedOut, uint256 collUsed) = IMinter_v3(minter).mintPeggedToken(collateralIn, alice, 0, 0); + vm.stopPrank(); + + assertEq(peggedOut, 0, "no pegged minted"); + assertEq(collUsed, 0, "no collateral used"); + // Alice still has all her collateral + assertEq(IERC20(wrappedCollateral).balanceOf(alice), collateralIn, "collateral returned"); + } + + // ═══════════════════════════════════════════════════════════════ + // Capped mint — partial mint when fee exceeds cap mid-band + // ═══════════════════════════════════════════════════════════════ + + function test_cappedMint_partialMint() public { + _bootstrapCollateralRatio(); + + address alice = makeAddr("alice"); + uint256 collateralIn = 100 ether; + deal(wrappedCollateral, alice, collateralIn); + + // Get uncapped result + (,, uint256 uncappedCollUsed, uint256 uncappedPegged,,) = + IMinter(minter).mintPeggedTokenDryRun(collateralIn); + + // Set cap to half the uncapped fee ratio — should produce a partial mint + (int256 incentiveRatio,,,,,) = IMinter(minter).mintPeggedTokenDryRun(collateralIn); + uint256 maxFeeRatio = uint256(incentiveRatio) / 2; + + // Capped dry run + (,, uint256 dryCollUsed, uint256 dryPegged,,) = + IMinter_v3(minter).mintPeggedTokenDryRun(collateralIn, maxFeeRatio); + + // Actual capped mint + vm.startPrank(alice); + IERC20(wrappedCollateral).approve(minter, collateralIn); + (uint256 peggedOut, uint256 collUsed) = IMinter_v3(minter).mintPeggedToken(collateralIn, alice, 0, maxFeeRatio); + vm.stopPrank(); + + assertEq(peggedOut, dryPegged, "pegged matches capped dry run"); + assertEq(collUsed, dryCollUsed, "collateral used matches capped dry run"); + + // Partial: used less collateral, minted less pegged + if (maxFeeRatio > 0) { + assertGt(collUsed, 0, "some collateral used"); + assertLt(collUsed, uncappedCollUsed, "less than uncapped"); + assertGt(peggedOut, 0, "some pegged minted"); + assertLt(peggedOut, uncappedPegged, "less than uncapped pegged"); + } + + // Alice keeps the unused portion + assertEq(IERC20(wrappedCollateral).balanceOf(alice), collateralIn - collUsed, "remaining collateral"); + } + + // ═══════════════════════════════════════════════════════════════ + // Fuzz: dry run always matches actual mint + // ═══════════════════════════════════════════════════════════════ + + function test_fuzz_dryRunMatchesActualMint(uint256 collateralIn, uint256 maxFeeRatio) public { + _bootstrapCollateralRatio(); + + // Bound inputs to reasonable ranges + collateralIn = bound(collateralIn, 0.01 ether, 100 ether); + maxFeeRatio = bound(maxFeeRatio, 0, 0.5 ether); // 0% to 50% + + address alice = makeAddr("alice"); + deal(wrappedCollateral, alice, collateralIn); + + // Capped dry run + (,, uint256 dryCollUsed, uint256 dryPegged,,) = + IMinter_v3(minter).mintPeggedTokenDryRun(collateralIn, maxFeeRatio); + + // Actual capped mint + vm.startPrank(alice); + IERC20(wrappedCollateral).approve(minter, collateralIn); + (uint256 peggedOut, uint256 collUsed) = IMinter_v3(minter).mintPeggedToken(collateralIn, alice, 0, maxFeeRatio); + vm.stopPrank(); + + assertEq(peggedOut, dryPegged, "pegged matches dry run"); + assertEq(collUsed, dryCollUsed, "collateral used matches dry run"); + } + + // ═══════════════════════════════════════════════════════════════ + // Fuzz: capped mint uses <= uncapped mint collateral + // ═══════════════════════════════════════════════════════════════ + + function test_fuzz_cappedUsesLessOrEqualCollateral(uint256 collateralIn, uint256 maxFeeRatio) public { + _bootstrapCollateralRatio(); + + collateralIn = bound(collateralIn, 0.01 ether, 100 ether); + maxFeeRatio = bound(maxFeeRatio, 0, 1 ether); // 0% to 100% + + // Uncapped dry run + (,, uint256 uncappedCollUsed, uint256 uncappedPegged,,) = + IMinter(minter).mintPeggedTokenDryRun(collateralIn); + + // Capped dry run + (,, uint256 cappedCollUsed, uint256 cappedPegged,,) = + IMinter_v3(minter).mintPeggedTokenDryRun(collateralIn, maxFeeRatio); + + assertLe(cappedCollUsed, uncappedCollUsed, "capped uses <= uncapped collateral"); + assertLe(cappedPegged, uncappedPegged, "capped mints <= uncapped pegged"); + } + + // ═══════════════════════════════════════════════════════════════ + // Fuzz: fee never exceeds the cap + // ═══════════════════════════════════════════════════════════════ + + function test_fuzz_feeNeverExceedsCap(uint256 collateralIn, uint256 maxFeeRatio) public { + _bootstrapCollateralRatio(); + + collateralIn = bound(collateralIn, 0.01 ether, 100 ether); + maxFeeRatio = bound(maxFeeRatio, 0.001 ether, 0.5 ether); // 0.1% to 50% + + // Capped dry run + (, uint256 dryFee,,,,) = + IMinter_v3(minter).mintPeggedTokenDryRun(collateralIn, maxFeeRatio); + + // Absolute fee must not exceed the budget (maxFeeRatio * collateralIn) + uint256 maxFee = (collateralIn * maxFeeRatio) / 1 ether; + assertLe(dryFee, maxFee, "fee <= maxFeeRatio * collateralIn"); + } + + // ═══════════════════════════════════════════════════════════════ + // Uncapped via type(uint256).max matches 3-arg version + // ═══════════════════════════════════════════════════════════════ + + function test_cappedWithMaxUint_matchesUncapped() public { + _bootstrapCollateralRatio(); + + uint256 collateralIn = 50 ether; + + // Uncapped dry run (3-arg) + (int256 ir1, uint256 fee1, uint256 coll1, uint256 peg1, uint256 p1, uint256 r1) = + IMinter(minter).mintPeggedTokenDryRun(collateralIn); + + // Capped with max (4-arg) + (int256 ir2, uint256 fee2, uint256 coll2, uint256 peg2, uint256 p2, uint256 r2) = + IMinter_v3(minter).mintPeggedTokenDryRun(collateralIn, type(uint256).max); + + assertEq(ir1, ir2, "incentive ratio matches"); + assertEq(fee1, fee2, "fee matches"); + assertEq(coll1, coll2, "collateral used matches"); + assertEq(peg1, peg2, "pegged minted matches"); + assertEq(p1, p2, "price matches"); + assertEq(r1, r2, "rate matches"); + } +} diff --git a/test/deployment/RebalanceFairness.t.sol b/test/deployment/RebalanceFairness.t.sol index b9a61595..1912efce 100644 --- a/test/deployment/RebalanceFairness.t.sol +++ b/test/deployment/RebalanceFairness.t.sol @@ -57,7 +57,8 @@ contract RebalanceFairnessSetUp is BaoTest, Deploy_ETH_Minter { address factory = _ensureBaoFactory(); // Fork mainnet so real token contracts (fxSAVE, fxUSD, etc.) exist - uint256 forkId = vm.createSelectFork(vm.rpcUrl("mainnet")); + // Pinned after latest Harbor deployment (SPL remediation, 2026-03-25) for caching + uint256 forkId = vm.createSelectFork(vm.rpcUrl("mainnet"), 24699497); vm.selectFork(forkId); // Register as factory operator diff --git a/test/deployment/StabilityPoolAliasDeployment.t.sol b/test/deployment/StabilityPoolAliasDeployment.t.sol index afeff528..0acd086d 100644 --- a/test/deployment/StabilityPoolAliasDeployment.t.sol +++ b/test/deployment/StabilityPoolAliasDeployment.t.sol @@ -52,7 +52,8 @@ contract StabilityPoolAliasDeploymentSetUp is BaoTest, Deploy_ETH_Minter { address factory = _ensureBaoFactory(); // Fork mainnet so real token contracts (fxSAVE, fxUSD, etc.) exist - vm.createSelectFork(vm.rpcUrl("mainnet")); + // Pinned after latest Harbor deployment (SPL remediation, 2026-03-25) for caching + vm.createSelectFork(vm.rpcUrl("mainnet"), 24699497); // Register as factory operator vm.prank(IBaoFactory(factory).owner()); From 9d8122293673e656d2a5691a812fece6874ebfc2 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Thu, 2 Apr 2026 20:25:41 +0100 Subject: [PATCH 011/232] fmt, lint & slither fixes --- script/src/contracts/StabilityPool.sol | 13 +-- src/interfaces/IMinter_v3.sol | 1 + src/interfaces/IRewardAlias.sol | 2 + src/interfaces/IStabilityPool_v3.sol | 3 +- src/minter/Minter_v3.sol | 5 +- .../{RewardAlias.sol => RewardAlias_v1.sol} | 16 +++- ...ultipleRewardCompoundingAccumulator_v3.sol | 2 +- .../LinearMultipleRewardDistributor_v3.sol | 4 +- test/StabilityPoolClaimable.t.sol | 93 ++++++++++++------- test/StabilityPool_v3_ERC20.t.sol | 22 +---- test/deployment/MinterCappedMint.t.sol | 55 ++++++----- test/deployment/RebalanceFairness.t.sol | 15 ++- .../StabilityPoolAliasDeployment.t.sol | 32 +++++-- 13 files changed, 158 insertions(+), 105 deletions(-) rename src/reward/{RewardAlias.sol => RewardAlias_v1.sol} (82%) diff --git a/script/src/contracts/StabilityPool.sol b/script/src/contracts/StabilityPool.sol index 1848f123..07fc8f22 100644 --- a/script/src/contracts/StabilityPool.sol +++ b/script/src/contracts/StabilityPool.sol @@ -8,7 +8,7 @@ import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; import {DeploymentState} from "@bao-script/deployment/DeploymentState.sol"; import {StabilityPool_v3} from "@harbor/minter/StabilityPool_v3.sol"; -import {RewardAlias} from "@harbor/reward/RewardAlias.sol"; +import {RewardAlias_v1} from "@harbor/reward/RewardAlias_v1.sol"; import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; import {Config_MinterMarket, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; import {ConfigTokenNames} from "script/config/ConfigTokenNames.sol"; @@ -133,21 +133,18 @@ abstract contract StabilityPool is HarborFactoryDeployer { string memory aliasKey = _key(spKey, aliasName); console.log(" > %s", aliasKey); - address impl = address(new RewardAlias(underlying)); + address impl = address(new RewardAlias_v1(underlying)); console.log(" Impl: %s", impl); console.log(" Underlying: %s", underlying); - bytes memory initData = abi.encodeCall( - RewardAlias.initialize, - (address(this), owner()) - ); + bytes memory initData = abi.encodeCall(RewardAlias_v1.initialize, (address(this), owner())); aliasProxy = _deployProxyAndRecord( stateData, aliasKey, impl, - "@harbor/reward/RewardAlias.sol", - "RewardAlias", + "@harbor/reward/RewardAlias_v1.sol", + "RewardAlias_v1", initData ); } diff --git a/src/interfaces/IMinter_v3.sol b/src/interfaces/IMinter_v3.sol index 0cff28ea..52e68f32 100644 --- a/src/interfaces/IMinter_v3.sol +++ b/src/interfaces/IMinter_v3.sol @@ -3,6 +3,7 @@ pragma solidity >=0.8.28 <0.9.0; /// @notice Minter v3 extensions: fee-capped minting. +// solhint-disable-next-line contract-name-capwords interface IMinter_v3 { /// @notice Mint pegged tokens with a fee cap. Stops minting when cumulative fee would exceed maxFeeRatio. /// Returns (0, 0) gracefully if the fee exceeds the cap from the start (does not revert). diff --git a/src/interfaces/IRewardAlias.sol b/src/interfaces/IRewardAlias.sol index ed809567..7d92bb7e 100644 --- a/src/interfaces/IRewardAlias.sol +++ b/src/interfaces/IRewardAlias.sol @@ -7,6 +7,8 @@ pragma solidity >=0.8.28 <0.9.0; /// the reward system treats it as an alias: integrals track under the alias address, /// but token transfers use the underlying address. interface IRewardAlias { + error ZeroAddress(); + /// @notice Returns the underlying token this alias represents. /// @return The underlying token address. address(0) means not an alias. function underlying() external view returns (address); diff --git a/src/interfaces/IStabilityPool_v3.sol b/src/interfaces/IStabilityPool_v3.sol index 7baed10e..32e44ebe 100644 --- a/src/interfaces/IStabilityPool_v3.sol +++ b/src/interfaces/IStabilityPool_v3.sol @@ -7,6 +7,7 @@ import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; /// @notice Interface for StabilityPool_v3 additions (selective claim). /// @dev Extends IStabilityPool with single-token claim functions. /// Parameter order matches claimable(address account, address token). +// solhint-disable-next-line contract-name-capwords interface IStabilityPool_v3 is IStabilityPool { /// @notice Claim pending rewards of a single token for some user. /// @param account The address of the user. @@ -18,4 +19,4 @@ interface IStabilityPool_v3 is IStabilityPool { /// @param token The reward token address to claim. /// @param receiver The address of the recipient. function claimSingle(address account, address token, address receiver) external; -} \ No newline at end of file +} diff --git a/src/minter/Minter_v3.sol b/src/minter/Minter_v3.sol index 6ac1cf8f..ffc5a246 100644 --- a/src/minter/Minter_v3.sol +++ b/src/minter/Minter_v3.sol @@ -697,7 +697,10 @@ contract Minter_v3 is uint256 maxFeeRatio ) external nonReentrant returns (uint256 peggedOut, uint256 wrappedCollateralUsed) { (peggedOut, wrappedCollateralUsed) = _mintPeggedTokenCapped( - wrappedCollateralIn, receiver, minPeggedOut, maxFeeRatio + wrappedCollateralIn, + receiver, + minPeggedOut, + maxFeeRatio ); } diff --git a/src/reward/RewardAlias.sol b/src/reward/RewardAlias_v1.sol similarity index 82% rename from src/reward/RewardAlias.sol rename to src/reward/RewardAlias_v1.sol index 38b3a792..c328f993 100644 --- a/src/reward/RewardAlias.sol +++ b/src/reward/RewardAlias_v1.sol @@ -10,21 +10,24 @@ import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/U import {HarborOwnable} from "@bao/HarborOwnable.sol"; import {IRewardAlias} from "src/interfaces/IRewardAlias.sol"; -/// @title RewardAlias +/// @title RewardAlias_v1 /// @notice A minimal UUPS-upgradeable contract that identifies itself as an alias for an underlying reward token. /// @dev Deploy via BaoFactory (CREATE3) at a predictable address. /// The reward system detects aliases via IRewardAlias.underlying() during registration. /// The alias address is used for integral tracking; the underlying is used for token transfers. // solhint-disable-next-line contract-name-capwords -contract RewardAlias is Initializable, UUPSUpgradeable, HarborOwnable, IERC5313, IRewardAlias { +contract RewardAlias_v1 is Initializable, UUPSUpgradeable, HarborOwnable, IERC5313, IRewardAlias { /// @notice The underlying reward token this alias represents. /// @custom:oz-upgrades-unsafe-allow state-variable-immutable - address public immutable override underlying; + address internal immutable UNDERLYING; // solhint-disable-line immutable-vars-naming /// @custom:oz-upgrades-unsafe-allow constructor constructor(address underlying_) { _disableInitializers(); - underlying = underlying_; + if (underlying_ == address(0)) { + revert ZeroAddress(); + } + UNDERLYING = underlying_; } /// @notice Initialize ownership. @@ -40,6 +43,11 @@ contract RewardAlias is Initializable, UUPSUpgradeable, HarborOwnable, IERC5313, owner_ = HarborOwnable.owner(); } + /// @inheritdoc IRewardAlias + function underlying() external view returns (address) { + return UNDERLYING; + } + /// @inheritdoc IERC165 function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { return interfaceId == type(IERC5313).interfaceId || super.supportsInterface(interfaceId); diff --git a/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol b/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol index 50099355..bf5c912c 100644 --- a/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol +++ b/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol @@ -111,7 +111,7 @@ import {LinearMultipleRewardDistributor_v3} from "src/reward/distributor/LinearM /// /// @dev The method comes from liquity's StabilityPool, the paper is in /// https://github.com/liquity/dev/blob/main/papers/Scalable_Reward_Distribution_with_Compounding_Stakes.pdf - +// solhint-disable-next-line contract-name-capwords abstract contract MultipleRewardCompoundingAccumulator_v3 is ReentrancyGuardTransientUpgradeable, LinearMultipleRewardDistributor_v3, diff --git a/src/reward/distributor/LinearMultipleRewardDistributor_v3.sol b/src/reward/distributor/LinearMultipleRewardDistributor_v3.sol index d20261eb..01b7cf4f 100644 --- a/src/reward/distributor/LinearMultipleRewardDistributor_v3.sol +++ b/src/reward/distributor/LinearMultipleRewardDistributor_v3.sol @@ -34,7 +34,7 @@ import {LinearReward} from "./LinearReward.sol"; /// The contract uses a role-based access control system to manage distributors /// and supports immediate or time-based reward distribution depending on the /// configured period length. - +// solhint-disable-next-line contract-name-capwords abstract contract LinearMultipleRewardDistributor_v3 is Initializable, ContextUpgradeable, @@ -194,6 +194,7 @@ abstract contract LinearMultipleRewardDistributor_v3 is address underlying = _tryGetUnderlying(token); if (underlying != address(0)) { $.aliasUnderlying[token] = underlying; + // slither-disable-next-line unused-return $.underlyingAliases[underlying].add(token); } @@ -289,6 +290,7 @@ abstract contract LinearMultipleRewardDistributor_v3 is /// @dev Try to read underlying() from a token. Returns address(0) if not an alias. function _tryGetUnderlying(address token) internal view returns (address underlying) { + // slither-disable-next-line low-level-calls (bool success, bytes memory data) = token.staticcall(abi.encodeCall(IRewardAlias.underlying, ())); if (success && data.length >= 32) { underlying = abi.decode(data, (address)); diff --git a/test/StabilityPoolClaimable.t.sol b/test/StabilityPoolClaimable.t.sol index 91d37da9..5277e984 100644 --- a/test/StabilityPoolClaimable.t.sol +++ b/test/StabilityPoolClaimable.t.sol @@ -629,8 +629,14 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { _depositRewardAndWait(address(rewardToken1), 100 ether); _depositRewardAndWait(address(rewardToken2), 200 ether); - uint256 claimable1 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(rewardToken1)); - uint256 claimable2 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(rewardToken2)); + uint256 claimable1 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( + user1, + address(rewardToken1) + ); + uint256 claimable2 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( + user1, + address(rewardToken2) + ); assertGt(claimable1, 0, "should have claimable rewardToken1"); assertGt(claimable2, 0, "should have claimable rewardToken2"); @@ -657,7 +663,10 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { _depositForUsers(); _depositRewardAndWait(address(rewardToken1), 100 ether); - uint256 claimable1 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(rewardToken1)); + uint256 claimable1 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( + user1, + address(rewardToken1) + ); address receiver = makeAddr("receiver"); vm.prank(user1); @@ -671,7 +680,10 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { _depositForUsers(); _depositRewardAndWait(address(rewardToken1), 100 ether); - uint256 claimable1 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(rewardToken1)); + uint256 claimable1 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( + user1, + address(rewardToken1) + ); // Anyone can trigger claim for user1 — tokens go to user1 vm.prank(user2); @@ -705,14 +717,13 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { // Reward Alias Tests // ═══════════════════════════════════════════════════════════════════════════ -import {RewardAlias} from "src/reward/RewardAlias.sol"; -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {RewardAlias_v1} from "src/reward/RewardAlias_v1.sol"; import {IStabilityPool_v3} from "src/interfaces/IStabilityPool_v3.sol"; contract TestRewardAlias is TestStabilityPoolRebalanceSetUp { MockERC20 aliasUnderlying; - RewardAlias harvestAlias; - RewardAlias boostAlias; + RewardAlias_v1 harvestAlias; + RewardAlias_v1 boostAlias; uint256 constant DEPOSIT_AMOUNT = 10 ether; @@ -722,9 +733,9 @@ contract TestRewardAlias is TestStabilityPoolRebalanceSetUp { // Create a reward token and two aliases for it aliasUnderlying = new MockERC20("Reward", "RWD", 18); vm.label(address(aliasUnderlying), "AliasUnderlying"); - harvestAlias = new RewardAlias(address(aliasUnderlying)); + harvestAlias = new RewardAlias_v1(address(aliasUnderlying)); vm.label(address(harvestAlias), "HARVEST_ALIAS"); - boostAlias = new RewardAlias(address(aliasUnderlying)); + boostAlias = new RewardAlias_v1(address(aliasUnderlying)); vm.label(address(boostAlias), "BOOST_ALIAS"); // Register both aliases as reward tokens @@ -784,7 +795,11 @@ contract TestRewardAlias is TestStabilityPoolRebalanceSetUp { // The underlying token was transferred, not the alias assertEq(aliasUnderlying.balanceOf(stabilityPoolCollateral) - spBalBefore, 100 ether, "SP received underlying"); - assertEq(depositorBalBefore - aliasUnderlying.balanceOf(rewardDepositor), 100 ether, "depositor sent underlying"); + assertEq( + depositorBalBefore - aliasUnderlying.balanceOf(rewardDepositor), + 100 ether, + "depositor sent underlying" + ); } // ── Claimable per alias ───────────────────────────────────────────── @@ -794,15 +809,24 @@ contract TestRewardAlias is TestStabilityPoolRebalanceSetUp { _depositRewardAndWait(address(boostAlias), 200 ether); uint256 claimHarvest = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( - user1, address(harvestAlias) - ); - uint256 claimBoost = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( - user1, address(boostAlias) + user1, + address(harvestAlias) ); + uint256 claimBoost = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(boostAlias)); // user1 has 50% of the pool → gets 50% of each alias's reward - assertApproxEqAbs(claimHarvest, 50 ether, 2 * 604800, "harvest claimable ~50 (tolerance: 2 periods of rate truncation)"); - assertApproxEqAbs(claimBoost, 100 ether, 2 * 604800, "boost claimable ~100 (tolerance: 2 periods of rate truncation)"); + assertApproxEqAbs( + claimHarvest, + 50 ether, + 2 * 604800, + "harvest claimable ~50 (tolerance: 2 periods of rate truncation)" + ); + assertApproxEqAbs( + claimBoost, + 100 ether, + 2 * 604800, + "boost claimable ~100 (tolerance: 2 periods of rate truncation)" + ); } // ── Claim via alias → transfers underlying ────────────────────────── @@ -810,9 +834,7 @@ contract TestRewardAlias is TestStabilityPoolRebalanceSetUp { function testAlias_claimSingleTransfersUnderlying() public { _depositRewardAndWait(address(harvestAlias), 100 ether); - uint256 claimable = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( - user1, address(harvestAlias) - ); + uint256 claimable = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(harvestAlias)); assertGt(claimable, 0, "has claimable"); uint256 rwdBefore = aliasUnderlying.balanceOf(user1); @@ -829,18 +851,14 @@ contract TestRewardAlias is TestStabilityPoolRebalanceSetUp { _depositRewardAndWait(address(harvestAlias), 100 ether); _depositRewardAndWait(address(boostAlias), 200 ether); - uint256 boostBefore = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( - user1, address(boostAlias) - ); + uint256 boostBefore = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(boostAlias)); // Claim only harvest vm.prank(user1); IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, address(harvestAlias)); // Boost should be unchanged - uint256 boostAfter = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( - user1, address(boostAlias) - ); + uint256 boostAfter = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(boostAlias)); assertEq(boostAfter, boostBefore, "boost unaffected by harvest claim"); } @@ -856,7 +874,12 @@ contract TestRewardAlias is TestStabilityPoolRebalanceSetUp { uint256 received = aliasUnderlying.balanceOf(user1) - rwdBefore; // Should have received harvest + boost combined (~150 ether for 50% of pool) - assertApproxEqAbs(received, 150 ether, 4 * 604800, "received total from both aliases (tolerance: 4 periods of rate truncation)"); + assertApproxEqAbs( + received, + 150 ether, + 4 * 604800, + "received total from both aliases (tolerance: 4 periods of rate truncation)" + ); // Both should be zero after claim assertEq( @@ -871,7 +894,7 @@ contract TestRewardAlias is TestStabilityPoolRebalanceSetUp { ); } - // ── RewardAlias contract ──────────────────────────────────────────── + // ── RewardAlias_v1 contract ──────────────────────────────────────────── function testAlias_underlyingReturnsCorrectToken() public view { assertEq(harvestAlias.underlying(), address(aliasUnderlying), "harvest underlying"); @@ -890,15 +913,15 @@ contract TestRewardAlias is TestStabilityPoolRebalanceSetUp { // Claimable for each alias individually uint256 harvestOnly = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( - user1, address(harvestAlias) - ); - uint256 boostOnly = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( - user1, address(boostAlias) + user1, + address(harvestAlias) ); + uint256 boostOnly = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(boostAlias)); // Claimable for the raw underlying — should sum both aliases uint256 aggregated = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( - user1, address(aliasUnderlying) + user1, + address(aliasUnderlying) ); assertEq(aggregated, harvestOnly + boostOnly, "aggregated = harvest + boost"); @@ -918,9 +941,7 @@ contract TestRewardAlias is TestStabilityPoolRebalanceSetUp { skip(8 days); // Claimable for a plain token with no aliases — should return its own claimable only - uint256 claimable = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( - user1, address(plainToken) - ); + uint256 claimable = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(plainToken)); assertGt(claimable, 0, "plain token has claimable"); // No aliases exist for this token, so aggregation adds nothing diff --git a/test/StabilityPool_v3_ERC20.t.sol b/test/StabilityPool_v3_ERC20.t.sol index ddcbd906..c43a4f32 100644 --- a/test/StabilityPool_v3_ERC20.t.sol +++ b/test/StabilityPool_v3_ERC20.t.sol @@ -57,9 +57,7 @@ contract TestStabilityPool_v3_ERC20 is TestStabilityPoolSetUp { function test_name_shortString() public { // < 32 chars - StabilityPool_v3 sp = new StabilityPool_v3( - minter, wrappedCollateralToken, 3600, 90000, 1 ether, "Short", "S" - ); + StabilityPool_v3 sp = new StabilityPool_v3(minter, wrappedCollateralToken, 3600, 90000, 1 ether, "Short", "S"); assertEq(sp.name(), "Short", "short name"); assertEq(sp.symbol(), "S", "short symbol"); } @@ -68,9 +66,7 @@ contract TestStabilityPool_v3_ERC20 is TestStabilityPoolSetUp { // Exactly 32 chars string memory name32 = "12345678901234567890123456789012"; assertEq(bytes(name32).length, 32, "sanity"); - StabilityPool_v3 sp = new StabilityPool_v3( - minter, wrappedCollateralToken, 3600, 90000, 1 ether, name32, "S" - ); + StabilityPool_v3 sp = new StabilityPool_v3(minter, wrappedCollateralToken, 3600, 90000, 1 ether, name32, "S"); assertEq(sp.name(), name32, "32-char name"); } @@ -78,18 +74,14 @@ contract TestStabilityPool_v3_ERC20 is TestStabilityPoolSetUp { // 40 chars (between 32 and 64) string memory name40 = "1234567890123456789012345678901234567890"; assertEq(bytes(name40).length, 40, "sanity"); - StabilityPool_v3 sp = new StabilityPool_v3( - minter, wrappedCollateralToken, 3600, 90000, 1 ether, name40, "S" - ); + StabilityPool_v3 sp = new StabilityPool_v3(minter, wrappedCollateralToken, 3600, 90000, 1 ether, name40, "S"); assertEq(sp.name(), name40, "40-char name"); } function test_name_exactly64chars() public { string memory name64 = "1234567890123456789012345678901234567890123456789012345678901234"; assertEq(bytes(name64).length, 64, "sanity"); - StabilityPool_v3 sp = new StabilityPool_v3( - minter, wrappedCollateralToken, 3600, 90000, 1 ether, name64, "S" - ); + StabilityPool_v3 sp = new StabilityPool_v3(minter, wrappedCollateralToken, 3600, 90000, 1 ether, name64, "S"); assertEq(sp.name(), name64, "64-char name"); } @@ -237,11 +229,7 @@ contract TestStabilityPool_v3_ERC20 is TestStabilityPoolSetUp { vm.prank(user2); IERC20(stabilityPoolCollateral).transferFrom(user1, user2, 3 ether); - assertEq( - IERC20(stabilityPoolCollateral).allowance(user1, user2), - type(uint256).max, - "infinite not deducted" - ); + assertEq(IERC20(stabilityPoolCollateral).allowance(user1, user2), type(uint256).max, "infinite not deducted"); } function test_transferFrom_insufficientAllowance_reverts() public { diff --git a/test/deployment/MinterCappedMint.t.sol b/test/deployment/MinterCappedMint.t.sol index ad96ec0b..9deb23f4 100644 --- a/test/deployment/MinterCappedMint.t.sol +++ b/test/deployment/MinterCappedMint.t.sol @@ -5,7 +5,7 @@ import {BaoTest} from "@bao-test/BaoTest.sol"; import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; import {Deploy_ETH_Minter} from "script/src/Deploy_ETH_Minter.sol"; import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; -import {Config_MinterMarket, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; +import {Config_MinterMarket} from "script/config/ConfigBase.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IMinter} from "src/interfaces/IMinter.sol"; @@ -13,7 +13,6 @@ import {IMinter_v3} from "src/interfaces/IMinter_v3.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; import {MockWrappedPriceOracle} from "test/mocks/MockWrappedPriceOracle.sol"; - /// @title MinterCappedMintTest /// @notice Tests for Minter_v3 fee-capped minting, deployed via production deployment scripts. contract MinterCappedMintSetUp is BaoTest, Deploy_ETH_Minter { @@ -89,8 +88,7 @@ contract MinterCappedMintTest is MinterCappedMintSetUp { deal(wrappedCollateral, alice, collateralIn); // Dry run - (,, uint256 dryCollUsed, uint256 dryPegged,,) = - IMinter(minter).mintPeggedTokenDryRun(collateralIn); + (, , uint256 dryCollUsed, uint256 dryPegged, , ) = IMinter(minter).mintPeggedTokenDryRun(collateralIn); // Actual mint vm.startPrank(alice); @@ -116,14 +114,16 @@ contract MinterCappedMintTest is MinterCappedMintSetUp { deal(wrappedCollateral, alice, collateralIn); // Get current fee ratio - (int256 incentiveRatio,,,,,) = IMinter(minter).mintPeggedTokenDryRun(collateralIn); + (int256 incentiveRatio, , , , , ) = IMinter(minter).mintPeggedTokenDryRun(collateralIn); // Set cap well above current fee uint256 maxFeeRatio = uint256(incentiveRatio) * 2; // Capped dry run - (,, uint256 dryCollUsed, uint256 dryPegged,,) = - IMinter_v3(minter).mintPeggedTokenDryRun(collateralIn, maxFeeRatio); + (, , uint256 dryCollUsed, uint256 dryPegged, , ) = IMinter_v3(minter).mintPeggedTokenDryRun( + collateralIn, + maxFeeRatio + ); // Actual capped mint vm.startPrank(alice); @@ -172,16 +172,19 @@ contract MinterCappedMintTest is MinterCappedMintSetUp { deal(wrappedCollateral, alice, collateralIn); // Get uncapped result - (,, uint256 uncappedCollUsed, uint256 uncappedPegged,,) = - IMinter(minter).mintPeggedTokenDryRun(collateralIn); + (, , uint256 uncappedCollUsed, uint256 uncappedPegged, , ) = IMinter(minter).mintPeggedTokenDryRun( + collateralIn + ); // Set cap to half the uncapped fee ratio — should produce a partial mint - (int256 incentiveRatio,,,,,) = IMinter(minter).mintPeggedTokenDryRun(collateralIn); + (int256 incentiveRatio, , , , , ) = IMinter(minter).mintPeggedTokenDryRun(collateralIn); uint256 maxFeeRatio = uint256(incentiveRatio) / 2; // Capped dry run - (,, uint256 dryCollUsed, uint256 dryPegged,,) = - IMinter_v3(minter).mintPeggedTokenDryRun(collateralIn, maxFeeRatio); + (, , uint256 dryCollUsed, uint256 dryPegged, , ) = IMinter_v3(minter).mintPeggedTokenDryRun( + collateralIn, + maxFeeRatio + ); // Actual capped mint vm.startPrank(alice); @@ -219,8 +222,10 @@ contract MinterCappedMintTest is MinterCappedMintSetUp { deal(wrappedCollateral, alice, collateralIn); // Capped dry run - (,, uint256 dryCollUsed, uint256 dryPegged,,) = - IMinter_v3(minter).mintPeggedTokenDryRun(collateralIn, maxFeeRatio); + (, , uint256 dryCollUsed, uint256 dryPegged, , ) = IMinter_v3(minter).mintPeggedTokenDryRun( + collateralIn, + maxFeeRatio + ); // Actual capped mint vm.startPrank(alice); @@ -243,12 +248,15 @@ contract MinterCappedMintTest is MinterCappedMintSetUp { maxFeeRatio = bound(maxFeeRatio, 0, 1 ether); // 0% to 100% // Uncapped dry run - (,, uint256 uncappedCollUsed, uint256 uncappedPegged,,) = - IMinter(minter).mintPeggedTokenDryRun(collateralIn); + (, , uint256 uncappedCollUsed, uint256 uncappedPegged, , ) = IMinter(minter).mintPeggedTokenDryRun( + collateralIn + ); // Capped dry run - (,, uint256 cappedCollUsed, uint256 cappedPegged,,) = - IMinter_v3(minter).mintPeggedTokenDryRun(collateralIn, maxFeeRatio); + (, , uint256 cappedCollUsed, uint256 cappedPegged, , ) = IMinter_v3(minter).mintPeggedTokenDryRun( + collateralIn, + maxFeeRatio + ); assertLe(cappedCollUsed, uncappedCollUsed, "capped uses <= uncapped collateral"); assertLe(cappedPegged, uncappedPegged, "capped mints <= uncapped pegged"); @@ -265,8 +273,7 @@ contract MinterCappedMintTest is MinterCappedMintSetUp { maxFeeRatio = bound(maxFeeRatio, 0.001 ether, 0.5 ether); // 0.1% to 50% // Capped dry run - (, uint256 dryFee,,,,) = - IMinter_v3(minter).mintPeggedTokenDryRun(collateralIn, maxFeeRatio); + (, uint256 dryFee, , , , ) = IMinter_v3(minter).mintPeggedTokenDryRun(collateralIn, maxFeeRatio); // Absolute fee must not exceed the budget (maxFeeRatio * collateralIn) uint256 maxFee = (collateralIn * maxFeeRatio) / 1 ether; @@ -283,12 +290,12 @@ contract MinterCappedMintTest is MinterCappedMintSetUp { uint256 collateralIn = 50 ether; // Uncapped dry run (3-arg) - (int256 ir1, uint256 fee1, uint256 coll1, uint256 peg1, uint256 p1, uint256 r1) = - IMinter(minter).mintPeggedTokenDryRun(collateralIn); + (int256 ir1, uint256 fee1, uint256 coll1, uint256 peg1, uint256 p1, uint256 r1) = IMinter(minter) + .mintPeggedTokenDryRun(collateralIn); // Capped with max (4-arg) - (int256 ir2, uint256 fee2, uint256 coll2, uint256 peg2, uint256 p2, uint256 r2) = - IMinter_v3(minter).mintPeggedTokenDryRun(collateralIn, type(uint256).max); + (int256 ir2, uint256 fee2, uint256 coll2, uint256 peg2, uint256 p2, uint256 r2) = IMinter_v3(minter) + .mintPeggedTokenDryRun(collateralIn, type(uint256).max); assertEq(ir1, ir2, "incentive ratio matches"); assertEq(fee1, fee2, "fee matches"); diff --git a/test/deployment/RebalanceFairness.t.sol b/test/deployment/RebalanceFairness.t.sol index 1912efce..931080a9 100644 --- a/test/deployment/RebalanceFairness.t.sol +++ b/test/deployment/RebalanceFairness.t.sol @@ -12,7 +12,6 @@ import {IMinter} from "src/interfaces/IMinter.sol"; import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; import {IStabilityPoolManager} from "src/interfaces/IStabilityPoolManager.sol"; import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; -import {IWrappedPriceOracle} from "src/interfaces/IWrappedPriceOracle.sol"; import {Minter_v2} from "src/minter/Minter_v2.sol"; import {StabilityPoolManager_v1} from "src/minter/StabilityPoolManager_v1.sol"; import {MockWrappedPriceOracle} from "test/mocks/MockWrappedPriceOracle.sol"; @@ -168,7 +167,7 @@ contract RebalanceFairnessSetUp is BaoTest, Deploy_ETH_Minter { function _triggerHarvest() internal returns (uint256 harvested) { // Increase rate by 5% to simulate yield accrual - oracleRate = oracleRate * 105 / 100; + oracleRate = (oracleRate * 105) / 100; mockOracle.setLatestAnswer(oraclePrice, oracleRate); harvested = IStabilityPoolManager(stabilityPoolManager).harvest(makeAddr("bountyReceiver"), 0); } @@ -209,7 +208,10 @@ contract RebalanceFairnessSetUp is BaoTest, Deploy_ETH_Minter { function _logActor(string memory name, address who) internal view { console2.log("--- %s ---", name); console2.log(" pegged (wallet): %e", IERC20(pegged).balanceOf(who)); - console2.log(" Coll SP deposit: %e", IStabilityPool(stabilityPoolCollateral).assetBalanceOf(who)); + console2.log( + " Coll SP deposit: %e", + IStabilityPool(stabilityPoolCollateral).assetBalanceOf(who) + ); console2.log(" Lev SP deposit: %e", IStabilityPool(stabilityPoolLeveraged).assetBalanceOf(who)); console2.log(" fxSAVE (wallet): %e", IERC20(wrappedCollateral).balanceOf(who)); console2.log(" leveraged (wallet): %e", IERC20(leveraged).balanceOf(who)); @@ -261,7 +263,12 @@ contract RebalanceFairnessSetUp is BaoTest, Deploy_ETH_Minter { ? current[i].levToken_levSP - preRebal[i].levToken_levSP : current[i].levToken_levSP; console2.log(" %s", names[i]); - console2.log(" fxSAVE (coll SP): %e | fxSAVE (lev SP): %e | lev tokens: %e", fxSAVE_coll, fxSAVE_lev, levToken); + console2.log( + " fxSAVE (coll SP): %e | fxSAVE (lev SP): %e | lev tokens: %e", + fxSAVE_coll, + fxSAVE_lev, + levToken + ); } } diff --git a/test/deployment/StabilityPoolAliasDeployment.t.sol b/test/deployment/StabilityPoolAliasDeployment.t.sol index 0acd086d..b64f52c4 100644 --- a/test/deployment/StabilityPoolAliasDeployment.t.sol +++ b/test/deployment/StabilityPoolAliasDeployment.t.sol @@ -16,7 +16,6 @@ import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistribu import {IRewardAlias} from "src/interfaces/IRewardAlias.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; import {Minter_v2} from "src/minter/Minter_v2.sol"; -import {StabilityPoolManager_v1} from "src/minter/StabilityPoolManager_v1.sol"; import {MockWrappedPriceOracle} from "test/mocks/MockWrappedPriceOracle.sol"; /// @title StabilityPoolAliasDeploymentTest @@ -134,8 +133,12 @@ contract StabilityPoolAliasDeploymentTest is StabilityPoolAliasDeploymentSetUp { bool foundHarvest; bool foundRebalance; for (uint256 i = 0; i < tokens.length; i++) { - if (tokens[i] == collHarvestAlias) { foundHarvest = true; } - if (tokens[i] == collRebalanceAlias) { foundRebalance = true; } + if (tokens[i] == collHarvestAlias) { + foundHarvest = true; + } + if (tokens[i] == collRebalanceAlias) { + foundRebalance = true; + } } assertTrue(foundHarvest, "harvest alias registered on coll SP"); assertTrue(foundRebalance, "rebalance alias registered on coll SP"); @@ -146,8 +149,12 @@ contract StabilityPoolAliasDeploymentTest is StabilityPoolAliasDeploymentSetUp { bool foundHarvest; bool foundRebalance; for (uint256 i = 0; i < tokens.length; i++) { - if (tokens[i] == levHarvestAlias) { foundHarvest = true; } - if (tokens[i] == levRebalanceAlias) { foundRebalance = true; } + if (tokens[i] == levHarvestAlias) { + foundHarvest = true; + } + if (tokens[i] == levRebalanceAlias) { + foundRebalance = true; + } } assertTrue(foundHarvest, "harvest alias registered on lev SP"); assertTrue(foundRebalance, "rebalance alias registered on lev SP"); @@ -217,14 +224,23 @@ contract StabilityPoolAliasDeploymentTest is StabilityPoolAliasDeploymentSetUp { skip(8 days); // Each alias tracks separately - uint256 claimableHarvest = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(alice, collHarvestAlias); - uint256 claimableRebalance = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(alice, collRebalanceAlias); + uint256 claimableHarvest = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( + alice, + collHarvestAlias + ); + uint256 claimableRebalance = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( + alice, + collRebalanceAlias + ); assertApprox(claimableHarvest, harvestReward, 604800, "harvest alias tracked separately"); assertApprox(claimableRebalance, rebalanceReward, 604800, "rebalance alias tracked separately"); // Aggregated claimable for underlying should be sum of aliases - uint256 claimableTotal = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(alice, wrappedCollateral); + uint256 claimableTotal = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( + alice, + wrappedCollateral + ); assertApprox(claimableTotal, harvestReward + rebalanceReward, 2 * 604800, "aggregated claimable"); } From 0f7e4756258eaf57a92f349488192e37ebab9c3e Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Sat, 4 Apr 2026 15:20:53 +0100 Subject: [PATCH 012/232] redesign of autocompounding added fractional claim to SP --- .claude/settings.json | 8 +- CLAUDE.md | 6 + doc/aladdin/fxSAVE.md | 182 ++++++ doc/ideas/autocompounding-vault-design.md | 468 +++++++++++----- regression/coverage.txt | 10 +- regression/sizes.txt | 55 -- script/config/ConfigTokenNames.sol | 4 +- script/src/DeployMintersShared.sol | 39 +- script/src/contracts/StabilityPool.sol | 11 - src/interfaces/IMultipleRewardDistributor.sol | 3 + src/interfaces/IStabilityPool_v3.sol | 13 + src/minter/StabilityPool_v3.sol | 19 +- ...ultipleRewardCompoundingAccumulator_v3.sol | 56 +- .../LinearMultipleRewardDistributor_v3.sol | 85 +-- test/StabilityPoolClaimable.t.sol | 523 +++++++++++++----- test/deployment/RewardSystem.t.sol | 395 +++++++++++++ 16 files changed, 1483 insertions(+), 394 deletions(-) create mode 100644 doc/aladdin/fxSAVE.md create mode 100644 test/deployment/RewardSystem.t.sol diff --git a/.claude/settings.json b/.claude/settings.json index 5ff68069..2e7863ae 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -9,7 +9,13 @@ "Read(//home/tfras/github/baofinance/harbor-yield.wip-hytoken/**)", "Bash(ls -la /home/tfras/github/baofinance/harbor-yield.wip-hytoken/*.md)", "Bash(forge coverage:*)", - "Bash(yarn sizes:*)" + "Bash(yarn sizes:*)", + "WebSearch", + "Read(//home/tfras/github/AladdinDAO/aladdin-v3-contracts/**)", + "WebFetch(domain:fxprotocol.gitbook.io)", + "WebFetch(domain:medium.com)", + "WebFetch(domain:www.openzeppelin.com)", + "Read(//home/tfras/github/AladdinDAO/fx-protocol-contracts/**)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index dd34114f..89f9992b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,9 +1,13 @@ # CLAUDE.md +- When discussing design decisions, do not present disconnected multiple-choice questions. Instead, write out the full picture first — user flows, accounting, consequences — so the decision context is clear. Present a recommendation with reasoning, not a menu of options without enough background. Use the plan document or design docs for detailed analysis, not the question dialog. - Do not create functions that are only called once. Inline the logic instead. - When diagnosing an issue, do not use words like "likely", "probably", or "may" to describe a root cause. Either verify the hypothesis with data (dry run, log, trace) or state explicitly that it is unverified. Never proceed with a fix based on an unverified hypothesis. - use forge install/remove for managing submodule dependencies - In tests and scripts, use interface types (e.g. `IStabilityPool_v3(address)`) not concrete contract types (e.g. `StabilityPool_v3(address)`) when calling functions. This verifies the interface matches the implementation. Concrete types are only for initialisation (constructor, deploy). + - **Declarations:** use `address`, not typed contract variables. E.g. `address rewardToken = address(new MockERC20(...))`, not `MockERC20 rewardToken = new MockERC20(...)`. + - **Calls:** cast to the interface at the call site. E.g. `IERC20(rewardToken).balanceOf(user)`. + - **Setup/mock operations:** casting to concrete types is acceptable for mock-specific functions like `MockERC20(token).mint()`. - Every branch must have each path on a separate line so coverage tools can distinguish them. Use curly brackets on all if/for/while statements (no single-line bodies). Ternary expressions are fine — formatters already split the branches across lines. - In deployment scripts, use salt keys and `_predictAddress(key)` to reference contracts — not deployed addresses. BaoFactory CREATE3 gives deterministic addresses from salts, so contracts can reference each other before deployment. For example, `registerRewardToken(_predictAddress(aliasKey))` works even if the alias hasn't been deployed yet. This decouples deployment order from contract dependencies. - In deployment scripts, build salt strings using `_saltString()` / `_predictAddress()` library functions from FactoryDeployer — never manually concat salt strings with `string.concat`. @@ -12,6 +16,8 @@ - **HarborOwnable** (modern): `_initializeOwner(deployerOwner, pendingOwner)` takes explicit deployer. Deploy via `_deployProxyAndRecord` (direct, no stub). Used by: RewardAlias, all new contracts. - **HarborFixedOwnable** (hardcoded): Owner is immutable constructor param (Harbor multisig). Deploy via `_deployProxyAndRecord` with empty initData. Used by: HarborPauser_v1. - Each UUPS contract composes Initializable + UUPSUpgradeable + ownership mixin directly — don't create "Upgradeable" base contracts that bundle these, as each contract has different init needs (roles, reentrancy, custom state). The "Upgradeable" suffix means something different in OZ (storage-safe proxy variant) and combining meanings causes confusion. +- When adding functions to interfaces in an inheritance hierarchy, avoid creating diamond inheritance. If a function is defined on both an interface and a concrete base, the derived contract must override to resolve the ambiguity. Instead, put the function on only one path — either a new versioned interface (e.g. `IMultipleRewardDistributor_v3`) or directly on the implementation. Prefer eliminating the diamond over resolving it with overrides. - In tests, never create and then remove files or directories — forge runs tests in parallel so you can create a race condition. Write test output to `./results` and leave it there. +- Tests verify *what code is supposed to do*, not merely that lines execute. When asked to improve testing, think: "what is the intended behaviour?" — then construct scenarios that demonstrate the code fulfils that intent. If unsure what a function is supposed to do, ask — the specification is not in the code. Avoid writing tests that only exercise code paths to increase coverage metrics; such tests reinforce any misunderstanding in the implementation and give false confidence. - In tests, prefer `console2.log` over `emit` for debug logging — it shows in `forge test -vvv` output without cluttering the event log. Use the `Fmt` library with `string.concat` for readable formatted messages. - Never modify a deployed contract's source file. Check `deployments/*.state.json` for deployed contracts. To add functionality: inherit from the deployed version (e.g. `Minter_v3 is Minter_v2`) if the changes are additive, or clone and modify if the inheritance chain doesn't work. The proxy upgrade mechanism allows swapping implementations, but the old source must remain unchanged for audit traceability. \ No newline at end of file diff --git a/doc/aladdin/fxSAVE.md b/doc/aladdin/fxSAVE.md new file mode 100644 index 00000000..49c3098b --- /dev/null +++ b/doc/aladdin/fxSAVE.md @@ -0,0 +1,182 @@ +# Aladdin fxSAVE Analysis + +## Overview + +fxSAVE (SavingFxUSD) is Aladdin's auto-compounding yield product built on the f(x) protocol. It wraps stability pool LP tokens as an ERC4626 vault. + +**Contract chain:** User -> SavingFxUSD (ERC4626) -> Convex StakingProxy -> Gauge -> FxUSDBasePool + +**Repos:** +- New system: [fx-protocol-contracts](https://github.com/AladdinDAO/fx-protocol-contracts) +- Old system: [aladdin-v3-contracts](https://github.com/AladdinDAO/aladdin-v3-contracts) + +## The f(x) Protocol Invariant + +Splits yield-bearing collateral (wstETH, wBTC, etc.) into two derivative tokens: +- **fToken** (fractional): low-volatility, stablecoin-like (~$1) +- **xPOSITION** (leveraged): absorbs all volatility, up to 10x leverage + +`total_fToken_value + total_xPOSITION_value = total_collateral_value` + +**fxUSD** wraps a basket of fTokens from multiple collateral markets. + +**Collaterals used by fxUSD:** wstETH, sfrxETH, weETH (all ETH-denominated yield-bearing tokens). + +## FxUSDBasePool (Stability Pool) + +Holds TWO asset types simultaneously: +- `totalYieldToken` (fxUSD) +- `totalStableToken` (USDC) + +### Deposit + +Both fxUSD and USDC accepted, priced to USD via Chainlink oracle: +``` +amountUSD = (fxUSD deposit) or (USDC * stablePrice / 1e18) +totalUSD = totalYieldToken + totalStableToken * stablePrice / 1e18 +shares = amountUSD * totalSupply / totalUSD +``` + +### Redemption + +Users receive BOTH tokens pro-rata regardless of what they deposited: +``` +amountFxUSD = shares * totalYieldToken / totalSupply +amountUSDC = shares * totalStableToken / totalSupply +``` + +This is the key design decision: the pool socialises the token mix across all depositors. + +### Peg Operations + +`arbitrage()` (restricted to `pegKeeper`): swaps fxUSD for USDC or vice versa within the pool at oracle prices. Changes the ratio but not the total USD value. + +### Rebalancing + +The f(x) protocol's leverage mechanism can reach unsafe ratios. When the leverage ratio of any collateral market exceeds its maximum (e.g. 10x), the system needs to "deleverage" -- reduce the xPOSITION size relative to fToken. + +**How it works in FxUSDBasePool:** + +1. Anyone can call `rebalance()` when a collateral market's leverage ratio exceeds the threshold +2. The pool contributes fxUSD and/or USDC to buy back (burn) xPOSITION tokens from the overleveraged market +3. In exchange, the pool receives the underlying collateral (e.g. wstETH) at a slight bonus +4. `totalYieldToken` and/or `totalStableToken` decrease (pool gave up stablecoins) +5. The pool now holds some collateral tokens alongside its remaining stablecoins + +**Impact on fxSAVE depositors:** +- Pool shares represent a reduced stablecoin balance but the pool gained collateral +- The collateral is worth slightly more than the stablecoins given up (the bonus) +- Net effect: small positive for the pool (the bonus is the profit for providing the deleveraging service) +- However, the pool's composition changed -- it now holds collateral tokens that need to be managed + +**Comparison with Harbor's rebalancing:** +- Harbor: the SPM rebalances by redeeming haXXX for wCOLn (collateral SP) or hsXXX.COLn (leveraged SP). The SP's haXXX balance drops, and it receives liquid wCOLn or illiquid hsXXX.COLn. +- f(x): the base pool rebalances by contributing stablecoins to buy back leveraged positions. The pool's stablecoin balance drops, and it receives underlying collateral. +- Key difference: in Harbor, the rebalance is a loss-distribution event (SP depositors lose haXXX). In f(x), it's more of a swap (stablecoins for collateral at a bonus). Harbor's mechanism is closer to Liquity's liquidation model; f(x)'s is closer to a peg-stabilisation mechanism. + +## SavingFxUSD (fxSAVE) + +Proper ERC4626 (inherits OZ `ERC4626Upgradeable`). Asset = FxUSDBasePool LP tokens. + +### Auto-Compounding + +1. Claims rewards from Convex gauge (`IStakingProxyERC20.getReward()`) +2. Sends reward tokens to harvester contract +3. Harvester converts rewards to base pool LP tokens +4. LP tokens deposited back to gauge +5. `totalAssets()` increases -> share price rises + +### Batch Deposit Threshold + +Small deposits are held locally (not immediately staked to gauge). When the balance exceeds a threshold, batch-deposited to gauge. Amortises gas costs but creates a window where LP tokens earn no gauge rewards. + +### Withdrawal + +Two-step with cooldown: +1. `requestRedeem(shares)` -> burns shares, creates `LockedFxSaveProxy` per user +2. After cooldown: `redeem()` via proxy +3. `instantRedeem(shares)` available with fee (up to 5%) + +## Design Decisions Relevant to Harbor + +### What Works Well + +1. **ERC4626 wrapping a stability pool** -- proven pattern. Share price rises from compounding, drops from rebalancing. +2. **Two-asset pool (socialised mix)** -- simple accounting. Every share is a proportional claim on both tokens. No per-user tracking of deposit type. +3. **Oracle-priced deposits** -- prevents sandwich attacks on deposit. +4. **Harvest/convert/redeposit cycle** -- standard auto-compounding pattern. + +### Weaknesses + +1. **No virtual shares defense** -- relies on guarded launch for ERC4626 inflation attack. Harbor should use `_decimalsOffset()`. +2. **Stale view functions** -- `previewDeposit`, `nav` skip `sync` modifier. View functions can return incorrect values for off-chain consumers. +3. **Deep dependency chain** -- user -> fxSAVE -> Convex -> gauge -> pool -> manager -> collateral. Single point of trust at Convex layer. +4. **Socialised redemption** -- depositors can't choose to receive only fxUSD or only USDC. May receive a mix they don't want. +5. **NAV manipulation** -- code comments explicitly warn exchange rate "can be manipulated to increase to any larger value". Unsafe for lending protocol integrations. +6. **Batch threshold gap** -- between deposit and threshold, LP tokens are un-staked and earn no gauge rewards. +7. **Redemption cooldown** -- requires per-user proxy contracts. Adds complexity and gas. +8. **No Liquity products in new system** -- abandoned epoch/scale/product mechanism in favour of simple ERC20 totals. Loses the no-iteration loss distribution property that Harbor retains. + +### Abandoning the Liquity Product Mechanism + +Aladdin's old system (`ShareableRebalancePool` in `aladdin-v3-contracts`) used the same Liquity-derived epoch/scale/product mechanism that Harbor's StabilityPool uses. This is the `DecrementalFloatingPoint` encoding of a running product `P`: + +**How it works (Harbor's current approach):** +- Each depositor stores a snapshot of the running product `P` at deposit time +- On liquidation, `P *= (1 - loss / totalDeposits)` — the product decreases +- A depositor's current balance = `initialDeposit * currentP / snapshotP` +- Rewards use a similar integral: `reward = initialDeposit * (S_current - S_snapshot) / P_snapshot` +- **Key property: no iteration.** Loss distribution across N depositors is O(1) — a single product update. No loops, no per-depositor state changes. Gas cost is constant regardless of depositor count. + +**What Aladdin changed (FxUSDBasePool):** +- The new system simply tracks `totalYieldToken` and `totalStableToken` as two uint256 values +- On rebalance: `totalYieldToken -= amount` and/or `totalStableToken -= amount` +- Each share is a proportional claim on both totals: `myYield = shares * totalYieldToken / totalSupply` +- **No product, no snapshots, no epochs.** Just ERC20 shares over two running totals. + +**Why they changed:** +- Simpler code — no epoch/scale overflow handling, no product precision management +- Their base pool accepts two token types (fxUSD + USDC), which complicates the product approach (would need two products or a combined one) +- The peg-keeping arbitrage mechanism changes the token mix constantly, making product-based tracking harder to maintain correctly + +**What Harbor loses by NOT changing:** +- Nothing — Harbor keeps the Liquity product mechanism because it has critical advantages: + 1. **O(1) loss distribution** — no iteration over depositors during rebalance + 2. **Per-deposit precision** — each depositor's loss is tracked from their exact entry point + 3. **Battle-tested** — the same mechanism runs in Liquity ($1B+ TVL) and has been audited extensively + 4. **Reward integrals** — the same product feeds into harvest reward distribution, giving proportional rewards without iteration +- The downside (complexity, epoch/scale/exponent tracking) is already implemented and working in SP_v3 + +**What Harbor gains from NOT changing:** +- The auto-compounder can rely on `claimable()` being accurate per-depositor without any sync calls +- No stale view function problem (fxSAVE's `nav()` and `previewDeposit()` can return stale values because `sync` is only called on mutations) + +### Key Differences from Harbor + +| Aspect | fxSAVE | Harbor | +|--------|--------|--------| +| Stability pool assets | fxUSD + USDC in one pool | Multiple SPs per peg (one per collateral) | +| Equivalent handling | USDC is native pool asset | wXXXn held at PV level, wCOLn from failed mints | +| Rebalance mechanism | Pool contributes fxUSD+USDC to reduce leverage | SP absorbs loss via Liquity product mechanism | +| Loss distribution | Simple total reduction | Per-deposit product tracking (no iteration) | +| Withdrawal | Cooldown + proxy contracts | Dynamic fees (planned), atomic withdraw | +| Auto-compound | Gauge rewards -> LP -> re-stake | SP rewards -> mint haXXX -> redeposit | +| Layers | 2 (pool -> fxSAVE) | 3 (SP -> AC -> PV) | + +## Audit Findings (OpenZeppelin f(x) v2) + +- **ERC-4626 Share Inflation Attack (Medium)**: zero totalSupply allows price inflation. Mitigated by guarded launch. +- **Stale Stability Pool Values (Medium)**: preview functions skip sync. +- **Redemption Request Gaming (Medium)**: no expiration on requests. +- **Capacity Constraint Blocks Liquidations (Medium)**: added collateral can exceed pool capacity. +- **Oracle Manipulation (High)**: single low-liquidity pool compromise allows price manipulation. + +## Sources + +- [f(x) Protocol Documentation](https://fxprotocol.gitbook.io/fx-docs) +- [Stability Pool Documentation](https://fxprotocol.gitbook.io/fx-docs/f-x-protocol-mechanisms/stability-pool) +- [Introducing fxSAVE (Medium)](https://medium.com/@protocol_fx_667/introducing-fxsave-1980231cea6d) +- [fxUSD: The Nuts and the Bolts (Medium)](https://medium.com/@protocol_fx_667/fxusd-the-nuts-and-the-bolts-335408276073) +- [fx-protocol-contracts](https://github.com/AladdinDAO/fx-protocol-contracts) +- [aladdin-v3-contracts](https://github.com/AladdinDAO/aladdin-v3-contracts) +- [OpenZeppelin f(x) v2 Audit](https://www.openzeppelin.com/news/fx-v2-audit) diff --git a/doc/ideas/autocompounding-vault-design.md b/doc/ideas/autocompounding-vault-design.md index 5294a1d8..6e25a1db 100644 --- a/doc/ideas/autocompounding-vault-design.md +++ b/doc/ideas/autocompounding-vault-design.md @@ -1,210 +1,406 @@ # Autocompounding Vault: Design & Requirements -## 1. Overview +## 1. Nomenclature + +| Symbol | Meaning | Example (USD peg) | +|--------|---------|-------------------| +| **haXXX** | Pegged token for peg XXX | haUSD | +| **COLn** | Unwrapped collateral n | stETH (COL1), fxUSD (COL2) | +| **wCOLn** | Wrapped collateral n (interest-bearing) | wstETH (wCOL1), fxSAVE (wCOL2) | +| **hsXXX.COLn** | Leveraged (sail) token for collateral n | hsUSD.stETH | +| **hpXXX.COLn** | Rebasing SP token -- collateral pool | hpUSD.stETH | +| **hpXXX.hsCOLn** | Rebasing SP token -- leveraged pool | hpUSD.hsstETH | +| **hcXXX.COLn** | Auto-compounder share -- collateral pool | hcUSD.stETH | +| **hcXXX.hsCOLn** | Auto-compounder share -- leveraged pool | hcUSD.hsstETH | +| **hyXXX** | Peg Vault share | hyUSD | +| **wXXXn** | Interest-bearing equivalent for peg XXX | fxSAVE (wUSD1) | +| **SP** | Stability Pool | | +| **AC** | Auto-Compounder (Level 1 ERC4626) | | +| **PV** | Peg Vault (Level 2 ERC4626/ERC-7575) | | + +## 2. Architecture Overview + +Three layers offering escalating pooling. Each level gives up control in exchange for convenience: + +```mermaid +graph TD + subgraph "Level 0: Raw Stability Pools" + SP_COL1["SP hpUSD.stETH
(rebasing ERC20)"] + SP_COL2["SP hpUSD.fxUSD
(rebasing ERC20)"] + SP_LEV1["SP hpUSD.hsstETH
(rebasing ERC20)"] + end + + subgraph "Level 1: Auto-Compounders (one per SP)" + AC_COL1["AC hcUSD.stETH
(non-rebasing ERC4626)"] + AC_COL2["AC hcUSD.fxUSD
(non-rebasing ERC4626)"] + AC_LEV1["AC hcUSD.hsstETH
(non-rebasing ERC4626)
standalone, not in PV"] + end + + subgraph "Level 2: Peg Vault" + PV["PV hyUSD
(ERC4626 / ERC-7575)
holds: AC shares + wXXXn"] + end + + User_L0["User: full control"] -->|"deposit haUSD"| SP_COL1 + User_L1["User: auto-compound"] -->|"deposit hpUSD.stETH"| AC_COL1 + User_L2["User: pooled + equivalents"] -->|"deposit haUSD / hpUSD.COLn / wCOLn / wXXXn"| PV + + AC_COL1 --> SP_COL1 + AC_COL2 --> SP_COL2 + AC_LEV1 --> SP_LEV1 + PV -->|"holds hcUSD.stETH"| AC_COL1 + PV -->|"holds hcUSD.fxUSD"| AC_COL2 + PV -->|"holds wXXXn directly"| wXXXn_pool["wXXXn (e.g. fxSAVE)"] +``` -The system has two layers: +**Level 0 -- Raw SP:** User chooses collateral type, manages claims manually. Rebasing ERC20. Full control. -- **SP Wrappers** — one per stability pool. Each wraps a single rebasing SP token (hpXXX.YYY) into a non-rebasing ERC4626 share. Handles compounding for that one SP. +**Level 1 -- Auto-Compounder (AC):** User chooses collateral type, gets autocompounding. Non-rebasing ERC4626 (fixed share count, moving price -- same as stETH/wstETH). Losses and rewards within one SP only. Available for both collateral and leveraged SPs. -- **Peg Vault** — one per peg (XXX). Combines all SP Wrappers for that peg plus equivalent token holdings into a single interest-bearing ERC4626 token. This is what users hold for composable, auto-compounding exposure to a peg. +**Level 2 -- Peg Vault (PV):** User gives up collateral choice. Losses socialised across all collateral SPs. Holds AC shares + equivalent tokens (wXXXn). ERC-7575 multi-asset entry. Leveraged SPs NOT included (rebalance into hsXXX.COLn which is not liquid). -A prerequisite: making the SP a rebasing ERC20 token with transferable positions. +## 3. Level 0: Raw Stability Pool -## 2. Token Naming & Structure +### Deposit / Withdraw -### Tokens +```mermaid +sequenceDiagram + participant User + participant SP as Stability Pool -| Token | Type | Description | Example | -|-------|------|-------------|---------| -| `haXXX` | ERC20 | Pegged token | haETH, haBTC, haUSD | -| `hpXXX.YYY` | Rebasing ERC20 | Stability pool token | hpUSD.fxUSD, hpETH.stETH, hpBTC.hsFXUSD | -| SP Wrapper share | ERC4626 | Non-rebasing wrapper for one SP | One per hpXXX.YYY | -| `wXXX1`, `wXXX2` | ERC4626 (or wrappable) | Interest-bearing equivalent tokens denominated in XXX | wstETH (ETH peg), fxSAVE (USD peg) | -| Peg Vault share | ERC4626 | Combined interest-bearing token for peg XXX | One per peg | + Note over User,SP: Deposit haXXX → receive rebasing hpXXX.COLn position -### Stability Pool Naming + User->>SP: approve(SP, amount) + User->>SP: deposit(amount, user, minSharesOut) + Note over SP: Transfer haXXX from user
Mint hpXXX.COLn position to user
(balance = deposit amount, rebases on loss/reward) + SP-->>User: hpXXX.COLn position active -`hpXXX.YYY` where XXX is the peg and YYY is the collateral or liquidation token: -- `hpXXX.col1` — collateral pool, first collateral type -- `hpXXX.lev1` — leveraged pool, first collateral type -- `hpXXX.col2` — collateral pool, second collateral type -- `hpXXX.lev2` — leveraged pool, second collateral type + Note over User,SP: Withdraw hpXXX.COLn → receive haXXX -## 3. Architecture + User->>SP: requestWithdrawal() + Note over SP: Opens withdrawal window after delay + Note over User: Wait for window to open + User->>SP: withdraw(amount, user, minAmountOut) + Note over SP: Burn hpXXX.COLn position
Transfer haXXX to user + SP-->>User: haXXX returned +``` -### Two-Layer Design +### Claim +```mermaid +sequenceDiagram + participant User + participant SP as Stability Pool + + Note over User,SP: After harvest/rebalance, wCOLn is claimable + + User->>SP: claimable(user, wCOLn) + SP-->>User: amount available + User->>SP: claimSingle(user, wCOLn) + SP-->>User: wCOLn transferred (all pending) + + Note over User,SP: Fractional claim — take only part + + User->>SP: claimSingle(user, wCOLn, maxAmount) + SP-->>User: min(pending, maxAmount) transferred + Note over SP: Remainder stays as pending,
included in claimable() ``` -┌─────────────────────────────────────────────────────────────┐ -│ Peg Vault (XXX) │ -│ ERC4626 share │ -│ │ -│ ┌──────────────┐ ┌──────────────┐ ┌────────┐ ┌────────┐ │ -│ │ SP Wrapper │ │ SP Wrapper │ │ wXXX1 │ │ wXXX2 │ │ -│ │ hpXXX.col1 │ │ hpXXX.lev1 │ │(equiv) │ │(equiv) │ │ -│ │ ERC4626 │ │ ERC4626 │ │ERC4626 │ │ERC4626 │ │ -│ └──────┬───────┘ └──────┬───────┘ └────────┘ └────────┘ │ -│ │ │ │ -│ ┌──────┴───────┐ ┌──────┴───────┐ │ -│ │ SP Wrapper │ │ SP Wrapper │ │ -│ │ hpXXX.col2 │ │ hpXXX.lev2 │ │ -│ │ ERC4626 │ │ ERC4626 │ │ -│ └──────┬───────┘ └──────┴───────┘ │ -└─────────┼──────────────────┼────────────────────────────────┘ - │ │ - ┌──────┴───────┐ ┌──────┴───────┐ - │ StabilityPool │ │ StabilityPool │ - │ hpXXX.col2 │ │ hpXXX.lev2 │ - │ Rebasing ERC20│ │ Rebasing ERC20│ - └───────────────┘ └──────────────┘ -``` -### SP Wrapper +--- + +## 4. Level 1: Auto-Compounder + +### What it does + +Wraps a rebasing hpXXX.COLn into a non-rebasing hcXXX.COLn share. Non-rebasing because the ERC4626 share count is fixed on deposit -- the share *price* changes, driven by `totalAssets() / totalSupply()`. + +### Deposit / Withdraw -One per stability pool. Wraps a single rebasing hpXXX.YYY token into a non-rebasing ERC4626 share. The SP Wrapper: +```mermaid +sequenceDiagram + participant User + participant AC as Auto-Compounder + participant SP as Stability Pool -- **Asset:** hpXXX.YYY (the rebasing SP token) -- **Compounds:** claims harvest rewards from its SP, mints haXXX via the minter, deposits back into the SP -- **Holds equivalent:** when minting fails (high fee), swaps collateral to preferred wXXX equivalent -- **Share price:** increases via compounding, decreases on SP rebalance (loss passthrough) + Note over User,SP: Deposit hpXXX.COLn → receive hcXXX.COLn shares -Each SP Wrapper is independently compounded. The hpXXX.YYY tokens it wraps could also be wrapped in a standalone ERC4626 interface for users who want single-SP exposure without the Peg Vault. + User->>SP: approve(AC, amount) + User->>AC: deposit(amount, user) + AC->>SP: transferFrom(user, AC, amount) + Note over AC: hcShares = amount * totalSupply / totalAssets + AC-->>User: hcXXX.COLn shares minted -### Peg Vault + Note over User,SP: Deposit haXXX (convenience) → deposits to SP first -One per peg. Combines all SP Wrappers for that peg into a single ERC4626 token. The Peg Vault: + User->>AC: depositPegged(haXXX_amount, user) + AC->>SP: deposit(haXXX_amount, AC) + Note over AC: AC's SP position grows + Note over AC: hcShares = hpAmount * totalSupply / totalAssets + AC-->>User: hcXXX.COLn shares minted -- **Holds:** SP Wrapper shares + equivalent tokens (wXXX1, wXXX2) -- **totalAssets():** sum of all SP Wrapper values + equivalent token values, priced in haXXX terms -- **Multiple entry points (EIP-7575):** accepts deposits of any hpXXX.YYY token (routed to the appropriate SP Wrapper) and issues one share token + Note over User,SP: Withdraw hcXXX.COLn → receive hpXXX.COLn + User->>AC: redeem(hcShares, user, user) + Note over AC: hpAmount = hcShares * totalAssets / totalSupply + AC->>SP: transfer(user, hpAmount) + Note over AC: User receives rebasing hpXXX.COLn.
Their share of the unclaimed queue
is reflected in the higher hpAmount
(totalAssets includes claimable). + AC-->>User: hpXXX.COLn transferred ``` -User deposits hpXXX.col1 ──> SP Wrapper(col1) ──┐ -User deposits hpXXX.lev1 ──> SP Wrapper(lev1) ──┤──> Peg Vault(XXX) ──> vault shares -User deposits hpXXX.col2 ──> SP Wrapper(col2) ──┤ -User deposits hpXXX.lev2 ──> SP Wrapper(lev2) ──┘ + +### Compound flow + +```mermaid +sequenceDiagram + participant Bot as Compound caller + participant AC as Auto-Compounder + participant SP as Stability Pool + participant Minter + + Bot->>AC: compound() + AC->>SP: claimable(AC, wCOLn) + SP-->>AC: claimable_wCOLn + AC->>Minter: mintPeggedTokenDryRun(claimable_wCOLn, maxFeeRatio) + Minter-->>AC: (fee, collUsed, pegged, ...) + + alt collUsed > 0 (profitable to mint) + AC->>SP: claimSingle(AC, wCOLn, collUsed) + Note over SP: Fractional claim: only transfers collUsed,
leaves remainder as unclaimed + SP-->>AC: wCOLn (collUsed amount only) + AC->>Minter: mintPeggedToken(wCOLn, collUsed, AC, 0, maxFeeRatio) + Minter-->>AC: haXXX minted + AC->>SP: deposit(haXXX, AC) + Note over AC: SP position grows, share price up + else collUsed == 0 (fee too high) + Note over AC: Skip. wCOLn stays as unclaimed
rewards in SP. Included in totalAssets
via claimable(). No value lost. + end ``` -### ERC4626 Composability +### Share accounting -All components present an ERC4626 interface: +``` +totalAssets() = + SP.balanceOf(AC) // SP position (haXXX terms, rebasing) + + SP.claimable(AC, wCOLn) * oraclePrice // unclaimed wCOLn valued in haXXX +``` -- **SP Wrappers** — ERC4626 with asset = hpXXX.YYY -- **Equivalent tokens** — wXXX1, wXXX2 are ERC4626-compatible (or trivially wrappable to be so). This means the Peg Vault holds a portfolio of ERC4626 tokens. -- **Peg Vault** — ERC4626 that holds other ERC4626 tokens. A vault-of-vaults. +Oracle read from `IMinter(minter).priceOracle()` at runtime -- always in sync with the Minter. -This uniform interface means any ERC4626-aware protocol can integrate with any layer. +### Rebalance impact -### Contract Structure +**Collateral SP rebalance:** haXXX burned, wCOLn received via `_accumulateReward`. wCOLn is liquid and valued in totalAssets via claimable. AC share price holds through rebalance -- lost haXXX position is offset by gained claimable wCOLn. The AC auto-compounds this back to haXXX when fees are acceptable. -Whether SP Wrappers are separate contracts or internal accounting within the Peg Vault is a gas/size trade-off: +**Leveraged SP rebalance:** haXXX burned, hsXXX.COLn received. hsXXX.COLn is NOT liquid. AC share price drops because leveraged tokens can't be easily converted back. The AC can only compound the harvest wCOLn; leveraged token rewards queue indefinitely until manually claimed. Included in totalAssets via `leveragedTokenPrice()`. -- **Separate contracts:** cleaner separation, each SP Wrapper is independently deployable and usable. Users can hold SP Wrapper shares directly for single-SP exposure. More gas for cross-contract calls. -- **Internal accounting:** single contract, less gas, but contract size may be prohibitive. Users can't hold individual SP Wrapper shares. +### Fractional claim -## 4. Motivation +`claimSingle(account, token, maxAmount)` on SP_v3 -- claims up to maxAmount, leaves the rest as pending. Enables the AC to claim only what can be profitably minted. Remainder stays in SP reward accounting, included in `totalAssets()` via `claimable()`. -### Problem -SP depositors earn wrapped collateral from harvests but must manually claim and reinvest. This delivers simple interest. +### Fairness -### Solution -Automate claim-convert-redeposit. Compound interest. Long-term holders benefit more. +Standard ERC4626. `totalAssets()` includes all value (SP position + unclaimed queue at oracle price). Deposits buy at current `totalAssets/totalShares`. No dilution, no cross-subsidy regardless of queue size. -### Fairness Guarantee +### No equivalents at AC level -`totalAssets()` includes pending claimable rewards via SP's `claimable()` view function. New depositors buy at correct price — no dilution. +The AC does NOT convert wCOLn to wXXXn. It either mints haXXX from wCOLn or leaves it unclaimed in the SP. No value transfers out of the AC. wXXXn equivalents exist only at the PV level (from direct user deposits). This resolves the fairness concern from the earlier options analysis -- no cross-subsidy between layers. -## 5. Design Decisions +### Deposit convenience -### 5.1 SP as Rebasing ERC20 +Core asset is hpXXX.COLn. Also accepts haXXX via `depositPegged(haXXX, amount)` which atomically deposits to SP then mints AC shares. -**Decision:** `balanceOf()` returns compounded real value. `totalSupply()` returns `totalAssetSupply()`. New: `transfer`, `transferFrom`, `approve`, `allowance`. +## 5. Level 2: Peg Vault -**Why rebasing:** Non-rebasing would duplicate the SP Wrapper's role. The SP Wrapper IS the non-rebasing wrapped version. Like stETH/wstETH. +### What it does -**Transfer:** No minimum constraints — total supply invariant is preserved since transfer doesn't change total supply. +Combines all collateral AC shares for a peg + equivalent tokens (wXXXn) into a single hyXXX share. ERC-7575: multiple entry assets, one share token. -**Approval:** Rebases downward on liquidation — approval may exceed balance. Same as stETH. +### Deposit flows -### 5.2 SP Wrapper Valuation +```mermaid +sequenceDiagram + participant User + participant PV as Peg Vault + participant AC as Auto-Compounder + participant SP as Stability Pool + participant Minter -Per SP Wrapper: `totalAssets()` = `hpXXX.YYY.balanceOf(wrapper)` + pending claimable (via `claimable()` + mint dry-run) + equivalent holdings attributed to this wrapper. + alt deposit hpXXX.COLn + User->>PV: deposit(hpXXX.COLn, amount) + PV->>AC: deposit(hpXXX.COLn, amount) + AC-->>PV: hcXXX.COLn shares + PV-->>User: hyXXX shares + end -On SP liquidation, `balanceOf(wrapper)` drops automatically. Share price drops. + alt deposit haXXX + User->>PV: deposit(haXXX, amount) + PV->>SP: deposit(haXXX, PV) + SP-->>PV: hpXXX.COLn + PV->>AC: deposit(hpXXX.COLn) + AC-->>PV: hcXXX.COLn shares + PV-->>User: hyXXX shares + end -### 5.3 Peg Vault Valuation + alt deposit wCOLn (wrapped collateral) + User->>PV: deposit(wCOLn, amount) + PV->>Minter: mintPeggedToken(wCOLn) + Minter-->>PV: haXXX + PV->>SP: deposit(haXXX, PV) + SP-->>PV: hpXXX.COLn + PV->>AC: deposit(hpXXX.COLn) + AC-->>PV: hcXXX.COLn shares + PV-->>User: hyXXX shares + end -`totalAssets()` = sum of all SP Wrapper share values + all equivalent token values, priced in haXXX terms. + alt deposit wXXXn (equivalent) + User->>PV: deposit(wXXXn, amount) + Note over PV: PV holds wXXXn directly,
priced via oracle + PV-->>User: hyXXX shares + end +``` -Equivalent tokens (wXXX1, wXXX2) are denominated in the same underlying as haXXX, priced via the minter's oracle. +### Withdrawal -### 5.4 Minting: Fees and maxFeeRatio +User receives proportional mix of all PV holdings: hpXXX.COLn (via AC redeem) for each collateral + wXXXn. -Use `mintPeggedToken()` with fees. Add `mintPeggedTokenCapped` with `maxFeeRatio` parameter. +```mermaid +sequenceDiagram + participant User + participant PV as Peg Vault + participant AC1 as AC (COL1) + participant AC2 as AC (COL2) + participant SP1 as SP (COL1) + participant SP2 as SP (COL2) -```solidity -// New: stops at fee threshold -function mintPeggedTokenCapped( - uint256 wrappedIn, address receiver, uint256 minPeggedOut, int256 maxFeeRatio -) returns (uint256 peggedOut, uint256 wrappedCollateralUsed) -``` + User->>PV: redeem(hyShares, user, user) -### 5.5 Compound Flow + Note over PV: For each AC, redeem proportional hcXXX.COLn shares -Per SP Wrapper, independently: + PV->>AC1: redeem(hcAmount1, user, PV) + AC1->>SP1: transfer(user, hpAmount1) + SP1-->>User: hpXXX.COL1 + + PV->>AC2: redeem(hcAmount2, user, PV) + AC2->>SP2: transfer(user, hpAmount2) + SP2-->>User: hpXXX.COL2 + + Note over PV: Transfer proportional wXXXn directly + + PV-->>User: wXXXn (proportional share) + + Note over PV: Burn hyXXX shares + PV-->>User: Withdrawal complete:
hpXXX.COL1 + hpXXX.COL2 + wXXXn ``` -compound() - 1. Claim all rewards from this SP - 2. mintPeggedTokenCapped(collateral, wrapper, 0, maxFeeRatio) - 3. Deposit minted haXXX into SP - 4. Remaining collateral -> swap to preferred wXXX equivalent + +### Compound + +```mermaid +sequenceDiagram + participant Bot as Compound caller + participant PV as Peg Vault + participant AC as Auto-Compounder (each) + participant Swapper as ISwapper + participant Minter + participant SP as Stability Pool + + Bot->>PV: compound() + + loop for each AC + PV->>AC: compound() + Note over AC: Claims profitable wCOLn,
mints haXXX, redeposits + end + + alt PV holds wXXXn and fees acceptable + PV->>Swapper: swap(wXXXn, wCOLn) + Swapper-->>PV: wCOLn + PV->>Minter: mintPeggedToken(wCOLn, maxFeeRatio) + Minter-->>PV: haXXX + PV->>SP: deposit(haXXX, PV) + SP-->>PV: hpXXX.COLn + PV->>AC: deposit(hpXXX.COLn) + Note over PV: wXXXn balance dropped,
AC shares increased + else fees too high + Note over PV: wXXXn stays, valued in totalAssets + end ``` -At the Peg Vault level: +### Share accounting + ``` - 5. Check equivalent holdings -> if fees acceptable, convert wXXX -> haXXX -> deposit into SP +totalAssets() = + SUM( AC.convertToAssets(PV's hcXXX.COLn shares) ) // includes unclaimed queue + + SUM( wXXXn.balanceOf(PV) * oracle_price ) // direct equivalent holdings ``` -### 5.6 Compound Trigger +### Fairness + +Same ERC4626 accounting over a portfolio. Collateral SP rebalances don't cause loss (wCOLn offsets haXXX). wXXXn deposits priced at oracle value, socialised across all hyXXX holders. + +## 6. Design Decisions + +### 5.1 SP as Rebasing ERC20 + +`balanceOf()` returns compounded real value. `totalSupply()` returns `totalAssetSupply()`. Transfer/approve/allowance added in v3. Like stETH. + +### 5.2 Non-rebasing AC shares + +The AC is the non-rebasing wrapped version. Like wstETH wraps stETH. Share count fixed, price moves. + +### 5.3 Collateral SP rebalance holds value + +Unlike leveraged SPs, collateral SP rebalance returns liquid wCOLn. The AC's totalAssets stays roughly constant (lost haXXX offset by gained claimable wCOLn). The AC auto-compounds back to haXXX when fees are acceptable. + +### 5.4 Leveraged SPs standalone -StabilityPoolManager calls compound during harvest and rebalance. Also permissionless. +Leveraged SPs rebalance into hsXXX.COLn which is not liquid. Leveraged AC only compounds harvest wCOLn. Not included in PV (different risk profile). -### 5.7 Equivalent Token Management +### 5.5 Minting: maxFeeRatio -Preference-ordered list of interest-bearing tokens denominated in XXX, updatable by keeper/bot. +`mintPeggedToken(wCOLn, receiver, minPeggedOut, maxFeeRatio)` on Minter_v3. Stops when cumulative fee exceeds maxFeeRatio * collateralIn. Returns (0, 0) gracefully if fee too high. -**Key properties:** -- Equivalent tokens are interest-bearing, denominated in the same underlying as haXXX -- Many are already ERC4626-compatible (e.g. fxSAVE wraps fxUSD, yield-bearing). Those that aren't can be trivially wrapped. -- NOT per-collateral — equivalents are per-peg. Harvest collateral from any SP is swapped to the preferred wXXX -- The Peg Vault's portfolio is: N SP Wrapper shares + M equivalent tokens — all ERC4626 +### 5.6 Fractional Claim -**User access:** -- `depositEquivalent(token, amount, receiver)` -> mint Peg Vault shares -- `withdrawEquivalent(token, shares, receiver)` -> return equivalent tokens if available +`claimSingle(account, token, maxAmount)` on SP_v3. Claims up to maxAmount, leaves rest as pending. Enables AC to claim only what can be profitably minted. -### 5.8 Withdrawal Time Lock +### 5.7 Oracle Coupling -No time lock in SP Wrapper or Peg Vault. SP's existing time lock governs haXXX withdrawals. +AC and PV read `IMinter(minter).priceOracle()` at runtime. Always in sync. No separate oracle config. -## 6. Access Control +### 5.8 Equivalent Token Management + +wXXXn held at PV level only (not in ACs). Preference-ordered list, updatable by keeper. PV converts wXXXn -> wCOLn (via ISwapper) -> haXXX (via Minter) -> SP when fees acceptable. + +### 5.9 No Equivalents in AC + +The AC does NOT hold wXXXn. Unprofitable wCOLn stays as unclaimed rewards in the SP, valued in totalAssets via claimable. This avoids the cross-subsidy fairness issue identified in the options analysis. + +### 5.10 Compound Trigger + +Permissionless. Also triggered by SPM during harvest/rebalance. + +### 5.11 Withdrawal + +AC uses EXEMPT_WITHDRAWAL_FEE_ROLE initially. Dynamic fees (CR-based) replace withdrawal delay in future SP version, enabling standard ERC4626 withdraw. + +## 7. Access Control | Role | On Contract | Purpose | |------|------------|---------| -| `KEEPER_ROLE` | Peg Vault | Swap execution + equivalent list ordering | -| Owner | Peg Vault | Configure swapper, maxFeeRatio, upgrade | -| Anyone | Both | `deposit`, `redeem`, `compound`, `convertEquivalent` | +| `KEEPER_ROLE` | PV | Swap execution + equivalent list ordering | +| Owner | PV, AC | Configure maxFeeRatio, swapper, upgrade | +| `EXEMPT_WITHDRAWAL_FEE_ROLE` | SP | AC withdraws without delay | +| Anyone | All | deposit, withdraw, compound | -## 7. Contracts +## 8. Contracts -| Contract | Action | Purpose | +| Contract | Status | Purpose | |----------|--------|---------| -| SP Wrapper | Create | ERC4626 per SP, compounds one SP | -| Peg Vault | Create | ERC4626 per peg, combines SP Wrappers + equivalents | -| `StabilityPool_v3` | Done | Rebasing ERC20 | -| `Minter_v2` | Modify | Add `mintPeggedTokenCapped` | -| `StabilityPoolManager_v1` | Modify | Add compound triggers | - -## 8. Future Directions - -- **On-chain APY calculation:** For automated equivalent token ordering without off-chain bot dependency. +| StabilityPool_v3 | In progress | Rebasing ERC20 + claimSingle + fractional claim | +| Minter_v3 | Done | mintPeggedTokenCapped | +| AutoCompounder | To build | ERC4626 per SP (Level 1) | +| PegVault | To build | ERC4626/ERC-7575 per peg (Level 2) | +| ISwapper / MockSwapper | To build | wXXXn conversion interface | +| StabilityPoolManager_v2 | To build | Compound triggers | + +## 9. References + +- [Aladdin fxSAVE analysis](../aladdin/fxSAVE.md) -- ERC4626 wrapping stability pool, proven pattern +- [SP dynamic fees](sp-dynamic-fees.md) -- CR-based fees replacing withdrawal delay +- [SP auto-compounding](sp-auto-compounding-harvests.md) -- deferred: two-product factor for SP-internal compounding diff --git a/regression/coverage.txt b/regression/coverage.txt index fb783353..156bcacd 100644 --- a/regression/coverage.txt +++ b/regression/coverage.txt @@ -46,20 +46,20 @@ | src/minter/StabilityPoolManager_v1.sol | X 99% (165/166) | X 99% (176/177) | X 95% (20/21) | ✓ 100% (24/24) | | src/minter/StabilityPool_v1.sol | X 61% (124/203) | X 58% (129/223) | X 18% (6/33) | X 73% (16/22) | | src/minter/StabilityPool_v2.sol | X 68% (136/199) | X 68% (150/219) | X 32% (10/31) | X 64% (14/22) | -| src/minter/StabilityPool_v3.sol | ✓ 100% (294/294) | ✓ 100% (327/327) | ✓ 100% (42/42) | ✓ 100% (38/38) | +| src/minter/StabilityPool_v3.sol | ✓ 100% (302/302) | ✓ 100% (336/336) | ✓ 100% (43/43) | ✓ 100% (40/40) | | src/minter/TokenDistributor_v1.sol | X 96% (94/98) | X 97% (112/116) | X 77% (10/13) | X 93% (14/15) | | src/minter/library/ConfigIncentiveLib.sol | ✓ 100% (24/24) | ✓ 100% (17/17) | ✓ 100% (2/2) | ✓ 100% (9/9) | | src/minter/library/Config_v1.sol | X 96% (76/79) | X 97% (92/95) | X 90% (18/20) | ✓ 100% (6/6) | | src/price/PriceOracle_v1.sol | X 97% (31/32) | X 97% (36/37) | X 85% (11/13) | ✓ 100% (3/3) | | src/price/StakedETHWrappedPriceOracle_v1.sol | X 0% (0/29) | X 0% (0/27) | X 0% (0/5) | X 0% (0/4) | -| src/reward/RewardAlias.sol | X 73% (8/11) | X 56% (5/9) | ✓ 100% (0/0) | X 60% (3/5) | +| src/reward/RewardAlias_v1.sol | ✓ 100% (15/15) | ✓ 100% (12/12) | ✓ 100% (1/1) | ✓ 100% (6/6) | | src/reward/accumulator/MultipleRewardCompoundingAccumulator.sol | X 93% (127/136) | X 94% (161/171) | X 81% (13/16) | X 95% (20/21) | | src/reward/accumulator/MultipleRewardCompoundingAccumulator_v2.sol | X 96% (141/147) | X 96% (177/184) | X 83% (15/18) | ✓ 100% (22/22) | -| src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol | X 79% (116/147) | X 81% (149/184) | X 72% (13/18) | X 68% (15/22) | +| src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol | X 95% (144/151) | X 96% (181/188) | X 89% (17/19) | X 91% (21/23) | | src/reward/distributor/LinearMultipleRewardDistributor.sol | ✓ 100% (77/77) | ✓ 100% (86/86) | ✓ 100% (12/12) | ✓ 100% (14/14) | | src/reward/distributor/LinearMultipleRewardDistributor_v2.sol | ✓ 100% (77/77) | ✓ 100% (86/86) | ✓ 100% (12/12) | ✓ 100% (14/14) | -| src/reward/distributor/LinearMultipleRewardDistributor_v3.sol | X 91% (86/94) | X 93% (99/106) | X 53% (8/15) | X 94% (16/17) | +| src/reward/distributor/LinearMultipleRewardDistributor_v3.sol | X 96% (90/94) | X 96% (102/106) | X 73% (11/15) | ✓ 100% (17/17) | | src/reward/distributor/LinearReward.sol | ✓ 100% (25/25) | ✓ 100% (27/27) | ✓ 100% (6/6) | ✓ 100% (2/2) | | src/util/FmtLib.sol | X 0% (0/19) | X 0% (0/26) | X 0% (0/4) | X 0% (0/1) | | src/util/WordCodec.sol | ✓ 100% (15/15) | ✓ 100% (14/14) | ✓ 100% (0/0) | ✓ 100% (4/4) | -| Total | X 67% (5177/7759) | X 66% (5516/8388) | X 54% (478/888) | X 69% (779/1126) | +| Total | X 67% (5267/7818) | X 66% (5612/8449) | X 55% (487/891) | X 70% (795/1134) | diff --git a/regression/sizes.txt b/regression/sizes.txt index bcd08199..e69de29b 100644 --- a/regression/sizes.txt +++ b/regression/sizes.txt @@ -1,55 +0,0 @@ -| Contract | Runtime Size (B) | Runtime Margin (B) | Initcode Size (B) | Deploy Gas | Deploy Cost ($) | -|---------------------------------------------|--------------------|----------------------|---------------------|--------------|-------------------| -| ConfigIncentiveLib | 85 | 24,491 | 135 | 18,350 | 1.84 | -| ConfigMarket_BTC_fxUSD_mainnet | 6,175 | 18,401 | 6,203 | 1,297,030 | 129.70 | -| ConfigMarket_BTC_stETH_mainnet | 6,201 | 18,375 | 6,229 | 1,302,490 | 130.25 | -| ConfigMarket_ETH_fxUSD_mainnet | 6,175 | 18,401 | 6,203 | 1,297,030 | 129.70 | -| ConfigMarket_EUR_fxUSD_mainnet | 6,163 | 18,413 | 6,191 | 1,294,510 | 129.45 | -| ConfigMarket_EUR_stETH_mainnet | 6,189 | 18,387 | 6,217 | 1,299,970 | 130.00 | -| ConfigMarket_GOLD_fxUSD_mainnet | 6,179 | 18,397 | 6,207 | 1,297,870 | 129.79 | -| ConfigMarket_GOLD_stETH_mainnet | 6,205 | 18,371 | 6,233 | 1,303,330 | 130.33 | -| ConfigMarket_MCAP_fxUSD_mainnet | 6,181 | 18,395 | 6,209 | 1,298,290 | 129.83 | -| ConfigMarket_MCAP_stETH_mainnet | 6,207 | 18,369 | 6,235 | 1,303,750 | 130.38 | -| ConfigMarket_SILVER_fxUSD_mainnet | 6,175 | 18,401 | 6,203 | 1,297,030 | 129.70 | -| ConfigMarket_SILVER_stETH_mainnet | 6,201 | 18,375 | 6,229 | 1,302,490 | 130.25 | -| ConfigPeg_BTC | 766 | 23,810 | 794 | 161,140 | 16.11 | -| ConfigPeg_ETH | 766 | 23,810 | 794 | 161,140 | 16.11 | -| ConfigPeg_EUR | 768 | 23,808 | 796 | 161,560 | 16.16 | -| ConfigPeg_GOLD | 770 | 23,806 | 798 | 161,980 | 16.20 | -| ConfigPeg_MCAP | 772 | 23,804 | 800 | 162,400 | 16.24 | -| ConfigPeg_SILVER | 794 | 23,782 | 822 | 167,020 | 16.70 | -| ConfigPriceVolatility_105 | 3,019 | 21,557 | 3,047 | 634,270 | 63.43 | -| ConfigPriceVolatility_105_stable | 3,019 | 21,557 | 3,047 | 634,270 | 63.43 | -| ConfigPriceVolatility_115 | 3,019 | 21,557 | 3,047 | 634,270 | 63.43 | -| ConfigPriceVolatility_115_stable | 3,019 | 21,557 | 3,047 | 634,270 | 63.43 | -| ConfigPriceVolatility_125 | 3,019 | 21,557 | 3,047 | 634,270 | 63.43 | -| ConfigPriceVolatility_125_stable | 3,019 | 21,557 | 3,047 | 634,270 | 63.43 | -| ConfigPriceVolatility_130 | 3,019 | 21,557 | 3,047 | 634,270 | 63.43 | -| ConfigPriceVolatility_130_stable | 3,019 | 21,557 | 3,047 | 634,270 | 63.43 | -| Config_v1 | 3,789 | 20,787 | 3,841 | 796,210 | 79.62 | -| DecrementalFloatingPoint | 85 | 24,491 | 135 | 18,350 | 1.84 | -| FakeAccessControl | 1,626 | 22,950 | 1,654 | 341,740 | 34.17 | -| FakeBaoAccessControl | 1,487 | 23,089 | 1,515 | 312,550 | 31.26 | -| FakeInitializable | 389 | 24,187 | 417 | 81,970 | 8.20 | -| FakeOwnable2Step | 1,346 | 23,230 | 1,374 | 282,940 | 28.29 | -| FakeUUPSUpgradeable | 3,236 | 21,340 | 3,293 | 680,130 | 68.01 | -| FmtLib | 85 | 24,491 | 135 | 18,350 | 1.84 | -| ForceMigrateAccumulator_v1 | 3,364 | 21,212 | 3,847 | 711,270 | 71.13 | -| Genesis_v1 | 7,486 | 17,090 | 8,423 | 1,581,430 | 158.14 | -| LinearReward | 85 | 24,491 | 135 | 18,350 | 1.84 | -| MinterMarketConfigLib | 85 | 24,491 | 135 | 18,350 | 1.84 | -| Minter_v1 | 23,681 | 895 | 25,515 | 4,991,350 | 499.14 | -| Minter_v2 | 23,675 | 901 | 25,509 | 4,990,090 | 499.01 | -| Minter_v3 | 24,218 | 358 | 26,045 | 5,104,050 | 510.41 | -| MockWrappedPriceOracle | 373 | 24,203 | 435 | 78,950 | 7.90 | -| PostRebalanceRemediationForStabilityPool_v2 | 3,852 | 20,724 | 4,350 | 813,900 | 81.39 | -| PriceOracle_v1 | 1,958 | 22,618 | 2,010 | 411,700 | 41.17 | -| ReservePool_v1 | 4,760 | 19,816 | 5,009 | 1,002,090 | 100.21 | -| RewardAlias | 2,979 | 21,597 | 3,324 | 629,040 | 62.90 | -| StabilityPoolManager_v1 | 11,209 | 13,367 | 13,057 | 2,372,370 | 237.24 | -| StabilityPool_v1 | 21,092 | 3,484 | 23,085 | 4,449,250 | 444.93 | -| StabilityPool_v2 | 20,711 | 3,865 | 22,579 | 4,367,990 | 436.80 | -| StabilityPool_v3 | 23,697 | 879 | 26,225 | 5,001,650 | 500.17 | -| StakedETHWrappedPriceOracle_v1 | 3,695 | 20,881 | 4,653 | 785,530 | 78.55 | -| TokenDistributor_v1 | 10,116 | 14,460 | 10,365 | 2,126,850 | 212.69 | -| WordCodec | 85 | 24,491 | 135 | 18,350 | 1.84 | diff --git a/script/config/ConfigTokenNames.sol b/script/config/ConfigTokenNames.sol index 81ab06a4..48d5c22f 100644 --- a/script/config/ConfigTokenNames.sol +++ b/script/config/ConfigTokenNames.sol @@ -50,7 +50,9 @@ abstract contract ConfigTokenNames { } function _spStrings(Liquidation liquidation) private view returns (string memory name, string memory symbol) { - string memory liqSymbol = liquidation == Liquidation.Collateral ? _collateral() : leveragedSymbol(); + string memory liqSymbol = liquidation == Liquidation.Collateral + ? _collateral() + : string.concat("hs", _collateral().upper()); name = string.concat("Harbor stability pool: ", peggedSymbol(), " (", liqSymbol, ")"); symbol = string.concat("hsp", _peg(), "(", liqSymbol, ")"); diff --git a/script/src/DeployMintersShared.sol b/script/src/DeployMintersShared.sol index 1e2c3b6d..75143f19 100644 --- a/script/src/DeployMintersShared.sol +++ b/script/src/DeployMintersShared.sol @@ -16,6 +16,7 @@ import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; import {Config_MinterMarket, IMarketConfig, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; import {IMinter} from "src/interfaces/IMinter.sol"; import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; +import {LinearMultipleRewardDistributor_v3} from "src/reward/distributor/LinearMultipleRewardDistributor_v3.sol"; /// @notice Extended market config interface with methods from collateral and chain configs. interface IFullMinterConfig { @@ -226,17 +227,38 @@ abstract contract DeployMintersShared is address wrappedCollateral = cfg.wrappedCollateralToken(); address leveragedToken = _predictAddress(_key(marketKey, "leveraged")); - // Collateral SP: harvest + rebalance aliases (both underlying = wrappedCollateral) + // Collateral SP: wrappedCollateral with harvest + rebalance aliases deployRewardAlias(state, spCollKey, "harvest", wrappedCollateral); deployRewardAlias(state, spCollKey, "rebalance", wrappedCollateral); - registerRewardAlias(spCollKey, "harvest"); - registerRewardAlias(spCollKey, "rebalance"); + { + address[] memory collAliases = new address[](2); + collAliases[0] = _predictAddress(_key(spCollKey, "harvest")); + collAliases[1] = _predictAddress(_key(spCollKey, "rebalance")); + LinearMultipleRewardDistributor_v3(_predictAddress(spCollKey)).registerRewardToken( + wrappedCollateral, + collAliases + ); + } - // Leveraged SP: harvest alias (underlying = wrappedCollateral), rebalance alias (underlying = leveragedToken) + // Leveraged SP: wrappedCollateral with harvest alias, leveragedToken with rebalance alias deployRewardAlias(state, spLevKey, "harvest", wrappedCollateral); deployRewardAlias(state, spLevKey, "rebalance", leveragedToken); - registerRewardAlias(spLevKey, "harvest"); - registerRewardAlias(spLevKey, "rebalance"); + { + address[] memory levHarvestAliases = new address[](1); + levHarvestAliases[0] = _predictAddress(_key(spLevKey, "harvest")); + LinearMultipleRewardDistributor_v3(_predictAddress(spLevKey)).registerRewardToken( + wrappedCollateral, + levHarvestAliases + ); + } + { + address[] memory levRebalAliases = new address[](1); + levRebalAliases[0] = _predictAddress(_key(spLevKey, "rebalance")); + LinearMultipleRewardDistributor_v3(_predictAddress(spLevKey)).registerRewardToken( + leveragedToken, + levRebalAliases + ); + } } function _deployStabilityPoolManager( @@ -283,11 +305,6 @@ abstract contract DeployMintersShared is grantStabilityPoolRoles(string.concat(marketKey, "::stabilityPoolCollateral"), spCollateral, spm); grantStabilityPoolRoles(string.concat(marketKey, "::stabilityPoolLeveraged"), spLeveraged, spm); - // Register raw reward tokens (needed for SPM's depositReward calls) - IMultipleRewardDistributor(spCollateral).registerRewardToken(cfg.wrappedCollateralToken()); - IMultipleRewardDistributor(spLeveraged).registerRewardToken(cfg.wrappedCollateralToken()); - IMultipleRewardDistributor(spLeveraged).registerRewardToken(_predictAddress(_key(marketKey, "leveraged"))); - // Configure StabilityPoolManager configureStabilityPoolManager( spm, diff --git a/script/src/contracts/StabilityPool.sol b/script/src/contracts/StabilityPool.sol index 07fc8f22..976b91ae 100644 --- a/script/src/contracts/StabilityPool.sol +++ b/script/src/contracts/StabilityPool.sol @@ -148,15 +148,4 @@ abstract contract StabilityPool is HarborFactoryDeployer { initData ); } - - /// @notice Register a reward alias on a stability pool. - /// @dev The SP must be deployed. The alias address is predicted — doesn't need to be deployed yet. - /// @param spKey The stability pool local key. - /// @param aliasName Alias purpose suffix. - function registerRewardAlias(string memory spKey, string memory aliasName) internal { - address sp = _predictAddress(spKey); - address aliasAddr = _predictAddress(_key(spKey, aliasName)); - IMultipleRewardDistributor(sp).registerRewardToken(aliasAddr); - console.log(" > Registered %s on %s", aliasName, spKey); - } } diff --git a/src/interfaces/IMultipleRewardDistributor.sol b/src/interfaces/IMultipleRewardDistributor.sol index 51a88fb8..0f84157c 100644 --- a/src/interfaces/IMultipleRewardDistributor.sol +++ b/src/interfaces/IMultipleRewardDistributor.sol @@ -48,6 +48,9 @@ interface IMultipleRewardDistributor { /// @dev Thrown when period length is non-zero and outside the range 1 day to 28 day (inclusive). error InvalidPeriodLength(uint40 periodLength); + /// @dev Thrown when an alias's underlying() does not match the expected underlying token. + error AliasUnderlyingMismatch(); + /************************* * Public View Functions * *************************/ diff --git a/src/interfaces/IStabilityPool_v3.sol b/src/interfaces/IStabilityPool_v3.sol index 32e44ebe..468d4050 100644 --- a/src/interfaces/IStabilityPool_v3.sol +++ b/src/interfaces/IStabilityPool_v3.sol @@ -19,4 +19,17 @@ interface IStabilityPool_v3 is IStabilityPool { /// @param token The reward token address to claim. /// @param receiver The address of the recipient. function claimSingle(address account, address token, address receiver) external; + + /// @notice Claim up to maxAmount of a single token's pending rewards. + /// @param account The address of the user. + /// @param token The reward token address to claim. + /// @param maxAmount The maximum amount to claim. Remainder stays as pending. + function claimSingle(address account, address token, uint256 maxAmount) external; + + /// @notice Claim up to maxAmount of a single token's pending rewards, transferring to receiver. + /// @param account The address of the user. + /// @param token The reward token address to claim. + /// @param receiver The address of the recipient. + /// @param maxAmount The maximum amount to claim. Remainder stays as pending. + function claimSingle(address account, address token, address receiver, uint256 maxAmount) external; } diff --git a/src/minter/StabilityPool_v3.sol b/src/minter/StabilityPool_v3.sol index e7669ea4..f2a2be09 100644 --- a/src/minter/StabilityPool_v3.sol +++ b/src/minter/StabilityPool_v3.sol @@ -688,7 +688,7 @@ contract StabilityPool_v3 is /// @inheritdoc IStabilityPool_v3 function claimSingle(address account, address token) external nonReentrant { _checkpoint(account); - _claimSingle(account, token, account); + _claimSingle(account, token, account, type(uint256).max); } /// @inheritdoc IStabilityPool_v3 @@ -697,7 +697,22 @@ contract StabilityPool_v3 is revert ClaimOthersRewardToAnother(); } _checkpoint(account); - _claimSingle(account, token, receiver); + _claimSingle(account, token, receiver, type(uint256).max); + } + + /// @inheritdoc IStabilityPool_v3 + function claimSingle(address account, address token, uint256 maxAmount) external nonReentrant { + _checkpoint(account); + _claimSingle(account, token, account, maxAmount); + } + + /// @inheritdoc IStabilityPool_v3 + function claimSingle(address account, address token, address receiver, uint256 maxAmount) external nonReentrant { + if (account != _msgSender() && receiver != address(0)) { + revert ClaimOthersRewardToAnother(); + } + _checkpoint(account); + _claimSingle(account, token, receiver, maxAmount); } // ═══════════════════════════════════════════════════════════════════════ diff --git a/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol b/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol index bf5c912c..331c881a 100644 --- a/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol +++ b/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol @@ -344,7 +344,7 @@ abstract contract MultipleRewardCompoundingAccumulator_v3 is receiver = sender; } for (uint256 i = 0; i < tokens.length; i++) { - _claimSingle(sender, tokens[i], receiver); // wake-disable-line unchecked-return-value + _claimSingle(sender, tokens[i], receiver, type(uint256).max); // wake-disable-line unchecked-return-value } } @@ -358,7 +358,7 @@ abstract contract MultipleRewardCompoundingAccumulator_v3 is receiver = account; } for (uint256 i = 0; i < tokens.length; i++) { - _claimSingle(account, tokens[i], receiver); // wake-disable-line unchecked-return-value + _claimSingle(account, tokens[i], receiver, type(uint256).max); // wake-disable-line unchecked-return-value } } @@ -512,7 +512,7 @@ abstract contract MultipleRewardCompoundingAccumulator_v3 is } address[] memory activeRewardTokens = activeRewardTokens(); for (uint256 i = 0; i < activeRewardTokens.length; i++) { - _claimSingle(account, activeRewardTokens[i], receiver); // wake-disable-line unchecked-return-value + _claimSingle(account, activeRewardTokens[i], receiver, type(uint256).max); // wake-disable-line unchecked-return-value } } @@ -522,11 +522,57 @@ abstract contract MultipleRewardCompoundingAccumulator_v3 is /// @param account The address of user to claim. /// @param token The address of reward token. /// @param receiver The address of recipient of the reward token. - function _claimSingle(address account, address token, address receiver) internal virtual returns (uint256) { + // function _claimSingle(address account, address token, address receiver) internal virtual returns (uint256) { + // return _claimSingle(account, token, receiver, type(uint256).max); + // } + + /// @dev Internal function to claim up to maxAmount of a single reward token. + /// If token has registered aliases, drains them in order first, then the token's own pending. + /// If token is an alias (no aliases of its own), claims only from that alias. + /// Caller should make sure `_checkpoint` is called before this function. + /// + /// @param account The address of user to claim. + /// @param token The address of reward token (underlying or alias). + /// @param receiver The address of recipient of the reward token. + /// @param maxAmount The maximum amount to claim. Use type(uint256).max for all. + function _claimSingle( + address account, + address token, + address receiver, + uint256 maxAmount + ) internal virtual returns (uint256) { + address[] memory aliases = _getAliases(token); + uint256 totalClaimed; + // Drain aliases in registration order + for (uint256 i = 0; i < aliases.length; i++) { + if (maxAmount == 0) { + break; + } + uint256 aliasAmount = _claimFromToken(account, aliases[i], receiver, maxAmount); + totalClaimed += aliasAmount; + maxAmount -= aliasAmount; + } + // Then drain the token's own pending + if (maxAmount > 0) { + totalClaimed += _claimFromToken(account, token, receiver, maxAmount); + } + return totalClaimed; + } + + /// @dev Claim up to maxAmount from a single token address (no alias traversal). + function _claimFromToken( + address account, + address token, + address receiver, + uint256 maxAmount + ) private returns (uint256) { (uint64 ts, uint256 integral, uint128 pending, uint128 claimed_) = _getUserRewardSnapshot(account, token); uint256 amount = pending; + if (amount > maxAmount) { + amount = maxAmount; + } if (amount > 0) { - _setUserRewardSnapshot(account, token, ts, integral, 0, claimed_ + pending); + _setUserRewardSnapshot(account, token, ts, integral, pending - uint128(amount), claimed_ + uint128(amount)); IERC20(_resolveUnderlying(token)).safeTransfer(receiver, amount); diff --git a/src/reward/distributor/LinearMultipleRewardDistributor_v3.sol b/src/reward/distributor/LinearMultipleRewardDistributor_v3.sol index 01b7cf4f..0a3b2599 100644 --- a/src/reward/distributor/LinearMultipleRewardDistributor_v3.sol +++ b/src/reward/distributor/LinearMultipleRewardDistributor_v3.sol @@ -74,10 +74,10 @@ abstract contract LinearMultipleRewardDistributor_v3 is EnumerableSet.AddressSet activeRewardTokens; /// @dev The list of historical reward tokens. EnumerableSet.AddressSet historicalRewardTokens; - /// @dev Alias: token address => underlying token address. address(0) = not an alias. - mapping(address => address) aliasUnderlying; - /// @dev Reverse: underlying token => aliases pointing to it. - mapping(address => EnumerableSet.AddressSet) underlyingAliases; + /// @dev Alias address => underlying token address. Set at registration, used for token transfers. + mapping(address => address) aliasToUnderlying; + /// @dev Underlying token => ordered list of aliases (drain order for claimSingle(underlying)). + mapping(address => address[]) aliases; } // chisel eval 'keccak256(abi.encode(uint256(keccak256("bao.storage.LinearMultipleRewardDistributor")) - 1)) & ~bytes32(uint256(0xff))' @@ -179,30 +179,65 @@ abstract contract LinearMultipleRewardDistributor_v3 is /// @inheritdoc IMultipleRewardDistributor function registerRewardToken(address token) external onlyOwnerOrRoles(REWARD_MANAGER_ROLE) { + _registerRewardToken(token); + } + + /// @notice Register a reward token with an ordered list of aliases. + /// @dev Each alias must implement IRewardAlias.underlying() returning `token`. + /// Aliases are registered as active tokens with their own integrals. + /// claimSingle(underlying) drains aliases in this order, then underlying's own. + /// @param token The underlying reward token. + /// @param tokenAliases Ordered list of alias addresses (drain order). + function registerRewardToken( + address token, + address[] calldata tokenAliases + ) external onlyOwnerOrRoles(REWARD_MANAGER_ROLE) { + _registerRewardToken(token); + + LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); + for (uint256 i = 0; i < tokenAliases.length; i++) { + address alias_ = tokenAliases[i]; + // Reverts if alias doesn't implement underlying() or returns wrong address + if (IRewardAlias(alias_).underlying() != token) { + revert AliasUnderlyingMismatch(); + } + _registerRewardToken(alias_); + $.aliasToUnderlying[alias_] = token; + $.aliases[token].push(alias_); + } + } + + function _registerRewardToken(address token) internal { if (token == address(0)) { revert RewardTokenIsZero(); } LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); if (!$.activeRewardTokens.add(token)) { - revert DuplicatedRewardToken(); // if value was not added then it already exists + revert DuplicatedRewardToken(); } // slither-disable-next-line unused-return we don't care if the the token was already in the set $.historicalRewardTokens.remove(token); // wake-disable-line unchecked-return-value - // Detect alias: if token implements IRewardAlias.underlying() and returns non-zero - address underlying = _tryGetUnderlying(token); - if (underlying != address(0)) { - $.aliasUnderlying[token] = underlying; - // slither-disable-next-line unused-return - $.underlyingAliases[underlying].add(token); - } - emit RegisterRewardToken(token); } /// @inheritdoc IMultipleRewardDistributor function unregisterRewardToken(address token) external onlyOwnerOrRoles(REWARD_MANAGER_ROLE) { + _unregisterRewardToken(token); + + // If token has aliases, unregister them too (they're a unit) + LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); + address[] storage tokenAliases = $.aliases[token]; + for (uint256 i = 0; i < tokenAliases.length; i++) { + address alias_ = tokenAliases[i]; + _unregisterRewardToken(alias_); + delete $.aliasToUnderlying[alias_]; + } + delete $.aliases[token]; + } + + function _unregisterRewardToken(address token) internal { LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); if (!$.activeRewardTokens.remove(token)) { @@ -219,7 +254,7 @@ abstract contract LinearMultipleRewardDistributor_v3 is } } - // slither-disable-next-line unused-return we don't care if the the token was already in the set + // slither-disable-next-line unused-return $.historicalRewardTokens.add(token); // wake-disable-line unchecked-return-value emit UnregisterRewardToken(token); } @@ -288,28 +323,16 @@ abstract contract LinearMultipleRewardDistributor_v3 is // Alias support // ═══════════════════════════════════════════════════════════════════════ - /// @dev Try to read underlying() from a token. Returns address(0) if not an alias. - function _tryGetUnderlying(address token) internal view returns (address underlying) { - // slither-disable-next-line low-level-calls - (bool success, bytes memory data) = token.staticcall(abi.encodeCall(IRewardAlias.underlying, ())); - if (success && data.length >= 32) { - underlying = abi.decode(data, (address)); - } - } - /// @dev Returns the underlying token for transfers. If not an alias, returns the token itself. function _resolveUnderlying(address token) internal view returns (address) { LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); - address underlying = $.aliasUnderlying[token]; - if (underlying != address(0)) { - return underlying; - } - return token; + address underlying = $.aliasToUnderlying[token]; + return underlying != address(0) ? underlying : token; } - /// @dev Returns all aliases registered for an underlying token. - function _getAliases(address underlying) internal view returns (address[] memory) { + /// @dev Returns the ordered alias list for an underlying token. + function _getAliases(address token) internal view returns (address[] memory) { LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); - return $.underlyingAliases[underlying].values(); + return $.aliases[token]; } } diff --git a/test/StabilityPoolClaimable.t.sol b/test/StabilityPoolClaimable.t.sol index 5277e984..8d530309 100644 --- a/test/StabilityPoolClaimable.t.sol +++ b/test/StabilityPoolClaimable.t.sol @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.28 <0.9.0; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {ITokenHolder} from "@bao/TokenHolder.sol"; import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; @@ -12,8 +13,8 @@ import {IStabilityPool_v3} from "src/interfaces/IStabilityPool_v3.sol"; import {TestStabilityPoolRebalanceSetUp} from "test/StabilityPoolRebalance.t.sol"; contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { - MockERC20 rewardToken1; - MockERC20 rewardToken2; + address rewardToken1; + address rewardToken2; uint256 constant INITIAL_REWARD_AMOUNT = 2000 ether; uint256 constant DEPOSIT_AMOUNT = 10 ether; @@ -22,25 +23,25 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { super.setUp(); // Create reward tokens - rewardToken1 = new MockERC20("Reward Token 1", "RWD1", 18); - vm.label(address(rewardToken1), MockERC20(rewardToken1).symbol()); - rewardToken2 = new MockERC20("Reward Token 2", "RWD2", 18); - vm.label(address(rewardToken2), MockERC20(rewardToken2).symbol()); + rewardToken1 = address(new MockERC20("Reward Token 1", "RWD1", 18)); + vm.label(rewardToken1, MockERC20(rewardToken1).symbol()); + rewardToken2 = address(new MockERC20("Reward Token 2", "RWD2", 18)); + vm.label(rewardToken2, MockERC20(rewardToken2).symbol()); // register reward tokens vm.startPrank(rewardManager); - IMultipleRewardDistributor(stabilityPoolCollateral).registerRewardToken(address(rewardToken1)); - IMultipleRewardDistributor(stabilityPoolCollateral).registerRewardToken(address(rewardToken2)); + IMultipleRewardDistributor(stabilityPoolCollateral).registerRewardToken(rewardToken1); + IMultipleRewardDistributor(stabilityPoolCollateral).registerRewardToken(rewardToken2); vm.stopPrank(); // Initialize reward tokens with some balance for the rewardDepositor - rewardToken1.mint(rewardDepositor, INITIAL_REWARD_AMOUNT); - rewardToken2.mint(rewardDepositor, INITIAL_REWARD_AMOUNT); + MockERC20(rewardToken1).mint(rewardDepositor, INITIAL_REWARD_AMOUNT); + MockERC20(rewardToken2).mint(rewardDepositor, INITIAL_REWARD_AMOUNT); // Approve rewards to be spent by the stability pool vm.startPrank(rewardDepositor); - rewardToken1.approve(stabilityPoolCollateral, type(uint256).max); - rewardToken2.approve(stabilityPoolCollateral, type(uint256).max); + IERC20(rewardToken1).approve(stabilityPoolCollateral, type(uint256).max); + IERC20(rewardToken2).approve(stabilityPoolCollateral, type(uint256).max); vm.stopPrank(); // Give users some pegged tokens for deposits @@ -77,23 +78,23 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { // Distribute some rewards uint256 rewardAmount = 300 ether; // 100 per user - _depositRewardAndWait(address(rewardToken1), rewardAmount); + _depositRewardAndWait(rewardToken1, rewardAmount); // Check claimable amounts - should be distributed equally as all have equal deposits assertApproxEqRel( - IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(rewardToken1)), + IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1), rewardAmount / 3, 0.01e18 // Allow 1% deviation due to rounding ); assertApproxEqRel( - IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user2, address(rewardToken1)), + IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user2, rewardToken1), rewardAmount / 3, 0.01e18 ); assertApproxEqRel( - IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user3, address(rewardToken1)), + IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user3, rewardToken1), rewardAmount / 3, 0.01e18 ); @@ -105,7 +106,7 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { // Distribute some rewards uint256 rewardAmount = 300 ether; - _depositRewardAndWait(address(rewardToken1), rewardAmount); + _depositRewardAndWait(rewardToken1, rewardAmount); // User2 withdraws half their deposit vm.prank(user2); @@ -116,7 +117,7 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { IStabilityPool(stabilityPoolCollateral).withdraw(DEPOSIT_AMOUNT / 2, user2, 0); // Distribute more rewards - should be split proportionally to current deposits - _depositRewardAndWait(address(rewardToken1), rewardAmount); + _depositRewardAndWait(rewardToken1, rewardAmount); // First rewards should be split equally // Second rewards should be split as 2/5 to user1, 1/5 to user2, 2/5 to user3 @@ -125,19 +126,19 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { uint256 expectedUser3 = (rewardAmount / 3) + ((rewardAmount * 2) / 5); assertApproxEqRel( - IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(rewardToken1)), + IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1), expectedUser1, 0.01e18 ); assertApproxEqRel( - IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user2, address(rewardToken1)), + IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user2, rewardToken1), expectedUser2, 0.01e18 ); assertApproxEqRel( - IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user3, address(rewardToken1)), + IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user3, rewardToken1), expectedUser3, 0.01e18 ); @@ -152,24 +153,15 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { // Distribute some rewards uint256 rewardAmount = 300 ether; - _depositRewardAndWait(address(rewardToken1), rewardAmount); + _depositRewardAndWait(rewardToken1, rewardAmount); // Advance time again vm.warp(block.timestamp + 1 hours); // Record initial claimable amounts before withdrawal - uint256 initialUser1 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( - user1, - address(rewardToken1) - ); - uint256 initialUser2 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( - user2, - address(rewardToken1) - ); - uint256 initialUser3 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( - user3, - address(rewardToken1) - ); + uint256 initialUser1 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1); + uint256 initialUser2 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user2, rewardToken1); + uint256 initialUser3 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user3, rewardToken1); uint256 user1Balance = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1); uint256 user2Balance = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user2); @@ -192,7 +184,7 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { assertEq(IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user3), user3Balance); // Distribute more rewards - should be split proportionally to current deposits - _depositRewardAndWait(address(rewardToken1), rewardAmount); + _depositRewardAndWait(rewardToken1, rewardAmount); // After the second distribution, check each user's rewards: // First reward distribution: Each user gets 1/3 (equal shares) @@ -206,18 +198,9 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { uint256 expectedUser3 = initialUser3 + (rewardAmount * 40) / 100; // Get actual rewards for logging and comparison - uint256 actualUser1 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( - user1, - address(rewardToken1) - ); - uint256 actualUser2 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( - user2, - address(rewardToken1) - ); - uint256 actualUser3 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( - user3, - address(rewardToken1) - ); + uint256 actualUser1 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1); + uint256 actualUser2 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user2, rewardToken1); + uint256 actualUser3 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user3, rewardToken1); // Assert that each user gets their correct proportional share assertApproxEqRel(actualUser1, expectedUser1, 0.01e18); @@ -231,40 +214,40 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { // Distribute some rewards uint256 rewardAmount = 300 ether; - _depositRewardAndWait(address(rewardToken1), rewardAmount); + _depositRewardAndWait(rewardToken1, rewardAmount); // Record initial claimable amounts uint256 initialClaimableUser1 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( user1, - address(rewardToken1) + rewardToken1 ); uint256 initialClaimableUser2 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( user2, - address(rewardToken1) + rewardToken1 ); uint256 initialClaimableUser3 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( user3, - address(rewardToken1) + rewardToken1 ); // Rebalancer sweeps some non-asset tokens - rewardToken2.mint(address(stabilityPoolCollateral), 100 ether); + MockERC20(rewardToken2).mint(address(stabilityPoolCollateral), 100 ether); vm.prank(rebalancer); - ITokenHolder(stabilityPoolCollateral).sweep(address(rewardToken2), 100 ether, rebalancer); + ITokenHolder(stabilityPoolCollateral).sweep(rewardToken2, 100 ether, rebalancer); // Check claimable amounts - should remain unchanged for the first reward token assertEq( - IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(rewardToken1)), + IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1), initialClaimableUser1 ); assertEq( - IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user2, address(rewardToken1)), + IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user2, rewardToken1), initialClaimableUser2 ); assertEq( - IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user3, address(rewardToken1)), + IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user3, rewardToken1), initialClaimableUser3 ); } @@ -275,12 +258,12 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { // Distribute some rewards uint256 rewardAmount = 300 ether; - _depositRewardAndWait(address(rewardToken1), rewardAmount); + _depositRewardAndWait(rewardToken1, rewardAmount); // Record initial claimable amounts uint256 initialClaimableUser1 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( user1, - address(rewardToken1) + rewardToken1 ); // Rebalancer sweeps some asset tokens - this should trigger _notifyLoss @@ -289,18 +272,18 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { // The claimable amounts should remain the same despite the loss // because rewards are calculated based on proportional shares assertApproxEqRel( - IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(rewardToken1)), + IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1), initialClaimableUser1, 0.01e18 ); // Distribute more rewards after loss - _depositRewardAndWait(address(rewardToken1), rewardAmount); + _depositRewardAndWait(rewardToken1, rewardAmount); // Users should still get proportional rewards skip(8 days); assertApproxEqRel( - IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(rewardToken1)), + IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1), initialClaimableUser1 + (rewardAmount / 3), 0.01e18 ); @@ -312,21 +295,21 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { // Distribute rewards from first token uint256 rewardAmount1 = 300 ether; - _depositRewardAndWait(address(rewardToken1), rewardAmount1); + _depositRewardAndWait(rewardToken1, rewardAmount1); // Distribute rewards from second token uint256 rewardAmount2 = 600 ether; - _depositRewardAndWait(address(rewardToken2), rewardAmount2); + _depositRewardAndWait(rewardToken2, rewardAmount2); // Check claimable amounts for both tokens assertApproxEqRel( - IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(rewardToken1)), + IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1), rewardAmount1 / 3, 0.01e18 ); assertApproxEqRel( - IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(rewardToken2)), + IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken2), rewardAmount2 / 3, 0.01e18 ); @@ -338,7 +321,7 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { // Distribute some rewards uint256 rewardAmount = 300 ether; - _depositRewardAndWait(address(rewardToken1), rewardAmount); + _depositRewardAndWait(rewardToken1, rewardAmount); // User1 makes an additional deposit vm.prank(user1); @@ -347,15 +330,15 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { // Record claimable amounts after first distribution but before second uint256 claimableAfterFirstUser1 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( user1, - address(rewardToken1) + rewardToken1 ); uint256 claimableAfterFirstUser2 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( user2, - address(rewardToken1) + rewardToken1 ); // Distribute more rewards - now user1 should get a larger share - _depositRewardAndWait(address(rewardToken1), rewardAmount); + _depositRewardAndWait(rewardToken1, rewardAmount); // Calculate expected rewards: // User1 now has 2/4 of total deposits @@ -365,13 +348,13 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { uint256 expectedUser2 = claimableAfterFirstUser2 + ((rewardAmount * 1) / 4); assertApproxEqRel( - IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(rewardToken1)), + IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1), expectedUser1, 0.01e18 ); assertApproxEqRel( - IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user2, address(rewardToken1)), + IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user2, rewardToken1), expectedUser2, 0.01e18 ); @@ -383,12 +366,12 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { // Distribute some rewards uint256 rewardAmount = 300 ether; - _depositRewardAndWait(address(rewardToken1), rewardAmount); + _depositRewardAndWait(rewardToken1, rewardAmount); // Record initial claimable amounts uint256 initialClaimableUser1 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( user1, - address(rewardToken1) + rewardToken1 ); // Skip ahead in time @@ -396,7 +379,7 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { // Claimable amounts should not change just due to time passage assertEq( - IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(rewardToken1)), + IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1), initialClaimableUser1 ); } @@ -410,14 +393,14 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { IStabilityPool(stabilityPoolCollateral).deposit(DEPOSIT_AMOUNT, user2, 0); // Distribute first reward - _depositRewardAndWait(address(rewardToken1), 200 ether); + _depositRewardAndWait(rewardToken1, 200 ether); // User 3 joins with a deposit vm.prank(user3); IStabilityPool(stabilityPoolCollateral).deposit(DEPOSIT_AMOUNT * 2, user3, 0); // Distribute second reward - _depositRewardAndWait(address(rewardToken1), 300 ether); + _depositRewardAndWait(rewardToken1, 300 ether); // User 1 withdraws half vm.prank(user1); @@ -431,14 +414,14 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { vm.warp(block.timestamp + 3 days); // Distribute third reward - _depositRewardAndWait(address(rewardToken1), 150 ether); + _depositRewardAndWait(rewardToken1, 150 ether); // Sweep some asset tokens to simulate a loss vm.prank(rebalancer); ITokenHolder(stabilityPoolCollateral).sweep(peggedToken, DEPOSIT_AMOUNT / 4, rebalancer); // Distribute fourth reward - _depositRewardAndWait(address(rewardToken1), 100 ether); + _depositRewardAndWait(rewardToken1, 100 ether); // Calculate expected rewards through this complex scenario // First distribution: 50/50 split between user1 and user2 = 100 each @@ -451,19 +434,19 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { uint256 expectedUser3 = 0 ether + 150 ether + 85.7 ether + 57.1 ether; assertApproxEqRel( - IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(rewardToken1)), + IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1), expectedUser1, 0.05e18 // Allow 5% deviation due to complex scenario ); assertApproxEqRel( - IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user2, address(rewardToken1)), + IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user2, rewardToken1), expectedUser2, 0.05e18 ); assertApproxEqRel( - IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user3, address(rewardToken1)), + IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user3, rewardToken1), expectedUser3, 0.05e18 ); @@ -481,13 +464,13 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { // Distribute rewards uint256 rewardAmount = 101 ether; - _depositRewardAndWait(address(rewardToken1), rewardAmount); + _depositRewardAndWait(rewardToken1, rewardAmount); // Check the small deposit still gets some rewards, proportional to its share uint256 expectedUser2 = (rewardAmount * smallDeposit) / (DEPOSIT_AMOUNT + smallDeposit); assertApproxEqRel( - IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user2, address(rewardToken1)), + IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user2, rewardToken1), expectedUser2, 0.01e18 ); @@ -495,7 +478,7 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { // Optional: Also verify user1 gets the remaining rewards uint256 expectedUser1 = (rewardAmount * DEPOSIT_AMOUNT) / (DEPOSIT_AMOUNT + smallDeposit); assertApproxEqRel( - IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(rewardToken1)), + IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1), expectedUser1, 0.01e18 ); @@ -514,7 +497,7 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { // Distribute rewards uint256 rewardAmount = 1001 ether; - _depositRewardAndWait(address(rewardToken1), rewardAmount); + _depositRewardAndWait(rewardToken1, rewardAmount); // Check the small deposit gets proportional rewards // user2 should get: (1 ether / 1001 ether) * 1001 ether ≈ 1 ether @@ -522,14 +505,14 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { uint256 expectedUser1 = (rewardAmount * largeDeposit) / (largeDeposit + smallDeposit); assertApproxEqRel( - IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user2, address(rewardToken1)), + IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user2, rewardToken1), expectedUser2, 0.01e18, "Small deposit should get proportional rewards" ); assertApproxEqRel( - IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(rewardToken1)), + IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1), expectedUser1, 0.01e18, "Large deposit should get most of the rewards" @@ -542,12 +525,12 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { // Distribute some rewards uint256 rewardAmount = 300 ether; - _depositRewardAndWait(address(rewardToken1), rewardAmount); + _depositRewardAndWait(rewardToken1, rewardAmount); // Record initial claimable amounts uint256 initialClaimableUser1 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( user1, - address(rewardToken1) + rewardToken1 ); // Rebalancer sweeps ALL asset tokens - this should trigger _notifyLoss for everything @@ -556,18 +539,18 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { // Users should still be able to claim their rewards despite total loss of assets assertApproxEqRel( - IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(rewardToken1)), + IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1), initialClaimableUser1, 0.01e18 ); // Distribute more rewards - these SHOULD be claimable based on historical deposit ratios - _depositRewardAndWait(address(rewardToken1), rewardAmount); + _depositRewardAndWait(rewardToken1, rewardAmount); // User1 should now have: original claimable + 1/3 of new rewards uint256 expectedTotal = initialClaimableUser1 + (rewardAmount / 3); assertApproxEqRel( - IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(rewardToken1)), + IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1), expectedTotal, 0.01e18, "After total loss, new rewards should still be distributed based on historical ratios" @@ -584,10 +567,10 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { _liquidate(IStabilityPool(stabilityPoolCollateral).totalAssetSupply()); // Distribute more rewards after loss - _depositRewardAndWait(address(rewardToken1), rewardAmount); + _depositRewardAndWait(rewardToken1, rewardAmount); assertApproxEqAbs( - IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(rewardToken1)), + IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1), rewardAmount / 3, 1e4, "User claimable after full liquidation: %s" @@ -610,10 +593,10 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { vm.stopPrank(); // Distribute more rewards after loss - _depositRewardAndWait(address(rewardToken1), rewardAmount); + _depositRewardAndWait(rewardToken1, rewardAmount); assertApproxEqAbs( - IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(rewardToken1)), + IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1), rewardAmount / 3, 1e4, "User claimable after full liquidation: %s" @@ -626,34 +609,28 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { function testClaimSingle_claimsOnlySpecifiedToken() public { _depositForUsers(); - _depositRewardAndWait(address(rewardToken1), 100 ether); - _depositRewardAndWait(address(rewardToken2), 200 ether); + _depositRewardAndWait(rewardToken1, 100 ether); + _depositRewardAndWait(rewardToken2, 200 ether); - uint256 claimable1 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( - user1, - address(rewardToken1) - ); - uint256 claimable2 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( - user1, - address(rewardToken2) - ); + uint256 claimable1 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1); + uint256 claimable2 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken2); assertGt(claimable1, 0, "should have claimable rewardToken1"); assertGt(claimable2, 0, "should have claimable rewardToken2"); // Claim only rewardToken1 - uint256 bal1Before = rewardToken1.balanceOf(user1); - uint256 bal2Before = rewardToken2.balanceOf(user1); + uint256 bal1Before = IERC20(rewardToken1).balanceOf(user1); + uint256 bal2Before = IERC20(rewardToken2).balanceOf(user1); vm.prank(user1); - IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, address(rewardToken1)); + IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, rewardToken1); // rewardToken1 claimed - assertEq(rewardToken1.balanceOf(user1) - bal1Before, claimable1, "rewardToken1 claimed"); + assertEq(IERC20(rewardToken1).balanceOf(user1) - bal1Before, claimable1, "rewardToken1 claimed"); // rewardToken2 NOT claimed - assertEq(rewardToken2.balanceOf(user1), bal2Before, "rewardToken2 untouched"); + assertEq(IERC20(rewardToken2).balanceOf(user1), bal2Before, "rewardToken2 untouched"); // rewardToken2 still claimable assertGt( - IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(rewardToken2)), + IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken2), 0, "rewardToken2 still claimable" ); @@ -661,55 +638,221 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { function testClaimSingle_withReceiver() public { _depositForUsers(); - _depositRewardAndWait(address(rewardToken1), 100 ether); + _depositRewardAndWait(rewardToken1, 100 ether); - uint256 claimable1 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( - user1, - address(rewardToken1) - ); + uint256 claimable1 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1); address receiver = makeAddr("receiver"); vm.prank(user1); - IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, address(rewardToken1), receiver); + IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, rewardToken1, receiver); - assertEq(rewardToken1.balanceOf(receiver), claimable1, "receiver got tokens"); - assertEq(rewardToken1.balanceOf(user1), 0, "user1 got nothing"); + assertEq(IERC20(rewardToken1).balanceOf(receiver), claimable1, "receiver got tokens"); + assertEq(IERC20(rewardToken1).balanceOf(user1), 0, "user1 got nothing"); } function testClaimSingle_forOtherUser() public { _depositForUsers(); - _depositRewardAndWait(address(rewardToken1), 100 ether); + _depositRewardAndWait(rewardToken1, 100 ether); - uint256 claimable1 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( - user1, - address(rewardToken1) - ); + uint256 claimable1 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1); // Anyone can trigger claim for user1 — tokens go to user1 vm.prank(user2); - IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, address(rewardToken1)); + IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, rewardToken1); - assertEq(rewardToken1.balanceOf(user1), claimable1, "user1 received tokens"); + assertEq(IERC20(rewardToken1).balanceOf(user1), claimable1, "user1 received tokens"); } function testClaimSingle_cannotRedirectOthersReward() public { _depositForUsers(); - _depositRewardAndWait(address(rewardToken1), 100 ether); + _depositRewardAndWait(rewardToken1, 100 ether); address receiver = makeAddr("receiver"); // user2 cannot redirect user1's rewards to receiver vm.prank(user2); vm.expectRevert(IMultipleRewardAccumulator.ClaimOthersRewardToAnother.selector); - IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, address(rewardToken1), receiver); + IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, rewardToken1, receiver); } function testClaimSingle_zeroClaimable() public { _depositForUsers(); // No rewards deposited — claimSingle should not revert vm.prank(user1); - IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, address(rewardToken1)); - assertEq(rewardToken1.balanceOf(user1), 0, "nothing claimed"); + IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, rewardToken1); + assertEq(IERC20(rewardToken1).balanceOf(user1), 0, "nothing claimed"); + } + + // ═══════════════════════════════════════════════════════════════════════ + // Fractional claimSingle tests + // ═══════════════════════════════════════════════════════════════════════ + + function testClaimSingle_fractional_claimsPartial() public { + _depositForUsers(); + _depositRewardAndWait(rewardToken1, 100 ether); + + uint256 claimable = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1); + assertGt(claimable, 0, "should have claimable"); + + // Claim half + uint256 halfAmount = claimable / 2; + uint256 balBefore = IERC20(rewardToken1).balanceOf(user1); + vm.prank(user1); + IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, rewardToken1, halfAmount); + + // Received exactly halfAmount + assertEq(IERC20(rewardToken1).balanceOf(user1) - balBefore, halfAmount, "received half"); + + // Remainder still claimable + uint256 remaining = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1); + assertApproxEqAbs(remaining, claimable - halfAmount, 1, "remainder still claimable"); + } + + function testClaimSingle_fractional_claimAll() public { + _depositForUsers(); + _depositRewardAndWait(rewardToken1, 100 ether); + + uint256 claimable = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1); + + // Claim with maxAmount > claimable — should claim all + uint256 balBefore = IERC20(rewardToken1).balanceOf(user1); + vm.prank(user1); + IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, rewardToken1, type(uint256).max); + + assertEq(IERC20(rewardToken1).balanceOf(user1) - balBefore, claimable, "claimed all"); + + // Nothing remaining + uint256 remaining = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1); + assertEq(remaining, 0, "nothing remaining"); + } + + function testClaimSingle_fractional_claimZero() public { + _depositForUsers(); + _depositRewardAndWait(rewardToken1, 100 ether); + + uint256 claimable = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1); + + // Claim zero — should be a no-op + uint256 balBefore = IERC20(rewardToken1).balanceOf(user1); + vm.prank(user1); + IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, rewardToken1, 0); + + assertEq(IERC20(rewardToken1).balanceOf(user1), balBefore, "nothing transferred"); + assertEq( + IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1), + claimable, + "claimable unchanged" + ); + } + + function testClaimSingle_fractional_withReceiver() public { + _depositForUsers(); + _depositRewardAndWait(rewardToken1, 100 ether); + + uint256 claimable = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1); + + address receiver = makeAddr("receiver"); + uint256 partialAmount = claimable / 3; + vm.prank(user1); + IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, rewardToken1, receiver, partialAmount); + + assertEq(IERC20(rewardToken1).balanceOf(receiver), partialAmount, "receiver got partial"); + assertEq(IERC20(rewardToken1).balanceOf(user1), 0, "user got nothing"); + + // Remainder still claimable + uint256 remaining = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1); + assertApproxEqAbs(remaining, claimable - partialAmount, 1, "remainder still claimable"); + } + + function testClaimSingle_fractional_multipleClaims() public { + _depositForUsers(); + _depositRewardAndWait(rewardToken1, 100 ether); + + uint256 claimable = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1); + + // Claim in three tranches + uint256 tranche = claimable / 3; + vm.startPrank(user1); + IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, rewardToken1, tranche); + IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, rewardToken1, tranche); + IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, rewardToken1, type(uint256).max); + vm.stopPrank(); + + // Should have claimed everything + assertApproxEqAbs(IERC20(rewardToken1).balanceOf(user1), claimable, 1, "claimed everything in 3 tranches"); + assertEq( + IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1), + 0, + "nothing remaining after 3 tranches" + ); + } + + function testClaimSingle_fractional_linearAccrual_midPeriod() public { + _depositForUsers(); + + // Deposit reward but only wait half the distribution period (604800s = 7 days) + vm.prank(rewardDepositor); + IMultipleRewardDistributor(stabilityPoolCollateral).depositReward(rewardToken1, 300 ether); + skip(3.5 days); + + // ~half should be claimable (distributed linearly) + uint256 claimable = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1); + assertGt(claimable, 0, "mid-period claimable"); + // ~50 ether per user (300/3 users * 50% of period) + assertApproxEqRel(claimable, 50 ether, 0.02 ether, "~50 per user at midpoint"); + + // Fractional claim: take 20 ether + vm.prank(user1); + IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, rewardToken1, 20 ether); + assertEq(IERC20(rewardToken1).balanceOf(user1), 20 ether, "received 20"); + + // Wait for rest of period + skip(3.5 days); + + // Full amount now available (minus what was already claimed) + uint256 finalClaimable = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1); + // ~100 per user total, minus 20 already claimed = ~80 + assertApproxEqRel(finalClaimable, 80 ether, 0.02 ether, "~80 remaining after full period"); + + // Claim the rest + vm.prank(user1); + IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, rewardToken1, type(uint256).max); + assertApproxEqRel(IERC20(rewardToken1).balanceOf(user1), 100 ether, 0.02 ether, "~100 total"); + } + + function testClaimSingle_fractional_twoTokens_independent() public { + _depositForUsers(); + _depositRewardAndWait(rewardToken1, 90 ether); + _depositRewardAndWait(rewardToken2, 180 ether); + + uint256 claimable1 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1); + uint256 claimable2 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken2); + + // Partial claim from token1 only + vm.prank(user1); + IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, rewardToken1, claimable1 / 4); + + // token2 claimable unchanged + assertEq( + IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken2), + claimable2, + "token2 unaffected by token1 partial claim" + ); + + // token1 reduced + uint256 remaining1 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1); + assertApproxEqAbs(remaining1, claimable1 - claimable1 / 4, 1, "token1 reduced by claimed amount"); + } + + function testClaimSingle_fractional_withReceiver_revertsForOthers() public { + _depositForUsers(); + _depositRewardAndWait(rewardToken1, 100 ether); + + // user2 tries to claim user1's reward to a custom receiver — should revert + address receiver = makeAddr("receiver"); + vm.prank(user2); + vm.expectRevert(); + IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, rewardToken1, receiver, 50 ether); } } @@ -719,6 +862,7 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { import {RewardAlias_v1} from "src/reward/RewardAlias_v1.sol"; import {IStabilityPool_v3} from "src/interfaces/IStabilityPool_v3.sol"; +import {LinearMultipleRewardDistributor_v3} from "src/reward/distributor/LinearMultipleRewardDistributor_v3.sol"; contract TestRewardAlias is TestStabilityPoolRebalanceSetUp { MockERC20 aliasUnderlying; @@ -738,11 +882,15 @@ contract TestRewardAlias is TestStabilityPoolRebalanceSetUp { boostAlias = new RewardAlias_v1(address(aliasUnderlying)); vm.label(address(boostAlias), "BOOST_ALIAS"); - // Register both aliases as reward tokens - vm.startPrank(rewardManager); - IMultipleRewardDistributor(stabilityPoolCollateral).registerRewardToken(address(harvestAlias)); - IMultipleRewardDistributor(stabilityPoolCollateral).registerRewardToken(address(boostAlias)); - vm.stopPrank(); + // Register underlying with both aliases (drain order: harvest first, then boost) + address[] memory aliases = new address[](2); + aliases[0] = address(harvestAlias); + aliases[1] = address(boostAlias); + vm.prank(rewardManager); + LinearMultipleRewardDistributor_v3(stabilityPoolCollateral).registerRewardToken( + address(aliasUnderlying), + aliases + ); // Fund the depositor with the underlying reward token aliasUnderlying.mint(rewardDepositor, 1000 ether); @@ -954,4 +1102,107 @@ contract TestRewardAlias is TestStabilityPoolRebalanceSetUp { } assertEq(aliasCount, 1, "plain token registered once"); } + + // ── Fractional claim with aliases ────────────────────────────────── + + function testAlias_fractionalClaim_partialFromAlias() public { + _depositRewardAndWait(address(harvestAlias), 100 ether); + + uint256 claimable = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(harvestAlias)); + assertGt(claimable, 0, "should have claimable via alias"); + + // Partial claim via alias — should receive underlying token + uint256 half = claimable / 2; + uint256 balBefore = IERC20(address(aliasUnderlying)).balanceOf(user1); + vm.prank(user1); + IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, address(harvestAlias), half); + + assertEq(IERC20(address(aliasUnderlying)).balanceOf(user1) - balBefore, half, "received underlying"); + + // Remainder still claimable via alias + uint256 remaining = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(harvestAlias)); + assertApproxEqAbs(remaining, claimable - half, 1, "remainder via alias"); + } + + function testAlias_fractionalClaim_partialFromOneAlias_otherUnaffected() public { + _depositRewardAndWait(address(harvestAlias), 60 ether); + _depositRewardAndWait(address(boostAlias), 40 ether); + + uint256 claimableHarvest = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( + user1, + address(harvestAlias) + ); + uint256 claimableBoost = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( + user1, + address(boostAlias) + ); + + // Partial claim from harvestAlias only + vm.prank(user1); + IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, address(harvestAlias), claimableHarvest / 3); + + // boostAlias claimable unchanged + assertEq( + IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(boostAlias)), + claimableBoost, + "boost alias unaffected" + ); + + // harvestAlias reduced + uint256 remainHarvest = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( + user1, + address(harvestAlias) + ); + assertApproxEqAbs(remainHarvest, claimableHarvest - claimableHarvest / 3, 1, "harvest alias reduced"); + } + + function testAlias_fractionalClaim_thenClaimUnderlying_aggregated() public { + _depositRewardAndWait(address(harvestAlias), 60 ether); + _depositRewardAndWait(address(boostAlias), 40 ether); + + // Aggregated claimable for underlying = sum of both aliases + uint256 aggregated = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( + user1, + address(aliasUnderlying) + ); + uint256 claimableHarvest = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( + user1, + address(harvestAlias) + ); + uint256 claimableBoost = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( + user1, + address(boostAlias) + ); + assertApproxEqAbs(aggregated, claimableHarvest + claimableBoost, 2, "aggregated = sum of aliases"); + + // Partial claim from harvestAlias + vm.prank(user1); + IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, address(harvestAlias), claimableHarvest / 2); + + // Aggregated drops by the claimed amount + uint256 newAggregated = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( + user1, + address(aliasUnderlying) + ); + assertApproxEqAbs(newAggregated, aggregated - claimableHarvest / 2, 2, "aggregated reduced by partial claim"); + } + + function testAlias_fractionalClaim_withReceiver() public { + _depositRewardAndWait(address(harvestAlias), 100 ether); + + uint256 claimable = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(harvestAlias)); + address receiver = makeAddr("aliasReceiver"); + uint256 partialAmount = claimable / 4; + + vm.prank(user1); + IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, address(harvestAlias), receiver, partialAmount); + + // Receiver gets the underlying token, not the alias + assertEq(IERC20(address(aliasUnderlying)).balanceOf(receiver), partialAmount, "receiver got underlying"); + assertEq(IERC20(address(aliasUnderlying)).balanceOf(user1), 0, "user got nothing"); + + // Remainder + uint256 remaining = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(harvestAlias)); + assertApproxEqAbs(remaining, claimable - partialAmount, 1, "remainder after partial alias claim"); + } } diff --git a/test/deployment/RewardSystem.t.sol b/test/deployment/RewardSystem.t.sol new file mode 100644 index 00000000..61c143a8 --- /dev/null +++ b/test/deployment/RewardSystem.t.sol @@ -0,0 +1,395 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.28 <0.9.0; + +import {BaoTest} from "@bao-test/BaoTest.sol"; +import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; +import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; +import {Deploy_ETH_Minter} from "script/src/Deploy_ETH_Minter.sol"; +import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; +import {Config_MinterMarket, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; +import {IERC5313} from "@openzeppelin/contracts/interfaces/IERC5313.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {IMinter} from "src/interfaces/IMinter.sol"; +import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; +import {IStabilityPool_v3} from "src/interfaces/IStabilityPool_v3.sol"; +import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; +import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; +import {IRewardAlias} from "src/interfaces/IRewardAlias.sol"; +import {RewardAlias_v1} from "src/reward/RewardAlias_v1.sol"; +import {Minter_v2} from "src/minter/Minter_v2.sol"; +import {StabilityPoolManager_v1} from "src/minter/StabilityPoolManager_v1.sol"; +import {MockWrappedPriceOracle} from "test/mocks/MockWrappedPriceOracle.sol"; + +/// @title Reward system tests — aliases, accumulator, distributor — using deployment framework +contract RewardSystemSetUp is BaoTest, Deploy_ETH_Minter { + address minter; + address stabilityPoolCollateral; + address stabilityPoolLeveraged; + address stabilityPoolManager; + address pegged; + address leveraged; + address wrappedCollateral; + + address collHarvestAlias; + address collRebalanceAlias; + + MockWrappedPriceOracle mockOracle; + + function _shouldPersistState() internal pure override returns (bool) { + return false; + } + + function setUp() public virtual { + address factory = _ensureBaoFactory(); + // Pinned after latest Harbor deployment (SPL remediation, 2026-03-25) for caching + vm.createSelectFork(vm.rpcUrl("mainnet"), 24699497); + + vm.prank(IBaoFactory(factory).owner()); + IBaoFactory(factory).setOperator(address(this), 365 days); + + (ConfigPeg peg, Config_MinterMarket[] memory mktConfigs) = createETHMintersConfig(); + Config_MinterMarket[] memory toDeploy = new Config_MinterMarket[](1); + toDeploy[0] = mktConfigs[0]; + deployForPeg("reward_cov", peg, mktConfigs, "mainnet", true, toDeploy); + + _setSaltPrefix("reward_cov"); + string memory marketKey = "ETH::fxUSD"; + minter = _predictAddress(_key(marketKey, "minter")); + stabilityPoolCollateral = _predictAddress(_key(marketKey, "stabilityPoolCollateral")); + stabilityPoolLeveraged = _predictAddress(_key(marketKey, "stabilityPoolLeveraged")); + stabilityPoolManager = _predictAddress(_key(marketKey, "stabilityPoolManager")); + pegged = _predictAddress(_key("ETH", "pegged")); + leveraged = _predictAddress(_key(marketKey, "leveraged")); + wrappedCollateral = IMinter(minter).WRAPPED_COLLATERAL_TOKEN(); + + collHarvestAlias = _predictAddress(_key(marketKey, "stabilityPoolCollateral", "harvest")); + collRebalanceAlias = _predictAddress(_key(marketKey, "stabilityPoolCollateral", "rebalance")); + + mockOracle = new MockWrappedPriceOracle(); + mockOracle.setLatestAnswer(1 ether, 1 ether); + + vm.startPrank(HARBOR_MULTISIG); + IMinter(minter).updatePriceOracle(address(mockOracle)); + uint256 zeroFeeRole = IMinter(minter).ZERO_FEE_ROLE(); + IBaoRoles(minter).grantRoles(address(this), zeroFeeRole); + uint256 depositorRole = IMultipleRewardDistributor(stabilityPoolCollateral).REWARD_DEPOSITOR_ROLE(); + IBaoRoles(stabilityPoolCollateral).grantRoles(address(this), depositorRole); + vm.stopPrank(); + } + + function _mintAndDeposit(address user, uint256 amount) internal { + deal(wrappedCollateral, address(this), amount); + IERC20(wrappedCollateral).approve(minter, amount); + uint256 peggedMinted = IMinter(minter).freeMintPeggedToken(amount, user); + vm.startPrank(user); + IERC20(pegged).approve(stabilityPoolCollateral, peggedMinted); + IStabilityPool(stabilityPoolCollateral).deposit(peggedMinted, user, 0); + vm.stopPrank(); + } + + function _depositReward(address token, uint256 amount) internal { + deal(wrappedCollateral, address(this), amount); + IERC20(wrappedCollateral).approve(stabilityPoolCollateral, amount); + IMultipleRewardDistributor(stabilityPoolCollateral).depositReward(token, amount); + } +} + +// ═══════════════════════════════════════════════════════════════ +// RewardAlias_v1 contract coverage +// ═══════════════════════════════════════════════════════════════ + +contract RewardAliasTest is RewardSystemSetUp { + function test_constructorRevertsZeroAddress() public { + vm.expectRevert(); + new RewardAlias_v1(address(0)); + } + + function test_supportsInterface() public view { + assertTrue(IERC165(collHarvestAlias).supportsInterface(type(IERC5313).interfaceId), "IERC5313"); + assertTrue(IERC165(collHarvestAlias).supportsInterface(type(IERC165).interfaceId), "IERC165"); + assertFalse(IERC165(collHarvestAlias).supportsInterface(0xdeadbeef), "random"); + } + + function test_upgradeByOwner() public { + RewardAlias_v1 newImpl = new RewardAlias_v1(wrappedCollateral); + + // Owner (multisig after transferAllOwnerships) can upgrade + vm.prank(HARBOR_MULTISIG); + UUPSUpgradeable(collHarvestAlias).upgradeToAndCall(address(newImpl), ""); + + // Underlying unchanged (immutable in new impl) + assertEq(IRewardAlias(collHarvestAlias).underlying(), wrappedCollateral, "underlying preserved"); + } + + function test_upgradeRevertsNotOwner() public { + RewardAlias_v1 newImpl = new RewardAlias_v1(wrappedCollateral); + vm.prank(makeAddr("attacker")); + vm.expectRevert(); + UUPSUpgradeable(collHarvestAlias).upgradeToAndCall(address(newImpl), ""); + } +} + +// ═══════════════════════════════════════════════════════════════ +// Accumulator v3 coverage (via SP deployed with deployment scripts) +// ═══════════════════════════════════════════════════════════════ + +contract AccumulatorTest is RewardSystemSetUp { + address alice; + address bob; + address carol; + + function setUp() public override { + super.setUp(); + alice = makeAddr("alice"); + bob = makeAddr("bob"); + carol = makeAddr("carol"); + _mintAndDeposit(alice, 100 ether); + _mintAndDeposit(bob, 100 ether); + _mintAndDeposit(carol, 100 ether); + } + + // ── claimable / claimed views ────────────────────────────── + + function test_claimedView() public { + _depositReward(wrappedCollateral, 10 ether); + skip(8 days); + + uint256 claimable = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(alice, wrappedCollateral); + assertGt(claimable, 0, "has claimable"); + + // Claim + vm.prank(alice); + IStabilityPool_v3(stabilityPoolCollateral).claimSingle(alice, wrappedCollateral); + + // claimed() should return the claimed amount + uint256 claimedAmount = IMultipleRewardAccumulator(stabilityPoolCollateral).claimed(alice, wrappedCollateral); + assertEq(claimedAmount, claimable, "claimed matches"); + } + + // ── checkpoint ───────────────────────────────────────────── + + function test_checkpoint() public { + _depositReward(wrappedCollateral, 10 ether); + skip(4 days); + + // Checkpoint updates state without claiming + IMultipleRewardAccumulator(stabilityPoolCollateral).checkpoint(alice); + + // Claimable should reflect distributed rewards + uint256 claimable = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(alice, wrappedCollateral); + assertGt(claimable, 0, "claimable after checkpoint"); + } + + function test_checkpointBeforeAnyRewardTokens() public { + // Deploy a fresh SP with no reward tokens registered — but we can't easily do that + // via the deployment framework. Instead, checkpoint with address(0) which is the + // "distribute all" path — exercises the early return when called before deposits change. + IMultipleRewardAccumulator(stabilityPoolCollateral).checkpoint(makeAddr("nobody")); + } + + // ── claim() and claim(account, receiver) ─────────────────── + + function test_claimAll() public { + _depositReward(wrappedCollateral, 10 ether); + skip(8 days); + + uint256 balBefore = IERC20(wrappedCollateral).balanceOf(alice); + vm.prank(alice); + IMultipleRewardAccumulator(stabilityPoolCollateral).claim(alice); + uint256 received = IERC20(wrappedCollateral).balanceOf(alice) - balBefore; + assertGt(received, 0, "claimed via claim()"); + } + + function test_claimToReceiver() public { + _depositReward(wrappedCollateral, 10 ether); + skip(8 days); + + address receiver = makeAddr("receiver"); + vm.prank(alice); + IMultipleRewardAccumulator(stabilityPoolCollateral).claim(alice, receiver); + assertGt(IERC20(wrappedCollateral).balanceOf(receiver), 0, "receiver got tokens"); + } + + function test_claimOthersToSelf() public { + _depositReward(wrappedCollateral, 10 ether); + skip(8 days); + + // bob claims alice's rewards — tokens go to alice (receiver=address(0)) + vm.prank(bob); + IMultipleRewardAccumulator(stabilityPoolCollateral).claim(alice, address(0)); + assertGt(IERC20(wrappedCollateral).balanceOf(alice), 0, "alice received"); + } + + function test_claimOthersToThirdParty_reverts() public { + _depositReward(wrappedCollateral, 10 ether); + skip(8 days); + + // bob tries to claim alice's rewards to a third party — should revert + vm.prank(bob); + vm.expectRevert(); + IMultipleRewardAccumulator(stabilityPoolCollateral).claim(alice, makeAddr("thirdParty")); + } + + // ── claimHistorical ──────────────────────────────────────── + + function test_claimHistorical() public { + _depositReward(collHarvestAlias, 30 ether); + skip(8 days); + + // Checkpoint alice to update her pending — but don't claim + IMultipleRewardAccumulator(stabilityPoolCollateral).checkpoint(alice); + + // Bob and carol claim to drain the pool's distributable balance + vm.prank(bob); + IMultipleRewardAccumulator(stabilityPoolCollateral).claim(bob); + vm.prank(carol); + IMultipleRewardAccumulator(stabilityPoolCollateral).claim(carol); + + // Flush any remaining queued dust + _depositReward(collHarvestAlias, 1); + skip(8 days); + vm.prank(bob); + IMultipleRewardAccumulator(stabilityPoolCollateral).claim(bob); + vm.prank(carol); + IMultipleRewardAccumulator(stabilityPoolCollateral).claim(carol); + // Alice still hasn't claimed — her pending is sitting in her snapshot + + // Unregister + uint256 managerRole = IMultipleRewardDistributor(stabilityPoolCollateral).REWARD_MANAGER_ROLE(); + vm.prank(HARBOR_MULTISIG); + IBaoRoles(stabilityPoolCollateral).grantRoles(address(this), managerRole); + IMultipleRewardDistributor(stabilityPoolCollateral).unregisterRewardToken(collHarvestAlias); + + // Verify it's historical + address[] memory historical = IMultipleRewardDistributor(stabilityPoolCollateral).historicalRewardTokens(); + bool found; + for (uint256 i = 0; i < historical.length; i++) { + if (historical[i] == collHarvestAlias) { + found = true; + } + } + assertTrue(found, "alias is historical"); + + // Alice claims via claimHistorical — her pending should still be there + address[] memory tokens = new address[](1); + tokens[0] = collHarvestAlias; + uint256 balBefore = IERC20(wrappedCollateral).balanceOf(alice); + vm.prank(alice); + IMultipleRewardAccumulator(stabilityPoolCollateral).claimHistorical(tokens); + assertGt(IERC20(wrappedCollateral).balanceOf(alice) - balBefore, 0, "claimed historical"); + } + + function test_claimHistorical_forAccount() public { + _depositReward(collHarvestAlias, 30 ether); + skip(8 days); + + // Checkpoint alice but don't claim + IMultipleRewardAccumulator(stabilityPoolCollateral).checkpoint(alice); + + // Drain via bob and carol + vm.prank(bob); + IMultipleRewardAccumulator(stabilityPoolCollateral).claim(bob); + vm.prank(carol); + IMultipleRewardAccumulator(stabilityPoolCollateral).claim(carol); + _depositReward(collHarvestAlias, 1); + skip(8 days); + vm.prank(bob); + IMultipleRewardAccumulator(stabilityPoolCollateral).claim(bob); + vm.prank(carol); + IMultipleRewardAccumulator(stabilityPoolCollateral).claim(carol); + + uint256 managerRole = IMultipleRewardDistributor(stabilityPoolCollateral).REWARD_MANAGER_ROLE(); + vm.prank(HARBOR_MULTISIG); + IBaoRoles(stabilityPoolCollateral).grantRoles(address(this), managerRole); + IMultipleRewardDistributor(stabilityPoolCollateral).unregisterRewardToken(collHarvestAlias); + + // Bob triggers historical claim for alice — tokens go to alice + address[] memory tokens = new address[](1); + tokens[0] = collHarvestAlias; + uint256 balBefore = IERC20(wrappedCollateral).balanceOf(alice); + vm.prank(bob); + IMultipleRewardAccumulator(stabilityPoolCollateral).claimHistorical(alice, tokens); + assertGt(IERC20(wrappedCollateral).balanceOf(alice) - balBefore, 0, "alice got historical claim"); + } +} + +// ═══════════════════════════════════════════════════════════════ +// Distributor v3 coverage +// ═══════════════════════════════════════════════════════════════ + +contract DistributorTest is RewardSystemSetUp { + function test_historicalRewardTokens_empty() public view { + // No tokens have been unregistered yet + address[] memory historical = IMultipleRewardDistributor(stabilityPoolCollateral).historicalRewardTokens(); + assertEq(historical.length, 0, "no historical tokens initially"); + } + + function test_registerRewardToken_zeroAddress_reverts() public { + uint256 managerRole = IMultipleRewardDistributor(stabilityPoolCollateral).REWARD_MANAGER_ROLE(); + vm.prank(HARBOR_MULTISIG); + IBaoRoles(stabilityPoolCollateral).grantRoles(address(this), managerRole); + + vm.expectRevert(); + IMultipleRewardDistributor(stabilityPoolCollateral).registerRewardToken(address(0)); + } + + function test_registerRewardToken_duplicate_reverts() public { + uint256 managerRole = IMultipleRewardDistributor(stabilityPoolCollateral).REWARD_MANAGER_ROLE(); + vm.prank(HARBOR_MULTISIG); + IBaoRoles(stabilityPoolCollateral).grantRoles(address(this), managerRole); + + // wrappedCollateral is already registered + vm.expectRevert(); + IMultipleRewardDistributor(stabilityPoolCollateral).registerRewardToken(wrappedCollateral); + } + + function test_unregisterRewardToken_withPendingRewards_reverts() public { + address alice = makeAddr("alice"); + _mintAndDeposit(alice, 100 ether); + + // Deposit reward that hasn't fully distributed + _depositReward(collHarvestAlias, 10 ether); + // Don't wait — rewards still pending + + uint256 managerRole = IMultipleRewardDistributor(stabilityPoolCollateral).REWARD_MANAGER_ROLE(); + vm.prank(HARBOR_MULTISIG); + IBaoRoles(stabilityPoolCollateral).grantRoles(address(this), managerRole); + + vm.expectRevert(); + IMultipleRewardDistributor(stabilityPoolCollateral).unregisterRewardToken(collHarvestAlias); + } + + function test_unregisterAndReregister() public { + address alice = makeAddr("alice"); + _mintAndDeposit(alice, 100 ether); + + _depositReward(collHarvestAlias, 10 ether); + skip(8 days); // Wait for full distribution + + // Claim all so pending is zero + vm.prank(alice); + IMultipleRewardAccumulator(stabilityPoolCollateral).claim(alice); + + uint256 managerRole = IMultipleRewardDistributor(stabilityPoolCollateral).REWARD_MANAGER_ROLE(); + vm.prank(HARBOR_MULTISIG); + IBaoRoles(stabilityPoolCollateral).grantRoles(address(this), managerRole); + + // Unregister + IMultipleRewardDistributor(stabilityPoolCollateral).unregisterRewardToken(collHarvestAlias); + + // Historical should contain it + address[] memory historical = IMultipleRewardDistributor(stabilityPoolCollateral).historicalRewardTokens(); + assertEq(historical.length, 1, "one historical token"); + + // Re-register — moves from historical back to active + IMultipleRewardDistributor(stabilityPoolCollateral).registerRewardToken(collHarvestAlias); + + // Historical should be empty again + historical = IMultipleRewardDistributor(stabilityPoolCollateral).historicalRewardTokens(); + assertEq(historical.length, 0, "historical cleared after re-register"); + } +} From db960fb7757ab6aeb31daeaebe4de0959db4a40e Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Sun, 5 Apr 2026 08:16:51 +0100 Subject: [PATCH 013/232] remove some claim external functions move some new StabilityPool code into external library and correct interfaces --- lib/bao-base | 2 +- regression/sizes.txt | 56 ++++++++ .../IMultipleRewardAccumulator_v3.sol | 21 +++ src/interfaces/IStabilityPool_v3.sol | 33 +---- src/minter/StabilityPool_v3.sol | 84 +----------- src/minter/library/StringPacking_v1.sol | 54 ++++++++ ...ultipleRewardCompoundingAccumulator_v3.sol | 128 ++++++++++-------- test/StabilityPool.t.sol | 2 +- test/StabilityPoolClaimable.t.sol | 48 +++---- test/StabilityPool_v3_ERC20.t.sol | 5 +- test/deployment/DeployETHfxUSD.t.sol | 103 ++++++++++++++ test/deployment/RewardSystem.t.sol | 4 +- .../StabilityPoolAliasDeployment.t.sol | 4 +- 13 files changed, 348 insertions(+), 196 deletions(-) create mode 100644 src/interfaces/IMultipleRewardAccumulator_v3.sol create mode 100644 src/minter/library/StringPacking_v1.sol create mode 100644 test/deployment/DeployETHfxUSD.t.sol diff --git a/lib/bao-base b/lib/bao-base index 6e68da70..bfa1425e 160000 --- a/lib/bao-base +++ b/lib/bao-base @@ -1 +1 @@ -Subproject commit 6e68da702919ba380bf23f0721b25a3e903d46b4 +Subproject commit bfa1425e9ac3637aff22cdd429c6040727cc4c01 diff --git a/regression/sizes.txt b/regression/sizes.txt index e69de29b..cfc5bb1f 100644 --- a/regression/sizes.txt +++ b/regression/sizes.txt @@ -0,0 +1,56 @@ +| Contract | Runtime Size (B) | Runtime Margin (B) | Initcode Size (B) | Deploy Gas | Deploy Cost ($) | +|---------------------------------------------|--------------------|----------------------|---------------------|--------------|-------------------| +| ConfigIncentiveLib | 85 | 24,491 | 135 | 18,350 | 1.84 | +| ConfigMarket_BTC_fxUSD_mainnet | 6,231 | 18,345 | 6,259 | 1,308,790 | 130.88 | +| ConfigMarket_BTC_stETH_mainnet | 6,257 | 18,319 | 6,285 | 1,314,250 | 131.43 | +| ConfigMarket_ETH_fxUSD_mainnet | 6,231 | 18,345 | 6,259 | 1,308,790 | 130.88 | +| ConfigMarket_EUR_fxUSD_mainnet | 6,219 | 18,357 | 6,247 | 1,306,270 | 130.63 | +| ConfigMarket_EUR_stETH_mainnet | 6,245 | 18,331 | 6,273 | 1,311,730 | 131.17 | +| ConfigMarket_GOLD_fxUSD_mainnet | 6,235 | 18,341 | 6,263 | 1,309,630 | 130.96 | +| ConfigMarket_GOLD_stETH_mainnet | 6,261 | 18,315 | 6,289 | 1,315,090 | 131.51 | +| ConfigMarket_MCAP_fxUSD_mainnet | 6,237 | 18,339 | 6,265 | 1,310,050 | 131.00 | +| ConfigMarket_MCAP_stETH_mainnet | 6,263 | 18,313 | 6,291 | 1,315,510 | 131.55 | +| ConfigMarket_SILVER_fxUSD_mainnet | 6,231 | 18,345 | 6,259 | 1,308,790 | 130.88 | +| ConfigMarket_SILVER_stETH_mainnet | 6,257 | 18,319 | 6,285 | 1,314,250 | 131.43 | +| ConfigPeg_BTC | 766 | 23,810 | 794 | 161,140 | 16.11 | +| ConfigPeg_ETH | 766 | 23,810 | 794 | 161,140 | 16.11 | +| ConfigPeg_EUR | 768 | 23,808 | 796 | 161,560 | 16.16 | +| ConfigPeg_GOLD | 770 | 23,806 | 798 | 161,980 | 16.20 | +| ConfigPeg_MCAP | 772 | 23,804 | 800 | 162,400 | 16.24 | +| ConfigPeg_SILVER | 794 | 23,782 | 822 | 167,020 | 16.70 | +| ConfigPriceVolatility_105 | 3,019 | 21,557 | 3,047 | 634,270 | 63.43 | +| ConfigPriceVolatility_105_stable | 3,019 | 21,557 | 3,047 | 634,270 | 63.43 | +| ConfigPriceVolatility_115 | 3,019 | 21,557 | 3,047 | 634,270 | 63.43 | +| ConfigPriceVolatility_115_stable | 3,019 | 21,557 | 3,047 | 634,270 | 63.43 | +| ConfigPriceVolatility_125 | 3,019 | 21,557 | 3,047 | 634,270 | 63.43 | +| ConfigPriceVolatility_125_stable | 3,019 | 21,557 | 3,047 | 634,270 | 63.43 | +| ConfigPriceVolatility_130 | 3,019 | 21,557 | 3,047 | 634,270 | 63.43 | +| ConfigPriceVolatility_130_stable | 3,019 | 21,557 | 3,047 | 634,270 | 63.43 | +| Config_v1 | 3,789 | 20,787 | 3,841 | 796,210 | 79.62 | +| DecrementalFloatingPoint | 85 | 24,491 | 135 | 18,350 | 1.84 | +| FakeAccessControl | 1,626 | 22,950 | 1,654 | 341,740 | 34.17 | +| FakeBaoAccessControl | 1,487 | 23,089 | 1,515 | 312,550 | 31.26 | +| FakeInitializable | 389 | 24,187 | 417 | 81,970 | 8.20 | +| FakeOwnable2Step | 1,346 | 23,230 | 1,374 | 282,940 | 28.29 | +| FakeUUPSUpgradeable | 3,236 | 21,340 | 3,293 | 680,130 | 68.01 | +| FmtLib | 85 | 24,491 | 135 | 18,350 | 1.84 | +| ForceMigrateAccumulator_v1 | 3,364 | 21,212 | 3,847 | 711,270 | 71.13 | +| Genesis_v1 | 7,486 | 17,090 | 8,423 | 1,581,430 | 158.14 | +| LinearReward | 85 | 24,491 | 135 | 18,350 | 1.84 | +| MinterMarketConfigLib | 85 | 24,491 | 135 | 18,350 | 1.84 | +| Minter_v1 | 23,681 | 895 | 25,515 | 4,991,350 | 499.14 | +| Minter_v2 | 23,675 | 901 | 25,509 | 4,990,090 | 499.01 | +| Minter_v3 | 24,218 | 358 | 26,045 | 5,104,050 | 510.41 | +| MockWrappedPriceOracle | 373 | 24,203 | 435 | 78,950 | 7.90 | +| PostRebalanceRemediationForStabilityPool_v2 | 3,852 | 20,724 | 4,350 | 813,900 | 81.39 | +| PriceOracle_v1 | 1,958 | 22,618 | 2,010 | 411,700 | 41.17 | +| ReservePool_v1 | 4,760 | 19,816 | 5,009 | 1,002,090 | 100.21 | +| RewardAlias_v1 | 2,974 | 21,602 | 3,358 | 628,380 | 62.84 | +| StabilityPoolManager_v1 | 11,209 | 13,367 | 13,057 | 2,372,370 | 237.24 | +| StabilityPool_v1 | 21,092 | 3,484 | 23,085 | 4,449,250 | 444.93 | +| StabilityPool_v2 | 20,711 | 3,865 | 22,579 | 4,367,990 | 436.80 | +| StabilityPool_v3 | 24,376 | 200 | 27,002 | 5,145,220 | 514.52 | +| StakedETHWrappedPriceOracle_v1 | 3,695 | 20,881 | 4,653 | 785,530 | 78.55 | +| StringPacking | 1,218 | 23,358 | 1,270 | 256,300 | 25.63 | +| TokenDistributor_v1 | 10,116 | 14,460 | 10,365 | 2,126,850 | 212.69 | +| WordCodec | 85 | 24,491 | 135 | 18,350 | 1.84 | diff --git a/src/interfaces/IMultipleRewardAccumulator_v3.sol b/src/interfaces/IMultipleRewardAccumulator_v3.sol new file mode 100644 index 00000000..4fb808c7 --- /dev/null +++ b/src/interfaces/IMultipleRewardAccumulator_v3.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT + +pragma solidity >=0.8.28 <0.9.0; + +/// @notice Accumulator v3: unified claim interface replacing claim/claimSingle/claimHistorical. +// solhint-disable-next-line contract-name-capwords +interface IMultipleRewardAccumulator_v3 { + /// @notice Claim rewards for a single token (or all active tokens if token == address(0)). + /// @param account The address of the user to claim for. + /// @param receiver The address to receive the tokens. address(0) = use stored receiver or account. + /// @param token The reward token to claim. address(0) = all active tokens. + /// @param maxAmount Maximum amount to claim. type(uint256).max = all available. + function claim(address account, address receiver, address token, uint256 maxAmount) external; + + /// @notice Claim rewards for multiple tokens (active or historical). + /// @param account The address of the user to claim for. + /// @param receiver The address to receive the tokens. address(0) = use stored receiver or account. + /// @param tokens Array of reward token addresses to claim from. + /// @param maxAmount Maximum amount to claim per token. type(uint256).max = all available. + function claim(address account, address receiver, address[] calldata tokens, uint256 maxAmount) external; +} diff --git a/src/interfaces/IStabilityPool_v3.sol b/src/interfaces/IStabilityPool_v3.sol index 468d4050..aa275970 100644 --- a/src/interfaces/IStabilityPool_v3.sol +++ b/src/interfaces/IStabilityPool_v3.sol @@ -2,34 +2,9 @@ pragma solidity >=0.8.28 <0.9.0; -import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; +import {IMultipleRewardAccumulator_v3} from "src/interfaces/IMultipleRewardAccumulator_v3.sol"; -/// @notice Interface for StabilityPool_v3 additions (selective claim). -/// @dev Extends IStabilityPool with single-token claim functions. -/// Parameter order matches claimable(address account, address token). +/// @notice StabilityPool v3 additions: unified claim interface. +/// @dev Does NOT inherit IStabilityPool — the SP_v3 contract inherits both separately. // solhint-disable-next-line contract-name-capwords -interface IStabilityPool_v3 is IStabilityPool { - /// @notice Claim pending rewards of a single token for some user. - /// @param account The address of the user. - /// @param token The reward token address to claim. - function claimSingle(address account, address token) external; - - /// @notice Claim pending rewards of a single token for the user and transfer to others. - /// @param account The address of the user. - /// @param token The reward token address to claim. - /// @param receiver The address of the recipient. - function claimSingle(address account, address token, address receiver) external; - - /// @notice Claim up to maxAmount of a single token's pending rewards. - /// @param account The address of the user. - /// @param token The reward token address to claim. - /// @param maxAmount The maximum amount to claim. Remainder stays as pending. - function claimSingle(address account, address token, uint256 maxAmount) external; - - /// @notice Claim up to maxAmount of a single token's pending rewards, transferring to receiver. - /// @param account The address of the user. - /// @param token The reward token address to claim. - /// @param receiver The address of the recipient. - /// @param maxAmount The maximum amount to claim. Remainder stays as pending. - function claimSingle(address account, address token, address receiver, uint256 maxAmount) external; -} +interface IStabilityPool_v3 is IMultipleRewardAccumulator_v3 {} diff --git a/src/minter/StabilityPool_v3.sol b/src/minter/StabilityPool_v3.sol index f2a2be09..5a90b631 100644 --- a/src/minter/StabilityPool_v3.sol +++ b/src/minter/StabilityPool_v3.sol @@ -17,6 +17,7 @@ import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; import {IMinter} from "src/interfaces/IMinter.sol"; import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import {IStabilityPool_v3} from "src/interfaces/IStabilityPool_v3.sol"; +import {StringPacking_v1} from "src/minter/library/StringPacking_v1.sol"; // solhint-disable not-rely-on-time // slither-disable-start timestamp @@ -194,7 +195,6 @@ contract StabilityPool_v3 is error TransferExceedsBalance(address from, uint256 amount, uint256 balance); error InsufficientAllowance(address spender, uint256 currentAllowance, uint256 needed); - error StringTooLong(); /*************** * Constructor * @@ -239,8 +239,8 @@ contract StabilityPool_v3 is string memory symbol_ ) MultipleRewardCompoundingAccumulator_v3(_REWARD_MANAGER_ROLE, _REWARD_DEPOSITOR_ROLE, 1 weeks) { _disableInitializers(); - (_ERC20_NAME_0, _ERC20_NAME_1) = _packString64(name_); - (_ERC20_SYMBOL, ) = _packString64(symbol_); + (_ERC20_NAME_0, _ERC20_NAME_1) = StringPacking_v1.pack64(name_); + (_ERC20_SYMBOL, ) = StringPacking_v1.pack64(symbol_); address asset = IMinter(minter_).PEGGED_TOKEN(); _ERC20_DECIMALS = IERC20Metadata(asset).decimals(); Token.sanityCheckERC20Token(asset); @@ -640,11 +640,11 @@ contract StabilityPool_v3 is // ═══════════════════════════════════════════════════════════════════════ function name() external view returns (string memory) { - return _unpackString64(_ERC20_NAME_0, _ERC20_NAME_1); + return StringPacking_v1.unpack64(_ERC20_NAME_0, _ERC20_NAME_1); } function symbol() external view returns (string memory) { - return _unpackString64(_ERC20_SYMBOL, bytes32(0)); + return StringPacking_v1.unpack64(_ERC20_SYMBOL, bytes32(0)); } function decimals() external view returns (uint8) { @@ -681,40 +681,6 @@ contract StabilityPool_v3 is } } - // ═══════════════════════════════════════════════════════════════════════ - // Selective Claim - // ═══════════════════════════════════════════════════════════════════════ - - /// @inheritdoc IStabilityPool_v3 - function claimSingle(address account, address token) external nonReentrant { - _checkpoint(account); - _claimSingle(account, token, account, type(uint256).max); - } - - /// @inheritdoc IStabilityPool_v3 - function claimSingle(address account, address token, address receiver) external nonReentrant { - if (account != _msgSender() && receiver != address(0)) { - revert ClaimOthersRewardToAnother(); - } - _checkpoint(account); - _claimSingle(account, token, receiver, type(uint256).max); - } - - /// @inheritdoc IStabilityPool_v3 - function claimSingle(address account, address token, uint256 maxAmount) external nonReentrant { - _checkpoint(account); - _claimSingle(account, token, account, maxAmount); - } - - /// @inheritdoc IStabilityPool_v3 - function claimSingle(address account, address token, address receiver, uint256 maxAmount) external nonReentrant { - if (account != _msgSender() && receiver != address(0)) { - revert ClaimOthersRewardToAnother(); - } - _checkpoint(account); - _claimSingle(account, token, receiver, maxAmount); - } - // ═══════════════════════════════════════════════════════════════════════ // ERC20 Mutator Functions // ═══════════════════════════════════════════════════════════════════════ @@ -782,46 +748,6 @@ contract StabilityPool_v3 is emit Transfer(from, to, amount); } - function _packString64(string memory s) internal pure returns (bytes32 b0, bytes32 b1) { - bytes memory b = bytes(s); - if (b.length > 64) { - revert StringTooLong(); - } - // solhint-disable-next-line no-inline-assembly - assembly { - b0 := mload(add(b, 32)) - b1 := mload(add(b, 64)) - } - if (b.length < 32) { - b0 = bytes32(uint256(b0) & ~(type(uint256).max >> (b.length * 8))); - b1 = bytes32(0); - } else if (b.length < 64) { - b1 = bytes32(uint256(b1) & ~(type(uint256).max >> ((b.length - 32) * 8))); - } - } - - function _unpackString64(bytes32 b0, bytes32 b1) internal pure returns (string memory) { - uint256 len0; - for (len0 = 32; len0 > 0; len0--) { - if (b0[len0 - 1] != 0) { - break; - } - } - uint256 len1; - for (len1 = 32; len1 > 0; len1--) { - if (b1[len1 - 1] != 0) { - break; - } - } - bytes memory result = new bytes(len0 + len1); - for (uint256 i = 0; i < len0; i++) { - result[i] = b0[i]; - } - for (uint256 i = 0; i < len1; i++) { - result[len0 + i] = b1[i]; - } - return string(result); - } } // slither-disable-end timestamp diff --git a/src/minter/library/StringPacking_v1.sol b/src/minter/library/StringPacking_v1.sol new file mode 100644 index 00000000..9ff7368d --- /dev/null +++ b/src/minter/library/StringPacking_v1.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.30; + +/// @title StringPacking_v1 +/// @notice Library for packing strings into bytes32 pairs and unpacking them back. +/// @dev Functions are public (not internal) so they deploy as a linked library, +/// keeping bytecode out of contracts that use them. +// solhint-disable-next-line contract-name-capwords +library StringPacking_v1 { + error StringTooLong(); + + /// @notice Pack a string (up to 64 chars) into two bytes32 values. + function pack64(string memory s) public pure returns (bytes32 b0, bytes32 b1) { + bytes memory b = bytes(s); + if (b.length > 64) { + revert StringTooLong(); + } + // solhint-disable-next-line no-inline-assembly + assembly { + b0 := mload(add(b, 32)) + b1 := mload(add(b, 64)) + } + if (b.length < 32) { + b0 = bytes32(uint256(b0) & ~(type(uint256).max >> (b.length * 8))); + b1 = bytes32(0); + } else if (b.length < 64) { + b1 = bytes32(uint256(b1) & ~(type(uint256).max >> ((b.length - 32) * 8))); + } + } + + /// @notice Unpack two bytes32 values back into a string. + function unpack64(bytes32 b0, bytes32 b1) public pure returns (string memory) { + uint256 len0; + for (len0 = 32; len0 > 0; len0--) { + if (b0[len0 - 1] != 0) { + break; + } + } + uint256 len1; + for (len1 = 32; len1 > 0; len1--) { + if (b1[len1 - 1] != 0) { + break; + } + } + bytes memory result = new bytes(len0 + len1); + for (uint256 i = 0; i < len0; i++) { + result[i] = b0[i]; + } + for (uint256 i = 0; i < len1; i++) { + result[len0 + i] = b1[i]; + } + return string(result); + } +} diff --git a/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol b/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol index 331c881a..9eb2cd31 100644 --- a/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol +++ b/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol @@ -8,6 +8,7 @@ import {ReentrancyGuardTransientUpgradeable} from "@openzeppelin/contracts-upgra import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; +import {IMultipleRewardAccumulator_v3} from "src/interfaces/IMultipleRewardAccumulator_v3.sol"; import {DecrementalFloatingPoint} from "src/math/DecrementalFloatingPoint.sol"; import {LinearMultipleRewardDistributor_v3} from "src/reward/distributor/LinearMultipleRewardDistributor_v3.sol"; @@ -115,7 +116,8 @@ import {LinearMultipleRewardDistributor_v3} from "src/reward/distributor/LinearM abstract contract MultipleRewardCompoundingAccumulator_v3 is ReentrancyGuardTransientUpgradeable, LinearMultipleRewardDistributor_v3, - IMultipleRewardAccumulator + IMultipleRewardAccumulator, + IMultipleRewardAccumulator_v3 { using SafeERC20 for IERC20; using DecrementalFloatingPoint for uint128; @@ -312,53 +314,83 @@ abstract contract MultipleRewardCompoundingAccumulator_v3 is _checkpoint(account); } - /// @inheritdoc IMultipleRewardAccumulator - function claim() external override { - address sender = _msgSender(); - claim(sender, address(0)); - } + // ═══════════════════════════════════════════════════════════════════════ + // v3 unified claim + // ═══════════════════════════════════════════════════════════════════════ - /// @inheritdoc IMultipleRewardAccumulator - function claim(address account) external override { - claim(account, address(0)); + /// @inheritdoc IMultipleRewardAccumulator_v3 + function claim( + address account, + address receiver, + address token, + uint256 maxAmount + ) public nonReentrant { + if (account != _msgSender() && receiver != address(0)) { + revert ClaimOthersRewardToAnother(); + } + _checkpoint(account); + receiver = _resolveReceiver(account, receiver); + if (token == address(0)) { + address[] memory tokens = activeRewardTokens(); + for (uint256 i = 0; i < tokens.length; i++) { + _claimSingle(account, tokens[i], receiver, maxAmount); + } + } else { + _claimSingle(account, token, receiver, maxAmount); + } } - /// @inheritdoc IMultipleRewardAccumulator - function claim(address account, address receiver) public override nonReentrant { + /// @inheritdoc IMultipleRewardAccumulator_v3 + function claim( + address account, + address receiver, + address[] calldata tokens, + uint256 maxAmount + ) external nonReentrant { if (account != _msgSender() && receiver != address(0)) { revert ClaimOthersRewardToAnother(); } _checkpoint(account); - _claim(account, receiver); + receiver = _resolveReceiver(account, receiver); + for (uint256 i = 0; i < tokens.length; i++) { + _claimSingle(account, tokens[i], receiver, maxAmount); + } } + // ═══════════════════════════════════════════════════════════════════════ + // Legacy claim — thin wrappers with defaults + // ═══════════════════════════════════════════════════════════════════════ + /// @inheritdoc IMultipleRewardAccumulator - function claimHistorical(address[] memory tokens) external nonReentrant { - address sender = _msgSender(); - MultipleRewardCompoundingAccumulatorStorage storage $ = _getMultipleRewardCompoundingAccumulatorStorage(); + function claim() external override { + claim(_msgSender(), address(0), address(0), type(uint256).max); + } + + /// @inheritdoc IMultipleRewardAccumulator + function claim(address account) external override { + claim(account, address(0), address(0), type(uint256).max); + } - _checkpoint(sender); + /// @inheritdoc IMultipleRewardAccumulator + function claim(address account, address receiver) public override { + claim(account, receiver, address(0), type(uint256).max); + } - address receiver = $.rewardReceiver[sender]; - if (receiver == address(0)) { - receiver = sender; - } - for (uint256 i = 0; i < tokens.length; i++) { - _claimSingle(sender, tokens[i], receiver, type(uint256).max); // wake-disable-line unchecked-return-value - } + /// @inheritdoc IMultipleRewardAccumulator + function claimHistorical(address[] memory tokens) external { + _claimTokenList(_msgSender(), tokens); } /// @inheritdoc IMultipleRewardAccumulator - function claimHistorical(address account, address[] memory tokens) external nonReentrant { - _checkpoint(account); - MultipleRewardCompoundingAccumulatorStorage storage $ = _getMultipleRewardCompoundingAccumulatorStorage(); + function claimHistorical(address account, address[] memory tokens) external { + _claimTokenList(account, tokens); + } - address receiver = $.rewardReceiver[account]; - if (receiver == address(0)) { - receiver = account; - } + function _claimTokenList(address account, address[] memory tokens) private { + _checkpoint(account); + address receiver = _resolveReceiver(account, address(0)); for (uint256 i = 0; i < tokens.length; i++) { - _claimSingle(account, tokens[i], receiver, type(uint256).max); // wake-disable-line unchecked-return-value + _claimSingle(account, tokens[i], receiver, type(uint256).max); } } @@ -497,35 +529,19 @@ abstract contract MultipleRewardCompoundingAccumulator_v3 is } } - /// @dev Internal function to claim active reward tokens. - /// - /// @param account The address of user to claim. - /// @param receiver The address of recipient of the reward token. - function _claim(address account, address receiver) internal virtual { - MultipleRewardCompoundingAccumulatorStorage storage $ = _getMultipleRewardCompoundingAccumulatorStorage(); - address receiverStored = $.rewardReceiver[account]; - if (receiverStored != address(0) && receiver == address(0)) { - receiver = receiverStored; - } + /// @dev Resolve the receiver address: use stored receiver if set, otherwise account. + function _resolveReceiver(address account, address receiver) internal view returns (address) { if (receiver == address(0)) { - receiver = account; - } - address[] memory activeRewardTokens = activeRewardTokens(); - for (uint256 i = 0; i < activeRewardTokens.length; i++) { - _claimSingle(account, activeRewardTokens[i], receiver, type(uint256).max); // wake-disable-line unchecked-return-value + MultipleRewardCompoundingAccumulatorStorage storage $ = + _getMultipleRewardCompoundingAccumulatorStorage(); + receiver = $.rewardReceiver[account]; + if (receiver == address(0)) { + receiver = account; + } } + return receiver; } - /// @dev Internal function to claim single reward token. - /// Caller should make sure `_checkpoint` is called before this function. - /// - /// @param account The address of user to claim. - /// @param token The address of reward token. - /// @param receiver The address of recipient of the reward token. - // function _claimSingle(address account, address token, address receiver) internal virtual returns (uint256) { - // return _claimSingle(account, token, receiver, type(uint256).max); - // } - /// @dev Internal function to claim up to maxAmount of a single reward token. /// If token has registered aliases, drains them in order first, then the token's own pending. /// If token is an alias (no aliases of its own), claims only from that alias. diff --git a/test/StabilityPool.t.sol b/test/StabilityPool.t.sol index 06294222..e1850857 100644 --- a/test/StabilityPool.t.sol +++ b/test/StabilityPool.t.sol @@ -34,7 +34,7 @@ contract StabilityPool_vN is StabilityPool_v3 { constructor( address minter_, address liquidationToken_ - ) StabilityPool_v3(minter_, liquidationToken_, 3600, 90000, 1 ether, "SP vN", "spVN") {} + ) StabilityPool_v3(minter_, liquidationToken_, 3600, 90000, 1 ether, "Mock SP", "mSP") {} // Add a new function to verify the upgrade worked function version() external pure returns (string memory) { diff --git a/test/StabilityPoolClaimable.t.sol b/test/StabilityPoolClaimable.t.sol index 8d530309..4d5041f6 100644 --- a/test/StabilityPoolClaimable.t.sol +++ b/test/StabilityPoolClaimable.t.sol @@ -9,7 +9,7 @@ import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistribu import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; import {MockERC20} from "@bao-test/mocks/MockERC20.sol"; -import {IStabilityPool_v3} from "src/interfaces/IStabilityPool_v3.sol"; +import {IMultipleRewardAccumulator_v3} from "src/interfaces/IMultipleRewardAccumulator_v3.sol"; import {TestStabilityPoolRebalanceSetUp} from "test/StabilityPoolRebalance.t.sol"; contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { @@ -621,7 +621,7 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { uint256 bal1Before = IERC20(rewardToken1).balanceOf(user1); uint256 bal2Before = IERC20(rewardToken2).balanceOf(user1); vm.prank(user1); - IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, rewardToken1); + IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(user1, address(0), rewardToken1, type(uint256).max); // rewardToken1 claimed assertEq(IERC20(rewardToken1).balanceOf(user1) - bal1Before, claimable1, "rewardToken1 claimed"); @@ -644,7 +644,7 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { address receiver = makeAddr("receiver"); vm.prank(user1); - IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, rewardToken1, receiver); + IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(user1, receiver, rewardToken1, type(uint256).max); assertEq(IERC20(rewardToken1).balanceOf(receiver), claimable1, "receiver got tokens"); assertEq(IERC20(rewardToken1).balanceOf(user1), 0, "user1 got nothing"); @@ -658,7 +658,7 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { // Anyone can trigger claim for user1 — tokens go to user1 vm.prank(user2); - IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, rewardToken1); + IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(user1, address(0), rewardToken1, type(uint256).max); assertEq(IERC20(rewardToken1).balanceOf(user1), claimable1, "user1 received tokens"); } @@ -672,14 +672,14 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { // user2 cannot redirect user1's rewards to receiver vm.prank(user2); vm.expectRevert(IMultipleRewardAccumulator.ClaimOthersRewardToAnother.selector); - IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, rewardToken1, receiver); + IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(user1, receiver, rewardToken1, type(uint256).max); } function testClaimSingle_zeroClaimable() public { _depositForUsers(); // No rewards deposited — claimSingle should not revert vm.prank(user1); - IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, rewardToken1); + IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(user1, address(0), rewardToken1, type(uint256).max); assertEq(IERC20(rewardToken1).balanceOf(user1), 0, "nothing claimed"); } @@ -698,7 +698,7 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { uint256 halfAmount = claimable / 2; uint256 balBefore = IERC20(rewardToken1).balanceOf(user1); vm.prank(user1); - IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, rewardToken1, halfAmount); + IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(user1, address(0), rewardToken1, halfAmount); // Received exactly halfAmount assertEq(IERC20(rewardToken1).balanceOf(user1) - balBefore, halfAmount, "received half"); @@ -717,7 +717,7 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { // Claim with maxAmount > claimable — should claim all uint256 balBefore = IERC20(rewardToken1).balanceOf(user1); vm.prank(user1); - IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, rewardToken1, type(uint256).max); + IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(user1, address(0), rewardToken1, type(uint256).max); assertEq(IERC20(rewardToken1).balanceOf(user1) - balBefore, claimable, "claimed all"); @@ -735,7 +735,7 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { // Claim zero — should be a no-op uint256 balBefore = IERC20(rewardToken1).balanceOf(user1); vm.prank(user1); - IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, rewardToken1, 0); + IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(user1, address(0), rewardToken1, 0); assertEq(IERC20(rewardToken1).balanceOf(user1), balBefore, "nothing transferred"); assertEq( @@ -754,7 +754,7 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { address receiver = makeAddr("receiver"); uint256 partialAmount = claimable / 3; vm.prank(user1); - IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, rewardToken1, receiver, partialAmount); + IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(user1, receiver, rewardToken1, partialAmount); assertEq(IERC20(rewardToken1).balanceOf(receiver), partialAmount, "receiver got partial"); assertEq(IERC20(rewardToken1).balanceOf(user1), 0, "user got nothing"); @@ -773,9 +773,9 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { // Claim in three tranches uint256 tranche = claimable / 3; vm.startPrank(user1); - IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, rewardToken1, tranche); - IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, rewardToken1, tranche); - IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, rewardToken1, type(uint256).max); + IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(user1, address(0), rewardToken1, tranche); + IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(user1, address(0), rewardToken1, tranche); + IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(user1, address(0), rewardToken1, type(uint256).max); vm.stopPrank(); // Should have claimed everything @@ -803,7 +803,7 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { // Fractional claim: take 20 ether vm.prank(user1); - IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, rewardToken1, 20 ether); + IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(user1, address(0), rewardToken1, 20 ether); assertEq(IERC20(rewardToken1).balanceOf(user1), 20 ether, "received 20"); // Wait for rest of period @@ -816,7 +816,7 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { // Claim the rest vm.prank(user1); - IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, rewardToken1, type(uint256).max); + IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(user1, address(0), rewardToken1, type(uint256).max); assertApproxEqRel(IERC20(rewardToken1).balanceOf(user1), 100 ether, 0.02 ether, "~100 total"); } @@ -830,7 +830,7 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { // Partial claim from token1 only vm.prank(user1); - IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, rewardToken1, claimable1 / 4); + IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(user1, address(0), rewardToken1, claimable1 / 4); // token2 claimable unchanged assertEq( @@ -852,7 +852,7 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { address receiver = makeAddr("receiver"); vm.prank(user2); vm.expectRevert(); - IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, rewardToken1, receiver, 50 ether); + IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(user1, receiver, rewardToken1, 50 ether); } } @@ -861,7 +861,7 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { // ═══════════════════════════════════════════════════════════════════════════ import {RewardAlias_v1} from "src/reward/RewardAlias_v1.sol"; -import {IStabilityPool_v3} from "src/interfaces/IStabilityPool_v3.sol"; +import {IMultipleRewardAccumulator_v3} from "src/interfaces/IMultipleRewardAccumulator_v3.sol"; import {LinearMultipleRewardDistributor_v3} from "src/reward/distributor/LinearMultipleRewardDistributor_v3.sol"; contract TestRewardAlias is TestStabilityPoolRebalanceSetUp { @@ -987,7 +987,7 @@ contract TestRewardAlias is TestStabilityPoolRebalanceSetUp { uint256 rwdBefore = aliasUnderlying.balanceOf(user1); vm.prank(user1); - IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, address(harvestAlias)); + IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(user1, address(0), address(harvestAlias), type(uint256).max); // User received the underlying token, not the alias assertEq(aliasUnderlying.balanceOf(user1) - rwdBefore, claimable, "received underlying"); @@ -1003,7 +1003,7 @@ contract TestRewardAlias is TestStabilityPoolRebalanceSetUp { // Claim only harvest vm.prank(user1); - IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, address(harvestAlias)); + IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(user1, address(0), address(harvestAlias), type(uint256).max); // Boost should be unchanged uint256 boostAfter = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(boostAlias)); @@ -1115,7 +1115,7 @@ contract TestRewardAlias is TestStabilityPoolRebalanceSetUp { uint256 half = claimable / 2; uint256 balBefore = IERC20(address(aliasUnderlying)).balanceOf(user1); vm.prank(user1); - IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, address(harvestAlias), half); + IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(user1, address(0), address(harvestAlias), half); assertEq(IERC20(address(aliasUnderlying)).balanceOf(user1) - balBefore, half, "received underlying"); @@ -1139,7 +1139,7 @@ contract TestRewardAlias is TestStabilityPoolRebalanceSetUp { // Partial claim from harvestAlias only vm.prank(user1); - IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, address(harvestAlias), claimableHarvest / 3); + IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(user1, address(0), address(harvestAlias), claimableHarvest / 3); // boostAlias claimable unchanged assertEq( @@ -1177,7 +1177,7 @@ contract TestRewardAlias is TestStabilityPoolRebalanceSetUp { // Partial claim from harvestAlias vm.prank(user1); - IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, address(harvestAlias), claimableHarvest / 2); + IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(user1, address(0), address(harvestAlias), claimableHarvest / 2); // Aggregated drops by the claimed amount uint256 newAggregated = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( @@ -1195,7 +1195,7 @@ contract TestRewardAlias is TestStabilityPoolRebalanceSetUp { uint256 partialAmount = claimable / 4; vm.prank(user1); - IStabilityPool_v3(stabilityPoolCollateral).claimSingle(user1, address(harvestAlias), receiver, partialAmount); + IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(user1, receiver, address(harvestAlias), partialAmount); // Receiver gets the underlying token, not the alias assertEq(IERC20(address(aliasUnderlying)).balanceOf(receiver), partialAmount, "receiver got underlying"); diff --git a/test/StabilityPool_v3_ERC20.t.sol b/test/StabilityPool_v3_ERC20.t.sol index c43a4f32..e6776b79 100644 --- a/test/StabilityPool_v3_ERC20.t.sol +++ b/test/StabilityPool_v3_ERC20.t.sol @@ -6,6 +6,7 @@ import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IER import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; import {StabilityPool_v3} from "src/minter/StabilityPool_v3.sol"; +import {StringPacking_v1} from "src/minter/library/StringPacking_v1.sol"; import {TestStabilityPoolSetUp} from "test/StabilityPool.t.sol"; @@ -45,13 +46,13 @@ contract TestStabilityPool_v3_ERC20 is TestStabilityPoolSetUp { function test_stringTooLong_name_reverts() public { // 65-char string exceeds 64-char limit string memory longName = "12345678901234567890123456789012345678901234567890123456789012345"; - vm.expectRevert(StabilityPool_v3.StringTooLong.selector); + vm.expectRevert(StringPacking_v1.StringTooLong.selector); new StabilityPool_v3(minter, wrappedCollateralToken, 3600, 90000, 1 ether, longName, "s"); } function test_stringTooLong_symbol_reverts() public { string memory longSymbol = "12345678901234567890123456789012345678901234567890123456789012345"; - vm.expectRevert(StabilityPool_v3.StringTooLong.selector); + vm.expectRevert(StringPacking_v1.StringTooLong.selector); new StabilityPool_v3(minter, wrappedCollateralToken, 3600, 90000, 1 ether, "n", longSymbol); } diff --git a/test/deployment/DeployETHfxUSD.t.sol b/test/deployment/DeployETHfxUSD.t.sol new file mode 100644 index 00000000..ce03d0bf --- /dev/null +++ b/test/deployment/DeployETHfxUSD.t.sol @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.28 <0.9.0; + +import {BaoTest} from "@bao-test/BaoTest.sol"; +import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; +import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; +import {Deploy_ETH_Minter} from "script/src/Deploy_ETH_Minter.sol"; +import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; +import {Config_MinterMarket, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import {IMinter} from "src/interfaces/IMinter.sol"; +import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; +import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; +import {IMultipleRewardAccumulator_v3} from "src/interfaces/IMultipleRewardAccumulator_v3.sol"; +import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; +import {MockWrappedPriceOracle} from "test/mocks/MockWrappedPriceOracle.sol"; + +/// @title Common deployment setup for ETH::fxUSD market tests. +/// @dev Deploys a full ETH::fxUSD market via production deployment scripts. +/// Inherit this instead of rolling your own deployment setup. +abstract contract DeployETHfxUSDSetUp is BaoTest, Deploy_ETH_Minter { + address minter; + address stabilityPoolCollateral; + address stabilityPoolLeveraged; + address stabilityPoolManager; + address pegged; + address leveraged; + address wrappedCollateral; + + address collHarvestAlias; + address collRebalanceAlias; + address levHarvestAlias; + address levRebalanceAlias; + + MockWrappedPriceOracle mockOracle; + + function _shouldPersistState() internal pure override returns (bool) { + return false; + } + + function setUp() public virtual { + address factory = _ensureBaoFactory(); + // Pinned after latest Harbor deployment (SPL remediation, 2026-03-25) for caching + vm.createSelectFork(vm.rpcUrl("mainnet"), 24699497); + + vm.prank(IBaoFactory(factory).owner()); + IBaoFactory(factory).setOperator(address(this), 365 days); + + (ConfigPeg peg, Config_MinterMarket[] memory mktConfigs) = createETHMintersConfig(); + Config_MinterMarket[] memory toDeploy = new Config_MinterMarket[](1); + toDeploy[0] = mktConfigs[0]; + deployForPeg("test_eth", peg, mktConfigs, "mainnet", true, toDeploy); + + _setSaltPrefix("test_eth"); + string memory mk = "ETH::fxUSD"; + minter = _predictAddress(_key(mk, "minter")); + stabilityPoolCollateral = _predictAddress(_key(mk, "stabilityPoolCollateral")); + stabilityPoolLeveraged = _predictAddress(_key(mk, "stabilityPoolLeveraged")); + stabilityPoolManager = _predictAddress(_key(mk, "stabilityPoolManager")); + pegged = _predictAddress(_key("ETH", "pegged")); + leveraged = _predictAddress(_key(mk, "leveraged")); + wrappedCollateral = IMinter(minter).WRAPPED_COLLATERAL_TOKEN(); + + collHarvestAlias = _predictAddress(_key(mk, "stabilityPoolCollateral", "harvest")); + collRebalanceAlias = _predictAddress(_key(mk, "stabilityPoolCollateral", "rebalance")); + levHarvestAlias = _predictAddress(_key(mk, "stabilityPoolLeveraged", "harvest")); + levRebalanceAlias = _predictAddress(_key(mk, "stabilityPoolLeveraged", "rebalance")); + + mockOracle = new MockWrappedPriceOracle(); + mockOracle.setLatestAnswer(1 ether, 1 ether); + + vm.startPrank(HARBOR_MULTISIG); + IMinter(minter).updatePriceOracle(address(mockOracle)); + IBaoRoles(minter).grantRoles(address(this), IMinter(minter).ZERO_FEE_ROLE()); + IBaoRoles(stabilityPoolCollateral).grantRoles( + address(this), + IMultipleRewardDistributor(stabilityPoolCollateral).REWARD_DEPOSITOR_ROLE() + ); + vm.stopPrank(); + } + + function _mintPegged(address to, uint256 collateralAmount) internal returns (uint256 peggedMinted) { + deal(wrappedCollateral, address(this), collateralAmount); + IERC20(wrappedCollateral).approve(minter, collateralAmount); + peggedMinted = IMinter(minter).freeMintPeggedToken(collateralAmount, to); + } + + function _mintAndDeposit(address user, uint256 amount) internal { + uint256 peggedMinted = _mintPegged(user, amount); + vm.startPrank(user); + IERC20(pegged).approve(stabilityPoolCollateral, peggedMinted); + IStabilityPool(stabilityPoolCollateral).deposit(peggedMinted, user, 0); + vm.stopPrank(); + } + + function _depositReward(address token, uint256 amount) internal { + deal(wrappedCollateral, address(this), amount); + IERC20(wrappedCollateral).approve(stabilityPoolCollateral, amount); + IMultipleRewardDistributor(stabilityPoolCollateral).depositReward(token, amount); + } +} diff --git a/test/deployment/RewardSystem.t.sol b/test/deployment/RewardSystem.t.sol index 61c143a8..8889501d 100644 --- a/test/deployment/RewardSystem.t.sol +++ b/test/deployment/RewardSystem.t.sol @@ -15,8 +15,8 @@ import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.s import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import {IMinter} from "src/interfaces/IMinter.sol"; import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; -import {IStabilityPool_v3} from "src/interfaces/IStabilityPool_v3.sol"; import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; +import {IMultipleRewardAccumulator_v3} from "src/interfaces/IMultipleRewardAccumulator_v3.sol"; import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; import {IRewardAlias} from "src/interfaces/IRewardAlias.sol"; import {RewardAlias_v1} from "src/reward/RewardAlias_v1.sol"; @@ -163,7 +163,7 @@ contract AccumulatorTest is RewardSystemSetUp { // Claim vm.prank(alice); - IStabilityPool_v3(stabilityPoolCollateral).claimSingle(alice, wrappedCollateral); + IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(alice, address(0), wrappedCollateral, type(uint256).max); // claimed() should return the claimed amount uint256 claimedAmount = IMultipleRewardAccumulator(stabilityPoolCollateral).claimed(alice, wrappedCollateral); diff --git a/test/deployment/StabilityPoolAliasDeployment.t.sol b/test/deployment/StabilityPoolAliasDeployment.t.sol index b64f52c4..8440f055 100644 --- a/test/deployment/StabilityPoolAliasDeployment.t.sol +++ b/test/deployment/StabilityPoolAliasDeployment.t.sol @@ -10,8 +10,8 @@ import {Config_MinterMarket, MinterMarketConfigLib} from "script/config/ConfigBa import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IMinter} from "src/interfaces/IMinter.sol"; import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; -import {IStabilityPool_v3} from "src/interfaces/IStabilityPool_v3.sol"; import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; +import {IMultipleRewardAccumulator_v3} from "src/interfaces/IMultipleRewardAccumulator_v3.sol"; import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; import {IRewardAlias} from "src/interfaces/IRewardAlias.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; @@ -191,7 +191,7 @@ contract StabilityPoolAliasDeploymentTest is StabilityPoolAliasDeploymentSetUp { // Claim via alias — should receive wrappedCollateral (the underlying) uint256 balBefore = IERC20(wrappedCollateral).balanceOf(alice); vm.prank(alice); - IStabilityPool_v3(stabilityPoolCollateral).claimSingle(alice, collHarvestAlias); + IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(alice, address(0), collHarvestAlias, type(uint256).max); uint256 received = IERC20(wrappedCollateral).balanceOf(alice) - balBefore; assertApprox(received, rewardAmount, 604800, "claimed underlying amount"); From cb499f70cb6a1ccd0a1c1ad87b2008d005cb592b Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Sun, 5 Apr 2026 15:22:17 +0100 Subject: [PATCH 014/232] fixed some issues - getting it clean --- CLAUDE.md | 3 + foundry.toml | 4 +- lib/bao-base | 2 +- regression/coverage.txt | 21 +- regression/gas.txt | 426 +++++------------- regression/sizes.txt | 2 +- .../sp-v2-upgrade}/MainnetUpgradeTest.t.sol | 0 src/interfaces/IStabilityPool_v3.sol | 2 +- src/minter/StabilityPool_v3.sol | 2 +- ...ultipleRewardCompoundingAccumulator_v3.sol | 10 +- .../LinearMultipleRewardDistributor_v3.sol | 1 + test/MinterUpgradeMigration.t.sol | 126 +++--- test/Minter_base.t.sol | 70 +-- test/RebalanceCheck.t.sol | 67 +-- test/StabilityPoolClaimable.t.sol | 77 +++- test/deployment/RebalanceFairness.t.sol | 15 +- test/deployment/RewardSystem.t.sol | 8 +- .../StabilityPoolAliasDeployment.t.sol | 11 +- 18 files changed, 356 insertions(+), 491 deletions(-) rename {test => script/verify/sp-v2-upgrade}/MainnetUpgradeTest.t.sol (100%) diff --git a/CLAUDE.md b/CLAUDE.md index 89f9992b..482025ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,6 +3,9 @@ - When discussing design decisions, do not present disconnected multiple-choice questions. Instead, write out the full picture first — user flows, accounting, consequences — so the decision context is clear. Present a recommendation with reasoning, not a menu of options without enough background. Use the plan document or design docs for detailed analysis, not the question dialog. - Do not create functions that are only called once. Inline the logic instead. - When diagnosing an issue, do not use words like "likely", "probably", or "may" to describe a root cause. Either verify the hypothesis with data (dry run, log, trace) or state explicitly that it is unverified. Never proceed with a fix based on an unverified hypothesis. +- When the user reports a problem, fix it — do not unilaterally decide the problem is out of scope, pre-existing, already resolved by another fix, or someone else's concern. If you believe any of those things, say so and ask whether the user still wants it addressed. Never declare a judgement like "this is pre-existing" or "the root cause is X" and then act on it without confirmation. Present your reasoning, then ask. +- When fixing error handling, do not silently skip or suppress errors. If something fails, the failure should be visible and the process should fail clearly. Do not work around errors by hiding them unless explicitly asked to. +- In bash, `set -e` does NOT catch failures in `[[ ]]` conditionals, variable assignments (e.g. `x=$(failing_cmd)`), commands in pipelines (use `set -o pipefail` AND check `${PIPESTATUS[@]}`), or sourced scripts. Always check exit status explicitly with `${PIPESTATUS[0]}` or `$?` after critical commands rather than relying on `set -e` alone. - use forge install/remove for managing submodule dependencies - In tests and scripts, use interface types (e.g. `IStabilityPool_v3(address)`) not concrete contract types (e.g. `StabilityPool_v3(address)`) when calling functions. This verifies the interface matches the implementation. Concrete types are only for initialisation (constructor, deploy). - **Declarations:** use `address`, not typed contract variables. E.g. `address rewardToken = address(new MockERC20(...))`, not `MockERC20 rewardToken = new MockERC20(...)`. diff --git a/foundry.toml b/foundry.toml index 1c2d0f6a..20325439 100644 --- a/foundry.toml +++ b/foundry.toml @@ -10,7 +10,9 @@ optimizer = true optimizer_runs = 700 # 500 builds & tests but 200 deploys # via_ir = true # makes the compiler run 7-10 times slower, is good at reducing code size or lower gas usage -deny = "warnings" # enable to stop compilations if there are warnings +deny = "warnings" # enable to stop compilations if there are warnings +ignored_error_codes = [4591] +ignored_warnings_from = ["test/", "lib/", "script/"] remappings = [ # foundry diff --git a/lib/bao-base b/lib/bao-base index bfa1425e..a926582c 160000 --- a/lib/bao-base +++ b/lib/bao-base @@ -1 +1 @@ -Subproject commit bfa1425e9ac3637aff22cdd429c6040727cc4c01 +Subproject commit a926582c82daa0d841154a541a718441ed58856e diff --git a/regression/coverage.txt b/regression/coverage.txt index 156bcacd..154732ab 100644 --- a/regression/coverage.txt +++ b/regression/coverage.txt @@ -23,7 +23,7 @@ | script/config/volatility/ConfigPriceVolatility_125_stable.sol | X 0% (0/65) | X 0% (0/71) | ✓ 100% (0/0) | X 0% (0/2) | | script/config/volatility/ConfigPriceVolatility_130.sol | X 0% (0/65) | X 0% (0/71) | ✓ 100% (0/0) | X 0% (0/2) | | script/config/volatility/ConfigPriceVolatility_130_stable.sol | ✓ 100% (65/65) | ✓ 100% (71/71) | ✓ 100% (0/0) | ✓ 100% (2/2) | -| script/src/DeployMintersShared.sol | X 86% (83/97) | X 85% (99/117) | X 25% (1/4) | X 80% (8/10) | +| script/src/DeployMintersShared.sol | X 86% (86/100) | X 85% (105/123) | X 25% (1/4) | X 80% (8/10) | | script/src/Deploy_BTC_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | | script/src/Deploy_ETH_Minter.sol | ✓ 100% (4/4) | ✓ 100% (3/3) | ✓ 100% (0/0) | ✓ 100% (1/1) | | script/src/Deploy_EUR_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | @@ -35,31 +35,32 @@ | script/src/contracts/LeveragedToken.sol | ✓ 100% (15/15) | ✓ 100% (23/23) | ✓ 100% (0/0) | ✓ 100% (1/1) | | script/src/contracts/Minter.sol | X 58% (23/40) | X 55% (23/42) | ✓ 100% (0/0) | X 62% (5/8) | | script/src/contracts/PeggedToken.sol | X 83% (20/24) | X 94% (31/33) | X 50% (3/6) | ✓ 100% (1/1) | -| script/src/contracts/StabilityPool.sol | ✓ 100% (39/39) | ✓ 100% (56/56) | ✓ 100% (0/0) | ✓ 100% (5/5) | +| script/src/contracts/StabilityPool.sol | ✓ 100% (34/34) | ✓ 100% (50/50) | ✓ 100% (0/0) | ✓ 100% (4/4) | | script/src/contracts/StabilityPoolManager.sol | X 54% (14/26) | X 50% (15/30) | ✓ 100% (0/0) | X 50% (2/4) | | src/math/DecrementalFloatingPoint.sol | ✓ 100% (31/31) | ✓ 100% (33/33) | ✓ 100% (9/9) | ✓ 100% (6/6) | | src/minter/Genesis_v1.sol | X 99% (88/89) | ✓ 100% (88/88) | ✓ 100% (14/14) | X 92% (11/12) | -| src/minter/Minter_v1.sol | X 31% (188/601) | X 29% (191/649) | X 16% (16/102) | X 56% (38/68) | -| src/minter/Minter_v2.sol | X 99% (593/601) | X 99% (642/649) | X 93% (95/102) | X 99% (67/68) | -| src/minter/Minter_v3.sol | X 41% (251/617) | X 40% (270/667) | X 26% (27/105) | X 54% (38/71) | +| src/minter/Minter_v1.sol | X 0% (0/601) | X 0% (0/649) | X 0% (0/102) | X 0% (0/68) | +| src/minter/Minter_v2.sol | X 31% (188/601) | X 29% (191/649) | X 16% (16/102) | X 56% (38/68) | +| src/minter/Minter_v3.sol | X 99% (609/617) | X 99% (660/667) | X 93% (98/105) | X 99% (70/71) | | src/minter/ReservePool_v1.sol | X 94% (15/16) | ✓ 100% (15/15) | ✓ 100% (3/3) | X 80% (4/5) | | src/minter/StabilityPoolManager_v1.sol | X 99% (165/166) | X 99% (176/177) | X 95% (20/21) | ✓ 100% (24/24) | | src/minter/StabilityPool_v1.sol | X 61% (124/203) | X 58% (129/223) | X 18% (6/33) | X 73% (16/22) | -| src/minter/StabilityPool_v2.sol | X 68% (136/199) | X 68% (150/219) | X 32% (10/31) | X 64% (14/22) | -| src/minter/StabilityPool_v3.sol | ✓ 100% (302/302) | ✓ 100% (336/336) | ✓ 100% (43/43) | ✓ 100% (40/40) | +| src/minter/StabilityPool_v2.sol | X 66% (131/199) | X 65% (143/219) | X 26% (8/31) | X 64% (14/22) | +| src/minter/StabilityPool_v3.sol | ✓ 100% (260/260) | ✓ 100% (285/285) | ✓ 100% (35/35) | ✓ 100% (34/34) | | src/minter/TokenDistributor_v1.sol | X 96% (94/98) | X 97% (112/116) | X 77% (10/13) | X 93% (14/15) | | src/minter/library/ConfigIncentiveLib.sol | ✓ 100% (24/24) | ✓ 100% (17/17) | ✓ 100% (2/2) | ✓ 100% (9/9) | | src/minter/library/Config_v1.sol | X 96% (76/79) | X 97% (92/95) | X 90% (18/20) | ✓ 100% (6/6) | +| src/minter/library/StringPacking_v1.sol | ✓ 100% (26/26) | ✓ 100% (33/33) | ✓ 100% (6/6) | ✓ 100% (2/2) | | src/price/PriceOracle_v1.sol | X 97% (31/32) | X 97% (36/37) | X 85% (11/13) | ✓ 100% (3/3) | | src/price/StakedETHWrappedPriceOracle_v1.sol | X 0% (0/29) | X 0% (0/27) | X 0% (0/5) | X 0% (0/4) | | src/reward/RewardAlias_v1.sol | ✓ 100% (15/15) | ✓ 100% (12/12) | ✓ 100% (1/1) | ✓ 100% (6/6) | | src/reward/accumulator/MultipleRewardCompoundingAccumulator.sol | X 93% (127/136) | X 94% (161/171) | X 81% (13/16) | X 95% (20/21) | | src/reward/accumulator/MultipleRewardCompoundingAccumulator_v2.sol | X 96% (141/147) | X 96% (177/184) | X 83% (15/18) | ✓ 100% (22/22) | -| src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol | X 95% (144/151) | X 96% (181/188) | X 89% (17/19) | X 91% (21/23) | +| src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol | X 91% (149/163) | X 91% (180/198) | X 86% (19/22) | X 88% (23/26) | | src/reward/distributor/LinearMultipleRewardDistributor.sol | ✓ 100% (77/77) | ✓ 100% (86/86) | ✓ 100% (12/12) | ✓ 100% (14/14) | | src/reward/distributor/LinearMultipleRewardDistributor_v2.sol | ✓ 100% (77/77) | ✓ 100% (86/86) | ✓ 100% (12/12) | ✓ 100% (14/14) | -| src/reward/distributor/LinearMultipleRewardDistributor_v3.sol | X 96% (90/94) | X 96% (102/106) | X 73% (11/15) | ✓ 100% (17/17) | +| src/reward/distributor/LinearMultipleRewardDistributor_v3.sol | X 93% (96/103) | X 93% (109/117) | X 62% (8/13) | ✓ 100% (19/19) | | src/reward/distributor/LinearReward.sol | ✓ 100% (25/25) | ✓ 100% (27/27) | ✓ 100% (6/6) | ✓ 100% (2/2) | | src/util/FmtLib.sol | X 0% (0/19) | X 0% (0/26) | X 0% (0/4) | X 0% (0/1) | | src/util/WordCodec.sol | ✓ 100% (15/15) | ✓ 100% (14/14) | ✓ 100% (0/0) | ✓ 100% (4/4) | -| Total | X 67% (5267/7818) | X 66% (5612/8449) | X 55% (487/891) | X 70% (795/1134) | +| Total | X 64% (5020/7866) | X 63% (5340/8495) | X 51% (458/890) | X 67% (759/1139) | diff --git a/regression/gas.txt b/regression/gas.txt index bbd30507..2c49043d 100644 --- a/regression/gas.txt +++ b/regression/gas.txt @@ -1,53 +1,19 @@ script/config/markets/ConfigMarket_BTC_fxUSD_mainnet.sol:ConfigMarket_BTC_fxUSD_mainnet -| function name | max | -|--------------------------------------|-----------| -| collateral | 4.990e+02 | -| getWellKnownAddresses | 3.698e+03 | -| harvestBountyRatio | 2.810e+02 | -| harvestCutRatio | 2.490e+02 | -| leveragedName | 5.704e+03 | -| leveragedSymbol | 6.807e+03 | -| minTotalSupply | 2.810e+02 | -| minterConfig | 1.169e+04 | -| peg | 5.010e+02 | -| rebalanceBountyRatio | 2.360e+02 | -| rebalanceThreshold | 2.590e+02 | -| spCollateralName | 8.085e+03 | -| spCollateralSymbol | 8.061e+03 | -| spLeveragedName | 1.070e+04 | -| spLeveragedSymbol | 1.074e+04 | -| stabilityPoolEarlyWithdrawalFeeRatio | 2.800e+02 | -| stabilityPoolWithdrawalDelay | 3.010e+02 | -| stabilityPoolWithdrawalPeriod | 2.820e+02 | -| wrappedCollateralToken | 2.790e+02 | +| function name | max | +|-----------------|-----------| +| collateral | 4.990e+02 | +| peg | 5.010e+02 | script/config/markets/ConfigMarket_BTC_stETH_mainnet.sol:ConfigMarket_BTC_stETH_mainnet -| function name | max | -|--------------------------------------|-----------| -| collateral | 4.990e+02 | -| harvestBountyRatio | 2.810e+02 | -| harvestCutRatio | 2.490e+02 | -| leveragedName | 5.704e+03 | -| leveragedSymbol | 6.807e+03 | -| minTotalSupply | 2.810e+02 | -| minterConfig | 1.169e+04 | -| peg | 5.010e+02 | -| rebalanceBountyRatio | 2.360e+02 | -| rebalanceThreshold | 2.590e+02 | -| spCollateralName | 8.085e+03 | -| spCollateralSymbol | 8.061e+03 | -| spLeveragedName | 1.070e+04 | -| spLeveragedSymbol | 1.074e+04 | -| stabilityPoolEarlyWithdrawalFeeRatio | 2.800e+02 | -| stabilityPoolWithdrawalDelay | 3.010e+02 | -| stabilityPoolWithdrawalPeriod | 2.820e+02 | -| wrappedCollateralToken | 2.790e+02 | +| function name | max | +|-----------------|-----------| +| collateral | 4.990e+02 | +| peg | 5.010e+02 | script/config/markets/ConfigMarket_ETH_fxUSD_mainnet.sol:ConfigMarket_ETH_fxUSD_mainnet | function name | max | |--------------------------------------|-----------| | collateral | 4.990e+02 | -| getWellKnownAddresses | 3.698e+03 | | harvestBountyRatio | 2.810e+02 | | harvestCutRatio | 2.490e+02 | | leveragedName | 5.704e+03 | @@ -59,35 +25,18 @@ script/config/markets/ConfigMarket_ETH_fxUSD_mainnet.sol:ConfigMarket_ETH_fxUSD_ | rebalanceThreshold | 2.590e+02 | | spCollateralName | 8.085e+03 | | spCollateralSymbol | 8.061e+03 | -| spLeveragedName | 1.070e+04 | -| spLeveragedSymbol | 1.074e+04 | +| spLeveragedName | 8.887e+03 | +| spLeveragedSymbol | 8.924e+03 | | stabilityPoolEarlyWithdrawalFeeRatio | 2.800e+02 | | stabilityPoolWithdrawalDelay | 3.010e+02 | | stabilityPoolWithdrawalPeriod | 2.820e+02 | | wrappedCollateralToken | 2.790e+02 | script/config/markets/ConfigMarket_EUR_fxUSD_mainnet.sol:ConfigMarket_EUR_fxUSD_mainnet -| function name | max | -|--------------------------------------|-----------| -| collateral | 4.990e+02 | -| getWellKnownAddresses | 3.698e+03 | -| harvestBountyRatio | 2.810e+02 | -| harvestCutRatio | 2.490e+02 | -| leveragedName | 5.704e+03 | -| leveragedSymbol | 6.807e+03 | -| minTotalSupply | 2.700e+02 | -| minterConfig | 1.169e+04 | -| peg | 5.010e+02 | -| rebalanceBountyRatio | 2.360e+02 | -| rebalanceThreshold | 2.590e+02 | -| spCollateralName | 8.085e+03 | -| spCollateralSymbol | 8.061e+03 | -| spLeveragedName | 1.070e+04 | -| spLeveragedSymbol | 1.074e+04 | -| stabilityPoolEarlyWithdrawalFeeRatio | 2.800e+02 | -| stabilityPoolWithdrawalDelay | 3.010e+02 | -| stabilityPoolWithdrawalPeriod | 2.820e+02 | -| wrappedCollateralToken | 2.790e+02 | +| function name | max | +|-----------------|-----------| +| collateral | 4.990e+02 | +| peg | 5.010e+02 | script/config/markets/ConfigMarket_EUR_stETH_mainnet.sol:ConfigMarket_EUR_stETH_mainnet | function name | max | @@ -96,143 +45,43 @@ script/config/markets/ConfigMarket_EUR_stETH_mainnet.sol:ConfigMarket_EUR_stETH_ | peg | 5.010e+02 | script/config/markets/ConfigMarket_GOLD_fxUSD_mainnet.sol:ConfigMarket_GOLD_fxUSD_mainnet -| function name | max | -|--------------------------------------|-----------| -| collateral | 4.990e+02 | -| getWellKnownAddresses | 3.698e+03 | -| harvestBountyRatio | 2.810e+02 | -| harvestCutRatio | 2.490e+02 | -| leveragedName | 5.704e+03 | -| leveragedSymbol | 6.893e+03 | -| minTotalSupply | 2.810e+02 | -| minterConfig | 1.169e+04 | -| peg | 5.010e+02 | -| rebalanceBountyRatio | 2.360e+02 | -| rebalanceThreshold | 2.590e+02 | -| spCollateralName | 8.171e+03 | -| spCollateralSymbol | 8.144e+03 | -| spLeveragedName | 1.087e+04 | -| spLeveragedSymbol | 1.090e+04 | -| stabilityPoolEarlyWithdrawalFeeRatio | 2.800e+02 | -| stabilityPoolWithdrawalDelay | 3.010e+02 | -| stabilityPoolWithdrawalPeriod | 2.820e+02 | -| wrappedCollateralToken | 2.790e+02 | - -script/config/markets/ConfigMarket_GOLD_stETH_mainnet.sol:ConfigMarket_GOLD_stETH_mainnet | function name | max | |-----------------|-----------| | collateral | 4.990e+02 | | peg | 5.010e+02 | -script/config/markets/ConfigMarket_SILVER_fxUSD_mainnet.sol:ConfigMarket_SILVER_fxUSD_mainnet -| function name | max | -|--------------------------------------|-----------| -| collateral | 4.990e+02 | -| getWellKnownAddresses | 3.698e+03 | -| harvestBountyRatio | 2.810e+02 | -| harvestCutRatio | 2.490e+02 | -| leveragedName | 5.704e+03 | -| leveragedSymbol | 7.059e+03 | -| minTotalSupply | 2.810e+02 | -| minterConfig | 1.169e+04 | -| peg | 5.010e+02 | -| rebalanceBountyRatio | 2.360e+02 | -| rebalanceThreshold | 2.590e+02 | -| spCollateralName | 8.337e+03 | -| spCollateralSymbol | 8.310e+03 | -| spLeveragedName | 1.120e+04 | -| spLeveragedSymbol | 1.124e+04 | -| stabilityPoolEarlyWithdrawalFeeRatio | 2.800e+02 | -| stabilityPoolWithdrawalDelay | 3.010e+02 | -| stabilityPoolWithdrawalPeriod | 2.820e+02 | -| wrappedCollateralToken | 2.790e+02 | - -script/config/markets/ConfigMarket_SILVER_stETH_mainnet.sol:ConfigMarket_SILVER_stETH_mainnet -| function name | max | -|--------------------------------------|-----------| -| collateral | 4.990e+02 | -| getWellKnownAddresses | 3.698e+03 | -| harvestBountyRatio | 2.810e+02 | -| harvestCutRatio | 2.490e+02 | -| leveragedName | 5.704e+03 | -| leveragedSymbol | 7.059e+03 | -| minTotalSupply | 2.810e+02 | -| minterConfig | 1.169e+04 | -| peg | 5.010e+02 | -| rebalanceBountyRatio | 2.360e+02 | -| rebalanceThreshold | 2.590e+02 | -| spCollateralName | 8.337e+03 | -| spCollateralSymbol | 8.310e+03 | -| spLeveragedName | 1.120e+04 | -| spLeveragedSymbol | 1.124e+04 | -| stabilityPoolEarlyWithdrawalFeeRatio | 2.800e+02 | -| stabilityPoolWithdrawalDelay | 3.010e+02 | -| stabilityPoolWithdrawalPeriod | 2.820e+02 | -| wrappedCollateralToken | 2.790e+02 | - -script/config/pegs/ConfigPeg_BTC.sol:ConfigPeg_BTC +script/config/markets/ConfigMarket_GOLD_stETH_mainnet.sol:ConfigMarket_GOLD_stETH_mainnet | function name | max | |-----------------|-----------| -| key | 4.270e+02 | -| name | 6.300e+02 | -| peg | 4.340e+02 | -| symbol | 1.155e+03 | +| collateral | 4.990e+02 | +| peg | 5.010e+02 | script/config/pegs/ConfigPeg_ETH.sol:ConfigPeg_ETH | function name | max | |-----------------|-----------| | key | 4.270e+02 | | name | 6.300e+02 | -| peg | 4.340e+02 | | symbol | 1.155e+03 | -script/config/pegs/ConfigPeg_EUR.sol:ConfigPeg_EUR -| function name | max | -|-----------------|-----------| -| key | 4.270e+02 | -| name | 6.300e+02 | -| peg | 4.340e+02 | -| symbol | 1.155e+03 | - -script/config/pegs/ConfigPeg_GOLD.sol:ConfigPeg_GOLD -| function name | max | -|-----------------|-----------| -| key | 4.270e+02 | -| name | 6.300e+02 | -| peg | 4.340e+02 | -| symbol | 1.238e+03 | - -script/config/pegs/ConfigPeg_SILVER.sol:ConfigPeg_SILVER -| function name | max | -|-----------------|-----------| -| key | 4.270e+02 | -| name | 6.300e+02 | -| peg | 4.340e+02 | -| symbol | 1.404e+03 | - src/minter/Genesis_v1.sol:Genesis_v1 -| function name | max | -|---------------------------|-----------| -| LEVERAGED_TOKEN | 2.820e+02 | -| MINTER | 3.030e+02 | -| PEGGED_TOKEN | 2.830e+02 | -| STABILITY_POOL_COLLATERAL | 3.040e+02 | -| STABILITY_POOL_LEVERAGED | 2.610e+02 | -| UPGRADE_INTERFACE_VERSION | 4.360e+02 | -| WRAPPED_COLLATERAL_TOKEN | 2.820e+02 | -| balanceOf | 2.591e+03 | -| claim | 8.751e+04 | -| claimable | 1.161e+04 | -| deposit | 6.747e+04 | -| endGenesis | 3.490e+05 | -| genesisIsEnded | 2.349e+03 | -| initialize | 7.146e+04 | -| owner | 2.401e+03 | -| proxiableUUID | 3.300e+02 | -| transferOwnership | 1.202e+04 | -| withdraw | 4.335e+04 | +| function name | max | +|--------------------------|-----------| +| LEVERAGED_TOKEN | 2.820e+02 | +| MINTER | 3.030e+02 | +| PEGGED_TOKEN | 2.830e+02 | +| WRAPPED_COLLATERAL_TOKEN | 2.820e+02 | +| balanceOf | 2.591e+03 | +| claim | 8.751e+04 | +| claimable | 1.161e+04 | +| deposit | 6.747e+04 | +| endGenesis | 3.490e+05 | +| genesisIsEnded | 2.349e+03 | +| initialize | 7.146e+04 | +| owner | 2.401e+03 | +| transferOwnership | 1.202e+04 | +| withdraw | 4.335e+04 | -src/minter/Minter_v1.sol:Minter_v1 +src/minter/Minter_v2.sol:Minter_v2 | function name | max | |--------------------------|-----------| | ZERO_FEE_ROLE | 2.850e+02 | @@ -242,7 +91,7 @@ src/minter/Minter_v1.sol:Minter_v1 | freeMintLeveragedToken | 1.096e+05 | | freeMintPeggedToken | 1.549e+05 | | freeRedeemLeveragedToken | 7.282e+04 | -| freeRedeemPeggedToken | 6.824e+04 | +| freeRedeemPeggedToken | 6.823e+04 | | grantRoles | 2.633e+04 | | hasAnyRole | 2.636e+03 | | initialize | 1.844e+05 | @@ -257,45 +106,43 @@ src/minter/Minter_v1.sol:Minter_v1 | updateReservePool | 2.631e+04 | | upgradeToAndCall | 1.094e+04 | -src/minter/Minter_v2.sol:Minter_v2 +src/minter/Minter_v3.sol:Minter_v3 | function name | max | |------------------------------------|-----------| | HARVESTER_ROLE | 2.830e+02 | | LEVERAGED_TOKEN | 2.830e+02 | -| PEGGED_TOKEN | 2.830e+02 | -| WRAPPED_COLLATERAL_TOKEN | 3.280e+02 | +| PEGGED_TOKEN | 3.050e+02 | +| WRAPPED_COLLATERAL_TOKEN | 2.830e+02 | | ZERO_FEE_ROLE | 2.850e+02 | | collateralRatio | 7.109e+04 | | collateralTokenBalance | 2.358e+03 | | config | 4.895e+04 | | feeReceiver | 2.442e+03 | | freeMintLeveragedToken | 1.438e+05 | -| freeMintPeggedToken | 1.549e+05 | +| freeMintPeggedToken | 1.674e+05 | | freeRedeemLeveragedToken | 8.668e+04 | | freeRedeemPeggedToken | 1.355e+05 | | grantRoles | 2.633e+04 | -| harvestable | 2.254e+04 | +| harvestable | 2.981e+04 | | hasAllRoles | 2.637e+03 | | hasAnyRole | 2.636e+03 | | initialize | 1.844e+05 | | leverageRatio | 1.957e+04 | | leveragedTokenBalance | 1.042e+04 | -| leveragedTokenPrice | 8.165e+04 | +| leveragedTokenPrice | 8.167e+04 | | mintLeveragedToken | 1.494e+05 | -| mintLeveragedTokenDryRun | 7.337e+04 | +| mintLeveragedTokenDryRun | 7.338e+04 | | mintLeveragedTokenIncentiveRatio | 3.108e+04 | -| mintPeggedToken | 1.909e+05 | -| mintPeggedTokenDryRun | 6.335e+04 | | mintPeggedTokenIncentiveRatio | 3.012e+04 | -| owner | 2.380e+03 | +| owner | 2.402e+03 | | peggedTokenBalance | 2.409e+03 | | peggedTokenPrice | 1.923e+04 | | priceOracle | 2.427e+03 | | proxiableUUID | 3.860e+02 | -| redeemLeveragedToken | 1.277e+05 | +| redeemLeveragedToken | 1.276e+05 | | redeemLeveragedTokenDryRun | 6.317e+04 | | redeemLeveragedTokenIncentiveRatio | 2.924e+04 | -| redeemPeggedForCollateralRatio | 1.958e+04 | +| redeemPeggedForCollateralRatio | 1.961e+04 | | redeemPeggedToken | 1.323e+05 | | redeemPeggedTokenDryRun | 6.178e+04 | | redeemPeggedTokenIncentiveRatio | 3.011e+04 | @@ -304,67 +151,27 @@ src/minter/Minter_v2.sol:Minter_v2 | supportsInterface | 9.430e+02 | | sweep | 4.031e+04 | | transferOwnership | 1.207e+04 | -| updateConfig | 2.950e+05 | +| updateConfig | 2.954e+05 | | updateFeeReceiver | 2.635e+04 | | updatePriceOracle | 2.636e+04 | | updateReservePool | 2.631e+04 | -src/minter/Minter_v3.sol:Minter_v3 -| function name | max | -|--------------------------------|-----------| -| HARVESTER_ROLE | 2.830e+02 | -| LEVERAGED_TOKEN | 2.830e+02 | -| PEGGED_TOKEN | 3.050e+02 | -| UPGRADE_INTERFACE_VERSION | 4.590e+02 | -| WRAPPED_COLLATERAL_TOKEN | 2.830e+02 | -| ZERO_FEE_ROLE | 2.850e+02 | -| collateralRatio | 1.912e+04 | -| config | 7.187e+04 | -| feeReceiver | 2.442e+03 | -| freeMintLeveragedToken | 1.150e+05 | -| freeMintPeggedToken | 1.674e+05 | -| freeRedeemPeggedToken | 1.346e+05 | -| grantRoles | 2.633e+04 | -| harvestable | 2.981e+04 | -| initialize | 1.844e+05 | -| owner | 2.402e+03 | -| peggedTokenPrice | 2.449e+03 | -| proxiableUUID | 3.530e+02 | -| redeemPeggedForCollateralRatio | 3.107e+03 | -| reservePool | 2.411e+03 | -| rolesOf | 2.609e+03 | -| sweep | 3.645e+04 | -| transferOwnership | 1.207e+04 | -| updateConfig | 2.740e+05 | -| updateFeeReceiver | 2.635e+04 | -| updatePriceOracle | 2.636e+04 | -| updateReservePool | 2.631e+04 | - src/minter/ReservePool_v1.sol:ReservePool_v1 -| function name | max | -|---------------------------|-----------| -| REQUESTER_ROLE | 2.390e+02 | -| UPGRADE_INTERFACE_VERSION | 4.130e+02 | -| grantRoles | 2.633e+04 | -| hasAnyRole | 2.569e+03 | -| initialize | 7.031e+04 | -| owner | 2.389e+03 | -| proxiableUUID | 2.860e+02 | -| requestBonus | 3.934e+04 | -| rolesOf | 2.542e+03 | -| supportsInterface | 8.420e+02 | -| sweep | 2.642e+03 | -| transferOwnership | 1.204e+04 | +| function name | max | +|-------------------|-----------| +| REQUESTER_ROLE | 2.390e+02 | +| grantRoles | 2.633e+04 | +| hasAnyRole | 2.569e+03 | +| initialize | 7.031e+04 | +| owner | 2.389e+03 | +| requestBonus | 3.934e+04 | +| supportsInterface | 8.420e+02 | +| sweep | 2.642e+03 | +| transferOwnership | 1.204e+04 | src/minter/StabilityPoolManager_v1.sol:StabilityPoolManager_v1 | function name | max | |----------------------------|-----------| -| LEVERAGED_TOKEN | 3.050e+02 | -| MINTER | 3.250e+02 | -| PEGGED_TOKEN | 2.610e+02 | -| TREASURY | 2.830e+02 | -| UPGRADE_INTERFACE_VERSION | 4.800e+02 | -| WRAPPED_COLLATERAL_TOKEN | 3.040e+02 | | feeReceiver | 2.441e+03 | | harvest | 4.488e+05 | | harvestBountyRatio | 2.369e+03 | @@ -378,7 +185,6 @@ src/minter/StabilityPoolManager_v1.sol:StabilityPoolManager_v1 | rebalanceBountyRatio | 2.354e+03 | | rebalanceThreshold | 2.347e+03 | | rebalanceable | 2.926e+04 | -| rolesOf | 2.598e+03 | | stabilityPools | 9.030e+02 | | supportsInterface | 5.690e+02 | | transferOwnership | 1.204e+04 | @@ -414,69 +220,52 @@ src/minter/StabilityPool_v1.sol:StabilityPool_v1 | upgradeToAndCall | 1.090e+04 | src/minter/StabilityPool_v2.sol:StabilityPool_v2 -| function name | max | -|-----------------------|-----------| -| ASSET_TOKEN | 3.270e+02 | -| REWARD_DEPOSITOR_ROLE | 2.840e+02 | -| activeRewardTokens | 7.416e+03 | -| assetBalanceOf | 8.049e+03 | -| checkpoint | 1.893e+05 | -| claim | 2.672e+05 | -| claimable | 2.550e+04 | -| claimed | 9.798e+03 | -| deposit | 2.800e+05 | -| depositReward | 6.693e+04 | -| getWithdrawalRequest | 2.745e+03 | -| grantRoles | 2.636e+04 | -| notifyLiquidation | 1.107e+05 | -| proxiableUUID | 3.410e+02 | -| sweep | 4.020e+04 | -| totalAssetSupply | 2.489e+03 | -| withdraw | 3.013e+05 | +| function name | max | +|----------------------|-----------| +| ASSET_TOKEN | 3.270e+02 | +| assetBalanceOf | 8.049e+03 | +| checkpoint | 1.893e+05 | +| claim | 2.672e+05 | +| claimable | 2.550e+04 | +| claimed | 9.798e+03 | +| deposit | 2.800e+05 | +| depositReward | 6.533e+04 | +| getWithdrawalRequest | 2.745e+03 | +| notifyLiquidation | 1.107e+05 | +| proxiableUUID | 3.410e+02 | +| sweep | 4.020e+04 | +| totalAssetSupply | 2.489e+03 | +| withdraw | 3.013e+05 | src/minter/StabilityPool_v3.sol:StabilityPool_v3 -| function name | max | -|----------------------------|-----------| -| ASSET_TOKEN | 2.820e+02 | -| EXEMPT_WITHDRAWAL_FEE_ROLE | 2.860e+02 | -| LIQUIDATION_TOKEN | 3.500e+02 | -| MIN_DEPOSIT | 3.270e+02 | -| MIN_TOTAL_ASSET_SUPPLY | 2.620e+02 | -| REBALANCER_ROLE | 2.840e+02 | -| REWARD_DEPOSITOR_ROLE | 2.840e+02 | -| REWARD_MANAGER_ROLE | 3.050e+02 | -| REWARD_PERIOD_LENGTH | 2.710e+02 | -| UPGRADE_INTERFACE_VERSION | 4.800e+02 | -| WITHDRAWAL_END_WINDOW | 3.150e+02 | -| WITHDRAWAL_START_DELAY | 2.930e+02 | -| activeRewardTokens | 1.195e+04 | -| assetBalanceOf | 8.043e+03 | -| claim | 2.762e+05 | -| claimSingle | 1.987e+05 | -| claimable | 5.993e+04 | -| deposit | 4.061e+05 | -| depositReward | 8.041e+04 | -| getEarlyWithdrawalFee | 2.395e+03 | -| getFeeAddress | 2.398e+03 | -| getWithdrawalRequest | 2.739e+03 | -| getWithdrawalWindow | 3.530e+02 | -| grantRoles | 2.637e+04 | -| historicalRewardTokens | 2.877e+03 | -| initialize | 2.041e+05 | -| isActiveRewardToken | 2.760e+03 | -| lastAssetLossError | 2.379e+03 | -| name | 1.563e+04 | -| notifyLiquidation | 1.198e+05 | -| owner | 2.424e+03 | -| proxiableUUID | 3.310e+02 | -| registerRewardToken | 1.704e+05 | -| requestWithdrawal | 2.503e+04 | -| rolesOf | 2.604e+03 | -| sweep | 3.605e+04 | -| symbol | 1.584e+04 | -| totalAssetSupply | 2.489e+03 | -| transferOwnership | 1.206e+04 | -| withdraw | 2.275e+05 | +| function name | max | +|------------------------|-----------| +| ASSET_TOKEN | 3.490e+02 | +| LIQUIDATION_TOKEN | 3.500e+02 | +| REBALANCER_ROLE | 2.840e+02 | +| REWARD_DEPOSITOR_ROLE | 3.060e+02 | +| REWARD_MANAGER_ROLE | 3.270e+02 | +| activeRewardTokens | 1.195e+04 | +| assetBalanceOf | 8.047e+03 | +| checkpoint | 1.788e+05 | +| claimable | 5.960e+04 | +| claimed | 7.465e+03 | +| deposit | 4.059e+05 | +| depositReward | 8.044e+04 | +| getWithdrawalRequest | 2.761e+03 | +| grantRoles | 2.637e+04 | +| historicalRewardTokens | 5.180e+03 | +| initialize | 2.041e+05 | +| name | 1.926e+04 | +| notifyLiquidation | 1.198e+05 | +| owner | 2.446e+03 | +| requestWithdrawal | 2.501e+04 | +| sweep | 3.610e+04 | +| symbol | 1.950e+04 | +| totalAssetSupply | 2.423e+03 | +| transferOwnership | 1.204e+04 | +| unregisterRewardToken | 1.046e+05 | +| withdraw | 2.272e+05 | src/minter/TokenDistributor_v1.sol:TokenDistributor_v1 | function name | max | @@ -499,13 +288,22 @@ src/minter/TokenDistributor_v1.sol:TokenDistributor_v1 | tokens | 7.448e+03 | | transferOwnership | 1.200e+04 | -src/reward/RewardAlias.sol:RewardAlias +src/minter/library/StringPacking_v1.sol:StringPacking_v1 +| function name | max | +|-----------------|-----------| +| pack64 | 9.370e+02 | +| unpack64 | 1.580e+04 | + +src/reward/RewardAlias_v1.sol:RewardAlias_v1 | function name | max | |-------------------|-----------| | initialize | 7.040e+04 | | owner | 2.371e+03 | +| proxiableUUID | 3.410e+02 | +| supportsInterface | 5.280e+02 | | transferOwnership | 1.202e+04 | -| underlying | 2.150e+02 | +| underlying | 2.010e+02 | +| upgradeToAndCall | 1.083e+04 | test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator.sol:MockMultipleRewardCompoundingAccumulator | function name | max | diff --git a/regression/sizes.txt b/regression/sizes.txt index cfc5bb1f..28fcf6fd 100644 --- a/regression/sizes.txt +++ b/regression/sizes.txt @@ -51,6 +51,6 @@ | StabilityPool_v2 | 20,711 | 3,865 | 22,579 | 4,367,990 | 436.80 | | StabilityPool_v3 | 24,376 | 200 | 27,002 | 5,145,220 | 514.52 | | StakedETHWrappedPriceOracle_v1 | 3,695 | 20,881 | 4,653 | 785,530 | 78.55 | -| StringPacking | 1,218 | 23,358 | 1,270 | 256,300 | 25.63 | +| StringPacking_v1 | 1,218 | 23,358 | 1,270 | 256,300 | 25.63 | | TokenDistributor_v1 | 10,116 | 14,460 | 10,365 | 2,126,850 | 212.69 | | WordCodec | 85 | 24,491 | 135 | 18,350 | 1.84 | diff --git a/test/MainnetUpgradeTest.t.sol b/script/verify/sp-v2-upgrade/MainnetUpgradeTest.t.sol similarity index 100% rename from test/MainnetUpgradeTest.t.sol rename to script/verify/sp-v2-upgrade/MainnetUpgradeTest.t.sol diff --git a/src/interfaces/IStabilityPool_v3.sol b/src/interfaces/IStabilityPool_v3.sol index aa275970..fe338c4e 100644 --- a/src/interfaces/IStabilityPool_v3.sol +++ b/src/interfaces/IStabilityPool_v3.sol @@ -6,5 +6,5 @@ import {IMultipleRewardAccumulator_v3} from "src/interfaces/IMultipleRewardAccum /// @notice StabilityPool v3 additions: unified claim interface. /// @dev Does NOT inherit IStabilityPool — the SP_v3 contract inherits both separately. -// solhint-disable-next-line contract-name-capwords +// solhint-disable-next-line contract-name-capwords,no-empty-blocks interface IStabilityPool_v3 is IMultipleRewardAccumulator_v3 {} diff --git a/src/minter/StabilityPool_v3.sol b/src/minter/StabilityPool_v3.sol index 5a90b631..1e7f4a6e 100644 --- a/src/minter/StabilityPool_v3.sol +++ b/src/minter/StabilityPool_v3.sol @@ -240,6 +240,7 @@ contract StabilityPool_v3 is ) MultipleRewardCompoundingAccumulator_v3(_REWARD_MANAGER_ROLE, _REWARD_DEPOSITOR_ROLE, 1 weeks) { _disableInitializers(); (_ERC20_NAME_0, _ERC20_NAME_1) = StringPacking_v1.pack64(name_); + // slither-disable-next-line unused-return (_ERC20_SYMBOL, ) = StringPacking_v1.pack64(symbol_); address asset = IMinter(minter_).PEGGED_TOKEN(); _ERC20_DECIMALS = IERC20Metadata(asset).decimals(); @@ -747,7 +748,6 @@ contract StabilityPool_v3 is emit Transfer(from, to, amount); } - } // slither-disable-end timestamp diff --git a/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol b/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol index 9eb2cd31..a53a96fb 100644 --- a/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol +++ b/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol @@ -319,12 +319,7 @@ abstract contract MultipleRewardCompoundingAccumulator_v3 is // ═══════════════════════════════════════════════════════════════════════ /// @inheritdoc IMultipleRewardAccumulator_v3 - function claim( - address account, - address receiver, - address token, - uint256 maxAmount - ) public nonReentrant { + function claim(address account, address receiver, address token, uint256 maxAmount) public nonReentrant { if (account != _msgSender() && receiver != address(0)) { revert ClaimOthersRewardToAnother(); } @@ -532,8 +527,7 @@ abstract contract MultipleRewardCompoundingAccumulator_v3 is /// @dev Resolve the receiver address: use stored receiver if set, otherwise account. function _resolveReceiver(address account, address receiver) internal view returns (address) { if (receiver == address(0)) { - MultipleRewardCompoundingAccumulatorStorage storage $ = - _getMultipleRewardCompoundingAccumulatorStorage(); + MultipleRewardCompoundingAccumulatorStorage storage $ = _getMultipleRewardCompoundingAccumulatorStorage(); receiver = $.rewardReceiver[account]; if (receiver == address(0)) { receiver = account; diff --git a/src/reward/distributor/LinearMultipleRewardDistributor_v3.sol b/src/reward/distributor/LinearMultipleRewardDistributor_v3.sol index 0a3b2599..5a1444a5 100644 --- a/src/reward/distributor/LinearMultipleRewardDistributor_v3.sol +++ b/src/reward/distributor/LinearMultipleRewardDistributor_v3.sol @@ -198,6 +198,7 @@ abstract contract LinearMultipleRewardDistributor_v3 is for (uint256 i = 0; i < tokenAliases.length; i++) { address alias_ = tokenAliases[i]; // Reverts if alias doesn't implement underlying() or returns wrong address + // slither-disable-next-line calls-loop if (IRewardAlias(alias_).underlying() != token) { revert AliasUnderlyingMismatch(); } diff --git a/test/MinterUpgradeMigration.t.sol b/test/MinterUpgradeMigration.t.sol index 4fc6300e..6254c09c 100644 --- a/test/MinterUpgradeMigration.t.sol +++ b/test/MinterUpgradeMigration.t.sol @@ -7,21 +7,21 @@ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; -import {Minter_v1} from "src/minter/Minter_v1.sol"; import {Minter_v2} from "src/minter/Minter_v2.sol"; +import {Minter_v3} from "src/minter/Minter_v3.sol"; import {IMinter} from "src/interfaces/IMinter.sol"; import {TestMinterSetUp} from "test/Minter_base.t.sol"; /// @title TestMinterUpgradeMigration -/// @notice Tests that upgrading Minter_v1 → Minter_v2 via UUPS proxy preserves +/// @notice Tests that upgrading Minter_v2 → Minter_v3 via UUPS proxy preserves /// all state and produces identical results at every lifecycle stage. contract TestMinterUpgradeMigration is TestMinterSetUp { - /// @dev Override to deploy with Minter_v1 implementation instead of Minter_v2 + /// @dev Override to deploy with Minter_v2 implementation instead of Minter_v3 function setUp_minter() internal override { minter = UnsafeUpgrades.deployUUPSProxy( - address(new Minter_v1(wrappedCollateralToken, peggedToken, leveragedToken, peggedTokenBurnSig)), - abi.encodeCall(Minter_v1.initialize, (owner)) + address(new Minter_v2(wrappedCollateralToken, peggedToken, leveragedToken, peggedTokenBurnSig)), + abi.encodeCall(Minter_v2.initialize, (owner)) ); vm.label(minter, "minter"); zeroFeeRole = IMinter(minter).ZERO_FEE_ROLE(); @@ -31,24 +31,24 @@ contract TestMinterUpgradeMigration is TestMinterSetUp { if (isConfigSet) IMinter(minter).updateConfig(config); IBaoOwnable(minter).transferOwnership(owner); } - function _upgradeToV2() internal { - address v2Impl = address( - new Minter_v2(wrappedCollateralToken, peggedToken, leveragedToken, peggedTokenBurnSig) + function _upgradeToV3() internal { + address v3Impl = address( + new Minter_v3(wrappedCollateralToken, peggedToken, leveragedToken, peggedTokenBurnSig) ); vm.prank(owner); - UUPSUpgradeable(minter).upgradeToAndCall(v2Impl, ""); + UUPSUpgradeable(minter).upgradeToAndCall(v3Impl, ""); } // ═══════════════════════════════════════════════════════════════════════ // 1. FreshMinter — upgrade empty minter // ═══════════════════════════════════════════════════════════════════════ - function test_upgradeFromV1_FreshMinter() public { + function test_upgradeFromV2_FreshMinter() public { // Verify initial state assertEq(IMinter(minter).peggedTokenBalance(), 0, "no pegged before upgrade"); assertEq(IMinter(minter).collateralTokenBalance(), 0, "no collateral before upgrade"); - _upgradeToV2(); + _upgradeToV3(); // State preserved assertEq(IMinter(minter).peggedTokenBalance(), 0, "no pegged after upgrade"); @@ -65,40 +65,40 @@ contract TestMinterUpgradeMigration is TestMinterSetUp { // 2. AfterMint — upgrade after minting pegged and leveraged // ═══════════════════════════════════════════════════════════════════════ - function test_upgradeFromV1_AfterMint() public { + function test_upgradeFromV2_AfterMint() public { setUp_collateral(5 ether, 5 ether); - // Snapshot v1 state + // Snapshot v2 state uint256 snap = vm.snapshotState(); - uint256 v1_pegged = IMinter(minter).peggedTokenBalance(); - uint256 v1_collateral = IMinter(minter).collateralTokenBalance(); - uint256 v1_cr = IMinter(minter).collateralRatio(); - uint256 v1_levPrice = IMinter(minter).leveragedTokenPrice(); - uint256 v1_pegPrice = IMinter(minter).peggedTokenPrice(); - uint256 v1_levRatio = IMinter(minter).leverageRatio(); + uint256 v2_pegged = IMinter(minter).peggedTokenBalance(); + uint256 v2_collateral = IMinter(minter).collateralTokenBalance(); + uint256 v2_cr = IMinter(minter).collateralRatio(); + uint256 v2_levPrice = IMinter(minter).leveragedTokenPrice(); + uint256 v2_pegPrice = IMinter(minter).peggedTokenPrice(); + uint256 v2_levRatio = IMinter(minter).leverageRatio(); // Revert and upgrade vm.revertToState(snap); - _upgradeToV2(); + _upgradeToV3(); // Assert identical state - assertEq(IMinter(minter).peggedTokenBalance(), v1_pegged, "pegged balance preserved"); - assertEq(IMinter(minter).collateralTokenBalance(), v1_collateral, "collateral balance preserved"); - assertEq(IMinter(minter).collateralRatio(), v1_cr, "CR preserved"); - assertEq(IMinter(minter).leveragedTokenPrice(), v1_levPrice, "leveraged price preserved"); - assertEq(IMinter(minter).peggedTokenPrice(), v1_pegPrice, "pegged price preserved"); - assertEq(IMinter(minter).leverageRatio(), v1_levRatio, "leverage ratio preserved"); + assertEq(IMinter(minter).peggedTokenBalance(), v2_pegged, "pegged balance preserved"); + assertEq(IMinter(minter).collateralTokenBalance(), v2_collateral, "collateral balance preserved"); + assertEq(IMinter(minter).collateralRatio(), v2_cr, "CR preserved"); + assertEq(IMinter(minter).leveragedTokenPrice(), v2_levPrice, "leveraged price preserved"); + assertEq(IMinter(minter).peggedTokenPrice(), v2_pegPrice, "pegged price preserved"); + assertEq(IMinter(minter).leverageRatio(), v2_levRatio, "leverage ratio preserved"); // Post-upgrade: more minting works setUp_collateral(1 ether, 1 ether); - assertGt(IMinter(minter).peggedTokenBalance(), v1_pegged, "more pegged minted post-upgrade"); + assertGt(IMinter(minter).peggedTokenBalance(), v2_pegged, "more pegged minted post-upgrade"); } // ═══════════════════════════════════════════════════════════════════════ // 3. AfterRedeem — upgrade after minting then partially redeeming // ═══════════════════════════════════════════════════════════════════════ - function test_upgradeFromV1_AfterRedeem() public { + function test_upgradeFromV2_AfterRedeem() public { setUp_collateral(5 ether, 5 ether); // Redeem some pegged @@ -109,22 +109,22 @@ contract TestMinterUpgradeMigration is TestMinterSetUp { IMinter(minter).freeRedeemPeggedToken(toRedeem, 0, zeroFee); vm.stopPrank(); - // Snapshot v1 state + // Snapshot v2 state uint256 snap = vm.snapshotState(); - uint256 v1_pegged = IMinter(minter).peggedTokenBalance(); - uint256 v1_collateral = IMinter(minter).collateralTokenBalance(); - uint256 v1_cr = IMinter(minter).collateralRatio(); - uint256 v1_levPrice = IMinter(minter).leveragedTokenPrice(); + uint256 v2_pegged = IMinter(minter).peggedTokenBalance(); + uint256 v2_collateral = IMinter(minter).collateralTokenBalance(); + uint256 v2_cr = IMinter(minter).collateralRatio(); + uint256 v2_levPrice = IMinter(minter).leveragedTokenPrice(); // Revert and upgrade vm.revertToState(snap); - _upgradeToV2(); + _upgradeToV3(); // Assert identical - assertEq(IMinter(minter).peggedTokenBalance(), v1_pegged, "pegged balance preserved after redeem"); - assertEq(IMinter(minter).collateralTokenBalance(), v1_collateral, "collateral preserved after redeem"); - assertEq(IMinter(minter).collateralRatio(), v1_cr, "CR preserved after redeem"); - assertEq(IMinter(minter).leveragedTokenPrice(), v1_levPrice, "leveraged price preserved after redeem"); + assertEq(IMinter(minter).peggedTokenBalance(), v2_pegged, "pegged balance preserved after redeem"); + assertEq(IMinter(minter).collateralTokenBalance(), v2_collateral, "collateral preserved after redeem"); + assertEq(IMinter(minter).collateralRatio(), v2_cr, "CR preserved after redeem"); + assertEq(IMinter(minter).leveragedTokenPrice(), v2_levPrice, "leveraged price preserved after redeem"); // Post-upgrade: redeem more works uint256 remaining = IERC20(peggedToken).balanceOf(zeroFee); @@ -133,25 +133,25 @@ contract TestMinterUpgradeMigration is TestMinterSetUp { IERC20(peggedToken).approve(minter, toRedeemMore); IMinter(minter).freeRedeemPeggedToken(toRedeemMore, 0, zeroFee); vm.stopPrank(); - assertLt(IMinter(minter).peggedTokenBalance(), v1_pegged, "redeem works post-upgrade"); + assertLt(IMinter(minter).peggedTokenBalance(), v2_pegged, "redeem works post-upgrade"); } // ═══════════════════════════════════════════════════════════════════════ // 4. ConfigChange — upgrade preserves config // ═══════════════════════════════════════════════════════════════════════ - function test_upgradeFromV1_ConfigChange() public { - // Change config on v1 + function test_upgradeFromV2_ConfigChange() public { + // Change config on v2 setUp_config_free(); vm.prank(owner); IMinter(minter).updateConfig(config); - IMinter.Config memory v1_config = IMinter(minter).config(); + IMinter.Config memory v2_config = IMinter(minter).config(); - _upgradeToV2(); + _upgradeToV3(); - IMinter.Config memory v2_config = IMinter(minter).config(); - _assertEqConfig(v2_config, v1_config); + IMinter.Config memory v3_config = IMinter(minter).config(); + _assertEqConfig(v3_config, v2_config); // Post-upgrade: config update works setUp_config_flat(); @@ -165,7 +165,7 @@ contract TestMinterUpgradeMigration is TestMinterSetUp { // 5. MixedOperations — mint, redeem, price change, then upgrade // ═══════════════════════════════════════════════════════════════════════ - function test_upgradeFromV1_MixedOperations() public { + function test_upgradeFromV2_MixedOperations() public { // Mint pegged and leveraged setUp_collateral(5 ether, 5 ether); @@ -177,24 +177,24 @@ contract TestMinterUpgradeMigration is TestMinterSetUp { IMinter(minter).freeRedeemLeveragedToken(toRedeem, zeroFee); vm.stopPrank(); - // Snapshot v1 state + // Snapshot v2 state uint256 snap = vm.snapshotState(); - uint256 v1_pegged = IMinter(minter).peggedTokenBalance(); - uint256 v1_collateral = IMinter(minter).collateralTokenBalance(); - uint256 v1_cr = IMinter(minter).collateralRatio(); - uint256 v1_levPrice = IMinter(minter).leveragedTokenPrice(); - uint256 v1_pegPrice = IMinter(minter).peggedTokenPrice(); + uint256 v2_pegged = IMinter(minter).peggedTokenBalance(); + uint256 v2_collateral = IMinter(minter).collateralTokenBalance(); + uint256 v2_cr = IMinter(minter).collateralRatio(); + uint256 v2_levPrice = IMinter(minter).leveragedTokenPrice(); + uint256 v2_pegPrice = IMinter(minter).peggedTokenPrice(); // Revert and upgrade vm.revertToState(snap); - _upgradeToV2(); + _upgradeToV3(); // Assert identical - assertEq(IMinter(minter).peggedTokenBalance(), v1_pegged, "pegged preserved"); - assertEq(IMinter(minter).collateralTokenBalance(), v1_collateral, "collateral preserved"); - assertEq(IMinter(minter).collateralRatio(), v1_cr, "CR preserved"); - assertEq(IMinter(minter).leveragedTokenPrice(), v1_levPrice, "leveraged price preserved"); - assertEq(IMinter(minter).peggedTokenPrice(), v1_pegPrice, "pegged price preserved"); + assertEq(IMinter(minter).peggedTokenBalance(), v2_pegged, "pegged preserved"); + assertEq(IMinter(minter).collateralTokenBalance(), v2_collateral, "collateral preserved"); + assertEq(IMinter(minter).collateralRatio(), v2_cr, "CR preserved"); + assertEq(IMinter(minter).leveragedTokenPrice(), v2_levPrice, "leveraged price preserved"); + assertEq(IMinter(minter).peggedTokenPrice(), v2_pegPrice, "pegged price preserved"); } // ═══════════════════════════════════════════════════════════════════════ @@ -202,12 +202,12 @@ contract TestMinterUpgradeMigration is TestMinterSetUp { // ═══════════════════════════════════════════════════════════════════════ /// @notice The core bug fix: freeRedeemPeggedToken with both collateral and - /// leveraged paths. On v1, the leveraged path sees stale state. - /// On v2, both paths use consistent snapshots. - function test_upgradeFromV1_FreeRedeemBothPaths() public { + /// leveraged paths. On v2, the leveraged path sees stale state. + /// On v3, both paths use consistent snapshots. + function test_upgradeFromV2_FreeRedeemBothPaths() public { setUp_collateral(5 ether, 5 ether); - _upgradeToV2(); + _upgradeToV3(); uint256 priceBefore = IMinter(minter).leveragedTokenPrice(); uint256 peggedBal = IERC20(peggedToken).balanceOf(zeroFee); @@ -220,7 +220,7 @@ contract TestMinterUpgradeMigration is TestMinterSetUp { vm.stopPrank(); uint256 priceAfter = IMinter(minter).leveragedTokenPrice(); - // The v2 fix ensures leveraged token price doesn't drop from inconsistent state + // The v3 fix ensures leveraged token price doesn't drop from inconsistent state assertGe(priceAfter, priceBefore, "leveraged price must not decrease in freeRedeemPeggedToken"); } } diff --git a/test/Minter_base.t.sol b/test/Minter_base.t.sol index ac434feb..46a90990 100644 --- a/test/Minter_base.t.sol +++ b/test/Minter_base.t.sol @@ -14,7 +14,7 @@ import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; -import {Minter_v2} from "src/minter/Minter_v2.sol"; +import {Minter_v3} from "src/minter/Minter_v3.sol"; import {MintableBurnableERC20_v1} from "@bao/MintableBurnableERC20_v1.sol"; import {ReservePool_v1} from "src/minter/ReservePool_v1.sol"; @@ -439,8 +439,8 @@ contract TestMinterSetUp is TestExtras, Clog, Array, ConfigFile { function setUp_minter() internal virtual { minter = UnsafeUpgrades.deployUUPSProxy( - address(new Minter_v2(wrappedCollateralToken, peggedToken, leveragedToken, peggedTokenBurnSig)), - abi.encodeCall(Minter_v2.initialize, (owner)) + address(new Minter_v3(wrappedCollateralToken, peggedToken, leveragedToken, peggedTokenBurnSig)), + abi.encodeCall(Minter_v3.initialize, (owner)) ); vm.label(minter, "minter"); zeroFeeRole = IMinter(minter).ZERO_FEE_ROLE(); @@ -538,43 +538,43 @@ contract TestMinterInit is TestMinterSetUp { function setUp() public override { super.setUp(); - impl = address(new Minter_v2(wrappedCollateralToken, peggedToken, leveragedToken, peggedTokenBurnSig)); + impl = address(new Minter_v3(wrappedCollateralToken, peggedToken, leveragedToken, peggedTokenBurnSig)); } // TODO: do this test for all contracts // TODO: do test for initialize calls function test_notERC20() public { - new Minter_v2(wrappedCollateralToken, peggedToken, leveragedToken, peggedTokenBurnSig); + new Minter_v3(wrappedCollateralToken, peggedToken, leveragedToken, peggedTokenBurnSig); // zero address vm.expectRevert(abi.encodeWithSelector(Token.ZeroAddress.selector)); - new Minter_v2(address(0), peggedToken, leveragedToken, peggedTokenBurnSig); + new Minter_v3(address(0), peggedToken, leveragedToken, peggedTokenBurnSig); vm.expectRevert(abi.encodeWithSelector(Token.ZeroAddress.selector)); - new Minter_v2(Deployed.wstETH, address(0), leveragedToken, peggedTokenBurnSig); + new Minter_v3(Deployed.wstETH, address(0), leveragedToken, peggedTokenBurnSig); vm.expectRevert(abi.encodeWithSelector(Token.ZeroAddress.selector)); - new Minter_v2(Deployed.wstETH, peggedToken, address(0), peggedTokenBurnSig); + new Minter_v3(Deployed.wstETH, peggedToken, address(0), peggedTokenBurnSig); // not a contract vm.expectRevert(abi.encodeWithSelector(Token.NotContractAddress.selector, owner)); - new Minter_v2(owner, peggedToken, leveragedToken, peggedTokenBurnSig); + new Minter_v3(owner, peggedToken, leveragedToken, peggedTokenBurnSig); vm.expectRevert(abi.encodeWithSelector(Token.NotContractAddress.selector, owner)); - new Minter_v2(Deployed.wstETH, owner, leveragedToken, peggedTokenBurnSig); + new Minter_v3(Deployed.wstETH, owner, leveragedToken, peggedTokenBurnSig); vm.expectRevert(abi.encodeWithSelector(Token.NotContractAddress.selector, owner)); - new Minter_v2(Deployed.wstETH, peggedToken, owner, peggedTokenBurnSig); + new Minter_v3(Deployed.wstETH, peggedToken, owner, peggedTokenBurnSig); // contract but not ERC20 vm.expectRevert(abi.encodeWithSelector(Token.NotERC20Token.selector, priceOracle)); - new Minter_v2(priceOracle, peggedToken, leveragedToken, peggedTokenBurnSig); + new Minter_v3(priceOracle, peggedToken, leveragedToken, peggedTokenBurnSig); vm.expectRevert(abi.encodeWithSelector(Token.NotERC20Token.selector, priceOracle)); - new Minter_v2(Deployed.wstETH, priceOracle, leveragedToken, peggedTokenBurnSig); + new Minter_v3(Deployed.wstETH, priceOracle, leveragedToken, peggedTokenBurnSig); vm.expectRevert(abi.encodeWithSelector(Token.NotERC20Token.selector, priceOracle)); - new Minter_v2(Deployed.wstETH, peggedToken, priceOracle, peggedTokenBurnSig); + new Minter_v3(Deployed.wstETH, peggedToken, priceOracle, peggedTokenBurnSig); } function _burnSig() internal { @@ -623,16 +623,16 @@ contract TestMinterInit is TestMinterSetUp { minter = UnsafeUpgrades.deployUUPSProxy( // mock, no need to permission minting - address(new Minter_v2(wrappedCollateralToken, peggedToken, leveragedToken, peggedTokenBurnSig)), - abi.encodeCall(Minter_v2.initialize, (owner)) + address(new Minter_v3(wrappedCollateralToken, peggedToken, leveragedToken, peggedTokenBurnSig)), + abi.encodeCall(Minter_v3.initialize, (owner)) ); _burnSig(); peggedToken = Deployed.BaoUSD; peggedTokenBurnSig = "burn(uint256)"; minter = UnsafeUpgrades.deployUUPSProxy( - address(new Minter_v2(wrappedCollateralToken, peggedToken, leveragedToken, peggedTokenBurnSig)), - abi.encodeCall(Minter_v2.initialize, (owner)) + address(new Minter_v3(wrappedCollateralToken, peggedToken, leveragedToken, peggedTokenBurnSig)), + abi.encodeCall(Minter_v3.initialize, (owner)) ); vm.prank(IBaoUSD(peggedToken).operator()); IBaoUSD(peggedToken).addMinter(minter); @@ -641,40 +641,40 @@ contract TestMinterInit is TestMinterSetUp { peggedToken = address(new MockERC20Burn2Arg("burn", "2arg", 18)); peggedTokenBurnSig = MockERC20Burn2Arg(peggedToken).burnSignature(); minter = UnsafeUpgrades.deployUUPSProxy( - address(new Minter_v2(wrappedCollateralToken, peggedToken, leveragedToken, peggedTokenBurnSig)), - abi.encodeCall(Minter_v2.initialize, (owner)) + address(new Minter_v3(wrappedCollateralToken, peggedToken, leveragedToken, peggedTokenBurnSig)), + abi.encodeCall(Minter_v3.initialize, (owner)) ); _burnSig(); peggedToken = address(new MockERC20Burn1Arg("burn", "1arg", 18)); peggedTokenBurnSig = MockERC20Burn1Arg(peggedToken).burnSignature(); minter = UnsafeUpgrades.deployUUPSProxy( - address(new Minter_v2(wrappedCollateralToken, peggedToken, leveragedToken, peggedTokenBurnSig)), - abi.encodeCall(Minter_v2.initialize, (owner)) + address(new Minter_v3(wrappedCollateralToken, peggedToken, leveragedToken, peggedTokenBurnSig)), + abi.encodeCall(Minter_v3.initialize, (owner)) ); _burnSig(); peggedToken = address(new MockERC20BurnFrom("burn", "from", 18)); peggedTokenBurnSig = MockERC20BurnFrom(peggedToken).burnSignature(); minter = UnsafeUpgrades.deployUUPSProxy( - address(new Minter_v2(wrappedCollateralToken, peggedToken, leveragedToken, peggedTokenBurnSig)), - abi.encodeCall(Minter_v2.initialize, (owner)) + address(new Minter_v3(wrappedCollateralToken, peggedToken, leveragedToken, peggedTokenBurnSig)), + abi.encodeCall(Minter_v3.initialize, (owner)) ); _burnSig(); // burn sig is not a valid ERC20 burn sig - vm.expectRevert(abi.encodeWithSelector(Minter_v2.UnrecognisedBurnSignature.selector, "burn(address,address)")); - new Minter_v2(wrappedCollateralToken, peggedToken, leveragedToken, "burn(address,address)"); + vm.expectRevert(abi.encodeWithSelector(Minter_v3.UnrecognisedBurnSignature.selector, "burn(address,address)")); + new Minter_v3(wrappedCollateralToken, peggedToken, leveragedToken, "burn(address,address)"); // missing bracket - parse error - vm.expectRevert(abi.encodeWithSelector(Minter_v2.UnrecognisedBurnSignature.selector, "burn(address")); - new Minter_v2(wrappedCollateralToken, peggedToken, leveragedToken, "burn(address"); + vm.expectRevert(abi.encodeWithSelector(Minter_v3.UnrecognisedBurnSignature.selector, "burn(address")); + new Minter_v3(wrappedCollateralToken, peggedToken, leveragedToken, "burn(address"); } function test_initEventsImplementation() public { vm.expectEmit(); emit Initializable.Initialized(type(uint64).max); // from the logic contract constructor - address(new Minter_v2(Deployed.wstETH, peggedToken, address(leveragedToken), peggedTokenBurnSig)); + address(new Minter_v3(Deployed.wstETH, peggedToken, address(leveragedToken), peggedTokenBurnSig)); } function test_initEvents() public { @@ -686,15 +686,15 @@ contract TestMinterInit is TestMinterSetUp { emit Initializable.Initialized(1); // from the proxy delegate call UnsafeUpgrades.deployUUPSProxy( - impl, // "Minter_v2.sol", - abi.encodeCall(Minter_v2.initialize, (owner)) + impl, // "Minter_v3.sol", + abi.encodeCall(Minter_v3.initialize, (owner)) ); } function test_init() public { // expect a revert if initialize called twice vm.expectRevert(Initializable.InvalidInitialization.selector); - Minter_v2(minter).initialize(address(this)); + Minter_v3(minter).initialize(address(this)); setUp_config_free(); _assertEqConfig(IMinter(minter).config(), config); @@ -721,9 +721,9 @@ contract TestMinterBasics is TestMinterSetUp { } function test_introspection() public view { - assertTrue(Minter_v2(minter).supportsInterface(type(IMinter).interfaceId), "should support IMinter"); - assertTrue(Minter_v2(minter).supportsInterface(type(IMinter).interfaceId), "should support IMinter"); - assertFalse(Minter_v2(minter).supportsInterface(bytes4(0)), "doesn't support 0"); + assertTrue(Minter_v3(minter).supportsInterface(type(IMinter).interfaceId), "should support IMinter"); + assertTrue(Minter_v3(minter).supportsInterface(type(IMinter).interfaceId), "should support IMinter"); + assertFalse(Minter_v3(minter).supportsInterface(bytes4(0)), "doesn't support 0"); } function _checkConfig( diff --git a/test/RebalanceCheck.t.sol b/test/RebalanceCheck.t.sol index 2336e3d9..9e085c7f 100644 --- a/test/RebalanceCheck.t.sol +++ b/test/RebalanceCheck.t.sol @@ -4,13 +4,14 @@ pragma solidity >=0.8.28 <0.9.0; import {BaoTest} from "@bao-test/BaoTest.sol"; import {HarborFactoryDeployer} from "script/src/HarborFactoryDeployer.sol"; import {StabilityPoolManager_v1} from "src/minter/StabilityPoolManager_v1.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; import {IStabilityPoolManager} from "src/interfaces/IStabilityPoolManager.sol"; import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IMinter} from "src/interfaces/IMinter.sol"; -import {Minter_v2} from "src/minter/Minter_v2.sol"; +import {Minter_v3} from "src/minter/Minter_v3.sol"; abstract contract RebalanceCheckBase is BaoTest, HarborFactoryDeployer { uint256 constant FORK_BLOCK = 24687073; @@ -51,17 +52,17 @@ abstract contract RebalanceCheckBase is BaoTest, HarborFactoryDeployer { manager.upgradeToAndCall(address(newSpmImpl), ""); } - function _upgradeMinterV2() internal { + function _upgradeMinterV3() internal { address proxyOwner = IBaoOwnable(minter).owner(); IMinter m = IMinter(minter); - Minter_v2 newMinterImpl = new Minter_v2( + address newMinterImpl = address(new Minter_v3( m.WRAPPED_COLLATERAL_TOKEN(), m.PEGGED_TOKEN(), m.LEVERAGED_TOKEN(), "burn(uint256)" - ); + )); vm.prank(proxyOwner); - Minter_v2(minter).upgradeToAndCall(address(newMinterImpl), ""); + UUPSUpgradeable(minter).upgradeToAndCall(newMinterImpl, ""); } // ---- assertions ---- @@ -178,31 +179,31 @@ contract RebalanceCheck_v1 is RebalanceCheckBase { } } -/// @notice Tests after upgrading to Minter_v2 -contract RebalanceCheck_v2 is RebalanceCheckBase { +/// @notice Tests after upgrading to Minter_v3 +contract RebalanceCheck_v3 is RebalanceCheckBase { function setUp() public { _forkAndPredict(); - _upgradeMinterV2(); + _upgradeMinterV3(); _upgradeSpm(); } - function test_v2_leveragedTokenPrice_doesNotDecrease() public { + function test_v3_leveragedTokenPrice_doesNotDecrease() public { _assert_leveragedTokenPrice_doesNotDecrease(); } - function test_v2_collateralRatio_hitsThreshold() public { + function test_v3_collateralRatio_hitsThreshold() public { _assert_collateralRatio_hitsThreshold(); } - function test_v2_userClaimable_proportionalToDeposit() public { + function test_v3_userClaimable_proportionalToDeposit() public { _assert_userClaimable_proportionalToDeposit(); } - function test_v2_leveragedMint_doesNotExceedPeggedBurned() public { + function test_v3_leveragedMint_doesNotExceedPeggedBurned() public { _assert_leveragedMint_doesNotExceedPeggedBurned(); } - function test_v2_holderValues_preserved() public { + function test_v3_holderValues_preserved() public { _assert_holderValues_preserved(); } } @@ -215,7 +216,7 @@ contract RebalanceCheck_remediation is RebalanceCheckBase { _upgradeSpm(); } - /// @notice Runs rebalance under v1 and v2, logs the delta in leveraged supply + /// @notice Runs rebalance under v1 and v3, logs the delta in leveraged supply /// and underlyingCollateral, showing the exact over-minting. function test_log_overminting_delta() public { // --- snapshot v1 rebalance --- @@ -233,33 +234,33 @@ contract RebalanceCheck_remediation is RebalanceCheckBase { vm.revertToState(snap); - // --- upgrade to v2 and rebalance --- - _upgradeMinterV2(); + // --- upgrade to v3 and rebalance --- + _upgradeMinterV3(); - uint256 v2_priceBefore = IMinter(minter).leveragedTokenPrice(); + uint256 v3_priceBefore = IMinter(minter).leveragedTokenPrice(); StabilityPoolManager_v1(stabilityPoolManager).rebalance(makeAddr("bounty"), 0); - uint256 v2_collateralAfter = IMinter(minter).collateralTokenBalance(); - uint256 v2_levSupplyAfter = IERC20(leveraged).totalSupply(); - uint256 v2_priceAfter = IMinter(minter).leveragedTokenPrice(); + uint256 v3_collateralAfter = IMinter(minter).collateralTokenBalance(); + uint256 v3_levSupplyAfter = IERC20(leveraged).totalSupply(); + uint256 v3_priceAfter = IMinter(minter).leveragedTokenPrice(); // --- log results --- - uint256 excessLeveraged = v1_levSupplyAfter - v2_levSupplyAfter; - uint256 collateralDelta = v1_collateralAfter - v2_collateralAfter; + uint256 excessLeveraged = v1_levSupplyAfter - v3_levSupplyAfter; + uint256 collateralDelta = v1_collateralAfter - v3_collateralAfter; emit log_named_uint("v1 leveraged price BEFORE rebalance", v1_priceBefore); emit log_named_uint("v1 leveraged price AFTER rebalance", v1_priceAfter); - emit log_named_uint("v2 leveraged price BEFORE rebalance", v2_priceBefore); - emit log_named_uint("v2 leveraged price AFTER rebalance", v2_priceAfter); + emit log_named_uint("v3 leveraged price BEFORE rebalance", v3_priceBefore); + emit log_named_uint("v3 leveraged price AFTER rebalance", v3_priceAfter); emit log_named_uint("v1 leveraged minted", v1_levSupplyAfter - v1_levSupplyBefore); - emit log_named_uint("v2 leveraged minted", v2_levSupplyAfter - v1_levSupplyBefore); + emit log_named_uint("v3 leveraged minted", v3_levSupplyAfter - v1_levSupplyBefore); emit log_named_uint("EXCESS leveraged tokens minted by v1", excessLeveraged); emit log_named_uint("v1 underlyingCollateral after", v1_collateralAfter); - emit log_named_uint("v2 underlyingCollateral after", v2_collateralAfter); + emit log_named_uint("v3 underlyingCollateral after", v3_collateralAfter); emit log_named_uint("underlyingCollateral DELTA (v1 too high by)", collateralDelta); emit log_named_uint("v1 collateral removed", v1_collateralBefore - v1_collateralAfter); - emit log_named_uint("v2 collateral removed", v1_collateralBefore - v2_collateralAfter); + emit log_named_uint("v3 collateral removed", v1_collateralBefore - v3_collateralAfter); } /// @notice Confirms that the v1 bug is purely excess leveraged token supply, @@ -267,11 +268,11 @@ contract RebalanceCheck_remediation is RebalanceCheckBase { function test_confirm_collateralDelta_isZero() public { uint256 snapInit = vm.snapshotState(); - // --- Run v2 (correct) rebalance --- - _upgradeMinterV2(); + // --- Run v3 (correct) rebalance --- + _upgradeMinterV3(); StabilityPoolManager_v1(stabilityPoolManager).rebalance(makeAddr("bounty"), 0); - uint256 v2_collateralAfter = IMinter(minter).collateralTokenBalance(); - uint256 v2_levSupply = IERC20(leveraged).totalSupply(); + uint256 v3_collateralAfter = IMinter(minter).collateralTokenBalance(); + uint256 v3_levSupply = IERC20(leveraged).totalSupply(); // --- Revert and run v1 (buggy) rebalance --- vm.revertToState(snapInit); @@ -280,10 +281,10 @@ contract RebalanceCheck_remediation is RebalanceCheckBase { uint256 v1_levSupply = IERC20(leveraged).totalSupply(); // underlyingCollateral is identical — the bug doesn't affect collateral accounting - assertEq(v1_collateralAfter, v2_collateralAfter, "collateral must be identical"); + assertEq(v1_collateralAfter, v3_collateralAfter, "collateral must be identical"); // The ONLY difference is excess leveraged tokens minted - uint256 excess = v1_levSupply - v2_levSupply; + uint256 excess = v1_levSupply - v3_levSupply; assertGt(excess, 0, "v1 must over-mint leveraged tokens"); emit log_named_uint("excess leveraged tokens", excess); diff --git a/test/StabilityPoolClaimable.t.sol b/test/StabilityPoolClaimable.t.sol index 4d5041f6..c2a88466 100644 --- a/test/StabilityPoolClaimable.t.sol +++ b/test/StabilityPoolClaimable.t.sol @@ -621,7 +621,12 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { uint256 bal1Before = IERC20(rewardToken1).balanceOf(user1); uint256 bal2Before = IERC20(rewardToken2).balanceOf(user1); vm.prank(user1); - IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(user1, address(0), rewardToken1, type(uint256).max); + IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim( + user1, + address(0), + rewardToken1, + type(uint256).max + ); // rewardToken1 claimed assertEq(IERC20(rewardToken1).balanceOf(user1) - bal1Before, claimable1, "rewardToken1 claimed"); @@ -658,7 +663,12 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { // Anyone can trigger claim for user1 — tokens go to user1 vm.prank(user2); - IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(user1, address(0), rewardToken1, type(uint256).max); + IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim( + user1, + address(0), + rewardToken1, + type(uint256).max + ); assertEq(IERC20(rewardToken1).balanceOf(user1), claimable1, "user1 received tokens"); } @@ -679,7 +689,12 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { _depositForUsers(); // No rewards deposited — claimSingle should not revert vm.prank(user1); - IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(user1, address(0), rewardToken1, type(uint256).max); + IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim( + user1, + address(0), + rewardToken1, + type(uint256).max + ); assertEq(IERC20(rewardToken1).balanceOf(user1), 0, "nothing claimed"); } @@ -717,7 +732,12 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { // Claim with maxAmount > claimable — should claim all uint256 balBefore = IERC20(rewardToken1).balanceOf(user1); vm.prank(user1); - IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(user1, address(0), rewardToken1, type(uint256).max); + IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim( + user1, + address(0), + rewardToken1, + type(uint256).max + ); assertEq(IERC20(rewardToken1).balanceOf(user1) - balBefore, claimable, "claimed all"); @@ -775,7 +795,12 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { vm.startPrank(user1); IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(user1, address(0), rewardToken1, tranche); IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(user1, address(0), rewardToken1, tranche); - IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(user1, address(0), rewardToken1, type(uint256).max); + IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim( + user1, + address(0), + rewardToken1, + type(uint256).max + ); vm.stopPrank(); // Should have claimed everything @@ -816,7 +841,12 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { // Claim the rest vm.prank(user1); - IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(user1, address(0), rewardToken1, type(uint256).max); + IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim( + user1, + address(0), + rewardToken1, + type(uint256).max + ); assertApproxEqRel(IERC20(rewardToken1).balanceOf(user1), 100 ether, 0.02 ether, "~100 total"); } @@ -987,7 +1017,12 @@ contract TestRewardAlias is TestStabilityPoolRebalanceSetUp { uint256 rwdBefore = aliasUnderlying.balanceOf(user1); vm.prank(user1); - IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(user1, address(0), address(harvestAlias), type(uint256).max); + IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim( + user1, + address(0), + address(harvestAlias), + type(uint256).max + ); // User received the underlying token, not the alias assertEq(aliasUnderlying.balanceOf(user1) - rwdBefore, claimable, "received underlying"); @@ -1003,7 +1038,12 @@ contract TestRewardAlias is TestStabilityPoolRebalanceSetUp { // Claim only harvest vm.prank(user1); - IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(user1, address(0), address(harvestAlias), type(uint256).max); + IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim( + user1, + address(0), + address(harvestAlias), + type(uint256).max + ); // Boost should be unchanged uint256 boostAfter = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(boostAlias)); @@ -1139,7 +1179,12 @@ contract TestRewardAlias is TestStabilityPoolRebalanceSetUp { // Partial claim from harvestAlias only vm.prank(user1); - IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(user1, address(0), address(harvestAlias), claimableHarvest / 3); + IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim( + user1, + address(0), + address(harvestAlias), + claimableHarvest / 3 + ); // boostAlias claimable unchanged assertEq( @@ -1177,7 +1222,12 @@ contract TestRewardAlias is TestStabilityPoolRebalanceSetUp { // Partial claim from harvestAlias vm.prank(user1); - IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(user1, address(0), address(harvestAlias), claimableHarvest / 2); + IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim( + user1, + address(0), + address(harvestAlias), + claimableHarvest / 2 + ); // Aggregated drops by the claimed amount uint256 newAggregated = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( @@ -1195,7 +1245,12 @@ contract TestRewardAlias is TestStabilityPoolRebalanceSetUp { uint256 partialAmount = claimable / 4; vm.prank(user1); - IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(user1, receiver, address(harvestAlias), partialAmount); + IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim( + user1, + receiver, + address(harvestAlias), + partialAmount + ); // Receiver gets the underlying token, not the alias assertEq(IERC20(address(aliasUnderlying)).balanceOf(receiver), partialAmount, "receiver got underlying"); diff --git a/test/deployment/RebalanceFairness.t.sol b/test/deployment/RebalanceFairness.t.sol index 931080a9..aa5ec07a 100644 --- a/test/deployment/RebalanceFairness.t.sol +++ b/test/deployment/RebalanceFairness.t.sol @@ -12,7 +12,8 @@ import {IMinter} from "src/interfaces/IMinter.sol"; import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; import {IStabilityPoolManager} from "src/interfaces/IStabilityPoolManager.sol"; import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; -import {Minter_v2} from "src/minter/Minter_v2.sol"; +import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; +import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; import {StabilityPoolManager_v1} from "src/minter/StabilityPoolManager_v1.sol"; import {MockWrappedPriceOracle} from "test/mocks/MockWrappedPriceOracle.sol"; @@ -91,8 +92,8 @@ contract RebalanceFairnessSetUp is BaoTest, Deploy_ETH_Minter { oracleRate = 1 ether; mockOracle.setLatestAnswer(oraclePrice, oracleRate); - vm.prank(Minter_v2(minter).owner()); - Minter_v2(minter).updatePriceOracle(address(mockOracle)); + vm.prank(IBaoOwnable(minter).owner()); + IMinter(minter).updatePriceOracle(address(mockOracle)); // Override harvest config: set cut to 0 so harvest goes to pools, not treasury vm.startPrank(StabilityPoolManager_v1(stabilityPoolManager).owner()); @@ -129,8 +130,8 @@ contract RebalanceFairnessSetUp is BaoTest, Deploy_ETH_Minter { // Mint via zero-fee — this test contract has owner privileges from deployment uint256 zeroFeeRole = IMinter(minter).ZERO_FEE_ROLE(); - vm.prank(Minter_v2(minter).owner()); - Minter_v2(minter).grantRoles(address(this), zeroFeeRole); + vm.prank(IBaoOwnable(minter).owner()); + IBaoRoles(minter).grantRoles(address(this), zeroFeeRole); peggedMinted = IMinter(minter).freeMintPeggedToken(collateralAmount, to); } @@ -140,8 +141,8 @@ contract RebalanceFairnessSetUp is BaoTest, Deploy_ETH_Minter { IERC20(wrappedCollateral).approve(minter, collateralAmount); uint256 zeroFeeRole = IMinter(minter).ZERO_FEE_ROLE(); - vm.prank(Minter_v2(minter).owner()); - Minter_v2(minter).grantRoles(address(this), zeroFeeRole); + vm.prank(IBaoOwnable(minter).owner()); + IBaoRoles(minter).grantRoles(address(this), zeroFeeRole); levMinted = IMinter(minter).freeMintLeveragedToken(collateralAmount, to); } diff --git a/test/deployment/RewardSystem.t.sol b/test/deployment/RewardSystem.t.sol index 8889501d..2a99e8c0 100644 --- a/test/deployment/RewardSystem.t.sol +++ b/test/deployment/RewardSystem.t.sol @@ -20,7 +20,6 @@ import {IMultipleRewardAccumulator_v3} from "src/interfaces/IMultipleRewardAccum import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; import {IRewardAlias} from "src/interfaces/IRewardAlias.sol"; import {RewardAlias_v1} from "src/reward/RewardAlias_v1.sol"; -import {Minter_v2} from "src/minter/Minter_v2.sol"; import {StabilityPoolManager_v1} from "src/minter/StabilityPoolManager_v1.sol"; import {MockWrappedPriceOracle} from "test/mocks/MockWrappedPriceOracle.sol"; @@ -163,7 +162,12 @@ contract AccumulatorTest is RewardSystemSetUp { // Claim vm.prank(alice); - IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(alice, address(0), wrappedCollateral, type(uint256).max); + IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim( + alice, + address(0), + wrappedCollateral, + type(uint256).max + ); // claimed() should return the claimed amount uint256 claimedAmount = IMultipleRewardAccumulator(stabilityPoolCollateral).claimed(alice, wrappedCollateral); diff --git a/test/deployment/StabilityPoolAliasDeployment.t.sol b/test/deployment/StabilityPoolAliasDeployment.t.sol index 8440f055..0e231028 100644 --- a/test/deployment/StabilityPoolAliasDeployment.t.sol +++ b/test/deployment/StabilityPoolAliasDeployment.t.sol @@ -15,7 +15,7 @@ import {IMultipleRewardAccumulator_v3} from "src/interfaces/IMultipleRewardAccum import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; import {IRewardAlias} from "src/interfaces/IRewardAlias.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; -import {Minter_v2} from "src/minter/Minter_v2.sol"; +import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; import {MockWrappedPriceOracle} from "test/mocks/MockWrappedPriceOracle.sol"; /// @title StabilityPoolAliasDeploymentTest @@ -86,7 +86,7 @@ contract StabilityPoolAliasDeploymentSetUp is BaoTest, Deploy_ETH_Minter { mockOracle.setLatestAnswer(1 ether, 1 ether); vm.startPrank(HARBOR_MULTISIG); - Minter_v2(minter).updatePriceOracle(address(mockOracle)); + IMinter(minter).updatePriceOracle(address(mockOracle)); IBaoRoles(minter).grantRoles(address(this), IMinter(minter).ZERO_FEE_ROLE()); IBaoRoles(stabilityPoolCollateral).grantRoles( address(this), @@ -191,7 +191,12 @@ contract StabilityPoolAliasDeploymentTest is StabilityPoolAliasDeploymentSetUp { // Claim via alias — should receive wrappedCollateral (the underlying) uint256 balBefore = IERC20(wrappedCollateral).balanceOf(alice); vm.prank(alice); - IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(alice, address(0), collHarvestAlias, type(uint256).max); + IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim( + alice, + address(0), + collHarvestAlias, + type(uint256).max + ); uint256 received = IERC20(wrappedCollateral).balanceOf(alice) - balBefore; assertApprox(received, rewardAmount, 604800, "claimed underlying amount"); From 71c53ca994fa94795fe5a5aa93433f5260a954e6 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Sun, 5 Apr 2026 19:49:01 +0100 Subject: [PATCH 015/232] fixed some issues - getting it clean #2 --- script/src/{ => v3}/DeployMintersShared.sol | 0 script/src/{ => v3}/Deploy_BTC_Minter.sol | 0 script/src/{ => v3}/Deploy_ETH_Minter.sol | 0 script/src/{ => v3}/Deploy_EUR_Minter.sol | 0 script/src/{ => v3}/Deploy_GOLD_Minter.sol | 0 script/src/{ => v3}/Deploy_MCAP_Minter.sol | 0 script/src/{ => v3}/Deploy_SILVER_Minter.sol | 0 script/src/{ => v3}/contracts/Genesis.sol | 0 script/src/{ => v3}/contracts/LeveragedToken.sol | 0 script/src/{ => v3}/contracts/Minter.sol | 0 script/src/{ => v3}/contracts/PeggedToken.sol | 0 script/src/{ => v3}/contracts/StabilityPool.sol | 0 script/src/{ => v3}/contracts/StabilityPoolManager.sol | 0 .../verify/minter-v2-upgrade}/MinterUpgradeMigration.t.sol | 0 {test => script/verify/minter-v2-upgrade}/RebalanceCheck.t.sol | 0 15 files changed, 0 insertions(+), 0 deletions(-) rename script/src/{ => v3}/DeployMintersShared.sol (100%) rename script/src/{ => v3}/Deploy_BTC_Minter.sol (100%) rename script/src/{ => v3}/Deploy_ETH_Minter.sol (100%) rename script/src/{ => v3}/Deploy_EUR_Minter.sol (100%) rename script/src/{ => v3}/Deploy_GOLD_Minter.sol (100%) rename script/src/{ => v3}/Deploy_MCAP_Minter.sol (100%) rename script/src/{ => v3}/Deploy_SILVER_Minter.sol (100%) rename script/src/{ => v3}/contracts/Genesis.sol (100%) rename script/src/{ => v3}/contracts/LeveragedToken.sol (100%) rename script/src/{ => v3}/contracts/Minter.sol (100%) rename script/src/{ => v3}/contracts/PeggedToken.sol (100%) rename script/src/{ => v3}/contracts/StabilityPool.sol (100%) rename script/src/{ => v3}/contracts/StabilityPoolManager.sol (100%) rename {test => script/verify/minter-v2-upgrade}/MinterUpgradeMigration.t.sol (100%) rename {test => script/verify/minter-v2-upgrade}/RebalanceCheck.t.sol (100%) diff --git a/script/src/DeployMintersShared.sol b/script/src/v3/DeployMintersShared.sol similarity index 100% rename from script/src/DeployMintersShared.sol rename to script/src/v3/DeployMintersShared.sol diff --git a/script/src/Deploy_BTC_Minter.sol b/script/src/v3/Deploy_BTC_Minter.sol similarity index 100% rename from script/src/Deploy_BTC_Minter.sol rename to script/src/v3/Deploy_BTC_Minter.sol diff --git a/script/src/Deploy_ETH_Minter.sol b/script/src/v3/Deploy_ETH_Minter.sol similarity index 100% rename from script/src/Deploy_ETH_Minter.sol rename to script/src/v3/Deploy_ETH_Minter.sol diff --git a/script/src/Deploy_EUR_Minter.sol b/script/src/v3/Deploy_EUR_Minter.sol similarity index 100% rename from script/src/Deploy_EUR_Minter.sol rename to script/src/v3/Deploy_EUR_Minter.sol diff --git a/script/src/Deploy_GOLD_Minter.sol b/script/src/v3/Deploy_GOLD_Minter.sol similarity index 100% rename from script/src/Deploy_GOLD_Minter.sol rename to script/src/v3/Deploy_GOLD_Minter.sol diff --git a/script/src/Deploy_MCAP_Minter.sol b/script/src/v3/Deploy_MCAP_Minter.sol similarity index 100% rename from script/src/Deploy_MCAP_Minter.sol rename to script/src/v3/Deploy_MCAP_Minter.sol diff --git a/script/src/Deploy_SILVER_Minter.sol b/script/src/v3/Deploy_SILVER_Minter.sol similarity index 100% rename from script/src/Deploy_SILVER_Minter.sol rename to script/src/v3/Deploy_SILVER_Minter.sol diff --git a/script/src/contracts/Genesis.sol b/script/src/v3/contracts/Genesis.sol similarity index 100% rename from script/src/contracts/Genesis.sol rename to script/src/v3/contracts/Genesis.sol diff --git a/script/src/contracts/LeveragedToken.sol b/script/src/v3/contracts/LeveragedToken.sol similarity index 100% rename from script/src/contracts/LeveragedToken.sol rename to script/src/v3/contracts/LeveragedToken.sol diff --git a/script/src/contracts/Minter.sol b/script/src/v3/contracts/Minter.sol similarity index 100% rename from script/src/contracts/Minter.sol rename to script/src/v3/contracts/Minter.sol diff --git a/script/src/contracts/PeggedToken.sol b/script/src/v3/contracts/PeggedToken.sol similarity index 100% rename from script/src/contracts/PeggedToken.sol rename to script/src/v3/contracts/PeggedToken.sol diff --git a/script/src/contracts/StabilityPool.sol b/script/src/v3/contracts/StabilityPool.sol similarity index 100% rename from script/src/contracts/StabilityPool.sol rename to script/src/v3/contracts/StabilityPool.sol diff --git a/script/src/contracts/StabilityPoolManager.sol b/script/src/v3/contracts/StabilityPoolManager.sol similarity index 100% rename from script/src/contracts/StabilityPoolManager.sol rename to script/src/v3/contracts/StabilityPoolManager.sol diff --git a/test/MinterUpgradeMigration.t.sol b/script/verify/minter-v2-upgrade/MinterUpgradeMigration.t.sol similarity index 100% rename from test/MinterUpgradeMigration.t.sol rename to script/verify/minter-v2-upgrade/MinterUpgradeMigration.t.sol diff --git a/test/RebalanceCheck.t.sol b/script/verify/minter-v2-upgrade/RebalanceCheck.t.sol similarity index 100% rename from test/RebalanceCheck.t.sol rename to script/verify/minter-v2-upgrade/RebalanceCheck.t.sol From ccf8d2d66f375fe70c6faa89ca8a71aa664846b1 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Sun, 5 Apr 2026 19:49:21 +0100 Subject: [PATCH 016/232] fixed some issues - getting it clean #3 versioned deploys --- script/Deploy_BTC_mainnet.s.sol | 2 +- script/Deploy_ETH_mainnet.s.sol | 2 +- script/Deploy_EUR_mainnet.s.sol | 2 +- script/Deploy_GOLD_mainnet.s.sol | 2 +- script/Deploy_MCAP_mainnet.s.sol | 2 +- script/Deploy_Minter_v2_mainnet.s.sol | 12 +- script/Deploy_SILVER_mainnet.s.sol | 2 +- script/Deploy_StabilityPool_v3_mainnet.s.sol | 16 +- .../Grant_Minter_ZeroFeeRoles_mainnet.s.sol | 12 +- script/Remediate_Accumulators.s.sol | 12 +- script/src/v2/DeployMintersShared.sol | 281 ++++++++++++++++++ script/src/v2/Deploy_BTC_Minter.sol | 22 ++ script/src/v2/Deploy_ETH_Minter.sol | 20 ++ script/src/v2/Deploy_EUR_Minter.sol | 22 ++ script/src/v2/Deploy_GOLD_Minter.sol | 22 ++ script/src/v2/Deploy_MCAP_Minter.sol | 22 ++ script/src/v2/Deploy_SILVER_Minter.sol | 22 ++ script/src/v2/contracts/Genesis.sol | 58 ++++ script/src/v2/contracts/LeveragedToken.sol | 56 ++++ script/src/v2/contracts/Minter.sol | 180 +++++++++++ script/src/v2/contracts/PeggedToken.sol | 82 +++++ script/src/v2/contracts/StabilityPool.sol | 112 +++++++ .../src/v2/contracts/StabilityPoolManager.sol | 108 +++++++ .../minter-v2-upgrade/DeployMinters.t.sol | 10 +- .../MinterUpgradeMigration.t.sol | 126 ++++---- .../minter-v2-upgrade/RebalanceCheck.t.sol | 69 ++--- test/deployment/DeployETHfxUSD.t.sol | 2 +- test/deployment/MinterCappedMint.t.sol | 2 +- test/deployment/RebalanceFairness.t.sol | 2 +- test/deployment/RewardSystem.t.sol | 2 +- .../StabilityPoolAliasDeployment.t.sol | 2 +- 31 files changed, 1145 insertions(+), 141 deletions(-) create mode 100644 script/src/v2/DeployMintersShared.sol create mode 100644 script/src/v2/Deploy_BTC_Minter.sol create mode 100644 script/src/v2/Deploy_ETH_Minter.sol create mode 100644 script/src/v2/Deploy_EUR_Minter.sol create mode 100644 script/src/v2/Deploy_GOLD_Minter.sol create mode 100644 script/src/v2/Deploy_MCAP_Minter.sol create mode 100644 script/src/v2/Deploy_SILVER_Minter.sol create mode 100644 script/src/v2/contracts/Genesis.sol create mode 100644 script/src/v2/contracts/LeveragedToken.sol create mode 100644 script/src/v2/contracts/Minter.sol create mode 100644 script/src/v2/contracts/PeggedToken.sol create mode 100644 script/src/v2/contracts/StabilityPool.sol create mode 100644 script/src/v2/contracts/StabilityPoolManager.sol diff --git a/script/Deploy_BTC_mainnet.s.sol b/script/Deploy_BTC_mainnet.s.sol index cf4f5108..c6f24a37 100644 --- a/script/Deploy_BTC_mainnet.s.sol +++ b/script/Deploy_BTC_mainnet.s.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {Script} from "forge-std/Script.sol"; -import {Deploy_BTC_Minter} from "script/src/Deploy_BTC_Minter.sol"; +import {Deploy_BTC_Minter} from "script/src/v3/Deploy_BTC_Minter.sol"; import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; import {Config_MinterMarket} from "script/config/ConfigBase.sol"; diff --git a/script/Deploy_ETH_mainnet.s.sol b/script/Deploy_ETH_mainnet.s.sol index 06c5678d..a46b8505 100644 --- a/script/Deploy_ETH_mainnet.s.sol +++ b/script/Deploy_ETH_mainnet.s.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {Script} from "forge-std/Script.sol"; -import {Deploy_ETH_Minter} from "script/src/Deploy_ETH_Minter.sol"; +import {Deploy_ETH_Minter} from "script/src/v3/Deploy_ETH_Minter.sol"; import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; import {Config_MinterMarket} from "script/config/ConfigBase.sol"; diff --git a/script/Deploy_EUR_mainnet.s.sol b/script/Deploy_EUR_mainnet.s.sol index a6d708ec..7358c612 100644 --- a/script/Deploy_EUR_mainnet.s.sol +++ b/script/Deploy_EUR_mainnet.s.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {Script} from "forge-std/Script.sol"; -import {Deploy_EUR_Minter} from "script/src/Deploy_EUR_Minter.sol"; +import {Deploy_EUR_Minter} from "script/src/v3/Deploy_EUR_Minter.sol"; import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; import {Config_MinterMarket} from "script/config/ConfigBase.sol"; diff --git a/script/Deploy_GOLD_mainnet.s.sol b/script/Deploy_GOLD_mainnet.s.sol index 2c86b64c..7a8d0580 100644 --- a/script/Deploy_GOLD_mainnet.s.sol +++ b/script/Deploy_GOLD_mainnet.s.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {Script} from "forge-std/Script.sol"; -import {Deploy_GOLD_Minter} from "script/src/Deploy_GOLD_Minter.sol"; +import {Deploy_GOLD_Minter} from "script/src/v3/Deploy_GOLD_Minter.sol"; import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; import {Config_MinterMarket} from "script/config/ConfigBase.sol"; diff --git a/script/Deploy_MCAP_mainnet.s.sol b/script/Deploy_MCAP_mainnet.s.sol index 661431cd..0dba9ad3 100644 --- a/script/Deploy_MCAP_mainnet.s.sol +++ b/script/Deploy_MCAP_mainnet.s.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {Script} from "forge-std/Script.sol"; -import {Deploy_MCAP_Minter} from "script/src/Deploy_MCAP_Minter.sol"; +import {Deploy_MCAP_Minter} from "script/src/v3/Deploy_MCAP_Minter.sol"; import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; import {Config_MinterMarket} from "script/config/ConfigBase.sol"; diff --git a/script/Deploy_Minter_v2_mainnet.s.sol b/script/Deploy_Minter_v2_mainnet.s.sol index ce963535..19a49bda 100644 --- a/script/Deploy_Minter_v2_mainnet.s.sol +++ b/script/Deploy_Minter_v2_mainnet.s.sol @@ -9,12 +9,12 @@ import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; import {Config_MinterMarket, IMarketConfig, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; -import {Deploy_BTC_Minter} from "script/src/Deploy_BTC_Minter.sol"; -import {Deploy_ETH_Minter} from "script/src/Deploy_ETH_Minter.sol"; -import {Deploy_EUR_Minter} from "script/src/Deploy_EUR_Minter.sol"; -import {Deploy_GOLD_Minter} from "script/src/Deploy_GOLD_Minter.sol"; -import {Deploy_MCAP_Minter} from "script/src/Deploy_MCAP_Minter.sol"; -import {Deploy_SILVER_Minter} from "script/src/Deploy_SILVER_Minter.sol"; +import {Deploy_BTC_Minter} from "script/src/v3/Deploy_BTC_Minter.sol"; +import {Deploy_ETH_Minter} from "script/src/v3/Deploy_ETH_Minter.sol"; +import {Deploy_EUR_Minter} from "script/src/v3/Deploy_EUR_Minter.sol"; +import {Deploy_GOLD_Minter} from "script/src/v3/Deploy_GOLD_Minter.sol"; +import {Deploy_MCAP_Minter} from "script/src/v3/Deploy_MCAP_Minter.sol"; +import {Deploy_SILVER_Minter} from "script/src/v3/Deploy_SILVER_Minter.sol"; import {SafeBatch} from "script/safe/SafeBatch.s.sol"; diff --git a/script/Deploy_SILVER_mainnet.s.sol b/script/Deploy_SILVER_mainnet.s.sol index f92291ad..d5761b85 100644 --- a/script/Deploy_SILVER_mainnet.s.sol +++ b/script/Deploy_SILVER_mainnet.s.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {Script} from "forge-std/Script.sol"; -import {Deploy_SILVER_Minter} from "script/src/Deploy_SILVER_Minter.sol"; +import {Deploy_SILVER_Minter} from "script/src/v3/Deploy_SILVER_Minter.sol"; import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; import {Config_MinterMarket} from "script/config/ConfigBase.sol"; diff --git a/script/Deploy_StabilityPool_v3_mainnet.s.sol b/script/Deploy_StabilityPool_v3_mainnet.s.sol index 8f1d7cf9..ad9cb606 100644 --- a/script/Deploy_StabilityPool_v3_mainnet.s.sol +++ b/script/Deploy_StabilityPool_v3_mainnet.s.sol @@ -8,14 +8,14 @@ import {DeploymentState} from "@bao-script/deployment/DeploymentState.sol"; import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; import {Config_MinterMarket, IMarketConfig, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; -import {StabilityPool} from "script/src/contracts/StabilityPool.sol"; - -import {Deploy_BTC_Minter} from "script/src/Deploy_BTC_Minter.sol"; -import {Deploy_ETH_Minter} from "script/src/Deploy_ETH_Minter.sol"; -import {Deploy_EUR_Minter} from "script/src/Deploy_EUR_Minter.sol"; -import {Deploy_GOLD_Minter} from "script/src/Deploy_GOLD_Minter.sol"; -import {Deploy_MCAP_Minter} from "script/src/Deploy_MCAP_Minter.sol"; -import {Deploy_SILVER_Minter} from "script/src/Deploy_SILVER_Minter.sol"; +import {StabilityPool} from "script/src/v3/contracts/StabilityPool.sol"; + +import {Deploy_BTC_Minter} from "script/src/v3/Deploy_BTC_Minter.sol"; +import {Deploy_ETH_Minter} from "script/src/v3/Deploy_ETH_Minter.sol"; +import {Deploy_EUR_Minter} from "script/src/v3/Deploy_EUR_Minter.sol"; +import {Deploy_GOLD_Minter} from "script/src/v3/Deploy_GOLD_Minter.sol"; +import {Deploy_MCAP_Minter} from "script/src/v3/Deploy_MCAP_Minter.sol"; +import {Deploy_SILVER_Minter} from "script/src/v3/Deploy_SILVER_Minter.sol"; import {SafeBatch} from "script/safe/SafeBatch.s.sol"; diff --git a/script/Grant_Minter_ZeroFeeRoles_mainnet.s.sol b/script/Grant_Minter_ZeroFeeRoles_mainnet.s.sol index 288a8780..13cfb201 100644 --- a/script/Grant_Minter_ZeroFeeRoles_mainnet.s.sol +++ b/script/Grant_Minter_ZeroFeeRoles_mainnet.s.sol @@ -8,12 +8,12 @@ import {Config_MinterMarket, MinterMarketConfigLib} from "script/config/ConfigBa import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; import {IMinter} from "src/interfaces/IMinter.sol"; -import {Deploy_BTC_Minter} from "script/src/Deploy_BTC_Minter.sol"; -import {Deploy_ETH_Minter} from "script/src/Deploy_ETH_Minter.sol"; -import {Deploy_EUR_Minter} from "script/src/Deploy_EUR_Minter.sol"; -import {Deploy_GOLD_Minter} from "script/src/Deploy_GOLD_Minter.sol"; -import {Deploy_MCAP_Minter} from "script/src/Deploy_MCAP_Minter.sol"; -import {Deploy_SILVER_Minter} from "script/src/Deploy_SILVER_Minter.sol"; +import {Deploy_BTC_Minter} from "script/src/v3/Deploy_BTC_Minter.sol"; +import {Deploy_ETH_Minter} from "script/src/v3/Deploy_ETH_Minter.sol"; +import {Deploy_EUR_Minter} from "script/src/v3/Deploy_EUR_Minter.sol"; +import {Deploy_GOLD_Minter} from "script/src/v3/Deploy_GOLD_Minter.sol"; +import {Deploy_MCAP_Minter} from "script/src/v3/Deploy_MCAP_Minter.sol"; +import {Deploy_SILVER_Minter} from "script/src/v3/Deploy_SILVER_Minter.sol"; import {SafeBatch} from "script/safe/SafeBatch.s.sol"; diff --git a/script/Remediate_Accumulators.s.sol b/script/Remediate_Accumulators.s.sol index 0f659702..4bdbd264 100644 --- a/script/Remediate_Accumulators.s.sol +++ b/script/Remediate_Accumulators.s.sol @@ -9,12 +9,12 @@ import {Config_MinterMarket, MinterMarketConfigLib} from "script/config/ConfigBa import {ForceMigrateAccumulator_v1} from "script/verify/sp-v3-migration/ForceMigrateAccumulator_v1.sol"; import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; -import {Deploy_BTC_Minter} from "script/src/Deploy_BTC_Minter.sol"; -import {Deploy_ETH_Minter} from "script/src/Deploy_ETH_Minter.sol"; -import {Deploy_EUR_Minter} from "script/src/Deploy_EUR_Minter.sol"; -import {Deploy_GOLD_Minter} from "script/src/Deploy_GOLD_Minter.sol"; -import {Deploy_MCAP_Minter} from "script/src/Deploy_MCAP_Minter.sol"; -import {Deploy_SILVER_Minter} from "script/src/Deploy_SILVER_Minter.sol"; +import {Deploy_BTC_Minter} from "script/src/v3/Deploy_BTC_Minter.sol"; +import {Deploy_ETH_Minter} from "script/src/v3/Deploy_ETH_Minter.sol"; +import {Deploy_EUR_Minter} from "script/src/v3/Deploy_EUR_Minter.sol"; +import {Deploy_GOLD_Minter} from "script/src/v3/Deploy_GOLD_Minter.sol"; +import {Deploy_MCAP_Minter} from "script/src/v3/Deploy_MCAP_Minter.sol"; +import {Deploy_SILVER_Minter} from "script/src/v3/Deploy_SILVER_Minter.sol"; import {SafeBatch} from "script/safe/SafeBatch.s.sol"; diff --git a/script/src/v2/DeployMintersShared.sol b/script/src/v2/DeployMintersShared.sol new file mode 100644 index 00000000..738a8600 --- /dev/null +++ b/script/src/v2/DeployMintersShared.sol @@ -0,0 +1,281 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.28 <0.9.0; + +import {console2 as console} from "forge-std/console2.sol"; +import {LibString} from "@solady/utils/LibString.sol"; +import {PeggedToken} from "./contracts/PeggedToken.sol"; +import {LeveragedToken} from "./contracts/LeveragedToken.sol"; +import {Minter} from "./contracts/Minter.sol"; +import {StabilityPool} from "./contracts/StabilityPool.sol"; +import {StabilityPoolManager} from "./contracts/StabilityPoolManager.sol"; +import {Genesis} from "./contracts/Genesis.sol"; +import {HarborFactoryDeployer} from "script/src/HarborFactoryDeployer.sol"; +import {DeploymentState} from "@bao-script/deployment/DeploymentState.sol"; +import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; +import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; +import {Config_MinterMarket, IMarketConfig, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; +import {Minter_v2} from "@harbor/minter/Minter_v2.sol"; +import {StabilityPool_v2} from "@harbor/minter/StabilityPool_v2.sol"; +import {IMinter} from "src/interfaces/IMinter.sol"; + +/// @notice Extended market config interface with methods from collateral and chain configs. +interface IFullMinterConfig { + function peg() external view returns (string memory); + function collateral() external view returns (string memory); + function wrappedCollateralToken() external view returns (address); + function minterConfig() external pure returns (IMinter.Config memory); + // Peg config + function minTotalSupply() external view returns (uint256); + // Stability pool config + function stabilityPoolWithdrawalDelay() external pure returns (uint256); + function stabilityPoolWithdrawalPeriod() external pure returns (uint256); + function stabilityPoolEarlyWithdrawalFeeRatio() external pure returns (uint256); + // StabilityPoolManager config (rebalanceThreshold comes from volatility config) + function rebalanceThreshold() external pure returns (uint256); + function rebalanceBountyRatio() external pure returns (uint256); + function harvestBountyRatio() external pure returns (uint256); + function harvestCutRatio() external pure returns (uint256); +} + +/// @notice Shared functionality for all minter deployment contracts. +/// @dev Provides common infrastructure and deployment primitives. +abstract contract DeployMintersShared is + PeggedToken, + LeveragedToken, + Minter, + StabilityPool, + StabilityPoolManager, + Genesis +{ + using LibString for string; + + // ========== MARKET LOOKUP ========== + + /// @notice Find a market config by collateral name. + /// @param markets Array of market configurations. + /// @param collateral Collateral name to find. + /// @return The matching market config. + /// @dev Reverts if not found. + function findMarket( + Config_MinterMarket[] memory markets, + string memory collateral + ) internal view returns (Config_MinterMarket) { + bytes32 target = keccak256(bytes(collateral)); + for (uint256 i = 0; i < markets.length; i++) { + if (keccak256(bytes(MinterMarketConfigLib.collateral(markets[i]))) == target) { + return markets[i]; + } + } + revert(string.concat("Market not found for collateral: ", collateral)); + } + + /// @notice Parse collateral filter string into array of markets. + /// @param allMarkets All configured markets. + /// @param collateralFilter "*" for all, or single collateral name. + /// @return Filtered array of markets. + function parseCollateralFilter( + Config_MinterMarket[] memory allMarkets, + string memory collateralFilter + ) internal view returns (Config_MinterMarket[] memory) { + if (collateralFilter.eq("*")) { + return allMarkets; + } + if (bytes(collateralFilter).length == 0) { + return new Config_MinterMarket[](0); + } + Config_MinterMarket[] memory result = new Config_MinterMarket[](1); + result[0] = findMarket(allMarkets, collateralFilter); + return result; + } + + // ========== MAIN ENTRY POINT ========== + + /// @notice Deploy pegged token and/or markets for a peg. + /// @param saltPrefix Salt prefix for CREATE3 deployment namespacing. + /// @param peg Peg configuration. + /// @param allMarkets All configured markets for this peg (used for role grants). + /// @param network Network name (e.g., "mainnet"). + /// @param deployPeg Whether to deploy the pegged token. + /// @param marketsToDeploy Markets to deploy (empty array = none). + function deployForPeg( + string memory saltPrefix, + ConfigPeg peg, + Config_MinterMarket[] memory allMarkets, + string memory network, + bool deployPeg, + Config_MinterMarket[] memory marketsToDeploy + ) internal { + _setSaltPrefix(saltPrefix); + + // Load or seed state + DeploymentTypes.State memory state = _shouldPersistState() + ? DeploymentState.load(_stateFileRead()) + : DeploymentTypes.State({ + network: network, + saltPrefix: saltPrefix, + directoryPrefix: "", + implementations: new DeploymentTypes.ImplementationRecord[](0), + proxies: new DeploymentTypes.ProxyRecord[](0), + baoFactory: address(0) + }); + state.baoFactory = baoFactory(); + + console.log("=== Deploying Minter Contracts ==="); + console.log(" Salt: %s", saltPrefix); + console.log(" Network: %s", network); + + if (deployPeg) { + console.log(""); + console.log("--- Deploying %s Pegged Token ---", peg.key()); + deployPeggedTokenWithRoles(state, peg, allMarkets); + } + + for (uint256 i = 0; i < marketsToDeploy.length; i++) { + _deployMinterInfrastructure(state, marketsToDeploy[i]); + } + + // Finalize: transfer ownerships and save state + console.log(""); + console.log("--- Transferring Ownerships ---"); + _transferAllOwnerships(); + _saveState(state); + console.log("=== Minter Deployment Done ==="); + } + + // ========== MINTER INFRASTRUCTURE DEPLOYMENT ========== + + /// @notice Deploy infrastructure for a single market. + /// @param state Deployment state (modified in place). + /// @param market Market configuration. + function _deployMinterInfrastructure(DeploymentTypes.State memory state, Config_MinterMarket market) private { + IFullMinterConfig cfg = IFullMinterConfig(address(market)); + string memory marketKey = MinterMarketConfigLib.salt(market); + + console.log(""); + console.log(" > Market: %s", marketKey); + + // Deploy LeveragedToken + _deployLeveragedTokenWithRoles(state, market); + + // Deploy ReservePool + deployReservePool(state, marketKey); + + // Deploy Minter + _deployMinter(state, cfg, marketKey); + + // Deploy Stability Pools + _deployStabilityPools(state, cfg, marketKey); + + // Deploy StabilityPoolManager + _deployStabilityPoolManager(state, cfg, marketKey); + + // Deploy Genesis + _deployGenesis(state, cfg, marketKey); + + // Configure Minter and grant roles + _configureMinter(market, marketKey); + + console.log(" [complete]"); + } + + function _deployMinter( + DeploymentTypes.State memory stateData, + IFullMinterConfig cfg, + string memory marketKey + ) internal { + address wrappedCollateral = cfg.wrappedCollateralToken(); + address peggedToken = _predictAddress(_key(cfg.peg(), "pegged")); + address leveragedToken = _predictAddress(_key(marketKey, "leveraged")); + + deployMinter(stateData, marketKey, wrappedCollateral, peggedToken, leveragedToken); + } + + function _deployStabilityPools( + DeploymentTypes.State memory stateData, + IFullMinterConfig cfg, + string memory marketKey + ) internal { + address minter = _predictAddress(_key(marketKey, "minter")); + + deployStabilityPool( + StabilityPoolCollateral, + stateData, + marketKey, + minter, + cfg.wrappedCollateralToken(), + address(cfg) + ); + + deployStabilityPool( + StabilityPoolLeveraged, + stateData, + marketKey, + minter, + _predictAddress(_key(marketKey, "leveraged")), + address(cfg) + ); + } + + function _deployStabilityPoolManager( + DeploymentTypes.State memory stateData, + IFullMinterConfig, + string memory marketKey + ) internal { + address minter = _predictAddress(_key(marketKey, "minter")); + address spCollateral = _predictAddress(_key(marketKey, "stabilityPoolCollateral")); + address spLeveraged = _predictAddress(_key(marketKey, "stabilityPoolLeveraged")); + + deployStabilityPoolManager(stateData, marketKey, minter, treasury(), spCollateral, spLeveraged); + } + + function _deployGenesis( + DeploymentTypes.State memory stateData, + IFullMinterConfig cfg, + string memory marketKey + ) internal { + cfg; + address minter = _predictAddress(_key(marketKey, "minter")); + deployGenesis(stateData, marketKey, minter); + } + + function _configureMinter(Config_MinterMarket market, string memory marketKey) internal { + IFullMinterConfig cfg = IFullMinterConfig(address(market)); + address minter = _predictAddress(_key(marketKey, "minter")); + address reservePool = _predictAddress(_key(marketKey, "reservePool")); + address spCollateral = _predictAddress(_key(marketKey, "stabilityPoolCollateral")); + address spLeveraged = _predictAddress(_key(marketKey, "stabilityPoolLeveraged")); + address spm = _predictAddress(_key(marketKey, "stabilityPoolManager")); + address genesis = _predictAddress(_key(marketKey, "genesis")); + address leveragedToken = _predictAddress(_key(marketKey, "leveraged")); + address priceOracle = _predictAddress(MinterMarketConfigLib.priceOracleKey(market)); + + // Update minter configuration (incentive ratios) + Minter_v2(minter).updateConfig(cfg.minterConfig()); + Minter_v2(minter).updateReservePool(reservePool); + Minter_v2(minter).updateFeeReceiver(treasury()); + Minter_v2(minter).updatePriceOracle(priceOracle); + + // Grant roles + grantReservePoolRoles(string.concat(marketKey, "::reservePool"), reservePool, minter); + grantMinterRoles(string.concat(marketKey, "::minter"), minter, spm, genesis); + grantStabilityPoolRoles(string.concat(marketKey, "::stabilityPoolCollateral"), spCollateral, spm); + grantStabilityPoolRoles(string.concat(marketKey, "::stabilityPoolLeveraged"), spLeveraged, spm); + + // Register reward tokens + StabilityPool_v2(spCollateral).registerRewardToken(cfg.wrappedCollateralToken()); + StabilityPool_v2(spLeveraged).registerRewardToken(cfg.wrappedCollateralToken()); + StabilityPool_v2(spLeveraged).registerRewardToken(leveragedToken); + + // Configure StabilityPoolManager + configureStabilityPoolManager( + spm, + SPMConfig({ + rebalanceThreshold: cfg.rebalanceThreshold(), + rebalanceBountyRatio: cfg.rebalanceBountyRatio(), + harvestBountyRatio: cfg.harvestBountyRatio(), + harvestCutRatio: cfg.harvestCutRatio(), + feeReceiver: treasury() + }) + ); + } +} diff --git a/script/src/v2/Deploy_BTC_Minter.sol b/script/src/v2/Deploy_BTC_Minter.sol new file mode 100644 index 00000000..9e0d2a5d --- /dev/null +++ b/script/src/v2/Deploy_BTC_Minter.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.28 <0.9.0; + +import {console2 as console} from "forge-std/console2.sol"; +import {DeployMintersShared} from "./DeployMintersShared.sol"; +import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; +import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; +import {ConfigPeg_BTC} from "script/config/pegs/ConfigPeg_BTC.sol"; +import {ConfigMarket_BTC_fxUSD_mainnet} from "script/config/markets/ConfigMarket_BTC_fxUSD_mainnet.sol"; +import {ConfigMarket_BTC_stETH_mainnet} from "script/config/markets/ConfigMarket_BTC_stETH_mainnet.sol"; +import {Config_MinterMarket} from "script/config/ConfigBase.sol"; + +/// @notice BTC-specific minter deployment functionality. +abstract contract Deploy_BTC_Minter is DeployMintersShared { + /// @notice Create BTC-specific config objects. + function createBTCMintersConfig() internal returns (ConfigPeg peg, Config_MinterMarket[] memory markets) { + peg = new ConfigPeg_BTC(); + markets = new Config_MinterMarket[](2); + markets[0] = new ConfigMarket_BTC_fxUSD_mainnet(); + markets[1] = new ConfigMarket_BTC_stETH_mainnet(); + } +} diff --git a/script/src/v2/Deploy_ETH_Minter.sol b/script/src/v2/Deploy_ETH_Minter.sol new file mode 100644 index 00000000..aa92c58a --- /dev/null +++ b/script/src/v2/Deploy_ETH_Minter.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.28 <0.9.0; + +import {console2 as console} from "forge-std/console2.sol"; +import {DeployMintersShared} from "./DeployMintersShared.sol"; +import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; +import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; +import {ConfigPeg_ETH} from "script/config/pegs/ConfigPeg_ETH.sol"; +import {ConfigMarket_ETH_fxUSD_mainnet} from "script/config/markets/ConfigMarket_ETH_fxUSD_mainnet.sol"; +import {Config_MinterMarket} from "script/config/ConfigBase.sol"; + +/// @notice ETH-specific minter deployment functionality. +abstract contract Deploy_ETH_Minter is DeployMintersShared { + /// @notice Create ETH-specific config objects. + function createETHMintersConfig() internal returns (ConfigPeg peg, Config_MinterMarket[] memory markets) { + peg = new ConfigPeg_ETH(); + markets = new Config_MinterMarket[](1); + markets[0] = new ConfigMarket_ETH_fxUSD_mainnet(); + } +} diff --git a/script/src/v2/Deploy_EUR_Minter.sol b/script/src/v2/Deploy_EUR_Minter.sol new file mode 100644 index 00000000..38aacfc9 --- /dev/null +++ b/script/src/v2/Deploy_EUR_Minter.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.28 <0.9.0; + +import {console2 as console} from "forge-std/console2.sol"; +import {DeployMintersShared} from "./DeployMintersShared.sol"; +import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; +import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; +import {ConfigPeg_EUR} from "script/config/pegs/ConfigPeg_EUR.sol"; +import {ConfigMarket_EUR_fxUSD_mainnet} from "script/config/markets/ConfigMarket_EUR_fxUSD_mainnet.sol"; +import {ConfigMarket_EUR_stETH_mainnet} from "script/config/markets/ConfigMarket_EUR_stETH_mainnet.sol"; +import {Config_MinterMarket} from "script/config/ConfigBase.sol"; + +/// @notice EUR-specific minter deployment functionality. +abstract contract Deploy_EUR_Minter is DeployMintersShared { + /// @notice Create EUR-specific config objects. + function createEURMintersConfig() internal returns (ConfigPeg peg, Config_MinterMarket[] memory markets) { + peg = new ConfigPeg_EUR(); + markets = new Config_MinterMarket[](2); + markets[0] = new ConfigMarket_EUR_fxUSD_mainnet(); + markets[1] = new ConfigMarket_EUR_stETH_mainnet(); + } +} diff --git a/script/src/v2/Deploy_GOLD_Minter.sol b/script/src/v2/Deploy_GOLD_Minter.sol new file mode 100644 index 00000000..73365b94 --- /dev/null +++ b/script/src/v2/Deploy_GOLD_Minter.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.28 <0.9.0; + +import {console2 as console} from "forge-std/console2.sol"; +import {DeployMintersShared} from "./DeployMintersShared.sol"; +import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; +import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; +import {ConfigPeg_GOLD} from "script/config/pegs/ConfigPeg_GOLD.sol"; +import {ConfigMarket_GOLD_fxUSD_mainnet} from "script/config/markets/ConfigMarket_GOLD_fxUSD_mainnet.sol"; +import {ConfigMarket_GOLD_stETH_mainnet} from "script/config/markets/ConfigMarket_GOLD_stETH_mainnet.sol"; +import {Config_MinterMarket} from "script/config/ConfigBase.sol"; + +/// @notice GOLD-specific minter deployment functionality. +abstract contract Deploy_GOLD_Minter is DeployMintersShared { + /// @notice Create GOLD-specific config objects. + function createGOLDMintersConfig() internal returns (ConfigPeg peg, Config_MinterMarket[] memory markets) { + peg = new ConfigPeg_GOLD(); + markets = new Config_MinterMarket[](2); + markets[0] = new ConfigMarket_GOLD_fxUSD_mainnet(); + markets[1] = new ConfigMarket_GOLD_stETH_mainnet(); + } +} diff --git a/script/src/v2/Deploy_MCAP_Minter.sol b/script/src/v2/Deploy_MCAP_Minter.sol new file mode 100644 index 00000000..436bc463 --- /dev/null +++ b/script/src/v2/Deploy_MCAP_Minter.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.28 <0.9.0; + +import {console2 as console} from "forge-std/console2.sol"; +import {DeployMintersShared} from "./DeployMintersShared.sol"; +import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; +import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; +import {ConfigPeg_MCAP} from "script/config/pegs/ConfigPeg_MCAP.sol"; +import {ConfigMarket_MCAP_fxUSD_mainnet} from "script/config/markets/ConfigMarket_MCAP_fxUSD_mainnet.sol"; +import {ConfigMarket_MCAP_stETH_mainnet} from "script/config/markets/ConfigMarket_MCAP_stETH_mainnet.sol"; +import {Config_MinterMarket} from "script/config/ConfigBase.sol"; + +/// @notice MCAP-specific minter deployment functionality. +abstract contract Deploy_MCAP_Minter is DeployMintersShared { + /// @notice Create MCAP-specific config objects. + function createMCAPMintersConfig() internal returns (ConfigPeg peg, Config_MinterMarket[] memory markets) { + peg = new ConfigPeg_MCAP(); + markets = new Config_MinterMarket[](2); + markets[0] = new ConfigMarket_MCAP_fxUSD_mainnet(); + markets[1] = new ConfigMarket_MCAP_stETH_mainnet(); + } +} diff --git a/script/src/v2/Deploy_SILVER_Minter.sol b/script/src/v2/Deploy_SILVER_Minter.sol new file mode 100644 index 00000000..d6cb1a7f --- /dev/null +++ b/script/src/v2/Deploy_SILVER_Minter.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.28 <0.9.0; + +import {console2 as console} from "forge-std/console2.sol"; +import {DeployMintersShared} from "./DeployMintersShared.sol"; +import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; +import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; +import {ConfigPeg_SILVER} from "script/config/pegs/ConfigPeg_SILVER.sol"; +import {ConfigMarket_SILVER_fxUSD_mainnet} from "script/config/markets/ConfigMarket_SILVER_fxUSD_mainnet.sol"; +import {ConfigMarket_SILVER_stETH_mainnet} from "script/config/markets/ConfigMarket_SILVER_stETH_mainnet.sol"; +import {Config_MinterMarket} from "script/config/ConfigBase.sol"; + +/// @notice SILVER-specific minter deployment functionality. +abstract contract Deploy_SILVER_Minter is DeployMintersShared { + /// @notice Create SILVER-specific config objects. + function createSILVERMintersConfig() internal returns (ConfigPeg peg, Config_MinterMarket[] memory markets) { + peg = new ConfigPeg_SILVER(); + markets = new Config_MinterMarket[](2); + markets[0] = new ConfigMarket_SILVER_fxUSD_mainnet(); + markets[1] = new ConfigMarket_SILVER_stETH_mainnet(); + } +} diff --git a/script/src/v2/contracts/Genesis.sol b/script/src/v2/contracts/Genesis.sol new file mode 100644 index 00000000..9e24363a --- /dev/null +++ b/script/src/v2/contracts/Genesis.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.28 <0.9.0; + +import {console2 as console} from "forge-std/console2.sol"; +import {HarborFactoryDeployer} from "script/src/HarborFactoryDeployer.sol"; +import {DeploymentState} from "@bao-script/deployment/DeploymentState.sol"; +import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; +import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; + +import {Genesis_v1} from "@harbor/minter/Genesis_v1.sol"; + +/// @notice Harbor Genesis_v1 deployment logic. +/// @dev File Organization Pattern (see deployment2-design.md Section 3.3.2): +/// @dev - This file: contract-specific deployment for Genesis +/// @dev - Uses DeploymentOwnership pattern: register deployed contracts, transfer at end +/// +/// @dev Genesis Ecosystem: +/// @dev - Genesis is a special contract for initial token minting during launch +/// @dev - Genesis needs: ZERO_FEE_ROLE on Minter (obtained via Minter deployment) +abstract contract Genesis is HarborFactoryDeployer { + // ========== GENESIS DEPLOYMENT ========== + + /// @notice Deploy Genesis impl+proxy, record both in state, register for ownership transfer. + function deployGenesis( + DeploymentTypes.State memory stateData, + string memory marketKey, + address minter + ) internal returns (address proxy) { + string memory genesisKey = string.concat(marketKey, "::genesis"); + console.log(" > %s", genesisKey); + + address impl = address(new Genesis_v1(minter)); + console.log(" Impl: %s", impl); + + bytes memory initData = abi.encodeCall(Genesis_v1.initialize, (owner())); + + proxy = _deployProxyAndRecord( + stateData, + genesisKey, + impl, + "@harbor/minter/Genesis_v1.sol", + "Genesis_v1", + initData + ); + } + + // ========== ADDRESS PREDICTION ========== + + /// @notice Predict genesis contract address from salt. + function predictGenesisAddress( + address baoFactoryAddr, + string memory saltPrefix, + string memory marketKey + ) internal view returns (address) { + bytes32 salt = keccak256(abi.encodePacked(saltPrefix, "::", marketKey, "::genesis")); + return IBaoFactory(baoFactoryAddr).predictAddress(salt); + } +} diff --git a/script/src/v2/contracts/LeveragedToken.sol b/script/src/v2/contracts/LeveragedToken.sol new file mode 100644 index 00000000..12713de6 --- /dev/null +++ b/script/src/v2/contracts/LeveragedToken.sol @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.28 <0.9.0; + +import {console2 as console} from "forge-std/console2.sol"; +import {HarborFactoryDeployer} from "script/src/HarborFactoryDeployer.sol"; +import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; +import {MintableBurnableERC20_v1} from "@bao/MintableBurnableERC20_v1.sol"; +import {IMintableRole} from "@bao/interfaces/IMintableRole.sol"; +import {IBurnableRole} from "@bao/interfaces/IBurnableRole.sol"; +import {Config_MinterMarket, IMarketConfig, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; +import {LibString} from "@solady/utils/LibString.sol"; + +/// @notice Harbor leveraged token deployment logic. +/// @dev Leveraged tokens are unique per market (e.g., hsFXUSD-BTC for BTC::fxUSD market). +abstract contract LeveragedToken is HarborFactoryDeployer { + using LibString for string; + + // ========== LEVERAGED TOKEN DEPLOYMENT ========== + + /// @notice Deploy a leveraged token and grant minter roles. + function _deployLeveragedTokenWithRoles( + DeploymentTypes.State memory stateData, + Config_MinterMarket marketConfig + ) internal returns (address leveragedToken) { + string memory marketKey = MinterMarketConfigLib.salt(marketConfig); + string memory peg = MinterMarketConfigLib.peg(marketConfig); + string memory collateral = MinterMarketConfigLib.collateral(marketConfig); + + string memory leveragedKey = string.concat(marketKey, "::leveraged"); + string memory tokenName = string.concat("Harbor sail: variable leveraged long ", collateral, " against ", peg); + string memory tokenSymbol = string.concat("hs", collateral.upper(), "-", peg.upper()); + + console.log(" > %s", leveragedKey); + console.log(" Name: %s", tokenName); + console.log(" Symbol: %s", tokenSymbol); + + address impl = address(new MintableBurnableERC20_v1()); + console.log(" Impl: %s", impl); + + bytes memory initData = abi.encodeCall(MintableBurnableERC20_v1.initialize, (owner(), tokenName, tokenSymbol)); + + leveragedToken = _deployProxyAndRecord( + stateData, + leveragedKey, + impl, + "@bao/MintableBurnableERC20_v1.sol", + "MintableBurnableERC20_v1", + initData + ); + + // Grant minter roles + address minter = _predictAddress(_key(marketKey, "minter")); + uint256 roles = IMintableRole(leveragedToken).MINTER_ROLE() | IBurnableRole(leveragedToken).BURNER_ROLE(); + _grantRoles(leveragedKey, leveragedToken, minter, marketKey, roles, "MINTER | BURNER"); + } +} diff --git a/script/src/v2/contracts/Minter.sol b/script/src/v2/contracts/Minter.sol new file mode 100644 index 00000000..f4042ed5 --- /dev/null +++ b/script/src/v2/contracts/Minter.sol @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.28 <0.9.0; + +import {console2 as console} from "forge-std/console2.sol"; +import {HarborFactoryDeployer} from "script/src/HarborFactoryDeployer.sol"; +import {DeploymentState} from "@bao-script/deployment/DeploymentState.sol"; +import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; +import {Config_MinterMarket, IMarketConfig, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; +import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; + +import {Minter_v2} from "@harbor/minter/Minter_v2.sol"; +import {ReservePool_v1} from "@harbor/minter/ReservePool_v1.sol"; +import {TokenDistributor_v1} from "@harbor/minter/TokenDistributor_v1.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; + +/// @notice Harbor Minter_v2 deployment logic (including ReservePool and FeeReceiver). +/// @dev File Organization Pattern (see deployment2-design.md Section 3.3.2): +/// @dev - This file: contract-specific deployment for Minter, ReservePool, MinterFeeReceiver +/// @dev - Uses DeploymentOwnership pattern: register deployed contracts, transfer at end +/// +/// @dev Minter Ecosystem Dependencies: +/// @dev - Minter needs: wrappedCollateral, peggedToken, leveragedToken, priceOracle, reservePool, feeReceiver +/// @dev - Minter grants: HARVESTER_ROLE to StabilityPoolManager, ZERO_FEE_ROLE to Genesis +/// @dev - ReservePool grants: REQUESTER_ROLE to Minter +abstract contract Minter is HarborFactoryDeployer { + // ========== MINTER DEPLOYMENT ========== + + function deployMinterImplementation( + DeploymentTypes.State memory stateData, + string memory marketKey, + address wrappedCollateral, + address peggedToken, + address leveragedToken + ) internal virtual returns (address impl, string memory minterKey) { + minterKey = string.concat(marketKey, "::minter"); + console.log(" > %s", minterKey); + + impl = address(new Minter_v2(wrappedCollateral, peggedToken, leveragedToken, "burn(uint256)")); + console.log(" Impl: %s", impl); + + DeploymentState.recordImplementation( + stateData, + DeploymentTypes.ImplementationRecord({ + proxy: minterKey, + contractSource: "@harbor/minter/Minter_v2.sol", + contractType: "Minter_v2", + implementation: impl, + deploymentTime: uint64(block.timestamp) + }) + ); + } + + /// @notice Deploy Minter impl+proxy, record both in state, register for ownership transfer. + function deployMinter( + DeploymentTypes.State memory stateData, + string memory marketKey, + address wrappedCollateral, + address peggedToken, + address leveragedToken + ) internal virtual returns (address proxy) { + (address impl, string memory minterKey) = deployMinterImplementation( + stateData, + marketKey, + wrappedCollateral, + peggedToken, + leveragedToken + ); + + bytes memory initData = abi.encodeCall(Minter_v2.initialize, (owner())); + + proxy = _deployProxyAndRecord(stateData, minterKey, impl, initData); + } + + /// @notice Configure a deployed Minter with its operational parameters. + function configureMinter( + address minterProxy, + IMinter.Config memory config, + address feeReceiver, + address priceOracle, + address reservePool + ) internal { + Minter_v2 minter = Minter_v2(minterProxy); + minter.updateConfig(config); + minter.updateFeeReceiver(feeReceiver); + minter.updatePriceOracle(priceOracle); + minter.updateReservePool(reservePool); + } + + /// @notice Grant Minter roles to downstream contracts. + function grantMinterRoles( + string memory minterKey, + address minterProxy, + address stabilityPoolManager, + address genesis + ) internal { + Minter_v2 minter = Minter_v2(minterProxy); + _grantRoles( + minterKey, + minterProxy, + stabilityPoolManager, + "stabilityPoolManager", + minter.HARVESTER_ROLE() | minter.ZERO_FEE_ROLE(), + "HARVESTER | ZERO_FEE" + ); + _grantRoles(minterKey, minterProxy, genesis, "genesis", minter.ZERO_FEE_ROLE(), "ZERO_FEE"); + } + + // ========== RESERVE POOL DEPLOYMENT ========== + + /// @notice Deploy ReservePool impl+proxy, record both in state, register for ownership transfer. + function deployReservePool( + DeploymentTypes.State memory stateData, + string memory marketKey + ) internal returns (address proxy) { + string memory reservePoolKey = string.concat(marketKey, "::reservePool"); + console.log(" > %s", reservePoolKey); + + address impl = address(new ReservePool_v1()); + console.log(" Impl: %s", impl); + + bytes memory initData = abi.encodeCall(ReservePool_v1.initialize, (owner())); + + proxy = _deployProxyAndRecord( + stateData, + reservePoolKey, + impl, + "@harbor/minter/ReservePool_v1.sol", + "ReservePool_v1", + initData + ); + } + + /// @notice Grant ReservePool REQUESTER_ROLE to Minter. + function grantReservePoolRoles(string memory reservePoolKey, address reservePoolProxy, address minter) internal { + ReservePool_v1 reservePool = ReservePool_v1(reservePoolProxy); + _grantRoles(reservePoolKey, reservePoolProxy, minter, "minter", reservePool.REQUESTER_ROLE(), "REQUESTER"); + } + + // ========== FEE RECEIVER (TOKEN DISTRIBUTOR) DEPLOYMENT ========== + + /// @notice Deploy TokenDistributor_v1 as Minter fee receiver. + function deployMinterFeeReceiver( + DeploymentTypes.State memory stateData, + string memory marketKey, + string memory name + ) internal returns (address proxy) { + string memory feeReceiverKey = string.concat(marketKey, "::minterFeeReceiver"); + console.log(" > %s", feeReceiverKey); + + address impl = address(new TokenDistributor_v1()); + console.log(" Impl: %s", impl); + + bytes memory initData = abi.encodeCall(TokenDistributor_v1.initialize, (owner(), name)); + + proxy = _deployProxyAndRecord( + stateData, + feeReceiverKey, + impl, + "@harbor/minter/TokenDistributor_v1.sol", + "TokenDistributor_v1", + initData + ); + } + + /// @notice Configure TokenDistributor with tokens and distribution. + function configureFeeReceiver( + address feeReceiverProxy, + address[] memory tokens, + address[] memory recipients, + uint256[] memory shares + ) internal { + TokenDistributor_v1 distributor = TokenDistributor_v1(feeReceiverProxy); + + for (uint256 i = 0; i < tokens.length; i++) { + distributor.addToken(tokens[i]); + } + + distributor.setDistribution(recipients, shares); + } +} diff --git a/script/src/v2/contracts/PeggedToken.sol b/script/src/v2/contracts/PeggedToken.sol new file mode 100644 index 00000000..70e27b7b --- /dev/null +++ b/script/src/v2/contracts/PeggedToken.sol @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.28 <0.9.0; + +import {console2 as console} from "forge-std/console2.sol"; +import {HarborFactoryDeployer} from "script/src/HarborFactoryDeployer.sol"; +import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; +import {MintableBurnableERC20_v1} from "@bao/MintableBurnableERC20_v1.sol"; +import {IMintableRole} from "@bao/interfaces/IMintableRole.sol"; +import {IBurnableRole} from "@bao/interfaces/IBurnableRole.sol"; +import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; +import {Config_MinterMarket, IMarketConfig, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; +import {LibString} from "@solady/utils/LibString.sol"; + +/// @notice Harbor pegged token deployment logic. +/// @dev Pegged tokens are one per peg (ETH, BTC, GOLD, EUR), shared by all markets with that peg. +/// @dev If a pegged token already exists, logs the manual grantRoles transactions required. +abstract contract PeggedToken is HarborFactoryDeployer { + using LibString for string; + + // ========== PEGGED TOKEN DEPLOYMENT ========== + + /// @notice Deploy a pegged token and grant minter roles to all markets using this peg. + /// @dev If the pegged token already exists at the predicted address, logs manual TX requirements. + function deployPeggedTokenWithRoles( + DeploymentTypes.State memory stateData, + ConfigPeg pegConfig, + Config_MinterMarket[] memory marketConfigs + ) internal returns (address peggedToken) { + string memory pegKey = pegConfig.key(); + string memory tokenKey = string.concat(pegKey, "::pegged"); + + console.log(" > %s", tokenKey); + + // Check if pegged token already exists at predicted address + peggedToken = _predictAddress(tokenKey); + bool alreadyDeployed = peggedToken.code.length > 0; + + if (alreadyDeployed) { + console.log(" Already deployed at: %s", peggedToken); + } else { + console.log(" Name: %s", pegConfig.name()); + console.log(" Symbol: %s", pegConfig.symbol()); + + address impl = address(new MintableBurnableERC20_v1()); + console.log(" Impl: %s", impl); + + bytes memory initData = abi.encodeCall( + MintableBurnableERC20_v1.initialize, + (owner(), pegConfig.name(), pegConfig.symbol()) + ); + + peggedToken = _deployProxyAndRecord( + stateData, + tokenKey, + impl, + "@bao/MintableBurnableERC20_v1.sol", + "MintableBurnableERC20_v1", + initData + ); + } + + // Grant minter roles for each market + for (uint256 i = 0; i < marketConfigs.length; i++) { + Config_MinterMarket market = marketConfigs[i]; + string memory configPeg = MinterMarketConfigLib.peg(market); + require( + configPeg.eq(pegKey), + string.concat("Market config peg '", configPeg, "' does not match pegged token '", pegKey, "'") + ); + + string memory marketKey = MinterMarketConfigLib.salt(marketConfigs[i]); + address minter = _predictAddress(_key(marketKey, "minter")); + uint256 roles = IMintableRole(peggedToken).MINTER_ROLE() | IBurnableRole(peggedToken).BURNER_ROLE(); + + if (alreadyDeployed) { + _logManualRoleGrant(tokenKey, peggedToken, minter, marketKey, roles, "MINTER | BURNER"); + } else { + _grantRoles(tokenKey, peggedToken, minter, marketKey, roles, "MINTER | BURNER"); + } + } + } +} diff --git a/script/src/v2/contracts/StabilityPool.sol b/script/src/v2/contracts/StabilityPool.sol new file mode 100644 index 00000000..f52bbfaf --- /dev/null +++ b/script/src/v2/contracts/StabilityPool.sol @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.28 <0.9.0; + +import {console2 as console} from "forge-std/console2.sol"; +import {HarborFactoryDeployer} from "script/src/HarborFactoryDeployer.sol"; +import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; +import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; +import {DeploymentState} from "@bao-script/deployment/DeploymentState.sol"; + +import {StabilityPool_v2} from "@harbor/minter/StabilityPool_v2.sol"; + +/// @notice Config interface for stability pool deployment parameters. +interface IStabilityPoolMarketConfig { + function stabilityPoolWithdrawalDelay() external pure returns (uint256); + function stabilityPoolWithdrawalPeriod() external pure returns (uint256); + function stabilityPoolEarlyWithdrawalFeeRatio() external pure returns (uint256); + function minTotalSupply() external view returns (uint256); +} + +/// @notice Harbor StabilityPool_v2 deployment logic. +/// @dev Each market has TWO stability pools: Collateral (wrapped collateral) and Leveraged (leveraged token). +/// @dev Both pools grant: REBALANCER_ROLE, REWARD_DEPOSITOR_ROLE to StabilityPoolManager. +abstract contract StabilityPool is HarborFactoryDeployer { + string StabilityPoolCollateral = "stabilityPoolCollateral"; + string StabilityPoolLeveraged = "stabilityPoolLeveraged"; + + // ========== STABILITY POOL DEPLOYMENT ========== + + /// @notice Deploy StabilityPool impl only, record in state. + function deployStabilityPoolImplementation( + string memory spType, + DeploymentTypes.State memory stateData, + string memory marketKey, + address minter, + address liquidationToken, + address configContract + ) internal virtual returns (address impl) { + string memory spKey = string.concat(marketKey, "::", spType); + console.log(" > %s", spKey); + + IStabilityPoolMarketConfig cfg = IStabilityPoolMarketConfig(configContract); + impl = address( + new StabilityPool_v2( + minter, + liquidationToken, + cfg.stabilityPoolWithdrawalDelay(), + cfg.stabilityPoolWithdrawalPeriod(), + cfg.minTotalSupply() + ) + ); + console.log(" Impl: %s", impl); + + DeploymentState.recordImplementation( + stateData, + DeploymentTypes.ImplementationRecord({ + proxy: spKey, + contractSource: "@harbor/minter/StabilityPool_v2.sol", + contractType: "StabilityPool_v2", + implementation: impl, + deploymentTime: uint64(block.timestamp) + }) + ); + } + + /// @notice Deploy StabilityPool impl+proxy, record in state. + function deployStabilityPool( + string memory spType, + DeploymentTypes.State memory stateData, + string memory marketKey, + address minter, + address liquidationToken, + address configContract + ) internal returns (address proxy) { + string memory spKey = string.concat(marketKey, "::", spType); + console.log(" > %s", spKey); + + address impl = deployStabilityPoolImplementation( + spType, + stateData, + marketKey, + minter, + liquidationToken, + configContract + ); + + IStabilityPoolMarketConfig cfg = IStabilityPoolMarketConfig(configContract); + bytes memory initData = abi.encodeCall( + StabilityPool_v2.initialize, + (owner(), cfg.stabilityPoolEarlyWithdrawalFeeRatio(), treasury()) + ); + + proxy = _deployProxyAndRecord(stateData, spKey, impl, initData); + } + + /// @notice Grant StabilityPool roles to StabilityPoolManager. + function grantStabilityPoolRoles( + string memory stabilityPoolKey, + address stabilityPoolProxy, + address stabilityPoolManager + ) internal { + StabilityPool_v2 pool = StabilityPool_v2(stabilityPoolProxy); + uint256 roles = pool.REBALANCER_ROLE() | pool.REWARD_DEPOSITOR_ROLE(); + _grantRoles( + stabilityPoolKey, + stabilityPoolProxy, + stabilityPoolManager, + "stabilityPoolManager", + roles, + "REBALANCER | REWARD_DEPOSITOR" + ); + } +} diff --git a/script/src/v2/contracts/StabilityPoolManager.sol b/script/src/v2/contracts/StabilityPoolManager.sol new file mode 100644 index 00000000..07be9425 --- /dev/null +++ b/script/src/v2/contracts/StabilityPoolManager.sol @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.28 <0.9.0; + +import {console2 as console} from "forge-std/console2.sol"; +import {HarborFactoryDeployer} from "script/src/HarborFactoryDeployer.sol"; +import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; +import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; + +import {StabilityPoolManager_v1} from "@harbor/minter/StabilityPoolManager_v1.sol"; +import {TokenDistributor_v1} from "@harbor/minter/TokenDistributor_v1.sol"; + +/// @notice Harbor StabilityPoolManager_v1 deployment logic (including SPMFeeReceiver). +/// @dev SPM coordinates the two stability pools per market. +/// @dev SPM grants: HARVESTER_ROLE on Minter (obtained via Minter deployment). +/// @dev SPM needs: REBALANCER_ROLE, REWARD_DEPOSITOR_ROLE on both stability pools. +abstract contract StabilityPoolManager is HarborFactoryDeployer { + /// @notice StabilityPoolManager configuration. + struct SPMConfig { + uint256 rebalanceThreshold; + uint256 rebalanceBountyRatio; + uint256 harvestBountyRatio; + uint256 harvestCutRatio; + address feeReceiver; + } + + // ========== STABILITY POOL MANAGER DEPLOYMENT ========== + + /// @notice Deploy StabilityPoolManager impl+proxy, record in state. + function deployStabilityPoolManager( + DeploymentTypes.State memory stateData, + string memory marketKey, + address minter, + address treasury, + address stabilityPoolCollateral, + address stabilityPoolLeveraged + ) internal virtual returns (address proxy) { + string memory spmKey = string.concat(marketKey, "::stabilityPoolManager"); + console.log(" > %s", spmKey); + + address impl = address( + new StabilityPoolManager_v1(minter, treasury, stabilityPoolCollateral, stabilityPoolLeveraged) + ); + console.log(" Impl: %s", impl); + + bytes memory initData = abi.encodeCall(StabilityPoolManager_v1.initialize, (owner())); + + proxy = _deployProxyAndRecord( + stateData, + spmKey, + impl, + "@harbor/minter/StabilityPoolManager_v1.sol", + "StabilityPoolManager_v1", + initData + ); + } + + /// @notice Configure a deployed StabilityPoolManager with its operational parameters. + function configureStabilityPoolManager(address spmProxy, SPMConfig memory config) internal { + StabilityPoolManager_v1 spm = StabilityPoolManager_v1(spmProxy); + spm.updateRebalanceThreshold(config.rebalanceThreshold); + spm.updateRebalanceBountyRatio(config.rebalanceBountyRatio); + spm.updateHarvestBountyRatio(config.harvestBountyRatio); + spm.updateHarvestCutRatio(config.harvestCutRatio); + spm.updateFeeReceiver(config.feeReceiver); + } + + // ========== SPM FEE RECEIVER (TOKEN DISTRIBUTOR) DEPLOYMENT ========== + + /// @notice Deploy TokenDistributor_v1 as SPMFeeReceiver impl+proxy, record in state. + function deploySPMFeeReceiver( + DeploymentTypes.State memory stateData, + string memory marketKey, + string memory name + ) internal returns (address proxy) { + string memory feeReceiverKey = string.concat(marketKey, "::spmFeeReceiver"); + console.log(" > %s", feeReceiverKey); + + address impl = address(new TokenDistributor_v1()); + console.log(" Impl: %s", impl); + + bytes memory initData = abi.encodeCall(TokenDistributor_v1.initialize, (owner(), name)); + + proxy = _deployProxyAndRecord( + stateData, + feeReceiverKey, + impl, + "@harbor/minter/TokenDistributor_v1.sol", + "TokenDistributor_v1", + initData + ); + } + + /// @notice Configure TokenDistributor with tokens and distribution. + function configureSPMFeeReceiver( + address feeReceiverProxy, + address[] memory tokens, + address[] memory recipients, + uint256[] memory shares + ) internal { + TokenDistributor_v1 distributor = TokenDistributor_v1(feeReceiverProxy); + + for (uint256 i = 0; i < tokens.length; i++) { + distributor.addToken(tokens[i]); + } + + distributor.setDistribution(recipients, shares); + } +} diff --git a/script/verify/minter-v2-upgrade/DeployMinters.t.sol b/script/verify/minter-v2-upgrade/DeployMinters.t.sol index 8cbac123..70bf052c 100644 --- a/script/verify/minter-v2-upgrade/DeployMinters.t.sol +++ b/script/verify/minter-v2-upgrade/DeployMinters.t.sol @@ -3,11 +3,11 @@ pragma solidity >=0.8.28 <0.9.0; import {BaoTest} from "@bao-test/BaoTest.sol"; import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; -import {Deploy_BTC_Minter} from "script/src/Deploy_BTC_Minter.sol"; -import {Deploy_ETH_Minter} from "script/src/Deploy_ETH_Minter.sol"; -import {Deploy_EUR_Minter} from "script/src/Deploy_EUR_Minter.sol"; -import {Deploy_GOLD_Minter} from "script/src/Deploy_GOLD_Minter.sol"; -import {Deploy_SILVER_Minter} from "script/src/Deploy_SILVER_Minter.sol"; +import {Deploy_BTC_Minter} from "script/src/v3/Deploy_BTC_Minter.sol"; +import {Deploy_ETH_Minter} from "script/src/v3/Deploy_ETH_Minter.sol"; +import {Deploy_EUR_Minter} from "script/src/v3/Deploy_EUR_Minter.sol"; +import {Deploy_GOLD_Minter} from "script/src/v3/Deploy_GOLD_Minter.sol"; +import {Deploy_SILVER_Minter} from "script/src/v3/Deploy_SILVER_Minter.sol"; import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; import {Config_MinterMarket, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; import {MintableBurnableERC20_v1} from "@bao/MintableBurnableERC20_v1.sol"; diff --git a/script/verify/minter-v2-upgrade/MinterUpgradeMigration.t.sol b/script/verify/minter-v2-upgrade/MinterUpgradeMigration.t.sol index 6254c09c..8baef205 100644 --- a/script/verify/minter-v2-upgrade/MinterUpgradeMigration.t.sol +++ b/script/verify/minter-v2-upgrade/MinterUpgradeMigration.t.sol @@ -7,21 +7,21 @@ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; +import {Minter_v1} from "src/minter/Minter_v1.sol"; import {Minter_v2} from "src/minter/Minter_v2.sol"; -import {Minter_v3} from "src/minter/Minter_v3.sol"; import {IMinter} from "src/interfaces/IMinter.sol"; import {TestMinterSetUp} from "test/Minter_base.t.sol"; /// @title TestMinterUpgradeMigration -/// @notice Tests that upgrading Minter_v2 → Minter_v3 via UUPS proxy preserves +/// @notice Tests that upgrading Minter_v1 → Minter_v2 via UUPS proxy preserves /// all state and produces identical results at every lifecycle stage. contract TestMinterUpgradeMigration is TestMinterSetUp { - /// @dev Override to deploy with Minter_v2 implementation instead of Minter_v3 + /// @dev Override to deploy with Minter_v1 implementation instead of Minter_v3 function setUp_minter() internal override { minter = UnsafeUpgrades.deployUUPSProxy( - address(new Minter_v2(wrappedCollateralToken, peggedToken, leveragedToken, peggedTokenBurnSig)), - abi.encodeCall(Minter_v2.initialize, (owner)) + address(new Minter_v1(wrappedCollateralToken, peggedToken, leveragedToken, peggedTokenBurnSig)), + abi.encodeCall(Minter_v1.initialize, (owner)) ); vm.label(minter, "minter"); zeroFeeRole = IMinter(minter).ZERO_FEE_ROLE(); @@ -31,24 +31,24 @@ contract TestMinterUpgradeMigration is TestMinterSetUp { if (isConfigSet) IMinter(minter).updateConfig(config); IBaoOwnable(minter).transferOwnership(owner); } - function _upgradeToV3() internal { - address v3Impl = address( - new Minter_v3(wrappedCollateralToken, peggedToken, leveragedToken, peggedTokenBurnSig) + function _upgradeToV2() internal { + address v2Impl = address( + new Minter_v2(wrappedCollateralToken, peggedToken, leveragedToken, peggedTokenBurnSig) ); vm.prank(owner); - UUPSUpgradeable(minter).upgradeToAndCall(v3Impl, ""); + UUPSUpgradeable(minter).upgradeToAndCall(v2Impl, ""); } // ═══════════════════════════════════════════════════════════════════════ // 1. FreshMinter — upgrade empty minter // ═══════════════════════════════════════════════════════════════════════ - function test_upgradeFromV2_FreshMinter() public { + function test_upgradeFromV1_FreshMinter() public { // Verify initial state assertEq(IMinter(minter).peggedTokenBalance(), 0, "no pegged before upgrade"); assertEq(IMinter(minter).collateralTokenBalance(), 0, "no collateral before upgrade"); - _upgradeToV3(); + _upgradeToV2(); // State preserved assertEq(IMinter(minter).peggedTokenBalance(), 0, "no pegged after upgrade"); @@ -65,40 +65,40 @@ contract TestMinterUpgradeMigration is TestMinterSetUp { // 2. AfterMint — upgrade after minting pegged and leveraged // ═══════════════════════════════════════════════════════════════════════ - function test_upgradeFromV2_AfterMint() public { + function test_upgradeFromV1_AfterMint() public { setUp_collateral(5 ether, 5 ether); - // Snapshot v2 state + // Snapshot v1 state uint256 snap = vm.snapshotState(); - uint256 v2_pegged = IMinter(minter).peggedTokenBalance(); - uint256 v2_collateral = IMinter(minter).collateralTokenBalance(); - uint256 v2_cr = IMinter(minter).collateralRatio(); - uint256 v2_levPrice = IMinter(minter).leveragedTokenPrice(); - uint256 v2_pegPrice = IMinter(minter).peggedTokenPrice(); - uint256 v2_levRatio = IMinter(minter).leverageRatio(); + uint256 v1_pegged = IMinter(minter).peggedTokenBalance(); + uint256 v1_collateral = IMinter(minter).collateralTokenBalance(); + uint256 v1_cr = IMinter(minter).collateralRatio(); + uint256 v1_levPrice = IMinter(minter).leveragedTokenPrice(); + uint256 v1_pegPrice = IMinter(minter).peggedTokenPrice(); + uint256 v1_levRatio = IMinter(minter).leverageRatio(); // Revert and upgrade vm.revertToState(snap); - _upgradeToV3(); + _upgradeToV2(); // Assert identical state - assertEq(IMinter(minter).peggedTokenBalance(), v2_pegged, "pegged balance preserved"); - assertEq(IMinter(minter).collateralTokenBalance(), v2_collateral, "collateral balance preserved"); - assertEq(IMinter(minter).collateralRatio(), v2_cr, "CR preserved"); - assertEq(IMinter(minter).leveragedTokenPrice(), v2_levPrice, "leveraged price preserved"); - assertEq(IMinter(minter).peggedTokenPrice(), v2_pegPrice, "pegged price preserved"); - assertEq(IMinter(minter).leverageRatio(), v2_levRatio, "leverage ratio preserved"); + assertEq(IMinter(minter).peggedTokenBalance(), v1_pegged, "pegged balance preserved"); + assertEq(IMinter(minter).collateralTokenBalance(), v1_collateral, "collateral balance preserved"); + assertEq(IMinter(minter).collateralRatio(), v1_cr, "CR preserved"); + assertEq(IMinter(minter).leveragedTokenPrice(), v1_levPrice, "leveraged price preserved"); + assertEq(IMinter(minter).peggedTokenPrice(), v1_pegPrice, "pegged price preserved"); + assertEq(IMinter(minter).leverageRatio(), v1_levRatio, "leverage ratio preserved"); // Post-upgrade: more minting works setUp_collateral(1 ether, 1 ether); - assertGt(IMinter(minter).peggedTokenBalance(), v2_pegged, "more pegged minted post-upgrade"); + assertGt(IMinter(minter).peggedTokenBalance(), v1_pegged, "more pegged minted post-upgrade"); } // ═══════════════════════════════════════════════════════════════════════ // 3. AfterRedeem — upgrade after minting then partially redeeming // ═══════════════════════════════════════════════════════════════════════ - function test_upgradeFromV2_AfterRedeem() public { + function test_upgradeFromV1_AfterRedeem() public { setUp_collateral(5 ether, 5 ether); // Redeem some pegged @@ -109,22 +109,22 @@ contract TestMinterUpgradeMigration is TestMinterSetUp { IMinter(minter).freeRedeemPeggedToken(toRedeem, 0, zeroFee); vm.stopPrank(); - // Snapshot v2 state + // Snapshot v1 state uint256 snap = vm.snapshotState(); - uint256 v2_pegged = IMinter(minter).peggedTokenBalance(); - uint256 v2_collateral = IMinter(minter).collateralTokenBalance(); - uint256 v2_cr = IMinter(minter).collateralRatio(); - uint256 v2_levPrice = IMinter(minter).leveragedTokenPrice(); + uint256 v1_pegged = IMinter(minter).peggedTokenBalance(); + uint256 v1_collateral = IMinter(minter).collateralTokenBalance(); + uint256 v1_cr = IMinter(minter).collateralRatio(); + uint256 v1_levPrice = IMinter(minter).leveragedTokenPrice(); // Revert and upgrade vm.revertToState(snap); - _upgradeToV3(); + _upgradeToV2(); // Assert identical - assertEq(IMinter(minter).peggedTokenBalance(), v2_pegged, "pegged balance preserved after redeem"); - assertEq(IMinter(minter).collateralTokenBalance(), v2_collateral, "collateral preserved after redeem"); - assertEq(IMinter(minter).collateralRatio(), v2_cr, "CR preserved after redeem"); - assertEq(IMinter(minter).leveragedTokenPrice(), v2_levPrice, "leveraged price preserved after redeem"); + assertEq(IMinter(minter).peggedTokenBalance(), v1_pegged, "pegged balance preserved after redeem"); + assertEq(IMinter(minter).collateralTokenBalance(), v1_collateral, "collateral preserved after redeem"); + assertEq(IMinter(minter).collateralRatio(), v1_cr, "CR preserved after redeem"); + assertEq(IMinter(minter).leveragedTokenPrice(), v1_levPrice, "leveraged price preserved after redeem"); // Post-upgrade: redeem more works uint256 remaining = IERC20(peggedToken).balanceOf(zeroFee); @@ -133,25 +133,25 @@ contract TestMinterUpgradeMigration is TestMinterSetUp { IERC20(peggedToken).approve(minter, toRedeemMore); IMinter(minter).freeRedeemPeggedToken(toRedeemMore, 0, zeroFee); vm.stopPrank(); - assertLt(IMinter(minter).peggedTokenBalance(), v2_pegged, "redeem works post-upgrade"); + assertLt(IMinter(minter).peggedTokenBalance(), v1_pegged, "redeem works post-upgrade"); } // ═══════════════════════════════════════════════════════════════════════ // 4. ConfigChange — upgrade preserves config // ═══════════════════════════════════════════════════════════════════════ - function test_upgradeFromV2_ConfigChange() public { - // Change config on v2 + function test_upgradeFromV1_ConfigChange() public { + // Change config on v1 setUp_config_free(); vm.prank(owner); IMinter(minter).updateConfig(config); - IMinter.Config memory v2_config = IMinter(minter).config(); + IMinter.Config memory v1_config = IMinter(minter).config(); - _upgradeToV3(); + _upgradeToV2(); - IMinter.Config memory v3_config = IMinter(minter).config(); - _assertEqConfig(v3_config, v2_config); + IMinter.Config memory v2_config = IMinter(minter).config(); + _assertEqConfig(v2_config, v1_config); // Post-upgrade: config update works setUp_config_flat(); @@ -165,7 +165,7 @@ contract TestMinterUpgradeMigration is TestMinterSetUp { // 5. MixedOperations — mint, redeem, price change, then upgrade // ═══════════════════════════════════════════════════════════════════════ - function test_upgradeFromV2_MixedOperations() public { + function test_upgradeFromV1_MixedOperations() public { // Mint pegged and leveraged setUp_collateral(5 ether, 5 ether); @@ -177,24 +177,24 @@ contract TestMinterUpgradeMigration is TestMinterSetUp { IMinter(minter).freeRedeemLeveragedToken(toRedeem, zeroFee); vm.stopPrank(); - // Snapshot v2 state + // Snapshot v1 state uint256 snap = vm.snapshotState(); - uint256 v2_pegged = IMinter(minter).peggedTokenBalance(); - uint256 v2_collateral = IMinter(minter).collateralTokenBalance(); - uint256 v2_cr = IMinter(minter).collateralRatio(); - uint256 v2_levPrice = IMinter(minter).leveragedTokenPrice(); - uint256 v2_pegPrice = IMinter(minter).peggedTokenPrice(); + uint256 v1_pegged = IMinter(minter).peggedTokenBalance(); + uint256 v1_collateral = IMinter(minter).collateralTokenBalance(); + uint256 v1_cr = IMinter(minter).collateralRatio(); + uint256 v1_levPrice = IMinter(minter).leveragedTokenPrice(); + uint256 v1_pegPrice = IMinter(minter).peggedTokenPrice(); // Revert and upgrade vm.revertToState(snap); - _upgradeToV3(); + _upgradeToV2(); // Assert identical - assertEq(IMinter(minter).peggedTokenBalance(), v2_pegged, "pegged preserved"); - assertEq(IMinter(minter).collateralTokenBalance(), v2_collateral, "collateral preserved"); - assertEq(IMinter(minter).collateralRatio(), v2_cr, "CR preserved"); - assertEq(IMinter(minter).leveragedTokenPrice(), v2_levPrice, "leveraged price preserved"); - assertEq(IMinter(minter).peggedTokenPrice(), v2_pegPrice, "pegged price preserved"); + assertEq(IMinter(minter).peggedTokenBalance(), v1_pegged, "pegged preserved"); + assertEq(IMinter(minter).collateralTokenBalance(), v1_collateral, "collateral preserved"); + assertEq(IMinter(minter).collateralRatio(), v1_cr, "CR preserved"); + assertEq(IMinter(minter).leveragedTokenPrice(), v1_levPrice, "leveraged price preserved"); + assertEq(IMinter(minter).peggedTokenPrice(), v1_pegPrice, "pegged price preserved"); } // ═══════════════════════════════════════════════════════════════════════ @@ -202,12 +202,12 @@ contract TestMinterUpgradeMigration is TestMinterSetUp { // ═══════════════════════════════════════════════════════════════════════ /// @notice The core bug fix: freeRedeemPeggedToken with both collateral and - /// leveraged paths. On v2, the leveraged path sees stale state. - /// On v3, both paths use consistent snapshots. - function test_upgradeFromV2_FreeRedeemBothPaths() public { + /// leveraged paths. On v1, the leveraged path sees stale state. + /// On v2, both paths use consistent snapshots. + function test_upgradeFromV1_FreeRedeemBothPaths() public { setUp_collateral(5 ether, 5 ether); - _upgradeToV3(); + _upgradeToV2(); uint256 priceBefore = IMinter(minter).leveragedTokenPrice(); uint256 peggedBal = IERC20(peggedToken).balanceOf(zeroFee); @@ -220,7 +220,7 @@ contract TestMinterUpgradeMigration is TestMinterSetUp { vm.stopPrank(); uint256 priceAfter = IMinter(minter).leveragedTokenPrice(); - // The v3 fix ensures leveraged token price doesn't drop from inconsistent state + // The v2 fix ensures leveraged token price doesn't drop from inconsistent state assertGe(priceAfter, priceBefore, "leveraged price must not decrease in freeRedeemPeggedToken"); } } diff --git a/script/verify/minter-v2-upgrade/RebalanceCheck.t.sol b/script/verify/minter-v2-upgrade/RebalanceCheck.t.sol index 9e085c7f..3ed17da5 100644 --- a/script/verify/minter-v2-upgrade/RebalanceCheck.t.sol +++ b/script/verify/minter-v2-upgrade/RebalanceCheck.t.sol @@ -11,7 +11,7 @@ import {IStabilityPoolManager} from "src/interfaces/IStabilityPoolManager.sol"; import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IMinter} from "src/interfaces/IMinter.sol"; -import {Minter_v3} from "src/minter/Minter_v3.sol"; +import {Minter_v2} from "src/minter/Minter_v2.sol"; abstract contract RebalanceCheckBase is BaoTest, HarborFactoryDeployer { uint256 constant FORK_BLOCK = 24687073; @@ -52,15 +52,12 @@ abstract contract RebalanceCheckBase is BaoTest, HarborFactoryDeployer { manager.upgradeToAndCall(address(newSpmImpl), ""); } - function _upgradeMinterV3() internal { + function _upgradeMinterV2() internal { address proxyOwner = IBaoOwnable(minter).owner(); IMinter m = IMinter(minter); - address newMinterImpl = address(new Minter_v3( - m.WRAPPED_COLLATERAL_TOKEN(), - m.PEGGED_TOKEN(), - m.LEVERAGED_TOKEN(), - "burn(uint256)" - )); + address newMinterImpl = address( + new Minter_v2(m.WRAPPED_COLLATERAL_TOKEN(), m.PEGGED_TOKEN(), m.LEVERAGED_TOKEN(), "burn(uint256)") + ); vm.prank(proxyOwner); UUPSUpgradeable(minter).upgradeToAndCall(newMinterImpl, ""); } @@ -179,31 +176,31 @@ contract RebalanceCheck_v1 is RebalanceCheckBase { } } -/// @notice Tests after upgrading to Minter_v3 -contract RebalanceCheck_v3 is RebalanceCheckBase { +/// @notice Tests after upgrading to Minter_v2 +contract RebalanceCheck_v2 is RebalanceCheckBase { function setUp() public { _forkAndPredict(); - _upgradeMinterV3(); + _upgradeMinterV2(); _upgradeSpm(); } - function test_v3_leveragedTokenPrice_doesNotDecrease() public { + function test_v2_leveragedTokenPrice_doesNotDecrease() public { _assert_leveragedTokenPrice_doesNotDecrease(); } - function test_v3_collateralRatio_hitsThreshold() public { + function test_v2_collateralRatio_hitsThreshold() public { _assert_collateralRatio_hitsThreshold(); } - function test_v3_userClaimable_proportionalToDeposit() public { + function test_v2_userClaimable_proportionalToDeposit() public { _assert_userClaimable_proportionalToDeposit(); } - function test_v3_leveragedMint_doesNotExceedPeggedBurned() public { + function test_v2_leveragedMint_doesNotExceedPeggedBurned() public { _assert_leveragedMint_doesNotExceedPeggedBurned(); } - function test_v3_holderValues_preserved() public { + function test_v2_holderValues_preserved() public { _assert_holderValues_preserved(); } } @@ -216,7 +213,7 @@ contract RebalanceCheck_remediation is RebalanceCheckBase { _upgradeSpm(); } - /// @notice Runs rebalance under v1 and v3, logs the delta in leveraged supply + /// @notice Runs rebalance under v1 and v2, logs the delta in leveraged supply /// and underlyingCollateral, showing the exact over-minting. function test_log_overminting_delta() public { // --- snapshot v1 rebalance --- @@ -234,33 +231,33 @@ contract RebalanceCheck_remediation is RebalanceCheckBase { vm.revertToState(snap); - // --- upgrade to v3 and rebalance --- - _upgradeMinterV3(); + // --- upgrade to v2 and rebalance --- + _upgradeMinterV2(); - uint256 v3_priceBefore = IMinter(minter).leveragedTokenPrice(); + uint256 v2_priceBefore = IMinter(minter).leveragedTokenPrice(); StabilityPoolManager_v1(stabilityPoolManager).rebalance(makeAddr("bounty"), 0); - uint256 v3_collateralAfter = IMinter(minter).collateralTokenBalance(); - uint256 v3_levSupplyAfter = IERC20(leveraged).totalSupply(); - uint256 v3_priceAfter = IMinter(minter).leveragedTokenPrice(); + uint256 v2_collateralAfter = IMinter(minter).collateralTokenBalance(); + uint256 v2_levSupplyAfter = IERC20(leveraged).totalSupply(); + uint256 v2_priceAfter = IMinter(minter).leveragedTokenPrice(); // --- log results --- - uint256 excessLeveraged = v1_levSupplyAfter - v3_levSupplyAfter; - uint256 collateralDelta = v1_collateralAfter - v3_collateralAfter; + uint256 excessLeveraged = v1_levSupplyAfter - v2_levSupplyAfter; + uint256 collateralDelta = v1_collateralAfter - v2_collateralAfter; emit log_named_uint("v1 leveraged price BEFORE rebalance", v1_priceBefore); emit log_named_uint("v1 leveraged price AFTER rebalance", v1_priceAfter); - emit log_named_uint("v3 leveraged price BEFORE rebalance", v3_priceBefore); - emit log_named_uint("v3 leveraged price AFTER rebalance", v3_priceAfter); + emit log_named_uint("v3 leveraged price BEFORE rebalance", v2_priceBefore); + emit log_named_uint("v3 leveraged price AFTER rebalance", v2_priceAfter); emit log_named_uint("v1 leveraged minted", v1_levSupplyAfter - v1_levSupplyBefore); - emit log_named_uint("v3 leveraged minted", v3_levSupplyAfter - v1_levSupplyBefore); + emit log_named_uint("v3 leveraged minted", v2_levSupplyAfter - v1_levSupplyBefore); emit log_named_uint("EXCESS leveraged tokens minted by v1", excessLeveraged); emit log_named_uint("v1 underlyingCollateral after", v1_collateralAfter); - emit log_named_uint("v3 underlyingCollateral after", v3_collateralAfter); + emit log_named_uint("v3 underlyingCollateral after", v2_collateralAfter); emit log_named_uint("underlyingCollateral DELTA (v1 too high by)", collateralDelta); emit log_named_uint("v1 collateral removed", v1_collateralBefore - v1_collateralAfter); - emit log_named_uint("v3 collateral removed", v1_collateralBefore - v3_collateralAfter); + emit log_named_uint("v3 collateral removed", v1_collateralBefore - v2_collateralAfter); } /// @notice Confirms that the v1 bug is purely excess leveraged token supply, @@ -268,11 +265,11 @@ contract RebalanceCheck_remediation is RebalanceCheckBase { function test_confirm_collateralDelta_isZero() public { uint256 snapInit = vm.snapshotState(); - // --- Run v3 (correct) rebalance --- - _upgradeMinterV3(); + // --- Run v2 (correct) rebalance --- + _upgradeMinterV2(); StabilityPoolManager_v1(stabilityPoolManager).rebalance(makeAddr("bounty"), 0); - uint256 v3_collateralAfter = IMinter(minter).collateralTokenBalance(); - uint256 v3_levSupply = IERC20(leveraged).totalSupply(); + uint256 v2_collateralAfter = IMinter(minter).collateralTokenBalance(); + uint256 v2_levSupply = IERC20(leveraged).totalSupply(); // --- Revert and run v1 (buggy) rebalance --- vm.revertToState(snapInit); @@ -281,10 +278,10 @@ contract RebalanceCheck_remediation is RebalanceCheckBase { uint256 v1_levSupply = IERC20(leveraged).totalSupply(); // underlyingCollateral is identical — the bug doesn't affect collateral accounting - assertEq(v1_collateralAfter, v3_collateralAfter, "collateral must be identical"); + assertEq(v1_collateralAfter, v2_collateralAfter, "collateral must be identical"); // The ONLY difference is excess leveraged tokens minted - uint256 excess = v1_levSupply - v3_levSupply; + uint256 excess = v1_levSupply - v2_levSupply; assertGt(excess, 0, "v1 must over-mint leveraged tokens"); emit log_named_uint("excess leveraged tokens", excess); diff --git a/test/deployment/DeployETHfxUSD.t.sol b/test/deployment/DeployETHfxUSD.t.sol index ce03d0bf..4d9f9573 100644 --- a/test/deployment/DeployETHfxUSD.t.sol +++ b/test/deployment/DeployETHfxUSD.t.sol @@ -4,7 +4,7 @@ pragma solidity >=0.8.28 <0.9.0; import {BaoTest} from "@bao-test/BaoTest.sol"; import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; -import {Deploy_ETH_Minter} from "script/src/Deploy_ETH_Minter.sol"; +import {Deploy_ETH_Minter} from "script/src/v3/Deploy_ETH_Minter.sol"; import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; import {Config_MinterMarket, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; diff --git a/test/deployment/MinterCappedMint.t.sol b/test/deployment/MinterCappedMint.t.sol index 9deb23f4..a7a54ca7 100644 --- a/test/deployment/MinterCappedMint.t.sol +++ b/test/deployment/MinterCappedMint.t.sol @@ -3,7 +3,7 @@ pragma solidity >=0.8.28 <0.9.0; import {BaoTest} from "@bao-test/BaoTest.sol"; import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; -import {Deploy_ETH_Minter} from "script/src/Deploy_ETH_Minter.sol"; +import {Deploy_ETH_Minter} from "script/src/v3/Deploy_ETH_Minter.sol"; import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; import {Config_MinterMarket} from "script/config/ConfigBase.sol"; diff --git a/test/deployment/RebalanceFairness.t.sol b/test/deployment/RebalanceFairness.t.sol index aa5ec07a..91e7e7fc 100644 --- a/test/deployment/RebalanceFairness.t.sol +++ b/test/deployment/RebalanceFairness.t.sol @@ -3,7 +3,7 @@ pragma solidity >=0.8.28 <0.9.0; import {BaoTest} from "@bao-test/BaoTest.sol"; import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; -import {Deploy_ETH_Minter} from "script/src/Deploy_ETH_Minter.sol"; +import {Deploy_ETH_Minter} from "script/src/v3/Deploy_ETH_Minter.sol"; import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; import {Config_MinterMarket, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; diff --git a/test/deployment/RewardSystem.t.sol b/test/deployment/RewardSystem.t.sol index 2a99e8c0..6d4f1f3c 100644 --- a/test/deployment/RewardSystem.t.sol +++ b/test/deployment/RewardSystem.t.sol @@ -4,7 +4,7 @@ pragma solidity >=0.8.28 <0.9.0; import {BaoTest} from "@bao-test/BaoTest.sol"; import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; -import {Deploy_ETH_Minter} from "script/src/Deploy_ETH_Minter.sol"; +import {Deploy_ETH_Minter} from "script/src/v3/Deploy_ETH_Minter.sol"; import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; import {Config_MinterMarket, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; diff --git a/test/deployment/StabilityPoolAliasDeployment.t.sol b/test/deployment/StabilityPoolAliasDeployment.t.sol index 0e231028..bdfeea31 100644 --- a/test/deployment/StabilityPoolAliasDeployment.t.sol +++ b/test/deployment/StabilityPoolAliasDeployment.t.sol @@ -3,7 +3,7 @@ pragma solidity >=0.8.28 <0.9.0; import {BaoTest} from "@bao-test/BaoTest.sol"; import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; -import {Deploy_ETH_Minter} from "script/src/Deploy_ETH_Minter.sol"; +import {Deploy_ETH_Minter} from "script/src/v3/Deploy_ETH_Minter.sol"; import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; import {Config_MinterMarket, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; From 3758765fc7756007c812d96e8c2dfa88486497b8 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Mon, 6 Apr 2026 09:00:39 +0100 Subject: [PATCH 017/232] updated sizes with fixed filtering --- .claude/settings.json | 4 +- lib/bao-base | 2 +- regression/coverage.txt | 43 +++++---- regression/gas.txt | 195 +++++++--------------------------------- regression/sizes.txt | 109 +++++++++++----------- 5 files changed, 119 insertions(+), 234 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index 2e7863ae..5cbe70f1 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -15,7 +15,9 @@ "WebFetch(domain:fxprotocol.gitbook.io)", "WebFetch(domain:medium.com)", "WebFetch(domain:www.openzeppelin.com)", - "Read(//home/tfras/github/AladdinDAO/fx-protocol-contracts/**)" + "Read(//home/tfras/github/AladdinDAO/fx-protocol-contracts/**)", + "Bash(ls script/src/*.sol)", + "Bash(ls script/src/Deploy_*)" ] } } diff --git a/lib/bao-base b/lib/bao-base index a926582c..9b46b18b 160000 --- a/lib/bao-base +++ b/lib/bao-base @@ -1 +1 @@ -Subproject commit a926582c82daa0d841154a541a718441ed58856e +Subproject commit 9b46b18b8ea893da0917e1cc635ab12d70bf28cd diff --git a/regression/coverage.txt b/regression/coverage.txt index 154732ab..3cb93707 100644 --- a/regression/coverage.txt +++ b/regression/coverage.txt @@ -23,24 +23,37 @@ | script/config/volatility/ConfigPriceVolatility_125_stable.sol | X 0% (0/65) | X 0% (0/71) | ✓ 100% (0/0) | X 0% (0/2) | | script/config/volatility/ConfigPriceVolatility_130.sol | X 0% (0/65) | X 0% (0/71) | ✓ 100% (0/0) | X 0% (0/2) | | script/config/volatility/ConfigPriceVolatility_130_stable.sol | ✓ 100% (65/65) | ✓ 100% (71/71) | ✓ 100% (0/0) | ✓ 100% (2/2) | -| script/src/DeployMintersShared.sol | X 86% (86/100) | X 85% (105/123) | X 25% (1/4) | X 80% (8/10) | -| script/src/Deploy_BTC_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | -| script/src/Deploy_ETH_Minter.sol | ✓ 100% (4/4) | ✓ 100% (3/3) | ✓ 100% (0/0) | ✓ 100% (1/1) | -| script/src/Deploy_EUR_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | -| script/src/Deploy_GOLD_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | -| script/src/Deploy_MCAP_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | -| script/src/Deploy_SILVER_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | | script/src/HarborFactoryDeployer.sol | X 56% (9/16) | X 73% (8/11) | ✓ 100% (0/0) | X 20% (1/5) | -| script/src/contracts/Genesis.sol | X 70% (7/10) | X 67% (8/12) | ✓ 100% (0/0) | X 50% (1/2) | -| script/src/contracts/LeveragedToken.sol | ✓ 100% (15/15) | ✓ 100% (23/23) | ✓ 100% (0/0) | ✓ 100% (1/1) | -| script/src/contracts/Minter.sol | X 58% (23/40) | X 55% (23/42) | ✓ 100% (0/0) | X 62% (5/8) | -| script/src/contracts/PeggedToken.sol | X 83% (20/24) | X 94% (31/33) | X 50% (3/6) | ✓ 100% (1/1) | -| script/src/contracts/StabilityPool.sol | ✓ 100% (34/34) | ✓ 100% (50/50) | ✓ 100% (0/0) | ✓ 100% (4/4) | -| script/src/contracts/StabilityPoolManager.sol | X 54% (14/26) | X 50% (15/30) | ✓ 100% (0/0) | X 50% (2/4) | +| script/src/v2/DeployMintersShared.sol | X 0% (0/84) | X 0% (0/102) | X 0% (0/4) | X 0% (0/9) | +| script/src/v2/Deploy_BTC_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | +| script/src/v2/Deploy_ETH_Minter.sol | X 0% (0/4) | X 0% (0/3) | ✓ 100% (0/0) | X 0% (0/1) | +| script/src/v2/Deploy_EUR_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | +| script/src/v2/Deploy_GOLD_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | +| script/src/v2/Deploy_MCAP_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | +| script/src/v2/Deploy_SILVER_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | +| script/src/v2/contracts/Genesis.sol | X 0% (0/10) | X 0% (0/12) | ✓ 100% (0/0) | X 0% (0/2) | +| script/src/v2/contracts/LeveragedToken.sol | X 0% (0/17) | X 0% (0/27) | ✓ 100% (0/0) | X 0% (0/1) | +| script/src/v2/contracts/Minter.sol | X 0% (0/42) | X 0% (0/46) | ✓ 100% (0/0) | X 0% (0/8) | +| script/src/v2/contracts/PeggedToken.sol | X 0% (0/24) | X 0% (0/33) | X 0% (0/6) | X 0% (0/1) | +| script/src/v2/contracts/StabilityPool.sol | X 0% (0/18) | X 0% (0/25) | ✓ 100% (0/0) | X 0% (0/3) | +| script/src/v2/contracts/StabilityPoolManager.sol | X 0% (0/26) | X 0% (0/30) | ✓ 100% (0/0) | X 0% (0/4) | +| script/src/v3/DeployMintersShared.sol | X 86% (86/100) | X 85% (105/123) | X 25% (1/4) | X 80% (8/10) | +| script/src/v3/Deploy_BTC_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | +| script/src/v3/Deploy_ETH_Minter.sol | ✓ 100% (4/4) | ✓ 100% (3/3) | ✓ 100% (0/0) | ✓ 100% (1/1) | +| script/src/v3/Deploy_EUR_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | +| script/src/v3/Deploy_GOLD_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | +| script/src/v3/Deploy_MCAP_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | +| script/src/v3/Deploy_SILVER_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | +| script/src/v3/contracts/Genesis.sol | X 70% (7/10) | X 67% (8/12) | ✓ 100% (0/0) | X 50% (1/2) | +| script/src/v3/contracts/LeveragedToken.sol | ✓ 100% (15/15) | ✓ 100% (23/23) | ✓ 100% (0/0) | ✓ 100% (1/1) | +| script/src/v3/contracts/Minter.sol | X 58% (23/40) | X 55% (23/42) | ✓ 100% (0/0) | X 62% (5/8) | +| script/src/v3/contracts/PeggedToken.sol | X 83% (20/24) | X 94% (31/33) | X 50% (3/6) | ✓ 100% (1/1) | +| script/src/v3/contracts/StabilityPool.sol | ✓ 100% (34/34) | ✓ 100% (50/50) | ✓ 100% (0/0) | ✓ 100% (4/4) | +| script/src/v3/contracts/StabilityPoolManager.sol | X 54% (14/26) | X 50% (15/30) | ✓ 100% (0/0) | X 50% (2/4) | | src/math/DecrementalFloatingPoint.sol | ✓ 100% (31/31) | ✓ 100% (33/33) | ✓ 100% (9/9) | ✓ 100% (6/6) | | src/minter/Genesis_v1.sol | X 99% (88/89) | ✓ 100% (88/88) | ✓ 100% (14/14) | X 92% (11/12) | | src/minter/Minter_v1.sol | X 0% (0/601) | X 0% (0/649) | X 0% (0/102) | X 0% (0/68) | -| src/minter/Minter_v2.sol | X 31% (188/601) | X 29% (191/649) | X 16% (16/102) | X 56% (38/68) | +| src/minter/Minter_v2.sol | X 0% (0/601) | X 0% (0/649) | X 0% (0/102) | X 0% (0/68) | | src/minter/Minter_v3.sol | X 99% (609/617) | X 99% (660/667) | X 93% (98/105) | X 99% (70/71) | | src/minter/ReservePool_v1.sol | X 94% (15/16) | ✓ 100% (15/15) | ✓ 100% (3/3) | X 80% (4/5) | | src/minter/StabilityPoolManager_v1.sol | X 99% (165/166) | X 99% (176/177) | X 95% (20/21) | ✓ 100% (24/24) | @@ -63,4 +76,4 @@ | src/reward/distributor/LinearReward.sol | ✓ 100% (25/25) | ✓ 100% (27/27) | ✓ 100% (6/6) | ✓ 100% (2/2) | | src/util/FmtLib.sol | X 0% (0/19) | X 0% (0/26) | X 0% (0/4) | X 0% (0/1) | | src/util/WordCodec.sol | ✓ 100% (15/15) | ✓ 100% (14/14) | ✓ 100% (0/0) | ✓ 100% (4/4) | -| Total | X 64% (5020/7866) | X 63% (5340/8495) | X 51% (458/890) | X 67% (759/1139) | +| Total | X 59% (4762/8116) | X 57% (5052/8793) | X 49% (442/900) | X 61% (713/1173) | diff --git a/regression/gas.txt b/regression/gas.txt index 2c49043d..35908131 100644 --- a/regression/gas.txt +++ b/regression/gas.txt @@ -11,26 +11,12 @@ script/config/markets/ConfigMarket_BTC_stETH_mainnet.sol:ConfigMarket_BTC_stETH_ | peg | 5.010e+02 | script/config/markets/ConfigMarket_ETH_fxUSD_mainnet.sol:ConfigMarket_ETH_fxUSD_mainnet -| function name | max | -|--------------------------------------|-----------| -| collateral | 4.990e+02 | -| harvestBountyRatio | 2.810e+02 | -| harvestCutRatio | 2.490e+02 | -| leveragedName | 5.704e+03 | -| leveragedSymbol | 6.807e+03 | -| minTotalSupply | 2.810e+02 | -| minterConfig | 1.169e+04 | -| peg | 5.010e+02 | -| rebalanceBountyRatio | 2.360e+02 | -| rebalanceThreshold | 2.590e+02 | -| spCollateralName | 8.085e+03 | -| spCollateralSymbol | 8.061e+03 | -| spLeveragedName | 8.887e+03 | -| spLeveragedSymbol | 8.924e+03 | -| stabilityPoolEarlyWithdrawalFeeRatio | 2.800e+02 | -| stabilityPoolWithdrawalDelay | 3.010e+02 | -| stabilityPoolWithdrawalPeriod | 2.820e+02 | -| wrappedCollateralToken | 2.790e+02 | +| function name | max | +|-----------------|-----------| +| collateral | 4.990e+02 | +| leveragedName | 5.704e+03 | +| leveragedSymbol | 6.807e+03 | +| peg | 5.010e+02 | script/config/markets/ConfigMarket_EUR_fxUSD_mainnet.sol:ConfigMarket_EUR_fxUSD_mainnet | function name | max | @@ -81,31 +67,6 @@ src/minter/Genesis_v1.sol:Genesis_v1 | transferOwnership | 1.202e+04 | | withdraw | 4.335e+04 | -src/minter/Minter_v2.sol:Minter_v2 -| function name | max | -|--------------------------|-----------| -| ZERO_FEE_ROLE | 2.850e+02 | -| collateralRatio | 1.912e+04 | -| collateralTokenBalance | 2.358e+03 | -| config | 3.370e+04 | -| freeMintLeveragedToken | 1.096e+05 | -| freeMintPeggedToken | 1.549e+05 | -| freeRedeemLeveragedToken | 7.282e+04 | -| freeRedeemPeggedToken | 6.823e+04 | -| grantRoles | 2.633e+04 | -| hasAnyRole | 2.636e+03 | -| initialize | 1.844e+05 | -| leverageRatio | 1.956e+04 | -| leveragedTokenPrice | 2.967e+04 | -| peggedTokenBalance | 2.409e+03 | -| peggedTokenPrice | 1.923e+04 | -| transferOwnership | 1.207e+04 | -| updateConfig | 2.092e+05 | -| updateFeeReceiver | 2.635e+04 | -| updatePriceOracle | 2.636e+04 | -| updateReservePool | 2.631e+04 | -| upgradeToAndCall | 1.094e+04 | - src/minter/Minter_v3.sol:Minter_v3 | function name | max | |------------------------------------|-----------| @@ -114,31 +75,32 @@ src/minter/Minter_v3.sol:Minter_v3 | PEGGED_TOKEN | 3.050e+02 | | WRAPPED_COLLATERAL_TOKEN | 2.830e+02 | | ZERO_FEE_ROLE | 2.850e+02 | -| collateralRatio | 7.109e+04 | +| collateralRatio | 1.912e+04 | | collateralTokenBalance | 2.358e+03 | | config | 4.895e+04 | | feeReceiver | 2.442e+03 | | freeMintLeveragedToken | 1.438e+05 | -| freeMintPeggedToken | 1.674e+05 | -| freeRedeemLeveragedToken | 8.668e+04 | -| freeRedeemPeggedToken | 1.355e+05 | +| freeMintPeggedToken | 1.549e+05 | +| freeRedeemLeveragedToken | 6.958e+04 | +| freeRedeemPeggedToken | 1.032e+05 | | grantRoles | 2.633e+04 | -| harvestable | 2.981e+04 | +| harvestable | 2.254e+04 | | hasAllRoles | 2.637e+03 | | hasAnyRole | 2.636e+03 | | initialize | 1.844e+05 | | leverageRatio | 1.957e+04 | | leveragedTokenBalance | 1.042e+04 | -| leveragedTokenPrice | 8.167e+04 | -| mintLeveragedToken | 1.494e+05 | -| mintLeveragedTokenDryRun | 7.338e+04 | +| leveragedTokenPrice | 3.000e+04 | +| mintLeveragedToken | 1.477e+05 | +| mintLeveragedTokenDryRun | 7.305e+04 | | mintLeveragedTokenIncentiveRatio | 3.108e+04 | +| mintPeggedToken | 1.911e+05 | +| mintPeggedTokenDryRun | 6.401e+04 | | mintPeggedTokenIncentiveRatio | 3.012e+04 | | owner | 2.402e+03 | | peggedTokenBalance | 2.409e+03 | | peggedTokenPrice | 1.923e+04 | | priceOracle | 2.427e+03 | -| proxiableUUID | 3.860e+02 | | redeemLeveragedToken | 1.276e+05 | | redeemLeveragedTokenDryRun | 6.317e+04 | | redeemLeveragedTokenIncentiveRatio | 2.924e+04 | @@ -161,12 +123,8 @@ src/minter/ReservePool_v1.sol:ReservePool_v1 |-------------------|-----------| | REQUESTER_ROLE | 2.390e+02 | | grantRoles | 2.633e+04 | -| hasAnyRole | 2.569e+03 | | initialize | 7.031e+04 | -| owner | 2.389e+03 | -| requestBonus | 3.934e+04 | -| supportsInterface | 8.420e+02 | -| sweep | 2.642e+03 | +| requestBonus | 1.774e+04 | | transferOwnership | 1.204e+04 | src/minter/StabilityPoolManager_v1.sol:StabilityPoolManager_v1 @@ -176,12 +134,10 @@ src/minter/StabilityPoolManager_v1.sol:StabilityPoolManager_v1 | harvest | 4.488e+05 | | harvestBountyRatio | 2.369e+03 | | harvestCutRatio | 2.371e+03 | -| harvestable | 3.052e+04 | | hasStabilityPool | 5.370e+02 | | initialize | 7.069e+04 | | owner | 2.423e+03 | -| proxiableUUID | 3.080e+02 | -| rebalance | 6.803e+05 | +| rebalance | 5.182e+05 | | rebalanceBountyRatio | 2.354e+03 | | rebalanceThreshold | 2.347e+03 | | rebalanceable | 2.926e+04 | @@ -193,100 +149,23 @@ src/minter/StabilityPoolManager_v1.sol:StabilityPoolManager_v1 | updateHarvestCutRatio | 2.572e+04 | | updateRebalanceBountyRatio | 2.565e+04 | | updateRebalanceThreshold | 2.571e+04 | -| upgradeToAndCall | 1.087e+04 | - -src/minter/StabilityPool_v1.sol:StabilityPool_v1 -| function name | max | -|-----------------------|-----------| -| ASSET_TOKEN | 3.270e+02 | -| REBALANCER_ROLE | 2.620e+02 | -| REWARD_DEPOSITOR_ROLE | 2.840e+02 | -| REWARD_MANAGER_ROLE | 3.270e+02 | -| assetBalanceOf | 8.053e+03 | -| claim | 1.209e+05 | -| claimable | 2.096e+04 | -| claimed | 2.848e+03 | -| deposit | 2.693e+05 | -| depositReward | 6.533e+04 | -| getWithdrawalRequest | 2.745e+03 | -| grantRoles | 2.636e+04 | -| initialize | 2.041e+05 | -| notifyLiquidation | 1.373e+05 | -| registerRewardToken | 7.292e+04 | -| requestWithdrawal | 2.504e+04 | -| sweep | 4.020e+04 | -| totalAssetSupply | 2.489e+03 | -| transferOwnership | 1.207e+04 | -| upgradeToAndCall | 1.090e+04 | - -src/minter/StabilityPool_v2.sol:StabilityPool_v2 -| function name | max | -|----------------------|-----------| -| ASSET_TOKEN | 3.270e+02 | -| assetBalanceOf | 8.049e+03 | -| checkpoint | 1.893e+05 | -| claim | 2.672e+05 | -| claimable | 2.550e+04 | -| claimed | 9.798e+03 | -| deposit | 2.800e+05 | -| depositReward | 6.533e+04 | -| getWithdrawalRequest | 2.745e+03 | -| notifyLiquidation | 1.107e+05 | -| proxiableUUID | 3.410e+02 | -| sweep | 4.020e+04 | -| totalAssetSupply | 2.489e+03 | -| withdraw | 3.013e+05 | src/minter/StabilityPool_v3.sol:StabilityPool_v3 -| function name | max | -|------------------------|-----------| -| ASSET_TOKEN | 3.490e+02 | -| LIQUIDATION_TOKEN | 3.500e+02 | -| REBALANCER_ROLE | 2.840e+02 | -| REWARD_DEPOSITOR_ROLE | 3.060e+02 | -| REWARD_MANAGER_ROLE | 3.270e+02 | -| activeRewardTokens | 1.195e+04 | -| assetBalanceOf | 8.047e+03 | -| checkpoint | 1.788e+05 | -| claimable | 5.960e+04 | -| claimed | 7.465e+03 | -| deposit | 4.059e+05 | -| depositReward | 8.044e+04 | -| getWithdrawalRequest | 2.761e+03 | -| grantRoles | 2.637e+04 | -| historicalRewardTokens | 5.180e+03 | -| initialize | 2.041e+05 | -| name | 1.926e+04 | -| notifyLiquidation | 1.198e+05 | -| owner | 2.446e+03 | -| requestWithdrawal | 2.501e+04 | -| sweep | 3.610e+04 | -| symbol | 1.950e+04 | -| totalAssetSupply | 2.423e+03 | -| transferOwnership | 1.204e+04 | -| unregisterRewardToken | 1.046e+05 | -| withdraw | 2.272e+05 | +| function name | max | +|-------------------|-----------| +| grantRoles | 2.637e+04 | +| initialize | 2.041e+05 | +| name | 1.926e+04 | +| symbol | 1.950e+04 | +| transferOwnership | 1.204e+04 | src/minter/TokenDistributor_v1.sol:TokenDistributor_v1 -| function name | max | -|----------------------|-----------| -| CLAIMER_ROLE | 2.820e+02 | -| addOrUpdateRecipient | 6.949e+04 | -| addToken | 8.818e+04 | -| distribute | 1.792e+05 | -| distribution | 1.454e+04 | -| grantRoles | 2.633e+04 | -| hasAnyRole | 2.635e+03 | -| initialize | 9.339e+04 | -| name | 3.197e+03 | -| owner | 2.345e+03 | -| removeRecipient | 2.482e+04 | -| removeToken | 1.829e+04 | -| setDistribution | 7.611e+04 | -| supportsInterface | 8.650e+02 | -| sweep | 4.108e+04 | -| tokens | 7.448e+03 | -| transferOwnership | 1.200e+04 | +| function name | max | +|-------------------|-----------| +| CLAIMER_ROLE | 2.820e+02 | +| grantRoles | 2.633e+04 | +| initialize | 9.339e+04 | +| transferOwnership | 1.200e+04 | src/minter/library/StringPacking_v1.sol:StringPacking_v1 | function name | max | @@ -295,15 +174,9 @@ src/minter/library/StringPacking_v1.sol:StringPacking_v1 | unpack64 | 1.580e+04 | src/reward/RewardAlias_v1.sol:RewardAlias_v1 -| function name | max | -|-------------------|-----------| -| initialize | 7.040e+04 | -| owner | 2.371e+03 | -| proxiableUUID | 3.410e+02 | -| supportsInterface | 5.280e+02 | -| transferOwnership | 1.202e+04 | -| underlying | 2.010e+02 | -| upgradeToAndCall | 1.083e+04 | +| function name | max | +|-----------------|-----------| +| underlying | 2.010e+02 | test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator.sol:MockMultipleRewardCompoundingAccumulator | function name | max | diff --git a/regression/sizes.txt b/regression/sizes.txt index 28fcf6fd..639cfa4d 100644 --- a/regression/sizes.txt +++ b/regression/sizes.txt @@ -1,56 +1,53 @@ -| Contract | Runtime Size (B) | Runtime Margin (B) | Initcode Size (B) | Deploy Gas | Deploy Cost ($) | -|---------------------------------------------|--------------------|----------------------|---------------------|--------------|-------------------| -| ConfigIncentiveLib | 85 | 24,491 | 135 | 18,350 | 1.84 | -| ConfigMarket_BTC_fxUSD_mainnet | 6,231 | 18,345 | 6,259 | 1,308,790 | 130.88 | -| ConfigMarket_BTC_stETH_mainnet | 6,257 | 18,319 | 6,285 | 1,314,250 | 131.43 | -| ConfigMarket_ETH_fxUSD_mainnet | 6,231 | 18,345 | 6,259 | 1,308,790 | 130.88 | -| ConfigMarket_EUR_fxUSD_mainnet | 6,219 | 18,357 | 6,247 | 1,306,270 | 130.63 | -| ConfigMarket_EUR_stETH_mainnet | 6,245 | 18,331 | 6,273 | 1,311,730 | 131.17 | -| ConfigMarket_GOLD_fxUSD_mainnet | 6,235 | 18,341 | 6,263 | 1,309,630 | 130.96 | -| ConfigMarket_GOLD_stETH_mainnet | 6,261 | 18,315 | 6,289 | 1,315,090 | 131.51 | -| ConfigMarket_MCAP_fxUSD_mainnet | 6,237 | 18,339 | 6,265 | 1,310,050 | 131.00 | -| ConfigMarket_MCAP_stETH_mainnet | 6,263 | 18,313 | 6,291 | 1,315,510 | 131.55 | -| ConfigMarket_SILVER_fxUSD_mainnet | 6,231 | 18,345 | 6,259 | 1,308,790 | 130.88 | -| ConfigMarket_SILVER_stETH_mainnet | 6,257 | 18,319 | 6,285 | 1,314,250 | 131.43 | -| ConfigPeg_BTC | 766 | 23,810 | 794 | 161,140 | 16.11 | -| ConfigPeg_ETH | 766 | 23,810 | 794 | 161,140 | 16.11 | -| ConfigPeg_EUR | 768 | 23,808 | 796 | 161,560 | 16.16 | -| ConfigPeg_GOLD | 770 | 23,806 | 798 | 161,980 | 16.20 | -| ConfigPeg_MCAP | 772 | 23,804 | 800 | 162,400 | 16.24 | -| ConfigPeg_SILVER | 794 | 23,782 | 822 | 167,020 | 16.70 | -| ConfigPriceVolatility_105 | 3,019 | 21,557 | 3,047 | 634,270 | 63.43 | -| ConfigPriceVolatility_105_stable | 3,019 | 21,557 | 3,047 | 634,270 | 63.43 | -| ConfigPriceVolatility_115 | 3,019 | 21,557 | 3,047 | 634,270 | 63.43 | -| ConfigPriceVolatility_115_stable | 3,019 | 21,557 | 3,047 | 634,270 | 63.43 | -| ConfigPriceVolatility_125 | 3,019 | 21,557 | 3,047 | 634,270 | 63.43 | -| ConfigPriceVolatility_125_stable | 3,019 | 21,557 | 3,047 | 634,270 | 63.43 | -| ConfigPriceVolatility_130 | 3,019 | 21,557 | 3,047 | 634,270 | 63.43 | -| ConfigPriceVolatility_130_stable | 3,019 | 21,557 | 3,047 | 634,270 | 63.43 | -| Config_v1 | 3,789 | 20,787 | 3,841 | 796,210 | 79.62 | -| DecrementalFloatingPoint | 85 | 24,491 | 135 | 18,350 | 1.84 | -| FakeAccessControl | 1,626 | 22,950 | 1,654 | 341,740 | 34.17 | -| FakeBaoAccessControl | 1,487 | 23,089 | 1,515 | 312,550 | 31.26 | -| FakeInitializable | 389 | 24,187 | 417 | 81,970 | 8.20 | -| FakeOwnable2Step | 1,346 | 23,230 | 1,374 | 282,940 | 28.29 | -| FakeUUPSUpgradeable | 3,236 | 21,340 | 3,293 | 680,130 | 68.01 | -| FmtLib | 85 | 24,491 | 135 | 18,350 | 1.84 | -| ForceMigrateAccumulator_v1 | 3,364 | 21,212 | 3,847 | 711,270 | 71.13 | -| Genesis_v1 | 7,486 | 17,090 | 8,423 | 1,581,430 | 158.14 | -| LinearReward | 85 | 24,491 | 135 | 18,350 | 1.84 | -| MinterMarketConfigLib | 85 | 24,491 | 135 | 18,350 | 1.84 | -| Minter_v1 | 23,681 | 895 | 25,515 | 4,991,350 | 499.14 | -| Minter_v2 | 23,675 | 901 | 25,509 | 4,990,090 | 499.01 | -| Minter_v3 | 24,218 | 358 | 26,045 | 5,104,050 | 510.41 | -| MockWrappedPriceOracle | 373 | 24,203 | 435 | 78,950 | 7.90 | -| PostRebalanceRemediationForStabilityPool_v2 | 3,852 | 20,724 | 4,350 | 813,900 | 81.39 | -| PriceOracle_v1 | 1,958 | 22,618 | 2,010 | 411,700 | 41.17 | -| ReservePool_v1 | 4,760 | 19,816 | 5,009 | 1,002,090 | 100.21 | -| RewardAlias_v1 | 2,974 | 21,602 | 3,358 | 628,380 | 62.84 | -| StabilityPoolManager_v1 | 11,209 | 13,367 | 13,057 | 2,372,370 | 237.24 | -| StabilityPool_v1 | 21,092 | 3,484 | 23,085 | 4,449,250 | 444.93 | -| StabilityPool_v2 | 20,711 | 3,865 | 22,579 | 4,367,990 | 436.80 | -| StabilityPool_v3 | 24,376 | 200 | 27,002 | 5,145,220 | 514.52 | -| StakedETHWrappedPriceOracle_v1 | 3,695 | 20,881 | 4,653 | 785,530 | 78.55 | -| StringPacking_v1 | 1,218 | 23,358 | 1,270 | 256,300 | 25.63 | -| TokenDistributor_v1 | 10,116 | 14,460 | 10,365 | 2,126,850 | 212.69 | -| WordCodec | 85 | 24,491 | 135 | 18,350 | 1.84 | +| Contract | Runtime Size (B) | Runtime Margin (B) | Initcode Size (B) | Deploy Gas | Deploy Cost ($) | +|-----------------------------------|--------------------|----------------------|---------------------|--------------|-------------------| +| ConfigIncentiveLib | 85 | 24,491 | 135 | 18,350 | 1.84 | +| ConfigMarket_BTC_fxUSD_mainnet | 6,231 | 18,345 | 6,259 | 1,308,790 | 130.88 | +| ConfigMarket_BTC_stETH_mainnet | 6,257 | 18,319 | 6,285 | 1,314,250 | 131.43 | +| ConfigMarket_ETH_fxUSD_mainnet | 6,231 | 18,345 | 6,259 | 1,308,790 | 130.88 | +| ConfigMarket_EUR_fxUSD_mainnet | 6,219 | 18,357 | 6,247 | 1,306,270 | 130.63 | +| ConfigMarket_EUR_stETH_mainnet | 6,245 | 18,331 | 6,273 | 1,311,730 | 131.17 | +| ConfigMarket_GOLD_fxUSD_mainnet | 6,235 | 18,341 | 6,263 | 1,309,630 | 130.96 | +| ConfigMarket_GOLD_stETH_mainnet | 6,261 | 18,315 | 6,289 | 1,315,090 | 131.51 | +| ConfigMarket_MCAP_fxUSD_mainnet | 6,237 | 18,339 | 6,265 | 1,310,050 | 131.00 | +| ConfigMarket_MCAP_stETH_mainnet | 6,263 | 18,313 | 6,291 | 1,315,510 | 131.55 | +| ConfigMarket_SILVER_fxUSD_mainnet | 6,231 | 18,345 | 6,259 | 1,308,790 | 130.88 | +| ConfigMarket_SILVER_stETH_mainnet | 6,257 | 18,319 | 6,285 | 1,314,250 | 131.43 | +| ConfigPeg_BTC | 766 | 23,810 | 794 | 161,140 | 16.11 | +| ConfigPeg_ETH | 766 | 23,810 | 794 | 161,140 | 16.11 | +| ConfigPeg_EUR | 768 | 23,808 | 796 | 161,560 | 16.16 | +| ConfigPeg_GOLD | 770 | 23,806 | 798 | 161,980 | 16.20 | +| ConfigPeg_MCAP | 772 | 23,804 | 800 | 162,400 | 16.24 | +| ConfigPeg_SILVER | 794 | 23,782 | 822 | 167,020 | 16.70 | +| ConfigPriceVolatility_105 | 3,019 | 21,557 | 3,047 | 634,270 | 63.43 | +| ConfigPriceVolatility_105_stable | 3,019 | 21,557 | 3,047 | 634,270 | 63.43 | +| ConfigPriceVolatility_115 | 3,019 | 21,557 | 3,047 | 634,270 | 63.43 | +| ConfigPriceVolatility_115_stable | 3,019 | 21,557 | 3,047 | 634,270 | 63.43 | +| ConfigPriceVolatility_125 | 3,019 | 21,557 | 3,047 | 634,270 | 63.43 | +| ConfigPriceVolatility_125_stable | 3,019 | 21,557 | 3,047 | 634,270 | 63.43 | +| ConfigPriceVolatility_130 | 3,019 | 21,557 | 3,047 | 634,270 | 63.43 | +| ConfigPriceVolatility_130_stable | 3,019 | 21,557 | 3,047 | 634,270 | 63.43 | +| Config_v1 | 3,789 | 20,787 | 3,841 | 796,210 | 79.62 | +| DecrementalFloatingPoint | 85 | 24,491 | 135 | 18,350 | 1.84 | +| FakeAccessControl | 1,626 | 22,950 | 1,654 | 341,740 | 34.17 | +| FakeBaoAccessControl | 1,487 | 23,089 | 1,515 | 312,550 | 31.26 | +| FakeInitializable | 389 | 24,187 | 417 | 81,970 | 8.20 | +| FakeOwnable2Step | 1,346 | 23,230 | 1,374 | 282,940 | 28.29 | +| FakeUUPSUpgradeable | 3,236 | 21,340 | 3,293 | 680,130 | 68.01 | +| FmtLib | 85 | 24,491 | 135 | 18,350 | 1.84 | +| Genesis_v1 | 7,486 | 17,090 | 8,423 | 1,581,430 | 158.14 | +| LinearReward | 85 | 24,491 | 135 | 18,350 | 1.84 | +| MinterMarketConfigLib | 85 | 24,491 | 135 | 18,350 | 1.84 | +| Minter_v1 | 23,681 | 895 | 25,515 | 4,991,350 | 499.14 | +| Minter_v2 | 23,675 | 901 | 25,509 | 4,990,090 | 499.01 | +| Minter_v3 | 24,218 | 358 | 26,045 | 5,104,050 | 510.41 | +| PriceOracle_v1 | 1,958 | 22,618 | 2,010 | 411,700 | 41.17 | +| ReservePool_v1 | 4,760 | 19,816 | 5,009 | 1,002,090 | 100.21 | +| RewardAlias_v1 | 2,974 | 21,602 | 3,358 | 628,380 | 62.84 | +| StabilityPoolManager_v1 | 11,209 | 13,367 | 13,057 | 2,372,370 | 237.24 | +| StabilityPool_v1 | 21,092 | 3,484 | 23,085 | 4,449,250 | 444.93 | +| StabilityPool_v2 | 20,711 | 3,865 | 22,579 | 4,367,990 | 436.80 | +| StabilityPool_v3 | 24,376 | 200 | 27,002 | 5,145,220 | 514.52 | +| StakedETHWrappedPriceOracle_v1 | 3,695 | 20,881 | 4,653 | 785,530 | 78.55 | +| StringPacking_v1 | 1,218 | 23,358 | 1,270 | 256,300 | 25.63 | +| TokenDistributor_v1 | 10,116 | 14,460 | 10,365 | 2,126,850 | 212.69 | +| WordCodec | 85 | 24,491 | 135 | 18,350 | 1.84 | From 4c047170d57c104b755bf874ddb7c6a7666ca15b Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Mon, 6 Apr 2026 09:09:43 +0100 Subject: [PATCH 018/232] fixed yarn lint:test issues --- test/deployment/DeployETHfxUSD.t.sol | 5 +---- test/deployment/RewardSystem.t.sol | 4 +--- test/deployment/StabilityPoolAliasDeployment.t.sol | 1 - 3 files changed, 2 insertions(+), 8 deletions(-) diff --git a/test/deployment/DeployETHfxUSD.t.sol b/test/deployment/DeployETHfxUSD.t.sol index 4d9f9573..719b40ed 100644 --- a/test/deployment/DeployETHfxUSD.t.sol +++ b/test/deployment/DeployETHfxUSD.t.sol @@ -6,14 +6,11 @@ import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; import {Deploy_ETH_Minter} from "script/src/v3/Deploy_ETH_Minter.sol"; import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; -import {Config_MinterMarket, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; +import {Config_MinterMarket} from "script/config/ConfigBase.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import {IMinter} from "src/interfaces/IMinter.sol"; import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; -import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; -import {IMultipleRewardAccumulator_v3} from "src/interfaces/IMultipleRewardAccumulator_v3.sol"; import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; import {MockWrappedPriceOracle} from "test/mocks/MockWrappedPriceOracle.sol"; diff --git a/test/deployment/RewardSystem.t.sol b/test/deployment/RewardSystem.t.sol index 6d4f1f3c..e940ff87 100644 --- a/test/deployment/RewardSystem.t.sol +++ b/test/deployment/RewardSystem.t.sol @@ -6,12 +6,11 @@ import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; import {Deploy_ETH_Minter} from "script/src/v3/Deploy_ETH_Minter.sol"; import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; -import {Config_MinterMarket, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; +import {Config_MinterMarket} from "script/config/ConfigBase.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; import {IERC5313} from "@openzeppelin/contracts/interfaces/IERC5313.sol"; -import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import {IMinter} from "src/interfaces/IMinter.sol"; import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; @@ -20,7 +19,6 @@ import {IMultipleRewardAccumulator_v3} from "src/interfaces/IMultipleRewardAccum import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; import {IRewardAlias} from "src/interfaces/IRewardAlias.sol"; import {RewardAlias_v1} from "src/reward/RewardAlias_v1.sol"; -import {StabilityPoolManager_v1} from "src/minter/StabilityPoolManager_v1.sol"; import {MockWrappedPriceOracle} from "test/mocks/MockWrappedPriceOracle.sol"; /// @title Reward system tests — aliases, accumulator, distributor — using deployment framework diff --git a/test/deployment/StabilityPoolAliasDeployment.t.sol b/test/deployment/StabilityPoolAliasDeployment.t.sol index bdfeea31..ac4d4bd8 100644 --- a/test/deployment/StabilityPoolAliasDeployment.t.sol +++ b/test/deployment/StabilityPoolAliasDeployment.t.sol @@ -15,7 +15,6 @@ import {IMultipleRewardAccumulator_v3} from "src/interfaces/IMultipleRewardAccum import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; import {IRewardAlias} from "src/interfaces/IRewardAlias.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; -import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; import {MockWrappedPriceOracle} from "test/mocks/MockWrappedPriceOracle.sol"; /// @title StabilityPoolAliasDeploymentTest From 30aba8b82a1162b82057758f4be870f4dcda7cc9 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Mon, 6 Apr 2026 10:34:26 +0100 Subject: [PATCH 019/232] fixed overloaded functions in gas report --- CLAUDE.md | 1 + lib/bao-base | 2 +- regression/gas.txt | 260 ++++++++++++++++++++++++++++++++------------- 3 files changed, 189 insertions(+), 74 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 482025ba..3fe5a58f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,6 +4,7 @@ - Do not create functions that are only called once. Inline the logic instead. - When diagnosing an issue, do not use words like "likely", "probably", or "may" to describe a root cause. Either verify the hypothesis with data (dry run, log, trace) or state explicitly that it is unverified. Never proceed with a fix based on an unverified hypothesis. - When the user reports a problem, fix it — do not unilaterally decide the problem is out of scope, pre-existing, already resolved by another fix, or someone else's concern. If you believe any of those things, say so and ask whether the user still wants it addressed. Never declare a judgement like "this is pre-existing" or "the root cause is X" and then act on it without confirmation. Present your reasoning, then ask. +- When fixing bugs, follow this process: (1) write a test that fails because of the bug, (2) write the fix, (3) run the test again to confirm it passes. This applies to both Solidity and script/tooling bugs. - When fixing error handling, do not silently skip or suppress errors. If something fails, the failure should be visible and the process should fail clearly. Do not work around errors by hiding them unless explicitly asked to. - In bash, `set -e` does NOT catch failures in `[[ ]]` conditionals, variable assignments (e.g. `x=$(failing_cmd)`), commands in pipelines (use `set -o pipefail` AND check `${PIPESTATUS[@]}`), or sourced scripts. Always check exit status explicitly with `${PIPESTATUS[0]}` or `$?` after critical commands rather than relying on `set -e` alone. - use forge install/remove for managing submodule dependencies diff --git a/lib/bao-base b/lib/bao-base index 9b46b18b..cc355a55 160000 --- a/lib/bao-base +++ b/lib/bao-base @@ -1 +1 @@ -Subproject commit 9b46b18b8ea893da0917e1cc635ab12d70bf28cd +Subproject commit cc355a55d4734c11142d0a506993a6e09614c1e4 diff --git a/regression/gas.txt b/regression/gas.txt index 35908131..d127f9e1 100644 --- a/regression/gas.txt +++ b/regression/gas.txt @@ -11,12 +11,26 @@ script/config/markets/ConfigMarket_BTC_stETH_mainnet.sol:ConfigMarket_BTC_stETH_ | peg | 5.010e+02 | script/config/markets/ConfigMarket_ETH_fxUSD_mainnet.sol:ConfigMarket_ETH_fxUSD_mainnet -| function name | max | -|-----------------|-----------| -| collateral | 4.990e+02 | -| leveragedName | 5.704e+03 | -| leveragedSymbol | 6.807e+03 | -| peg | 5.010e+02 | +| function name | max | +|--------------------------------------|-----------| +| collateral | 4.990e+02 | +| harvestBountyRatio | 2.810e+02 | +| harvestCutRatio | 2.490e+02 | +| leveragedName | 5.704e+03 | +| leveragedSymbol | 6.807e+03 | +| minTotalSupply | 2.810e+02 | +| minterConfig | 1.169e+04 | +| peg | 5.010e+02 | +| rebalanceBountyRatio | 2.360e+02 | +| rebalanceThreshold | 2.590e+02 | +| spCollateralName | 8.085e+03 | +| spCollateralSymbol | 8.061e+03 | +| spLeveragedName | 8.887e+03 | +| spLeveragedSymbol | 8.924e+03 | +| stabilityPoolEarlyWithdrawalFeeRatio | 2.800e+02 | +| stabilityPoolWithdrawalDelay | 3.010e+02 | +| stabilityPoolWithdrawalPeriod | 2.820e+02 | +| wrappedCollateralToken | 2.790e+02 | script/config/markets/ConfigMarket_EUR_fxUSD_mainnet.sol:ConfigMarket_EUR_fxUSD_mainnet | function name | max | @@ -68,63 +82,69 @@ src/minter/Genesis_v1.sol:Genesis_v1 | withdraw | 4.335e+04 | src/minter/Minter_v3.sol:Minter_v3 -| function name | max | -|------------------------------------|-----------| -| HARVESTER_ROLE | 2.830e+02 | -| LEVERAGED_TOKEN | 2.830e+02 | -| PEGGED_TOKEN | 3.050e+02 | -| WRAPPED_COLLATERAL_TOKEN | 2.830e+02 | -| ZERO_FEE_ROLE | 2.850e+02 | -| collateralRatio | 1.912e+04 | -| collateralTokenBalance | 2.358e+03 | -| config | 4.895e+04 | -| feeReceiver | 2.442e+03 | -| freeMintLeveragedToken | 1.438e+05 | -| freeMintPeggedToken | 1.549e+05 | -| freeRedeemLeveragedToken | 6.958e+04 | -| freeRedeemPeggedToken | 1.032e+05 | -| grantRoles | 2.633e+04 | -| harvestable | 2.254e+04 | -| hasAllRoles | 2.637e+03 | -| hasAnyRole | 2.636e+03 | -| initialize | 1.844e+05 | -| leverageRatio | 1.957e+04 | -| leveragedTokenBalance | 1.042e+04 | -| leveragedTokenPrice | 3.000e+04 | -| mintLeveragedToken | 1.477e+05 | -| mintLeveragedTokenDryRun | 7.305e+04 | -| mintLeveragedTokenIncentiveRatio | 3.108e+04 | -| mintPeggedToken | 1.911e+05 | -| mintPeggedTokenDryRun | 6.401e+04 | -| mintPeggedTokenIncentiveRatio | 3.012e+04 | -| owner | 2.402e+03 | -| peggedTokenBalance | 2.409e+03 | -| peggedTokenPrice | 1.923e+04 | -| priceOracle | 2.427e+03 | -| redeemLeveragedToken | 1.276e+05 | -| redeemLeveragedTokenDryRun | 6.317e+04 | -| redeemLeveragedTokenIncentiveRatio | 2.924e+04 | -| redeemPeggedForCollateralRatio | 1.961e+04 | -| redeemPeggedToken | 1.323e+05 | -| redeemPeggedTokenDryRun | 6.178e+04 | -| redeemPeggedTokenIncentiveRatio | 3.011e+04 | -| reservePool | 2.411e+03 | -| reset | 2.869e+04 | -| supportsInterface | 9.430e+02 | -| sweep | 4.031e+04 | -| transferOwnership | 1.207e+04 | -| updateConfig | 2.954e+05 | -| updateFeeReceiver | 2.635e+04 | -| updatePriceOracle | 2.636e+04 | -| updateReservePool | 2.631e+04 | +| function name | max | +|--------------------------------------------------|-----------| +| HARVESTER_ROLE | 2.830e+02 | +| LEVERAGED_TOKEN | 2.830e+02 | +| PEGGED_TOKEN | 3.050e+02 | +| WRAPPED_COLLATERAL_TOKEN | 2.830e+02 | +| ZERO_FEE_ROLE | 2.850e+02 | +| collateralRatio | 1.912e+04 | +| collateralTokenBalance | 2.358e+03 | +| config | 4.895e+04 | +| feeReceiver | 2.442e+03 | +| freeMintLeveragedToken | 1.438e+05 | +| freeMintPeggedToken | 1.674e+05 | +| freeRedeemLeveragedToken | 8.668e+04 | +| freeRedeemPeggedToken | 1.346e+05 | +| grantRoles | 2.633e+04 | +| harvestable | 2.981e+04 | +| hasAllRoles | 2.637e+03 | +| hasAnyRole | 2.636e+03 | +| initialize | 1.844e+05 | +| leverageRatio | 1.957e+04 | +| leveragedTokenBalance | 1.042e+04 | +| leveragedTokenPrice | 3.000e+04 | +| mintLeveragedToken | 1.494e+05 | +| mintLeveragedTokenDryRun | 7.338e+04 | +| mintLeveragedTokenIncentiveRatio | 3.108e+04 | +| mintPeggedToken(uint256,address,uint256) | 1.911e+05 | +| mintPeggedToken(uint256,address,uint256,uint256) | 1.251e+05 | +| mintPeggedTokenDryRun(uint256) | 6.401e+04 | +| mintPeggedTokenDryRun(uint256,uint256) | 4.556e+04 | +| mintPeggedTokenIncentiveRatio | 3.012e+04 | +| owner | 2.402e+03 | +| peggedTokenBalance | 2.409e+03 | +| peggedTokenPrice | 1.923e+04 | +| priceOracle | 2.427e+03 | +| redeemLeveragedToken | 1.276e+05 | +| redeemLeveragedTokenDryRun | 6.317e+04 | +| redeemLeveragedTokenIncentiveRatio | 2.924e+04 | +| redeemPeggedForCollateralRatio | 1.961e+04 | +| redeemPeggedToken | 1.323e+05 | +| redeemPeggedTokenDryRun | 6.178e+04 | +| redeemPeggedTokenIncentiveRatio | 3.011e+04 | +| reservePool | 2.411e+03 | +| reset | 2.869e+04 | +| supportsInterface | 9.430e+02 | +| sweep | 4.031e+04 | +| transferOwnership | 1.207e+04 | +| updateConfig | 2.954e+05 | +| updateFeeReceiver | 2.635e+04 | +| updatePriceOracle | 2.636e+04 | +| updateReservePool | 2.631e+04 | src/minter/ReservePool_v1.sol:ReservePool_v1 | function name | max | |-------------------|-----------| | REQUESTER_ROLE | 2.390e+02 | | grantRoles | 2.633e+04 | +| hasAnyRole | 2.569e+03 | | initialize | 7.031e+04 | -| requestBonus | 1.774e+04 | +| owner | 2.389e+03 | +| requestBonus | 3.934e+04 | +| supportsInterface | 8.420e+02 | +| sweep | 2.642e+03 | | transferOwnership | 1.204e+04 | src/minter/StabilityPoolManager_v1.sol:StabilityPoolManager_v1 @@ -134,10 +154,11 @@ src/minter/StabilityPoolManager_v1.sol:StabilityPoolManager_v1 | harvest | 4.488e+05 | | harvestBountyRatio | 2.369e+03 | | harvestCutRatio | 2.371e+03 | +| harvestable | 3.052e+04 | | hasStabilityPool | 5.370e+02 | | initialize | 7.069e+04 | | owner | 2.423e+03 | -| rebalance | 5.182e+05 | +| rebalance | 5.612e+05 | | rebalanceBountyRatio | 2.354e+03 | | rebalanceThreshold | 2.347e+03 | | rebalanceable | 2.926e+04 | @@ -149,23 +170,104 @@ src/minter/StabilityPoolManager_v1.sol:StabilityPoolManager_v1 | updateHarvestCutRatio | 2.572e+04 | | updateRebalanceBountyRatio | 2.565e+04 | | updateRebalanceThreshold | 2.571e+04 | +| upgradeToAndCall | 1.087e+04 | + +src/minter/StabilityPool_v1.sol:StabilityPool_v1 +| function name | max | +|-----------------------|-----------| +| ASSET_TOKEN | 3.270e+02 | +| REBALANCER_ROLE | 2.620e+02 | +| REWARD_DEPOSITOR_ROLE | 2.840e+02 | +| REWARD_MANAGER_ROLE | 3.270e+02 | +| assetBalanceOf | 8.053e+03 | +| claim | 1.209e+05 | +| claimable | 2.096e+04 | +| claimed | 2.848e+03 | +| deposit | 2.693e+05 | +| depositReward | 6.533e+04 | +| getWithdrawalRequest | 2.745e+03 | +| grantRoles | 2.636e+04 | +| initialize | 2.041e+05 | +| notifyLiquidation | 1.373e+05 | +| registerRewardToken | 7.292e+04 | +| requestWithdrawal | 2.504e+04 | +| sweep | 4.020e+04 | +| totalAssetSupply | 2.489e+03 | +| transferOwnership | 1.207e+04 | +| upgradeToAndCall | 1.090e+04 | + +src/minter/StabilityPool_v2.sol:StabilityPool_v2 +| function name | max | +|----------------------|-----------| +| ASSET_TOKEN | 3.270e+02 | +| assetBalanceOf | 8.049e+03 | +| checkpoint | 1.893e+05 | +| claim | 2.672e+05 | +| claimable | 2.550e+04 | +| claimed | 9.798e+03 | +| deposit | 2.800e+05 | +| depositReward | 6.533e+04 | +| getWithdrawalRequest | 2.745e+03 | +| notifyLiquidation | 1.107e+05 | +| proxiableUUID | 3.410e+02 | +| sweep | 4.020e+04 | +| totalAssetSupply | 2.489e+03 | +| withdraw | 3.013e+05 | src/minter/StabilityPool_v3.sol:StabilityPool_v3 -| function name | max | -|-------------------|-----------| -| grantRoles | 2.637e+04 | -| initialize | 2.041e+05 | -| name | 1.926e+04 | -| symbol | 1.950e+04 | -| transferOwnership | 1.204e+04 | +| function name | max | +|----------------------------------------|-----------| +| ASSET_TOKEN | 3.490e+02 | +| LIQUIDATION_TOKEN | 3.500e+02 | +| REBALANCER_ROLE | 2.840e+02 | +| REWARD_DEPOSITOR_ROLE | 3.060e+02 | +| REWARD_MANAGER_ROLE | 3.270e+02 | +| activeRewardTokens | 1.195e+04 | +| assetBalanceOf | 8.047e+03 | +| checkpoint | 1.788e+05 | +| claim(address) | 2.910e+05 | +| claim(address,address) | 2.200e+05 | +| claim(address,address,address,uint256) | 2.112e+05 | +| claimable | 5.960e+04 | +| claimed | 7.465e+03 | +| deposit | 4.059e+05 | +| depositReward | 8.044e+04 | +| getWithdrawalRequest | 2.761e+03 | +| grantRoles | 2.637e+04 | +| historicalRewardTokens | 5.180e+03 | +| initialize | 2.041e+05 | +| name | 1.926e+04 | +| notifyLiquidation | 1.198e+05 | +| owner | 2.446e+03 | +| registerRewardToken(address) | 7.144e+04 | +| requestWithdrawal | 2.501e+04 | +| sweep | 3.610e+04 | +| symbol | 1.950e+04 | +| totalAssetSupply | 2.423e+03 | +| transferOwnership | 1.204e+04 | +| unregisterRewardToken | 1.046e+05 | +| withdraw | 2.272e+05 | src/minter/TokenDistributor_v1.sol:TokenDistributor_v1 -| function name | max | -|-------------------|-----------| -| CLAIMER_ROLE | 2.820e+02 | -| grantRoles | 2.633e+04 | -| initialize | 9.339e+04 | -| transferOwnership | 1.200e+04 | +| function name | max | +|----------------------|-----------| +| CLAIMER_ROLE | 2.820e+02 | +| addOrUpdateRecipient | 6.949e+04 | +| addToken | 8.818e+04 | +| distribute | 1.792e+05 | +| distribution | 1.454e+04 | +| grantRoles | 2.633e+04 | +| hasAnyRole | 2.635e+03 | +| initialize | 9.339e+04 | +| name | 3.197e+03 | +| owner | 2.345e+03 | +| removeRecipient | 2.482e+04 | +| removeToken | 1.829e+04 | +| setDistribution | 7.611e+04 | +| supportsInterface | 8.650e+02 | +| sweep | 4.108e+04 | +| tokens | 7.448e+03 | +| transferOwnership | 1.200e+04 | src/minter/library/StringPacking_v1.sol:StringPacking_v1 | function name | max | @@ -174,9 +276,15 @@ src/minter/library/StringPacking_v1.sol:StringPacking_v1 | unpack64 | 1.580e+04 | src/reward/RewardAlias_v1.sol:RewardAlias_v1 -| function name | max | -|-----------------|-----------| -| underlying | 2.010e+02 | +| function name | max | +|-------------------|-----------| +| initialize | 7.040e+04 | +| owner | 2.371e+03 | +| proxiableUUID | 3.410e+02 | +| supportsInterface | 5.280e+02 | +| transferOwnership | 1.202e+04 | +| underlying | 2.010e+02 | +| upgradeToAndCall | 1.083e+04 | test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator.sol:MockMultipleRewardCompoundingAccumulator | function name | max | @@ -185,6 +293,9 @@ test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator.sol:MockM | REWARD_PERIOD_LENGTH | 2.710e+02 | | activeRewardTokens | 9.672e+03 | | checkpoint | 2.793e+05 | +| claim() | 2.063e+05 | +| claim(address) | 2.068e+05 | +| claim(address,address) | 2.073e+05 | | claimable | 1.983e+04 | | claimed | 2.848e+03 | | depositReward | 1.442e+05 | @@ -209,6 +320,9 @@ test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v2.sol:Mo | REWARD_PERIOD_LENGTH | 2.710e+02 | | activeRewardTokens | 9.672e+03 | | checkpoint | 3.529e+05 | +| claim() | 2.103e+05 | +| claim(address) | 2.108e+05 | +| claim(address,address) | 2.113e+05 | | claimable | 2.203e+04 | | claimed | 7.479e+03 | | depositReward | 1.442e+05 | From 5d935fb072d49439df3d235028337ab4eb8d8f07 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Mon, 6 Apr 2026 18:30:31 +0100 Subject: [PATCH 020/232] update the design doc with details --- .claude/settings.json | 4 +- CLAUDE.md | 1 + doc/ideas/autocompounding-vault-design.md | 45 +++++++++++++++-------- 3 files changed, 33 insertions(+), 17 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index 5cbe70f1..24646386 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -17,7 +17,9 @@ "WebFetch(domain:www.openzeppelin.com)", "Read(//home/tfras/github/AladdinDAO/fx-protocol-contracts/**)", "Bash(ls script/src/*.sol)", - "Bash(ls script/src/Deploy_*)" + "Bash(ls script/src/Deploy_*)", + "Read(//home/tfras/.claude/**)", + "Bash(git -C ~/.claude/plans commit -am \"fixed broken links at top of plan\")" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index 3fe5a58f..dee55bc0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,6 +5,7 @@ - When diagnosing an issue, do not use words like "likely", "probably", or "may" to describe a root cause. Either verify the hypothesis with data (dry run, log, trace) or state explicitly that it is unverified. Never proceed with a fix based on an unverified hypothesis. - When the user reports a problem, fix it — do not unilaterally decide the problem is out of scope, pre-existing, already resolved by another fix, or someone else's concern. If you believe any of those things, say so and ask whether the user still wants it addressed. Never declare a judgement like "this is pre-existing" or "the root cause is X" and then act on it without confirmation. Present your reasoning, then ask. - When fixing bugs, follow this process: (1) write a test that fails because of the bug, (2) write the fix, (3) run the test again to confirm it passes. This applies to both Solidity and script/tooling bugs. +- Plan files are under git at `~/.claude/plans/`. After modifying a plan file, commit it with a short message describing the change (e.g. `git -C ~/.claude/plans commit -am "added D.1 versioned directories"`). This allows reverting mistakes. - When fixing error handling, do not silently skip or suppress errors. If something fails, the failure should be visible and the process should fail clearly. Do not work around errors by hiding them unless explicitly asked to. - In bash, `set -e` does NOT catch failures in `[[ ]]` conditionals, variable assignments (e.g. `x=$(failing_cmd)`), commands in pipelines (use `set -o pipefail` AND check `${PIPESTATUS[@]}`), or sourced scripts. Always check exit status explicitly with `${PIPESTATUS[0]}` or `$?` after critical commands rather than relying on `set -e` alone. - use forge install/remove for managing submodule dependencies diff --git a/doc/ideas/autocompounding-vault-design.md b/doc/ideas/autocompounding-vault-design.md index 6e25a1db..67882e44 100644 --- a/doc/ideas/autocompounding-vault-design.md +++ b/doc/ideas/autocompounding-vault-design.md @@ -95,12 +95,12 @@ sequenceDiagram User->>SP: claimable(user, wCOLn) SP-->>User: amount available - User->>SP: claimSingle(user, wCOLn) + User->>SP: claim(user, address(0), wCOLn, type(uint256).max) SP-->>User: wCOLn transferred (all pending) Note over User,SP: Fractional claim — take only part - User->>SP: claimSingle(user, wCOLn, maxAmount) + User->>SP: claim(user, address(0), wCOLn, maxAmount) SP-->>User: min(pending, maxAmount) transferred Note over SP: Remainder stays as pending,
included in claimable() ``` @@ -335,47 +335,60 @@ Same ERC4626 accounting over a portfolio. Collateral SP rebalances don't cause l ## 6. Design Decisions -### 5.1 SP as Rebasing ERC20 +### 6.1 SP as Rebasing ERC20 `balanceOf()` returns compounded real value. `totalSupply()` returns `totalAssetSupply()`. Transfer/approve/allowance added in v3. Like stETH. -### 5.2 Non-rebasing AC shares +### 6.2 Non-rebasing AC shares The AC is the non-rebasing wrapped version. Like wstETH wraps stETH. Share count fixed, price moves. -### 5.3 Collateral SP rebalance holds value +### 6.3 Collateral SP rebalance holds value Unlike leveraged SPs, collateral SP rebalance returns liquid wCOLn. The AC's totalAssets stays roughly constant (lost haXXX offset by gained claimable wCOLn). The AC auto-compounds back to haXXX when fees are acceptable. -### 5.4 Leveraged SPs standalone +### 6.4 Leveraged SPs standalone Leveraged SPs rebalance into hsXXX.COLn which is not liquid. Leveraged AC only compounds harvest wCOLn. Not included in PV (different risk profile). -### 5.5 Minting: maxFeeRatio +### 6.5 Minting: maxFeeRatio `mintPeggedToken(wCOLn, receiver, minPeggedOut, maxFeeRatio)` on Minter_v3. Stops when cumulative fee exceeds maxFeeRatio * collateralIn. Returns (0, 0) gracefully if fee too high. -### 5.6 Fractional Claim +### 6.6 Unified Claim with Fractional Support -`claimSingle(account, token, maxAmount)` on SP_v3. Claims up to maxAmount, leaves rest as pending. Enables AC to claim only what can be profitably minted. +`claim(account, receiver, token, maxAmount)` on SP_v3 (via `IMultipleRewardAccumulator_v3`). Claims up to maxAmount from token (draining aliases in registration order first), leaves rest as pending. `token == address(0)` claims all active tokens. Array overload `claim(account, receiver, tokens[], maxAmount)` for batch/historical claims. -### 5.7 Oracle Coupling +### 6.7 Reward Alias Registration + +Reward tokens are registered with an explicit ordered list of aliases: +```solidity +registerRewardToken(underlying, [harvestAlias, rebalanceAlias]) +``` + +Each alias must implement `IRewardAlias.underlying()` returning the underlying token address, validated at registration time. The `aliasToUnderlying` mapping (populated at registration) is used for token transfers — no runtime external calls. + +Claiming from an underlying drains its aliases in registration order first, then the underlying's own pending. Claiming from an alias drains only that alias. + +Unregistering an underlying also unregisters its aliases and cleans up the alias mappings. + +### 6.8 Oracle Coupling AC and PV read `IMinter(minter).priceOracle()` at runtime. Always in sync. No separate oracle config. -### 5.8 Equivalent Token Management +### 6.9 Equivalent Token Management wXXXn held at PV level only (not in ACs). Preference-ordered list, updatable by keeper. PV converts wXXXn -> wCOLn (via ISwapper) -> haXXX (via Minter) -> SP when fees acceptable. -### 5.9 No Equivalents in AC +### 6.10 No Equivalents in AC The AC does NOT hold wXXXn. Unprofitable wCOLn stays as unclaimed rewards in the SP, valued in totalAssets via claimable. This avoids the cross-subsidy fairness issue identified in the options analysis. -### 5.10 Compound Trigger +### 6.11 Compound Trigger Permissionless. Also triggered by SPM during harvest/rebalance. -### 5.11 Withdrawal +### 6.12 Withdrawal AC uses EXEMPT_WITHDRAWAL_FEE_ROLE initially. Dynamic fees (CR-based) replace withdrawal delay in future SP version, enabling standard ERC4626 withdraw. @@ -392,8 +405,8 @@ AC uses EXEMPT_WITHDRAWAL_FEE_ROLE initially. Dynamic fees (CR-based) replace wi | Contract | Status | Purpose | |----------|--------|---------| -| StabilityPool_v3 | In progress | Rebasing ERC20 + claimSingle + fractional claim | -| Minter_v3 | Done | mintPeggedTokenCapped | +| StabilityPool_v3 | Done | Rebasing ERC20, unified claim, fractional claim, reward aliases, StringPacking_v1 | +| Minter_v3 | Done | mintPeggedTokenCapped, private→internal | | AutoCompounder | To build | ERC4626 per SP (Level 1) | | PegVault | To build | ERC4626/ERC-7575 per peg (Level 2) | | ISwapper / MockSwapper | To build | wXXXn conversion interface | From 0d7b98c3d98e7057b6fe362a721055c27a40413d Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Tue, 7 Apr 2026 11:46:17 +0100 Subject: [PATCH 021/232] fix: scale Minter_feeRange tolerance with step count The hardcoded tolerance of 80 didn't scale with the number of steps, causing failures at higher step counts. Changed to 10 * steps. Co-Authored-By: Claude Opus 4.6 (1M context) --- test/Minter_feeRange.t.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/Minter_feeRange.t.sol b/test/Minter_feeRange.t.sol index 2a9da749..623a0d5e 100644 --- a/test/Minter_feeRange.t.sol +++ b/test/Minter_feeRange.t.sol @@ -1075,7 +1075,7 @@ abstract contract TestMinterIntegralFees is TestMinterFeeRange { assertNear( post.userPegged, postSteps.userPegged, - areDisallows ? 80 : 0, + areDisallows ? 10 * steps : 0, areDisallows ? 0.0000003 ether : 0, "mp integral user pegged" ); @@ -1085,7 +1085,7 @@ abstract contract TestMinterIntegralFees is TestMinterFeeRange { assertNear( post.minterPegged, postSteps.minterPegged, - areDisallows ? 80 : 0, + areDisallows ? 10 * steps : 0, areDisallows ? 3e11 : 0, "mp integral minter pegged" ); From 33ca97e8312789b6f487687bf8bb3e8d14b0d373 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Tue, 7 Apr 2026 11:59:38 +0100 Subject: [PATCH 022/232] deployments history --- deployments/README.md | 163 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 deployments/README.md diff --git a/deployments/README.md b/deployments/README.md new file mode 100644 index 00000000..06b8803f --- /dev/null +++ b/deployments/README.md @@ -0,0 +1,163 @@ +# Harbor Deployment History + +All deployments use the `harbor_v1` salt prefix on Ethereum mainnet via BaoFactory CREATE3. + +Deployment versions are tracked with annotated git tags (`deploy/X.Y.Z`). Tags are placed on the nearest commit after the on-chain deployment, since some deployments were executed from uncommitted code. + +State file: `deployments/mainnet/harbor_v1.state.json` + +--- + +## deploy/1.0 — Initial Deployment + +**Date:** 2025-12-19 +**Git tag:** `deploy/1.0` → `040cd302` +**Commit:** "added production deployment (non-predictable price oracle addresses)" + +Deployed the complete harbor_v1 protocol for 4 pegs with fxUSD collateral, plus BTC with stETH collateral. + +### Contracts + +| Contract | Version | Count | +|----------|---------|-------| +| MintableBurnableERC20_v1 | v1 | 8 (pegged + leveraged per market) | +| Minter_v1 | v1 | 4 | +| StabilityPool_v1 | v1 | 8 (collateral + leveraged per market) | +| StabilityPoolManager_v1 | v1 | 4 | +| Genesis_v1 | v1 | 4 | +| ReservePool_v1 | v1 | 4 | +| TokenDistributor_v1 | v1 | 8 (minter + SPM fee receivers) | + +### Markets + +| Peg | Collateral | Pegged token | +|-----|-----------|--------------| +| BTC | fxSAVE (fxUSD) | haBTC | +| BTC | wstETH (stETH) | haBTC | +| ETH | fxSAVE (fxUSD) | haETH | +| EUR | fxSAVE (fxUSD) | haEUR | +| GOLD | fxSAVE (fxUSD) | haGOLD | + +--- + +## deploy/1.0.1 — StabilityPool Fix + +**Date:** 2026-01-03 +**Git tag:** `deploy/1.0.1` → `15f6e319` +**Commit:** "corrected stability pool deployment" +**Fix commit:** `e05850db` ("fixed the min_asset supply for SPs") + +The initial 1.0 deployment had an incorrect `minTotalAssetSupply` constructor parameter in the stability pools. Since this is an immutable, fixing it required deploying new StabilityPool_v1 implementations and upgrading the proxies. The same source code was used — only the constructor parameter changed. + +The new min asset supply was meant to be around $1 scaled to the price of the asset in USD. + +Deployed via `script/deploy-new-SP` bash script. + +### Markets affected + +- BTC::fxUSD (collateral + leveraged) +- BTC::stETH (collateral + leveraged) +- ETH::fxUSD (collateral + leveraged) +- GOLD::fxUSD (collateral + leveraged) + +EUR::fxUSD was not affected (deployed correctly in 1.0). + +--- + +## deploy/1.0.2 — New Markets + +**Date:** 2026-01-16 to 2026-01-17 +**Git tag:** `deploy/1.0.2` → `13fa8bcf` +**Commit:** "install EUR/GOLD tidy up" + +New market deployments using the same v1 infrastructure. Not a bugfix — additional pegs and collateral types added to complete the initial market lineup. + +### Markets added + +| Date | Peg | Collateral | +|------|-----|-----------| +| 2026-01-16 | SILVER | fxSAVE (fxUSD) | +| 2026-01-16 | SILVER | wstETH (stETH) | +| 2026-01-17 | MCAP | fxSAVE (fxUSD) | +| 2026-01-17 | MCAP | wstETH (stETH) | +| 2026-01-17 | EUR | wstETH (stETH) | +| 2026-01-17 | GOLD | wstETH (stETH) | + +After this deployment: 7 pegs (BTC, ETH, EUR, GOLD, SILVER, MCAP), 11 markets total. + +--- + +## deploy/1.1 — StabilityPool v2 Upgrade + +**Date:** 2026-02-11 +**Git tag:** `deploy/1.1` → `ac278bd` +**Commit:** "deployment simulation and testing" + +Upgraded all 24 stability pools (12 collateral + 12 leveraged across 7 pegs and 11 markets) from StabilityPool_v1 to StabilityPool_v2. + +### Fixes in StabilityPool_v2 + +- **SP overflow:** reward integral overflow at high cumulative reward/pool ratios (uint192 → uint256) +- **Epoch removal:** simplified the Liquity epoch/scale tracking +- **Linear reward underflow:** rate truncation at small reward amounts (minimum useful reward = periodLength + 1) +- **Finish-at-zero:** reward period end condition fix +- **Genesis end:** genesis end condition fix + +### Verification + +Verified via `script/verify/sp-v2-upgrade/`: +- Mainnet fork upgrade test comparing v1 and v2 behaviour +- Run script: `script/verify/sp-v2-upgrade/run-upgrade-test-StabilityPool_v2` +- Upgrade runbook: `script/verify/sp-v2-upgrade/upgrade-StabilityPool_v2.md` + +### Contract changes + +| Contract | Change | +|----------|--------| +| StabilityPool | v1 → v2 (all 24 pools) | +| Minter | v1 (unchanged) | +| StabilityPoolManager | v1 (unchanged) | + +--- + +## deploy/1.2 — Minter v2 Upgrade + SPL Remediation + +**Date:** 2026-03-21 to 2026-03-25 +**Git tag:** `deploy/1.2` → `1a892d8` +**Commit:** "deployed minter _v2" + +Three operations: +1. BaoPauser_v1 deployed (2026-03-21) — emergency pause implementation +2. Minter_v1 → Minter_v2 upgrade for all 11 markets (2026-03-24) +3. ETH::fxUSD leveraged SP remediation (2026-03-25) — corrected reward integral inflated by Minter v1 over-minting bug + +### SPL Remediation detail + +The ETH::fxUSD leveraged stability pool had an inflated reward integral from a Minter v1 over-minting bug during rebalance. The `PostRebalanceRemediationForStabilityPool_v2` one-shot contract: +1. Corrected the reward integral (scaled by v2/v1 distribution ratio) +2. Burned excess leveraged tokens from the pool and affected wallets +3. Restored missing collateral via free mint + burn + +See `script/verify/spl-remediation/remediation-ETH-fxUSD-SPL.md` for full analysis. + +### Verification + +- `script/verify/minter-v2-upgrade/run-upgrade-test-Minter_v2` +- `script/verify/spl-remediation/run-upgrade-test-remediate-ETH_fxUSD_SPL` +- `script/verify/spl-remediation/SPLRemediationTest.t.sol` — mainnet fork test +- `script/verify/spl-remediation/V2ReplaySimulation.t.sol` — v1 vs v2 replay + +### Contract changes + +| Contract | Change | +|----------|--------| +| Minter | v1 → v2 (all 11 markets) | +| BaoPauser | v1 (new) | +| StabilityPool | v2 (unchanged) | +| PostRebalanceRemediationForStabilityPool_v2 | one-shot (ETH::fxUSD SPL only) | + +--- + +## deploy/1.3 — In Development + +SP_v3, Minter_v3, reward aliases, autocompounding infrastructure. See `doc/ideas/autocompounding-vault-design.md`. From a527179108eafc75e9afaad65c4b29f9073b3bda Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Tue, 7 Apr 2026 12:55:01 +0100 Subject: [PATCH 023/232] autocompounder first cut --- src/autocompounding/AutoCompounder_v1.sol | 285 ++++++++++++++++++++++ src/interfaces/IAutoCompounder.sol | 18 ++ 2 files changed, 303 insertions(+) create mode 100644 src/autocompounding/AutoCompounder_v1.sol create mode 100644 src/interfaces/IAutoCompounder.sol diff --git a/src/autocompounding/AutoCompounder_v1.sol b/src/autocompounding/AutoCompounder_v1.sol new file mode 100644 index 00000000..694715d3 --- /dev/null +++ b/src/autocompounding/AutoCompounder_v1.sol @@ -0,0 +1,285 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.30; + +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {ERC4626Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC4626Upgradeable.sol"; +import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC5313} from "@openzeppelin/contracts/interfaces/IERC5313.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; + +import {HarborOwnable} from "@bao/HarborOwnable.sol"; + +import {IAutoCompounder} from "src/interfaces/IAutoCompounder.sol"; +import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; +import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; +import {IMultipleRewardAccumulator_v3} from "src/interfaces/IMultipleRewardAccumulator_v3.sol"; +import {IMinter} from "src/interfaces/IMinter.sol"; +import {IMinter_v3} from "src/interfaces/IMinter_v3.sol"; + +/// @title AutoCompounder_v1 +/// @notice Level 1 auto-compounder: non-rebasing ERC4626 vault wrapping a rebasing stability pool position. +/// @dev The ERC4626 asset is the SP token (rebasing ERC20). Share count is fixed on deposit; share price +/// moves as totalAssets changes from harvest rewards, compounding, and rebalance losses. +/// compound() claims wCOLn rewards, mints pegged tokens via the Minter (fee-capped), and redeposits to the SP. +/// totalAssets() includes the SP position plus unclaimed wCOLn valued via Minter dry run. +/// Works for both collateral and leveraged stability pools. +// solhint-disable-next-line contract-name-capwords +contract AutoCompounder_v1 is Initializable, UUPSUpgradeable, ERC4626Upgradeable, HarborOwnable, IERC5313, IAutoCompounder { + using SafeERC20 for IERC20; + using Math for uint256; + + /*////////////////////////////////////////////////////////////////////////// + ERRORS + //////////////////////////////////////////////////////////////////////////*/ + + /// @dev Thrown when compound() finds nothing to compound. + error NothingToCompound(); + + /// @dev Thrown when depositPeggedToken receives zero shares. + error DepositPeggedTokenZeroShares(); + + /*////////////////////////////////////////////////////////////////////////// + EVENTS + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice Emitted when compound() successfully converts rewards to SP position. + /// @param caller The address that triggered the compound. + /// @param collateralClaimed The amount of wrapped collateral claimed from the SP. + /// @param peggedMinted The amount of pegged tokens minted from the claimed collateral. + event Compounded(address indexed caller, uint256 collateralClaimed, uint256 peggedMinted); + + /// @notice Emitted when compound() skips because fees exceed the cap. + /// @param caller The address that triggered the compound. + /// @param claimableCollateral The amount of wrapped collateral available but not claimed. + event CompoundSkipped(address indexed caller, uint256 claimableCollateral); + + /// @notice Emitted when the max fee ratio is updated. + /// @param newMaxFeeRatio The new max fee ratio (18 decimals). + event MaxFeeRatioUpdated(uint256 newMaxFeeRatio); + + /*////////////////////////////////////////////////////////////////////////// + IMMUTABLES + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice The stability pool this vault wraps. + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + address public immutable STABILITY_POOL; // solhint-disable-line immutable-vars-naming + + /// @notice The minter for this market. + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + address public immutable MINTER; // solhint-disable-line immutable-vars-naming + + /// @notice The wrapped collateral token (reward token from harvests). + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + address public immutable WRAPPED_COLLATERAL; // solhint-disable-line immutable-vars-naming + + /// @notice The pegged token - the SP's underlying asset. + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + address public immutable PEGGED_TOKEN; // solhint-disable-line immutable-vars-naming + + /*////////////////////////////////////////////////////////////////////////// + STORAGE (ERC7201) + //////////////////////////////////////////////////////////////////////////*/ + + /// @custom:storage-location erc7201:harbor.storage.AutoCompounder_v1 + // chisel eval 'keccak256(abi.encode(uint256(keccak256("harbor.storage.AutoCompounder_v1")) - 1)) & ~bytes32(uint256(0xff))' + bytes32 private constant _AUTOCOMPOUNDER_STORAGE = + 0xaf31db2275af9d19e1d0340c8cbd037595d3c8505dede7df4950b3c168f87300; + + struct AutoCompounderStorage { + /// @dev Maximum fee ratio for compound minting (18 decimals). e.g. 0.05 ether = 5%. + uint256 maxFeeRatio; + } + + function _getAutoCompounderStorage() private pure returns (AutoCompounderStorage storage $) { + // solhint-disable-next-line no-inline-assembly + assembly { + $.slot := _AUTOCOMPOUNDER_STORAGE + } + } + + /*////////////////////////////////////////////////////////////////////////// + CONSTRUCTOR / INITIALIZER + //////////////////////////////////////////////////////////////////////////*/ + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor( + address stabilityPool_, + address minter_ + ) ERC20Upgradeable() ERC4626Upgradeable() { + _disableInitializers(); + STABILITY_POOL = stabilityPool_; + MINTER = minter_; + WRAPPED_COLLATERAL = IMinter(minter_).WRAPPED_COLLATERAL_TOKEN(); + PEGGED_TOKEN = IMinter(minter_).PEGGED_TOKEN(); + assert(IStabilityPool(stabilityPool_).ASSET_TOKEN() == PEGGED_TOKEN); + } + + /// @notice Initialize the auto-compounder. + /// @param deployerOwner_ The initial owner (typically the FactoryDeployer). + /// @param pendingOwner_ The final owner (typically the Harbor multisig). + /// @param maxFeeRatio_ The initial max fee ratio for compound minting (18 decimals). + /// @param name_ The ERC20 name for the AC share token. + /// @param symbol_ The ERC20 symbol for the AC share token. + function initialize( + address deployerOwner_, + address pendingOwner_, + uint256 maxFeeRatio_, + string memory name_, + string memory symbol_ + ) external initializer { + _initializeOwner(deployerOwner_, pendingOwner_); + __UUPSUpgradeable_init(); + __ERC4626_init(IERC20(STABILITY_POOL)); + __ERC20_init(name_, symbol_); + _getAutoCompounderStorage().maxFeeRatio = maxFeeRatio_; + + // Permanent approvals for compound flow + IERC20(PEGGED_TOKEN).approve(STABILITY_POOL, type(uint256).max); + IERC20(WRAPPED_COLLATERAL).approve(MINTER, type(uint256).max); + } + + /*////////////////////////////////////////////////////////////////////////// + UUPS + //////////////////////////////////////////////////////////////////////////*/ + + function _authorizeUpgrade(address) internal override onlyOwner {} // solhint-disable-line no-empty-blocks + + /*////////////////////////////////////////////////////////////////////////// + OWNERSHIP + //////////////////////////////////////////////////////////////////////////*/ + + /// @inheritdoc IERC5313 + function owner() public view override(HarborOwnable, IERC5313) returns (address owner_) { + owner_ = HarborOwnable.owner(); + } + + /*////////////////////////////////////////////////////////////////////////// + ADMIN + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice Update the maximum fee ratio for compound minting. + /// @param maxFeeRatio_ New max fee ratio (18 decimals). e.g. 0.05 ether = 5%. + function setMaxFeeRatio(uint256 maxFeeRatio_) external onlyOwner { + _getAutoCompounderStorage().maxFeeRatio = maxFeeRatio_; + emit MaxFeeRatioUpdated(maxFeeRatio_); + } + + /// @notice The current maximum fee ratio for compound minting. + function maxFeeRatio() external view returns (uint256) { + return _getAutoCompounderStorage().maxFeeRatio; + } + + /*////////////////////////////////////////////////////////////////////////// + ERC4626 OVERRIDES + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice Total assets under management, in SP share units. + /// @dev SP.balanceOf(this) + claimable wCOLn valued in pegged token terms via Minter dry run. + function totalAssets() public view override returns (uint256) { + uint256 spPosition = IERC20(STABILITY_POOL).balanceOf(address(this)); + uint256 claimableCollateral = IMultipleRewardAccumulator(STABILITY_POOL).claimable(address(this), WRAPPED_COLLATERAL); + if (claimableCollateral == 0) { + return spPosition; + } + // Use dry run with no fee cap to get price and rate, then value the claimable collateral. + // price = underlying collateral price in peg terms (18 dec) + // rate = wrapped-to-underlying rate (18 dec) + // claimableValue = claimableCollateral * rate * price / 1e36 + (,,,, uint256 price, uint256 rate) = + IMinter_v3(MINTER).mintPeggedTokenDryRun(claimableCollateral, type(uint256).max); + uint256 claimableValue = claimableCollateral.mulDiv(rate, 1e18).mulDiv(price, 1e18); + return spPosition + claimableValue; + } + + /*////////////////////////////////////////////////////////////////////////// + COMPOUND + //////////////////////////////////////////////////////////////////////////*/ + + /// @inheritdoc IAutoCompounder + function compound() external { + uint256 claimable = IMultipleRewardAccumulator(STABILITY_POOL).claimable(address(this), WRAPPED_COLLATERAL); + if (claimable == 0) { + revert NothingToCompound(); + } + + uint256 maxFee = _getAutoCompounderStorage().maxFeeRatio; + + // Dry run to see how much can be profitably minted within the fee cap + (,, uint256 collateralTaken,,,) = IMinter_v3(MINTER).mintPeggedTokenDryRun(claimable, maxFee); + + if (collateralTaken == 0) { + // Fee too high - skip. wCOLn stays as unclaimed in SP, included in totalAssets via claimable(). + emit CompoundSkipped(msg.sender, claimable); + return; + } + + // Fractional claim: only take what can be profitably minted + IMultipleRewardAccumulator_v3(STABILITY_POOL).claim( + address(this), address(this), WRAPPED_COLLATERAL, collateralTaken + ); + + // Mint pegged tokens from the claimed collateral + (uint256 minted,) = IMinter_v3(MINTER).mintPeggedToken(collateralTaken, address(this), 0, maxFee); + + // Deposit minted pegged tokens back into the SP + IStabilityPool(STABILITY_POOL).deposit(minted, address(this), 0); + + emit Compounded(msg.sender, collateralTaken, minted); + } + + /*////////////////////////////////////////////////////////////////////////// + CONVENIENCE DEPOSITS + //////////////////////////////////////////////////////////////////////////*/ + + /// @inheritdoc IAutoCompounder + function depositPeggedToken(uint256 peggedAmount, address receiver) external returns (uint256 shares) { + // Transfer pegged tokens from caller + IERC20(PEGGED_TOKEN).safeTransferFrom(msg.sender, address(this), peggedAmount); + + // Deposit to SP - AC receives rebasing SP position + uint256 spBalanceBefore = IERC20(STABILITY_POOL).balanceOf(address(this)); + IStabilityPool(STABILITY_POOL).deposit(peggedAmount, address(this), 0); + uint256 spReceived = IERC20(STABILITY_POOL).balanceOf(address(this)) - spBalanceBefore; + + // Mint AC shares for the SP shares received + shares = previewDeposit(spReceived); + if (shares == 0) { + revert DepositPeggedTokenZeroShares(); + } + _mint(receiver, shares); + } + + /*////////////////////////////////////////////////////////////////////////// + INTERNAL OVERRIDES + //////////////////////////////////////////////////////////////////////////*/ + + /// @dev Deposit SP tokens from caller into the vault. + function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal override { + IERC20(STABILITY_POOL).safeTransferFrom(caller, address(this), assets); + _mint(receiver, shares); + emit Deposit(caller, receiver, assets, shares); + } + + /// @dev Withdraw SP tokens from the vault to receiver. + /// Uses SP.transfer (not SP.withdraw) - the user receives the rebasing SP token directly. + function _withdraw(address caller, address receiver, address tokenOwner, uint256 assets, uint256 shares) + internal + override + { + if (caller != tokenOwner) { + _spendAllowance(tokenOwner, caller, shares); + } + _burn(tokenOwner, shares); + IERC20(STABILITY_POOL).safeTransfer(receiver, assets); + emit Withdraw(caller, receiver, tokenOwner, assets, shares); + } + + /// @dev Decimals match the SP token (18). + function decimals() public pure override returns (uint8) { + return 18; + } +} diff --git a/src/interfaces/IAutoCompounder.sol b/src/interfaces/IAutoCompounder.sol new file mode 100644 index 00000000..50273a20 --- /dev/null +++ b/src/interfaces/IAutoCompounder.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.28 <0.9.0; + +/// @title IAutoCompounder +/// @notice Interface for the Level 1 Auto-Compounder (ERC4626). +/// @dev Wraps a rebasing StabilityPool into a non-rebasing StabilityPool share. +interface IAutoCompounder { + /// @notice Compound pending rewards: claim wCOLn, mint pegged tokens, redeposit to SP. + /// Only claims what can be profitably minted. Remainder stays as unclaimed in SP. + /// Permissionless - anyone can trigger. + function compound() external; + + /// @notice Deposit pegged tokens directly - deposits to SP first, then mints AC shares. + /// @param peggedAmount Amount of pegged tokens to deposit. + /// @param receiver Address to receive the AC shares. + /// @return shares Amount of AC shares minted. + function depositPeggedToken(uint256 peggedAmount, address receiver) external returns (uint256 shares); +} From ca54fb1be42d7f3ffccb1b529a539b029f295abe Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Sat, 11 Apr 2026 12:30:19 +0100 Subject: [PATCH 024/232] completed Autocompounder implementation analysed rebalance fairness removed unneeded reward aliases and their implementation --- .claude/settings.json | 17 +- CLAUDE.md | 1 + doc/ideas/autocompounding-vault-design.md | 20 +- doc/ideas/proposed-fee-mechanism.md | 134 +++ doc/ideas/rebalance-fairness.md | 583 ++++++++++ foundry.toml | 19 +- regression/coverage.txt | 37 +- regression/gas.txt | 285 ++--- regression/sizes.txt | 26 +- results/rebalance_fairness_breakeven.csv | 3 + results/rebalance_fairness_fee_scan.csv | 11 + results/rebalance_fairness_fee_scan.gp | 36 + results/rebalance_fairness_scan.csv | 26 + results/rebalance_fairness_scan.gp | 38 + results/rebalance_fairness_timeline.csv | 27 + results/rebalance_fairness_timeline.gp | 34 + script/config/ConfigTokenNames.sol | 31 + .../autocompounder/ConfigAutoCompounder.sol | 11 + .../ConfigMarket_BTC_fxUSD_mainnet.sol | 4 +- .../ConfigMarket_BTC_stETH_mainnet.sol | 4 +- .../ConfigMarket_ETH_fxUSD_mainnet.sol | 4 +- .../ConfigMarket_EUR_fxUSD_mainnet.sol | 4 +- .../ConfigMarket_EUR_stETH_mainnet.sol | 4 +- .../ConfigMarket_GOLD_fxUSD_mainnet.sol | 4 +- .../ConfigMarket_GOLD_stETH_mainnet.sol | 4 +- .../ConfigMarket_MCAP_fxUSD_mainnet.sol | 4 +- .../ConfigMarket_MCAP_stETH_mainnet.sol | 4 +- .../ConfigMarket_SILVER_fxUSD_mainnet.sol | 4 +- .../ConfigMarket_SILVER_stETH_mainnet.sol | 4 +- script/src/v3/DeployMintersShared.sol | 97 +- script/src/v3/contracts/AutoCompounder.sol | 101 ++ script/src/v3/contracts/StabilityPool.sol | 33 - .../src/v3/contracts/StabilityPoolManager.sol | 4 +- src/autocompounding/AutoCompounder_v1.sol | 171 ++- src/interfaces/IAutoCompounder.sol | 2 +- src/interfaces/IMultipleRewardDistributor.sol | 3 - src/interfaces/IRewardAlias.sol | 15 - src/interfaces/IStabilityPool_v3.sol | 10 - src/minter/StabilityPool_v3.sol | 65 +- src/reward/RewardAlias_v1.sol | 58 - ...ultipleRewardCompoundingAccumulator_v3.sol | 39 +- .../LinearMultipleRewardDistributor_v3.sol | 339 ------ test/GraphsLiquidate.t.sol | 4 +- test/StabilityPool.t.sol | 14 +- test/StabilityPoolClaimable.t.sol | 392 +------ test/StabilityPoolExtras.t.sol | 42 +- test/StabilityPoolExtras2.t.sol | 22 +- test/StabilityPoolFeatures.t.sol | 4 +- test/StabilityPoolLoss.t.sol | 84 +- test/StabilityPoolRebalance.t.sol | 140 +-- test/StabilityPoolSpec.t.sol | 49 +- test/StabilityPoolUpgradeMigration.t.sol | 164 ++- test/StabilityPool_v3_ERC20.t.sol | 370 +++++- test/deployment/AutoCompounderTest.t.sol | 517 +++++++++ test/deployment/DeployEURSetUp.t.sol | 163 +++ test/deployment/RebalanceFairness.t.sol | 591 ++++++++-- test/deployment/RebalanceFairnessScan.t.sol | 1005 +++++++++++++++++ test/deployment/RewardSystem.t.sol | 76 +- .../StabilityPoolAliasDeployment.t.sol | 280 ----- ...ultipleRewardCompoundingAccumulator_v3.sol | 57 + .../reward/accumulator/ClaimEquivalence.t.sol | 361 ++++++ 61 files changed, 4592 insertions(+), 2063 deletions(-) create mode 100644 doc/ideas/proposed-fee-mechanism.md create mode 100644 doc/ideas/rebalance-fairness.md create mode 100644 results/rebalance_fairness_breakeven.csv create mode 100644 results/rebalance_fairness_fee_scan.csv create mode 100644 results/rebalance_fairness_fee_scan.gp create mode 100644 results/rebalance_fairness_scan.csv create mode 100644 results/rebalance_fairness_scan.gp create mode 100644 results/rebalance_fairness_timeline.csv create mode 100644 results/rebalance_fairness_timeline.gp create mode 100644 script/config/autocompounder/ConfigAutoCompounder.sol create mode 100644 script/src/v3/contracts/AutoCompounder.sol delete mode 100644 src/interfaces/IRewardAlias.sol delete mode 100644 src/interfaces/IStabilityPool_v3.sol delete mode 100644 src/reward/RewardAlias_v1.sol delete mode 100644 src/reward/distributor/LinearMultipleRewardDistributor_v3.sol create mode 100644 test/deployment/AutoCompounderTest.t.sol create mode 100644 test/deployment/DeployEURSetUp.t.sol create mode 100644 test/deployment/RebalanceFairnessScan.t.sol delete mode 100644 test/deployment/StabilityPoolAliasDeployment.t.sol create mode 100644 test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v3.sol create mode 100644 test/reward/accumulator/ClaimEquivalence.t.sol diff --git a/.claude/settings.json b/.claude/settings.json index 24646386..513fccf1 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -19,7 +19,22 @@ "Bash(ls script/src/*.sol)", "Bash(ls script/src/Deploy_*)", "Read(//home/tfras/.claude/**)", - "Bash(git -C ~/.claude/plans commit -am \"fixed broken links at top of plan\")" + "Bash(git -C ~/.claude/plans commit -am \"fixed broken links at top of plan\")", + "Bash(ls -la /home/tfras/github/baofinance/harbor/script/*.s.sol)", + "Bash(ls -lt /home/tfras/github/baofinance/harbor/script/*.s.sol)", + "Bash(yarn slither:*)", + "Read(//home/tfras/github/baofinance/harbor-app/**)", + "WebFetch(domain:github.com)", + "WebFetch(domain:docs.liquity.org)", + "WebFetch(domain:www.liquity.org)", + "WebFetch(domain:raw.githubusercontent.com)", + "WebFetch(domain:liquity.gitbook.io)", + "Bash(ls /home/tfras/github/baofinance/harbor/src/minter/StabilityPoolManager*.sol)", + "Bash(ls /home/tfras/github/baofinance/harbor/script/src/v3/Deploy_*_Minter.sol)", + "Bash(ls /home/tfras/github/baofinance/harbor/results/liquidate*.csv)", + "Read(//home/tfras/github/baofinance/**)", + "Bash(gnuplot ./results/rebalance_fairness_scan.gp)", + "Bash(gnuplot ./results/rebalance_fairness_timeline.gp)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index dee55bc0..042d7a2c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,4 +25,5 @@ - In tests, never create and then remove files or directories — forge runs tests in parallel so you can create a race condition. Write test output to `./results` and leave it there. - Tests verify *what code is supposed to do*, not merely that lines execute. When asked to improve testing, think: "what is the intended behaviour?" — then construct scenarios that demonstrate the code fulfils that intent. If unsure what a function is supposed to do, ask — the specification is not in the code. Avoid writing tests that only exercise code paths to increase coverage metrics; such tests reinforce any misunderstanding in the implementation and give false confidence. - In tests, prefer `console2.log` over `emit` for debug logging — it shows in `forge test -vvv` output without cluttering the event log. Use the `Fmt` library with `string.concat` for readable formatted messages. +- Never use module-level or contract-level flags/booleans to communicate state between functions within a single call. If a function needs to behave differently based on context, pass the context explicitly via parameters or use separate functions. Hidden state makes code harder to reason about and introduces coupling that isn't visible in function signatures. Use explicit parameters or dedicated function variants instead. - Never modify a deployed contract's source file. Check `deployments/*.state.json` for deployed contracts. To add functionality: inherit from the deployed version (e.g. `Minter_v3 is Minter_v2`) if the changes are additive, or clone and modify if the inheritance chain doesn't work. The proxy upgrade mechanism allows swapping implementations, but the old source must remain unchanged for audit traceability. \ No newline at end of file diff --git a/doc/ideas/autocompounding-vault-design.md b/doc/ideas/autocompounding-vault-design.md index 67882e44..15c54167 100644 --- a/doc/ideas/autocompounding-vault-design.md +++ b/doc/ideas/autocompounding-vault-design.md @@ -131,7 +131,7 @@ sequenceDiagram Note over User,SP: Deposit haXXX (convenience) → deposits to SP first - User->>AC: depositPegged(haXXX_amount, user) + User->>AC: depositPeggedToken(haXXX_amount, user) AC->>SP: deposit(haXXX_amount, AC) Note over AC: AC's SP position grows Note over AC: hcShares = hpAmount * totalSupply / totalAssets @@ -162,10 +162,10 @@ sequenceDiagram Minter-->>AC: (fee, collUsed, pegged, ...) alt collUsed > 0 (profitable to mint) - AC->>SP: claimSingle(AC, wCOLn, collUsed) + AC->>SP: claim(AC, AC, wCOLn, collUsed) Note over SP: Fractional claim: only transfers collUsed,
leaves remainder as unclaimed SP-->>AC: wCOLn (collUsed amount only) - AC->>Minter: mintPeggedToken(wCOLn, collUsed, AC, 0, maxFeeRatio) + AC->>Minter: mintPeggedToken(collUsed, AC, 0, maxFeeRatio) Minter-->>AC: haXXX minted AC->>SP: deposit(haXXX, AC) Note over AC: SP position grows, share price up @@ -178,21 +178,21 @@ sequenceDiagram ``` totalAssets() = - SP.balanceOf(AC) // SP position (haXXX terms, rebasing) - + SP.claimable(AC, wCOLn) * oraclePrice // unclaimed wCOLn valued in haXXX + SP.balanceOf(AC) // SP position (haXXX terms, rebasing) + + SP.claimable(AC, wCOLn) * price * rate / 1e36 // unclaimed wCOLn valued in haXXX ``` -Oracle read from `IMinter(minter).priceOracle()` at runtime -- always in sync with the Minter. +Price and rate obtained from `IMinter_v3(minter).mintPeggedTokenDryRun(claimable, type(uint256).max)` -- always in sync with the Minter, no direct oracle dependency. ### Rebalance impact **Collateral SP rebalance:** haXXX burned, wCOLn received via `_accumulateReward`. wCOLn is liquid and valued in totalAssets via claimable. AC share price holds through rebalance -- lost haXXX position is offset by gained claimable wCOLn. The AC auto-compounds this back to haXXX when fees are acceptable. -**Leveraged SP rebalance:** haXXX burned, hsXXX.COLn received. hsXXX.COLn is NOT liquid. AC share price drops because leveraged tokens can't be easily converted back. The AC can only compound the harvest wCOLn; leveraged token rewards queue indefinitely until manually claimed. Included in totalAssets via `leveragedTokenPrice()`. +**Leveraged SP rebalance:** haXXX burned, hsXXX.COLn received. hsXXX.COLn is NOT liquid. The AC's totalAssets() only values wrapped collateral (harvest rewards), not leveraged token rewards. This means AC share price drops on rebalance -- the lost haXXX position is not offset because leveraged tokens are not valued. The AC can only compound the harvest wCOLn; leveraged token rewards queue in the SP until manually claimed via sweep or direct claim. ### Fractional claim -`claimSingle(account, token, maxAmount)` on SP_v3 -- claims up to maxAmount, leaves the rest as pending. Enables the AC to claim only what can be profitably minted. Remainder stays in SP reward accounting, included in `totalAssets()` via `claimable()`. +`claim(account, receiver, token, maxAmount)` on SP_v3 -- claims up to maxAmount, leaves the rest as pending. Enables the AC to claim only what can be profitably minted. Remainder stays in SP reward accounting, included in `totalAssets()` via `claimable()`. ### Fairness @@ -204,7 +204,7 @@ The AC does NOT convert wCOLn to wXXXn. It either mints haXXX from wCOLn or leav ### Deposit convenience -Core asset is hpXXX.COLn. Also accepts haXXX via `depositPegged(haXXX, amount)` which atomically deposits to SP then mints AC shares. +Core asset is hpXXX.COLn. Also accepts haXXX via `depositPeggedToken(amount, receiver)` which atomically deposits to SP then mints AC shares. Supports `type(uint256).max` for full balance. ## 5. Level 2: Peg Vault @@ -374,7 +374,7 @@ Unregistering an underlying also unregisters its aliases and cleans up the alias ### 6.8 Oracle Coupling -AC and PV read `IMinter(minter).priceOracle()` at runtime. Always in sync. No separate oracle config. +AC reads price and rate from `IMinter_v3(minter).mintPeggedTokenDryRun()` -- always in sync with the Minter, no direct oracle dependency. PV uses the same approach. No separate oracle config. ### 6.9 Equivalent Token Management diff --git a/doc/ideas/proposed-fee-mechanism.md b/doc/ideas/proposed-fee-mechanism.md new file mode 100644 index 00000000..690fb80d --- /dev/null +++ b/doc/ideas/proposed-fee-mechanism.md @@ -0,0 +1,134 @@ +# Proposed Withdrawal Fee Mechanism + +**Status: Proposal** + +## Problem + +When a rebalance is anticipated, a depositor can withdraw from the stability pool before the rebalance fires, avoid the loss, and re-deposit afterwards with a larger share of the now-smaller pool. This "dodge" gives the returner a disproportionate harvest share at the expense of those who stayed. + +The existing withdrawal window mechanism (request → wait → withdraw fee-free) is clumsy: it adds UX friction for legitimate withdrawals and doesn't scale the penalty with systemic risk. A CR-dependent fee addresses both. + +## Proposed Formula + +Use the **negated `redeemPeggedTokenIncentiveRatio()`** from the Minter as the withdrawal fee: + +``` +fee = max(0, -redeemPeggedTokenIncentiveRatio()) +``` + +The `redeemPeggedTokenIncentiveRatio()` is an on-chain view function that returns the current minter fee/discount for redeeming pegged tokens, which varies with collateral ratio. At low CR, the minter offers a *discount* on redemption (negative ratio) to encourage shrinking the pegged supply. Negating this discount produces a *fee* on SP withdrawals that rises as CR falls. + +### Fee Schedule (ETH::fxUSD market, 130% rebalance threshold) + +| CR range | Minter redeemPegged ratio | SP withdrawal fee | +|----------|--------------------------|-------------------| +| < 1.00 | -1.00% (discount) | **1.00%** | +| 1.00 – 1.10 | -0.75% (discount) | **0.75%** | +| 1.10 – 1.29 | -0.30% (discount) | **0.30%** | +| 1.29 – 1.40 | 0% (neutral) | **0%** | +| > 1.40 | +0.25% to +0.50% (fee) | **0%** | + +Key properties: +- **Zero fee at healthy CR** (above 1.29): no friction for normal withdrawals +- **Highest fee at lowest CR** (1.00%): maximum deterrence when the system most needs deposits +- **Never blocks withdrawal**: the fee caps at 1.00%, depositors can always exit +- **No new parameters**: reads the existing minter config, which is already tuned per market +- **Scales automatically** with market volatility settings (different thresholds use different configs) + +### Conceptual Justification + +At low CR, two things are simultaneously true: +1. The minter *discounts* pegged redemption — it wants the pegged supply to shrink (the system is stressed) +2. The stability pool *needs* deposits — withdrawals weaken the rebalance buffer + +The minter's redemption discount is a signal of how stressed the system is. Negating it as a withdrawal fee means: "the cost of leaving the SP during stress equals the discount the system offers for Minter-level redemption". Both are the same CR stress signal, applied in opposite directions. + +## Quantitative Support + +Analysis from `test/deployment/RebalanceFairnessScan.t.sol` using the design case (10% price drop, 25% leveraged fraction, 37.5% liquidation, 10% APR): + +### Break-even fees (with weekly auto-compounding) + +The minimum fee that makes the dodge unprofitable over a 12-week horizon with weekly compounding: + +| Pool | Break-even fee | Proposed fee at CR=1.20 | +|------|---------------|------------------------| +| Coll SP | 0.17% (17 bp) | 0.30% | +| Lev SP | 0.60% (60 bp) | 0.30% | + +The proposed 0.30% fee at the design-case CR (1.20, in the 1.10–1.29 band) exceeds both Coll SP and Lev SP break-evens. The Lev SP break-even is higher because compounding leveraged tokens back to pegged is less efficient — but 0.30% still covers it with margin. + +### Why the break-even is so small + +The dodge advantage disappears quickly with compounding. Over 12 weeks with weekly auto-compounding: + +- **Coll SP**: Alice (stayer) starts at 62.5 haXXX deposit + 166,667 wCOL rebalance reward. Each week she compounds (claim wCOL → freeMint haXXX → re-deposit). By week 12, her haXXX-equivalent is 103.22 vs Bob's 103.23 — a gap of 0.01 haXXX (0.01%). +- **Lev SP**: Charlie (stayer) starts at 62.5 haXXX deposit + 62.5 hsXXX rebalance reward. He compounds via redeem hsXXX → wCOL → freeMint haXXX → re-deposit. By week 12: 103.15 vs Dave's 103.23 — a gap of 0.08 haXXX (0.08%). + +The total dodge profit over 12 weeks is ~0.17 haXXX for Coll SP and ~0.60 haXXX for Lev SP (out of 100 haXXX starting position). A 0.30% fee (0.30 haXXX from Bob's 100 haXXX) wipes out this advantage. + +### Without compounding (steady-state gap) + +If there were no compounding, the income gap would persist indefinitely: + +| Pool | Steady-state income gap (no fee) | With 0.30% fee | +|------|----------------------------------|----------------| +| Coll SP | 8.65% | ~6.5% (reduced but not eliminated) | +| Lev SP | 37.50% | ~36.2% (barely affected) | + +The fee alone doesn't eliminate the steady-state gap — compounding is essential. The fee's role is to cover the transient cost during the first few weeks before compounding catches up. + +## Implementation: Auto-Compounder + +The fee lives on the **auto-compounder (AC) contract**, not the stability pool. Rationale: + +### Why the AC, not the SP + +1. **The fee mechanism assumes auto-compounding.** The 0.30% fee is calibrated for a world where the AC compounds weekly. Without compounding, the fee would need to be much larger (closer to 10%) or supplemented with an effective-share mechanism. Placing the fee on the AC makes the dependency explicit. + +2. **The AC already has `EXEMPT_WITHDRAWAL_FEE_ROLE`** on the SP. Users who deposit via the AC (the recommended path) have their withdrawals routed through the AC contract, which can apply the CR-dependent fee. Users who deposit directly into the SP use the existing withdrawal window mechanism. + +3. **The SP's existing fee mechanism remains as a fallback.** Direct SP depositors still face the fixed early-withdrawal fee and the request/wait window. The AC fee is a better-calibrated alternative for the AC path. + +### AC withdrawal flow with CR-dependent fee + +Current: user calls `AC.withdraw(shares)` → AC calls `SP.withdraw(assets)` (exempt from SP fee) → AC sends pegged to user. + +Proposed: user calls `AC.withdraw(shares)` → AC reads `IMinter(minter).redeemPeggedTokenIncentiveRatio()` → computes `fee = max(0, -ratio)` → AC calls `SP.withdraw(assets)` → AC sends `assets × (1 - fee)` to user, retains `assets × fee`. + +The retained fee stays in the AC (increasing the exchange rate for remaining depositors) or is sent to the treasury. Sending it to remaining depositors is fairer — it partially compensates stayers. + +### Future: Moving the fee to the SP + +If the fee proves effective, a future upgrade could move it into the SP directly, replacing the withdrawal window entirely. This would: +- Apply the fee to all withdrawals (not just AC-routed ones) +- Remove the request/wait UX friction +- Enable clean ERC4626 integration (atomic withdraw with fee) + +This is a larger change (SP contract upgrade) and should be considered separately. The AC-based fee can be deployed immediately without modifying deployed contracts. + +## Fee Destination + +Three options for the fee proceeds: + +1. **Remaining AC depositors** (recommended): fee stays in the AC vault, increasing the share price. Stayers are directly compensated for the transient harvest disadvantage. This is the simplest and most aligned with the fairness goal. + +2. **Treasury**: fee is sent to the protocol treasury. Doesn't help stayers directly but funds protocol operations. + +3. **Burned**: fee is removed from circulation. Reduces haXXX supply, benefiting all holders equally. Doesn't specifically help stayers. + +Option 1 is recommended because the fee is designed to compensate for the *specific* disadvantage that stayers face. Sending it to stayers closes the loop. + +## Summary + +| Aspect | Detail | +|--------|--------| +| **Formula** | `fee = max(0, -redeemPeggedTokenIncentiveRatio())` | +| **Range** | 0% (healthy CR) to 1.0% (depegged) | +| **At design case (CR=1.20)** | 0.30% | +| **Break-even (Coll SP, 12wk)** | 0.17% — covered | +| **Break-even (Lev SP, 12wk)** | 0.60% — marginal, relies on compounding | +| **Where** | Auto-compounder contract | +| **Parameters** | None new — reads existing minter config | +| **Blocks withdrawal?** | Never | +| **Requires compounding?** | Yes — the fee is calibrated assuming weekly auto-compounding | diff --git a/doc/ideas/rebalance-fairness.md b/doc/ideas/rebalance-fairness.md new file mode 100644 index 00000000..688f4eb2 --- /dev/null +++ b/doc/ideas/rebalance-fairness.md @@ -0,0 +1,583 @@ +# Stability Pool Rebalance Fairness + +**Status: Under discussion** + +## 1. The Problem + +A user who anticipates a rebalance can profit by withdrawing pegged tokens beforehand and re-depositing afterwards. This works whether they frontrun a mempool transaction or simply monitor the collateral ratio. The attacker dodges the rebalance loss and re-enters with a larger share of the now-smaller pool, capturing more future harvest rewards. + +The system should be "fire and forget" -- fairness enforced on-chain, no manual intervention, no dependence on private mempools. + +Two complementary responses: +- **Penalise the withdrawer**: CR-based dynamic fees make withdrawing during stress expensive +- **Reward the stayer**: effective share boost ensures stayers earn fair harvest despite their reduced balance + +Both must be fair and balanced -- no governance parameters that shift the problem elsewhere. + +### How Harvests Work + +Harvests come from the yield on wrapped collateral (e.g., fxSAVE) held **by the Minter**. As fxSAVE appreciates, the Minter accumulates excess wrapped collateral above what's needed to back the underlying collateral. This excess is the harvestable amount. + +The StabilityPoolManager distributes harvested fxSAVE to the two stability pools **proportional to their current pegged token balances**. Within each pool, harvest rewards are distributed to depositors proportional to their pegged token holdings. + +Harvest income has three components -- yield on collateral backing: +1. Leveraged tokens (stays in Minter after collateral SP rebalance) +2. Pegged tokens deposited in THIS stability pool (removed from Minter on collateral SP rebalance) +3. Pegged tokens NOT in this stability pool (stays in Minter) + +All three contribute to the harvest for both pools, proportional to pool size. + +### What Happens During Rebalance + +**Collateral SP rebalance**: pegged tokens are redeemed for wrapped collateral. The wrapped collateral is **removed from the Minter** and transferred to the collateral SP as a reward. This: +- Reduces the Minter's collateral holdings, reducing future harvests for everyone (component 2 is lost from the shared harvest pool) +- But the transferred wCOL is itself interest-bearing (e.g., fxSAVE appreciates independently). This private yield accrues to the depositors who received it. + +**Leveraged SP rebalance**: pegged tokens are redeemed and the collateral is used to mint leveraged tokens. The collateral backing those redeemed pegged tokens is consumed in the process -- it leaves the Minter to become leveraged token collateral. This **does** reduce the Minter's total collateral and therefore reduces future harvest generation, just as collateral SP rebalances do. However, the leveraged tokens received by stayers are NOT interest-bearing in the same way as wCOL -- they don't generate private yield. + +### Worked Example + +All figures from `test/deployment/RebalanceFairness.t.sol`, deployed via production scripts (ETH::fxUSD market). The pegged token is **haETH** (Harbor anchored ETH — 1 haETH represents 1 ETH worth of value). The collateral side is **fxSAVE** (a yield-bearing wrapper of fxUSD). The test uses a **0.1% rate bump per week** (~5.2% APY) applied for 2 consecutive weeks. All numbers below are verified by test asserts. + +#### Setup + +- Oracle **price** = 1/4000 (units: ETH per fxUSD; ≈ ETH at $4000) +- Oracle **rate** = 1.0 initially (units: fxUSD per fxSAVE; 1 fxSAVE = 1 fxUSD before yield accrues) +- Eve mints 600 haETH (from 2,400,000 fxSAVE) + 200 leveraged tokens (from 800,000 fxSAVE) +- Minter holds **3,200,000 fxSAVE** of collateral, CR = `3.2M × (1/4000) / 600 = 1.333` +- Eve distributes 100 haETH to each of the 6 SP actors and keeps the 200 leveraged tokens herself +- Oracle price multiplied by 0.9 (1/4000 → 0.9/4000): CR drops to `3.2M × (0.9/4000) / 600 = `**`1.20`** (below 1.30 threshold). Equivalently, **ETH appreciated** ~11% relative to fxUSD +- Bounty/cut = 0 for clarity +- Harvest model: rate × 1.001 per week, applied for 2 weeks + +**Component proportions (Minter's 3,200,000 fxSAVE = $3.2M):** + +The Minter's collateral backs three components, all generating harvest yield: +1. **Leveraged tokens**: 800,000 fxSAVE (25%) — backs 200 leveraged tokens held by Eve +2. **Pegged tokens deposited in SPs**: 1,600,000 fxSAVE (50%) — backs 200 haETH in coll SP + 200 in lev SP +3. **Pegged tokens NOT in SPs**: 800,000 fxSAVE (25%) — backs 200 haETH held outside (Fred + George) + +A real production split is closer to 50% leveraged / 40% in SPs / 10% outside. The fairness analysis below is the same regardless — only the absolute numbers change. + +**Cast:** + +| Actor | Initial position | Behaviour | +|-------|-----------------|-----------| +| Alice | 100 haETH in Collateral SP | Stays through rebalance | +| Bob | 100 haETH in Collateral SP | Withdraws before, re-deposits after | +| Charlie | 100 haETH in Leveraged SP | Stays through rebalance | +| Dave | 100 haETH in Leveraged SP | Withdraws before, re-deposits after | +| Fred | 100 haETH outside SPs | Deposits into Collateral SP after rebalance | +| George | 100 haETH outside SPs | Deposits into Leveraged SP after rebalance | +| Eve | 200 leveraged tokens (no haETH) | Holds — provides leveraged-side liquidity, takes no actions | + +#### Token-to-dollar valuation + +- **haETH → $**: `haETH / oraclePrice`. With price = 1/4000 → 1 haETH = $4,000. After the price drop (×0.9) → 1 haETH = $4,444.44 (ETH appreciated ~11%). +- **fxSAVE → $**: `fxSAVE × oracleRate`. Rate starts at 1.0 → 1 fxSAVE = $1.00. After week 1 → $1.001. After week 2 → $1.002001. +- **Leveraged tokens → $**: `lev × leveragedTokenPrice() / oraclePrice`. `leveragedTokenPrice()` is in haETH-equivalent units (NAV per lev token); dividing by price converts to $. The rebalance is designed to preserve NAV across the event, so 1 lev token has the same $ value before and after. +- **Eve's Total $** moves only with the price (her lev tokens' NAV in haETH stays at 0.6 after the drop, which translates to a different $ value via the new price). + +Each cell below shows the **token amount** followed by the **$ value** in parentheses; an em-dash (—) means "no value of this type". The Total $ column is the **bold** $ figure summing wallet + deposit + claimable across all token types. The `Wallet` and `Deposit` columns split each actor's pegged/leveraged holdings by location, so the Bob/Dave dodge in Scenario B is visible as haETH moving from `Deposit` back to `Wallet`. + +#### Initial state (shared by both scenarios) + +Alice/Bob/Charlie/Dave have already deposited 100 haETH each into their respective SPs. Fred/George hold their 100 haETH in wallet. Eve holds her 200 leveraged tokens in wallet. + +**Stage 0 — After initial deposit (CR=1.333, price=1/4000, rate=1.000):** + +| Actor | Wallet | Deposit | Rebalance | Harvest | Total $ | +|---------|------------------------|------------------------|-----------|---------|---------------| +| Alice | — | 100 haETH ($400,000) | — | — | **$400,000** | +| Bob | — | 100 haETH ($400,000) | — | — | **$400,000** | +| Charlie | — | 100 haETH ($400,000) | — | — | **$400,000** | +| Dave | — | 100 haETH ($400,000) | — | — | **$400,000** | +| Fred | 100 haETH ($400,000) | — | — | — | **$400,000** | +| George | 100 haETH ($400,000) | — | — | — | **$400,000** | +| Eve | 200 lev ($800,000) | — | — | — | **$800,000** | + +**Stage 1 — After price drop (CR=1.20, price=0.9/4000, rate=1.000):** + +Oracle price multiplied by 0.9. ETH appreciates ~11% in fxUSD terms, so each haETH is now worth $4,444.44. The same Minter collateral can no longer fully back the haETH obligations → CR falls to 1.20, below the 1.30 rebalance threshold. **No actor has done anything** — the only change is the price. + +| Actor | Wallet | Deposit | Rebalance | Harvest | Total $ | +|---------|------------------------|------------------------|-----------|---------|---------------| +| Alice | — | 100 haETH ($444,444) | — | — | **$444,444** | +| Bob | — | 100 haETH ($444,444) | — | — | **$444,444** | +| Charlie | — | 100 haETH ($444,444) | — | — | **$444,444** | +| Dave | — | 100 haETH ($444,444) | — | — | **$444,444** | +| Fred | 100 haETH ($444,444) | — | — | — | **$444,444** | +| George | 100 haETH ($444,444) | — | — | — | **$444,444** | +| Eve | 200 lev ($533,333) | — | — | — | **$533,333** | + +> **Eve lost $266,667 from this price move alone.** Her leveraged tokens are effectively long-fxUSD / short-haETH; when ETH appreciates, their NAV in haETH terms drops from 1.0 to 0.6. The 6 SP actors each gained $44,444 from the same move. The total system value (3.2M fxSAVE = $3.2M) is conserved. + +#### Scenario A: Everyone Stays (Baseline) + +The rebalance fires from the Stage 1 state. No actor takes evasive action. + +- Total liquidated: 75 haETH (37.5 from each pool, 18.75 per actor) +- Coll SP: each depositor receives **83,333.33 fxSAVE** (= 18.75 / price) rebalance reward +- Lev SP: each depositor receives **31.25 leveraged tokens** rebalance reward +- Minter fxSAVE: 3,200,000 − 166,666.67 = **3,033,333.33** (only the Coll SP rebalance removes collateral) + +**Per-week harvest** (test-verified): +- Week 1 total: **3,030.30 fxSAVE** (= 0.1% × 3,033,333.33) +- Week 2 total: **3,027.28 fxSAVE** (slightly lower because the Minter's fxSAVE was reduced by week 1 harvest) +- Each pool gets 50% of the harvest (162.5 / 325) +- Each SP depositor gets ~**757.58 fxSAVE per week** (1,515.15 / 2) + +**Stage 2 — After rebalance (CR=1.30, price=0.9/4000, rate=1.000):** + +The exchange is at the fair rate, so **no actor's $ value changes** through the rebalance itself. + +| Actor | Wallet | Deposit | Rebalance | Harvest | Total $ | +|---------|------------------------|--------------------------|---------------------------------|---------|---------------| +| Alice | — | 81.25 haETH ($361,111) | 83,333.33 fxSAVE ($83,333) | — | **$444,444** | +| Bob | — | 81.25 haETH ($361,111) | 83,333.33 fxSAVE ($83,333) | — | **$444,444** | +| Charlie | — | 81.25 haETH ($361,111) | 31.25 lev ($83,333) | — | **$444,444** | +| Dave | — | 81.25 haETH ($361,111) | 31.25 lev ($83,333) | — | **$444,444** | +| Fred | 100 haETH ($444,444) | — | — | — | **$444,444** | +| George | 100 haETH ($444,444) | — | — | — | **$444,444** | +| Eve | 200 lev ($533,333) | — | — | — | **$533,333** | + +**Stage 3 — After week 1 harvest (rate = 1.001):** + +Total harvest = 3,030.30 fxSAVE, split 50/50 between pools, then split equally within each pool. Each SP depositor accrues ~757.58 fxSAVE. + +| Actor | Wallet | Deposit | Rebalance | Harvest | Total $ | +|---------|------------------------|--------------------------|---------------------------------|--------------------------|---------------| +| Alice | — | 81.25 haETH ($361,111) | 83,333.33 fxSAVE ($83,416.67) | 757.58 fxSAVE ($758.33) | **$445,286** | +| Bob | — | 81.25 haETH ($361,111) | 83,333.33 fxSAVE ($83,416.67) | 757.58 fxSAVE ($758.33) | **$445,286** | +| Charlie | — | 81.25 haETH ($361,111) | 31.25 lev ($83,333) | 757.58 fxSAVE ($758.33) | **$445,203** | +| Dave | — | 81.25 haETH ($361,111) | 31.25 lev ($83,333) | 757.58 fxSAVE ($758.33) | **$445,203** | +| Fred | 100 haETH ($444,444) | — | — | — | **$444,444** | +| George | 100 haETH ($444,444) | — | — | — | **$444,444** | +| Eve | 200 lev ($533,333) | — | — | — | **$533,333** | + +**Stage 4 — After week 2 harvest (rate = 1.002001):** + +Cumulative harvest (weeks 1 + 2): 1,514.39 fxSAVE per SP depositor. + +| Actor | Wallet | Deposit | Rebalance | Harvest | Total $ | +|---------|------------------------|--------------------------|---------------------------------|----------------------------|---------------| +| Alice | — | 81.25 haETH ($361,111) | 83,333.33 fxSAVE ($83,500.00) | 1,514.39 fxSAVE ($1,517.42)| **$446,128** | +| Bob | — | 81.25 haETH ($361,111) | 83,333.33 fxSAVE ($83,500.00) | 1,514.39 fxSAVE ($1,517.42)| **$446,128** | +| Charlie | — | 81.25 haETH ($361,111) | 31.25 lev ($83,333) | 1,514.39 fxSAVE ($1,517.42)| **$445,962** | +| Dave | — | 81.25 haETH ($361,111) | 31.25 lev ($83,333) | 1,514.39 fxSAVE ($1,517.42)| **$445,962** | +| Fred | 100 haETH ($444,444) | — | — | — | **$444,444** | +| George | 100 haETH ($444,444) | — | — | — | **$444,444** | +| Eve | 200 lev ($533,333) | — | — | — | **$533,333** | + +**Net income over the rebalance + 2 weeks ($, vs Stage 0 baseline):** + +| Actor | Stage 0 | Stage 4 | Net change | Notes | +|---------|-------------|-------------|-------------|-------| +| Alice | $400,000 | $446,128 | **+$46,128**| Price gain $44,444 + harvest $1,517 + wCOL appreciation $167 | +| Bob | $400,000 | $446,128 | **+$46,128**| (same as Alice) | +| Charlie | $400,000 | $445,962 | **+$45,962**| Price gain $44,444 + harvest $1,517 (no wCOL appreciation — lev tokens) | +| Dave | $400,000 | $445,962 | **+$45,962**| (same as Charlie) | +| Fred | $400,000 | $444,444 | **+$44,444**| Price gain only — not earning harvest | +| George | $400,000 | $444,444 | **+$44,444**| (same as Fred) | +| Eve | $800,000 | $533,333 | **−$266,667**| Lev tokens lose value when ETH appreciates | + +In Scenario A (everyone stays), Alice/Bob earn ~$167 more than Charlie/Dave over 2 weeks — this is the **wCOL appreciation** on Alice/Bob's 83,333 fxSAVE rebalance reward (83,333 × 0.002 ≈ $167). Charlie/Dave's lev token reward doesn't appreciate the same way. Within each pool the harvest is fair (equal share for equal balance). + +#### Scenario B: Bob and Dave Withdraw Before Rebalance + +Starting from the Stage 1 state, Bob and Dave anticipate the rebalance and withdraw their haETH from the SPs. Alice and Charlie absorb the full rebalance loss alone. Then Bob, Dave, Fred, and George (re)deposit into the SPs. + +- Alice: 100 → 62.5 haETH in Coll SP, receives **166,666.67 fxSAVE** rebalance reward +- Charlie: 100 → 62.5 haETH in Lev SP, receives **62.5 leveraged tokens** rebalance reward +- Bob/Dave/Fred/George each deposit 100 haETH into their respective SP after the rebalance +- Minter fxSAVE: 3,200,000 − 166,666.67 = **3,033,333.33** (only Coll SP rebalance removes collateral) +- Coll SP total after re-deposits: Alice 62.5 + Bob 100 + Fred 100 = **262.5** +- Lev SP total: Charlie 62.5 + Dave 100 + George 100 = **262.5** + +**Per-week harvest** (test-verified): +- Week 1 total: **3,030.30 fxSAVE** (same as Sc A — same Minter collateral after rebalance) +- Week 2 total: **3,027.28 fxSAVE** +- Each pool gets 50% (= 1,515.15 fxSAVE) +- Distributed within pools by individual balance: Alice 62.5/262.5 × 1515.15 ≈ **360.75 fxSAVE/wk**; Bob/Fred 100/262.5 × 1515.15 ≈ **577.20 fxSAVE/wk** + +**Stage 2 — After Bob/Dave withdraw (CR=1.20, rate=1.000):** + +Bob/Dave withdraw. Their haETH moves from `Deposit` back to `Wallet` — visible in the table — but their $ value is unchanged. + +| Actor | Wallet | Deposit | Rebalance | Harvest | Total $ | +|---------|------------------------|--------------------------|-----------|---------|---------------| +| Alice | — | 100 haETH ($444,444) | — | — | **$444,444** | +| Bob | 100 haETH ($444,444) | — | — | — | **$444,444** | +| Charlie | — | 100 haETH ($444,444) | — | — | **$444,444** | +| Dave | 100 haETH ($444,444) | — | — | — | **$444,444** | +| Fred | 100 haETH ($444,444) | — | — | — | **$444,444** | +| George | 100 haETH ($444,444) | — | — | — | **$444,444** | +| Eve | 200 lev ($533,333) | — | — | — | **$533,333** | + +**Stage 3 — After rebalance + re-deposits (CR=1.30, rate=1.000):** + +Rebalance liquidates 75 haETH (37.5 from each SP). Alice/Charlie absorb the full loss alone (they were the only depositors at rebal time). Then Bob/Dave/Fred/George (re)deposit into their respective SPs. + +| Actor | Wallet | Deposit | Rebalance | Harvest | Total $ | +|---------|------------------------|---------------------------|------------------------------------|---------|---------------| +| Alice | — | 62.50 haETH ($277,778) | 166,666.67 fxSAVE ($166,666.67) | — | **$444,444** | +| Bob | — | 100 haETH ($444,444) | — | — | **$444,444** | +| Charlie | — | 62.50 haETH ($277,778) | 62.50 lev ($166,666.67) | — | **$444,444** | +| Dave | — | 100 haETH ($444,444) | — | — | **$444,444** | +| Fred | — | 100 haETH ($444,444) | — | — | **$444,444** | +| George | — | 100 haETH ($444,444) | — | — | **$444,444** | +| Eve | 200 lev ($533,333) | — | — | — | **$533,333** | + +Note: **all SP actors have the same $444,444 at this point** — Bob's "dodge" gained him *nothing* in the rebalance itself. The rebalance is a fair token swap. Bob's advantage is purely about *future* harvest income (his 100 haETH > Alice's 62.5 haETH gives him a larger pool share). + +**Stage 4 — After week 1 harvest (rate = 1.001):** + +Harvest distributed proportionally: Alice 62.5/262.5 × 1515.15 ≈ 360.75 fxSAVE; Bob/Fred 100/262.5 × 1515.15 ≈ 577.20 fxSAVE. + +| Actor | Wallet | Deposit | Rebalance | Harvest | Total $ | +|---------|------------------------|---------------------------|------------------------------------|--------------------------|---------------| +| Alice | — | 62.50 haETH ($277,778) | 166,666.67 fxSAVE ($166,833.33) | 360.75 fxSAVE ($361.11) | **$444,972** | +| Bob | — | 100 haETH ($444,444) | — | 577.20 fxSAVE ($577.78) | **$445,022** | +| Charlie | — | 62.50 haETH ($277,778) | 62.50 lev ($166,666.67) | 360.75 fxSAVE ($361.11) | **$444,806** | +| Dave | — | 100 haETH ($444,444) | — | 577.20 fxSAVE ($577.78) | **$445,022** | +| Fred | — | 100 haETH ($444,444) | — | 577.20 fxSAVE ($577.78) | **$445,022** | +| George | — | 100 haETH ($444,444) | — | 577.20 fxSAVE ($577.78) | **$445,022** | +| Eve | 200 lev ($533,333) | — | — | — | **$533,333** | + +**Stage 5 — After week 2 harvest (rate = 1.002001):** + +Cumulative harvest: Alice/Charlie 721.14 fxSAVE; Bob/Dave/Fred/George 1,153.82 fxSAVE. + +| Actor | Wallet | Deposit | Rebalance | Harvest | Total $ | +|---------|------------------------|---------------------------|------------------------------------|----------------------------|---------------| +| Alice | — | 62.50 haETH ($277,778) | 166,666.67 fxSAVE ($167,000.00) | 721.14 fxSAVE ($722.59) | **$445,500** | +| Bob | — | 100 haETH ($444,444) | — | 1,153.82 fxSAVE ($1,156.13)| **$445,600** | +| Charlie | — | 62.50 haETH ($277,778) | 62.50 lev ($166,666.67) | 721.14 fxSAVE ($722.59) | **$445,167** | +| Dave | — | 100 haETH ($444,444) | — | 1,153.82 fxSAVE ($1,156.13)| **$445,600** | +| Fred | — | 100 haETH ($444,444) | — | 1,153.82 fxSAVE ($1,156.13)| **$445,600** | +| George | — | 100 haETH ($444,444) | — | 1,153.82 fxSAVE ($1,156.13)| **$445,600** | +| Eve | 200 lev ($533,333) | — | — | — | **$533,333** | + +**Net income over the rebalance + 2 weeks ($, vs Stage 0 baseline):** + +| Actor | Stage 0 | Stage 5 | Net change | Per-week ongoing income (Stage 3 → 5)/2 | +|---------|-------------|-------------|--------------|-----------------------------------------| +| Alice | $400,000 | $445,500 | **+$45,500** | $528 (= ($445,500 − $444,444)/2) | +| Bob | $400,000 | $445,600 | **+$45,600** | $578 | +| Fred | $400,000 | $445,600 | **+$45,600** | $578 | +| Charlie | $400,000 | $445,167 | **+$45,167** | $361 | +| Dave | $400,000 | $445,600 | **+$45,600** | $578 | +| George | $400,000 | $445,600 | **+$45,600** | $578 | +| Eve | $800,000 | $533,333 | **−$266,667**| 0 | + +The price-appreciation gain (~$44,444 per haETH-holder) dominates the absolute net change. The **fairness gap** is in the per-week ongoing income column: +- Alice (Coll SP stayer): **$528/week** vs Bob (returner): **$578/week** → Alice loses ~$50/week, an **8.6%** gap +- Charlie (Lev SP stayer): **$361/week** vs Dave (returner): **$578/week** → Charlie loses ~$217/week, a **37.5%** gap + +**Two views of the harvest unfairness:** + +| Metric | Alice (Coll stayer) | Bob (Coll returner) | Alice's gap | +|--------|---------------------|---------------------|-------------| +| 2-week harvest in fxSAVE | 721.14 | 1,153.82 | **−37.5%** | +| Per-week harvest in fxSAVE | 360.75 | 577.20 | **−37.5%** | +| Per-week ongoing income in $ (incl wCOL appreciation) | $528 | $578 | **−8.6%** | + +The fxSAVE-only view shows a 37.5% disadvantage for Alice. The dollar view — which includes the wCOL rate appreciation on Alice's unclaimed 166,666.67 fxSAVE — shows only an 8.6% disadvantage. **The wCOL appreciation closes most of the gap when measured in dollars.** + +**Why the gap is smaller in dollars:** + +Alice has 166,666.67 fxSAVE sitting unclaimed in the SP. As the rate goes up 0.1% per week, the dollar value of those 166,666.67 fxSAVE grows by ~$166.67 per week — essentially private yield captured by holding the wCOL. Bob has no such holding. + +Alice's per-week ongoing income breakdown: +- Harvest: 360.75 fxSAVE × 1.002 ≈ $361.47 +- wCOL appreciation: 166,666.67 × 0.001 ≈ $166.67 +- Total: ~$528 per week + +Bob's per-week: +- Harvest: 577.20 fxSAVE × 1.002 ≈ $578 +- No wCOL holding, no appreciation +- Total: ~$578 per week + +**Charlie is still the worst off**: leveraged tokens are NOT interest-bearing like wCOL, so Charlie has no appreciation income to offset his harvest disadvantage. In both fxSAVE and dollar terms, Charlie earns 37.5% less per week than Dave ($361 vs $578 per week). The leveraged SP unfairness is much more severe. + +**The fairness conclusion:** + +In dollar terms, the collateral SP unfairness is much smaller than the raw fxSAVE numbers suggest — but it still exists, and it's much worse for leveraged SP stayers (Charlie) than collateral SP stayers (Alice). The mechanism we choose needs to address both: + +1. **Coll SP stayers** (Alice): mostly self-correct via wCOL appreciation. Only need a small boost (~8% gap). +2. **Lev SP stayers** (Charlie): get nothing from appreciation. Need a much larger boost OR a different mechanism (~37.5% gap). + +--- + +## 2. The Three Components of Harvest Income After Rebalance + +Understanding why the unfairness exists and why naive corrections over-compensate: + +### Alice's income streams after rebalance + +**Stream 1: Harvest** from Minter's remaining collateral (3,033,333 fxSAVE after rebalance): +- Alice's share: 62.5 / totalDeposits × yieldOn(3,033,333) +- Reduced because (a) her balance is smaller, (b) total Minter collateral is smaller + +**Stream 2: Private wCOL yield** on the 166,666.67 fxSAVE she received as rebalance compensation: +- fxSAVE appreciates independently (it's interest-bearing) +- Alice gets 100% of this yield — not shared with anyone +- This replaces what used to be component (2) of the shared harvest + +### Before rebalance (what Alice would have earned) + +Alice would get `100 / 400 × yieldOn(3,200,000)` = her share of all three components. + +### After rebalance (what Alice actually earns) + +- **Harvest**: `62.5 / totalDeposits × yieldOn(3,033,333)` — reduced share of reduced pie +- **Private yield**: `yieldOn(166,666.67)` — exclusive, not shared + +The private yield partially compensates for the lost harvest. **A naive boost that restores Alice to her original harvest share would over-compensate** — she'd get boosted harvest (as if 100 deposit) PLUS private yield (from 166,666.67 wCOL). Her total income would exceed the no-rebalance baseline. + +### Charlie's situation is worse + +Charlie received 62.5 leveraged tokens (not wCOL). These are NOT interest-bearing in the same way. The collateral backing them stays with the Minter, generating harvest for everyone. Charlie cannot convert them to pegged tokens easily. His only income stream is the reduced harvest. + +--- + +## 3. Mathematical Proof: BOLD B-Sum Does Not Help + +The doc previously proposed a BOLD-inspired second integral ("B sum") for harvest fairness: +``` +B[scale] += P * harvestAmount / totalDeposits +harvestGain = initialDeposit * (B_current - B_snapshot) / P_snapshot +``` + +**This produces identical results to the existing integral.** Here's why: + +The current accumulator stores: `integral += reward × P_magnitude × PRECISION / totalShare` + +User gain: `gain = storedBalance × integralDelta / (storedProduct_magnitude × PRECISION)` + +Where `storedBalance` is the deposit amount at snapshot time and `storedProduct` is P at snapshot time. + +For a post-loss depositor: `storedProduct = P_current`, so: +``` +gain = deposit × (reward × P_current / totalShare) / P_current = deposit × reward / totalShare +``` + +For a pre-loss stayer: `storedProduct = P_before_loss > P_current`, so: +``` +gain = deposit × (reward × P_current / totalShare) / P_before_loss + = deposit × reward × (P_current / P_before_loss) / totalShare +``` + +The ratio `P_current / P_before_loss < 1` reduces the stayer's gain. A B-sum with the same `totalShare` denominator produces exactly this same ratio. **The P factors in numerator and denominator don't cancel for the stayer -- that's the unfairness, and B-sum doesn't change the denominator.** + +To fix the distribution, you must change what `totalShare` means -- which is the effective-share approach. + +--- + +## 4. Rational Actor Strategy Guide + +### Before an anticipated rebalance + +**If you hold pegged tokens in the SP:** + +| Strategy | Outcome | Risk | +|----------|---------|------| +| **Withdraw** | Dodge the loss. Re-deposit after for full harvest share. | Pay withdrawal fee (if CR-based fees active). May miss re-entry if fees persist. | +| **Stay** | Absorb proportional loss. Receive wCOL (collateral SP) or leveraged tokens (leveraged SP) as compensation. | Reduced harvest share going forward. But wCOL compensation appreciates independently (collateral SP only). | +| **Do nothing, let AC compound** | AC claims wCOL, mints pegged, redeposits -- restoring your effective balance. | Subject to mint fees (CR-dependent). AC compounds for all depositors equally. | + +**Current incentive (no protection):** Withdraw is strictly dominant. Zero cost, avoids loss, re-enter at full harvest rate. This is the problem. + +**With CR-based fees:** Withdrawal costs 25-50% during stress. Staying and absorbing the loss (with wCOL compensation) may be cheaper than the fee. + +**With effective share:** Staying preserves harvest earning power (boosted effective share). Withdrawing gives up future boost. + +### After a rebalance (you stayed) + +**Collateral SP:** +- You hold reduced pegged balance + unclaimed wCOL +- **Claim and compound** (manually or via AC): converts wCOL → pegged → redeposit. Restores harvest base. Subject to mint fee (may be high if CR is low post-rebalance). +- **Hold wCOL unclaimed**: wCOL appreciates independently. Your harvest is reduced but total income (harvest + private yield) partially compensates. Wait for CR to recover (lower mint fee) before compounding. +- **Optimal timing**: compound when mint fee is low (high CR). The system naturally gates this. + +**Leveraged SP:** +- You hold reduced pegged + unclaimed leveraged tokens +- Leveraged tokens are NOT interest-bearing like wCOL -- no private yield stream +- Leveraged tokens cannot be easily converted to pegged (no direct mint path, must sell on secondary market) +- The rebalance DOES reduce Minter's collateral (and thus future harvest) -- same as collateral SP +- Your ongoing harvest is permanently reduced unless you sell leveraged tokens and re-enter +- **The effective share boost applies here too** -- unclaimed leveraged tokens valued via `leveragedTokenPrice()` count toward your effective share. But the valuation is approximate and the tokens lack the private yield that wCOL provides. + +### After a rebalance (you were NOT in the pool) + +- Deposit at full value. Your deposit is a larger fraction of the now-smaller pool → higher harvest share. +- **With CR-based deposit fees:** you pay a fee during stress, reducing the advantage. +- **With effective share:** stayers have boosted effective shares, so your larger fraction is relative to a larger effective total. Less advantageous than without the boost. + +--- + +## 5. Mechanism Analysis + +### A. CR-Based Dynamic Fees (Penalise the Withdrawer) + +Replace the withdrawal window with dynamic fees on both deposits and withdrawals that scale with collateral ratio. When CR is healthy, fees are zero. + +``` +FEE_ACTIVATION_RATIO (immutable, e.g., 1.4 if rebalance threshold is 1.3) + +if CR >= FEE_ACTIVATION_RATIO: + feeRate = 0 +elif CR >= 1.0: + feeRate = (FEE_ACTIVATION_RATIO - CR) / (FEE_ACTIVATION_RATIO - 1.0) +else: + feeRate = 1.0 (100% -- depeg, effectively blocked) +``` + +**What it solves:** +- Deters both sides of the sandwich (withdraw + re-deposit) +- Scales with systemic risk -- zero fee under normal conditions +- Removes withdrawal window UX burden (atomic withdraw) +- Enables clean ERC4626 integration (no request/wait) +- Stateless (computed from CR on each call) + +**What it doesn't solve:** +- Post-rebalance gap: CR jumps back up after rebalance, fees drop +- Does not compensate stayers -- only deters leavers +- Fees go to protocol, not to remaining depositors + +**Bytecode impact on SP_v3:** Net -200 to -400 bytes (removing withdrawal window saves ~500-800, adding CR fee costs ~200-300). + +### B. Effective Share (Reward the Stayer) + +For harvest distribution, a depositor's effective share includes the pegged-equivalent value of their unclaimed rebalance reward. + +``` +effectiveShare(user) = compoundedBalance(user) + peggedValueOf(unclaimedRebalanceReward(user)) +``` + +**How it works in the accumulator:** + +The key change: when accumulating harvest rewards, use `totalEffectiveShare` as the denominator instead of `totalAssetSupply`: +``` +harvestIntegral += reward × P_magnitude × PRECISION / totalEffectiveShare +``` + +Where `totalEffectiveShare = totalAssetSupply + peggedValueOf(totalUnclaimedRebalanceReward)`. + +For the user's claimable harvest, use their effective share: +``` +harvestGain = effectiveShare(user) × harvestIntegralDelta / (userProduct_magnitude × PRECISION) +``` + +**The over-compensation correction:** + +The effective share must NOT be the full original deposit. It should be: +``` +effectiveShare = compoundedBalance + peggedValueOf(unclaimedRebalanceReward) +``` + +This naturally handles the over-compensation: +- **If user has NOT claimed wCOL:** boost = peggedValueOf(wCOL). They're earning private yield + boosted harvest. But the boost is based on the wCOL value, not the full lost amount. Since wCOL value ≈ lost pegged amount (at rebalance exchange rate), the total effective share ≈ original deposit. Slight over-compensation due to private yield, but it decays as users claim. +- **If user HAS claimed wCOL:** boost = 0. No double-dipping. They extracted the wCOL and lose the boost. +- **If user compounds (claims + mints + redeposits):** their compounded balance grows, boost drops to 0, net effect ≈ original deposit restored as pegged. Fair. + +**Multiple rebalances:** Each rebalance adds more unclaimed wCOL. The boost is cumulative -- `unclaimedRebalanceReward` is the total across all rebalances. Claiming any portion reduces the boost proportionally. + +**The AC interaction:** The AC claims wCOL (boost drops to 0) and mints pegged (balance grows). The AC's effective share is always close to its actual total value. No special handling needed. + +**What it solves:** +- Stayers earn harvest proportional to their full position value (pegged + compensation) +- Natural decay via claiming -- no governance parameter +- Compounding is incentivised when healthy (low mint fee), holding when stressed (high mint fee) +- No penalty on new depositors -- they have no unclaimed reward +- AC works correctly -- claim removes boost, redeposit restores balance + +**What it doesn't solve:** +- Oracle dependency: converting wCOL to pegged-equivalent requires price/rate +- Does not prevent the withdraw/re-deposit attack itself +- Leveraged SP: leveraged token pricing is approximate + +**Implementation: virtual effective share functions + separate accumulation paths.** + +The accumulator already has two virtual functions that the SP overrides: +- `_getTotalPoolShare()` → returns `(product, totalAssetSupply)` +- `_getUserPoolShare(account)` → returns `(product, storedBalance)` + +Add parallel virtuals in the accumulator: +- `_getEffectiveTotalPoolShare()` → default: delegates to `_getTotalPoolShare()`. SP overrides to return `(product, totalAssetSupply + peggedValueOf(totalUnclaimedRebalanceReward))`. +- `_getEffectiveUserPoolShare(account)` → default: delegates to `_getUserPoolShare()`. SP overrides to return `(product, storedBalance + peggedValueOf(unclaimedRebalanceReward(account)))`. + +Two accumulation paths (no flags, no hidden state): + +**Harvest path** (called from linear distributor drip via `depositReward`): +`_accumulateReward(token, amount)` uses `_getEffectiveTotalPoolShare()` for denominator. Harvest is distributed proportional to effective shares. + +**Rebalance path** (called only from `notifyLiquidation`): +New `_accumulateRewardAndNotifyLoss(token, reward, loss)` in the accumulator. Uses `_getTotalPoolShare()` (actual shares, no boost) for reward accumulation, then applies loss via product. These two operations must happen atomically at the same product value. + +`notifyLiquidation` becomes: +``` +_checkpoint(address(0)) // drips pending harvest (effective shares) +_accumulateRewardAndNotifyLoss(rewardToken, returned, liquidated) // rebalance reward (actual shares) + loss +``` + +User-side claimable: `_claimableFrom` uses `_getEffectiveUserPoolShare` for harvest tokens and `_getUserPoolShare` for rebalance tokens. The distinction between harvest and rebalance tokens is provided by the existing alias/token registration system. + +This approach: +- No `_accumulateReward` override in SP -- only the virtual share functions are overridden +- No flags or hidden state -- denomination choice is explicit in which virtual function each path calls +- The accumulator base owns both paths -- SP only provides the effective share calculation +- ~100 bytes in accumulator (new virtual functions + `_accumulateRewardAndNotifyLoss`), ~100 bytes in SP (two overrides returning boosted values) + +### C. Combined: CR Fees + Effective Share + +Fees deter the movement; effective share corrects the distribution. + +**Bob's attack (combined):** +1. Withdrawal fee at CR=1.20: `(1.40 - 1.20) / (1.40 - 1.00) = 50%`. Bob withdraws 100 haETH, pays 50, receives 50. +2. Re-deposit fee at CR=1.30 (post-rebalance): `(1.40 - 1.30) / (1.40 - 1.00) = 25%`. Bob deposits 50, pays 12.5, credited 37.5. +3. Alice's effective share: 62.5 + peggedValueOf(166,666.67 fxSAVE) ≈ 100 haETH (the wCOL is valued at exactly the lost 37.5 haETH at the rebalance exchange rate). Bob: 37.5, no boost. +4. Alice dominates. Attack clearly unprofitable. + +**Fred (legitimate new entrant):** +1. No withdrawal fee (wasn't in pool). +2. Deposit fee at CR=1.30: 25% of 100 = 25 fee. Credited 75. +3. No effective share boost (no unclaimed). + +Fred pays a fee for entering during stress. This is arguable -- a genuine supporter is penalised. The effective share alone (without deposit fee) handles Fred more fairly: no fee, but stayers have boosted shares so Fred doesn't dilute them. + +--- + +## 6. Mechanism Comparison Under Auto-Compounding + +(haETH balances; the dollar values follow the price.) + +| Mechanism | Alice (1yr compound) | Bob (1yr compound) | Notes | +|-----------|---------------------|-------------------|-------| +| **No protection** | 62.5 × (1+r)^52 | 100 × (1+r)^52 | Bob compounds from 1.6× base | +| **CR fees only** | 62.5 × (1+r)^52 | 37.5 × (1+r)^52 | Gap reversed by fees | +| **Effective share only** | ~100 effective, compounds when claimed | 100 × (1+r)^52 | Alice's effective share matches her pre-rebalance deposit; once she compounds, both grow | +| **Combined** | ~100 effective, compounds | 37.5 × (1+r)^52 | Strongest protection | + +--- + +## 7. Open Questions + +1. **Deposit fees for new entrants:** are they justified, or should only withdrawals during stress be penalised? +2. **FEE_ACTIVATION_RATIO calibration:** how far above the rebalance threshold? Too close = post-rebalance gap; too far = fees on healthy activity. +3. **Effective share oracle risk:** can oracle manipulation inflate the boost? The oracle is already trusted for CR, so this is not a new attack surface, but the magnitude of impact may differ. +4. **Charlie's leveraged token boost:** `leveragedTokenPrice()` from the Minter is an approximation. Is it accurate enough for the effective share calculation? +5. **Multiple rapid rebalances:** the mechanism handles them (cumulative boost), but the oracle price may differ at each rebalance. The boost is based on current claimable value (re-priced each time), not the historical exchange rate. Is this correct? + +--- + +## 8. Summary: Defence Layers + +| Layer | Mechanism | Addresses | +|-------|-----------|-----------| +| **CR-based withdrawal fee** | Dynamic fee scaling with CR | Deters frontrun withdrawal | +| **CR-based deposit fee** | Same formula on deposits | Deters address-switching, post-rebalance re-entry | +| **Effective share boost** | Unclaimed rebalance reward counts toward harvest share | Corrects harvest distribution for stayers | +| **Private mempool** (off-chain) | Submit rebalance via Flashbots Protect | Mempool frontrunning specifically | + +The effective share mechanism corrects the harvest distribution without governance parameters, decays naturally via claiming, and interacts correctly with auto-compounding (claim removes boost, redeposit restores balance). CR-based fees complement it by deterring the attack itself. Together they address both deterrence and compensation. diff --git a/foundry.toml b/foundry.toml index 20325439..7d42b23e 100644 --- a/foundry.toml +++ b/foundry.toml @@ -44,14 +44,17 @@ remappings = [ ] fuzz.gas_report_samples = 64 # gas report doesn't need so many test cases -gas_reports_ignore = [ - "ERC20Mock", - "ERC1967Proxy", - "MockBaoAccessControl", - "TestTokenDistributor", - "Useful", - "MockAggregator", - "MockWrappedPriceOracle", +# gas_reports_ignore takes contract names, not paths — use gas_reports whitelist instead +gas_reports = [ + "AutoCompounder_v1", + "Genesis_v1", + "Minter_v3", + "ReservePool_v1", + "StabilityPoolManager_v1", + "StabilityPool_v2", + "StabilityPool_v3", + "TokenDistributor_v1", + "StringPacking_v1", ] fs_permissions = [ diff --git a/regression/coverage.txt b/regression/coverage.txt index 3cb93707..1ebaa579 100644 --- a/regression/coverage.txt +++ b/regression/coverage.txt @@ -1,27 +1,28 @@ | File | % Lines | % Statements | % Branches | % Funcs | |--------------------------------------------------------------------|--------------------|--------------------|------------------|-------------------| | script/config/ConfigBase.sol | ✓ 100% (8/8) | ✓ 100% (8/8) | ✓ 100% (0/0) | ✓ 100% (4/4) | -| script/config/ConfigTokenNames.sol | X 88% (21/24) | X 90% (18/20) | ✓ 100% (0/0) | X 82% (9/11) | -| script/config/chains/ConfigChain_mainnet.sol | X 5% (1/21) | X 8% (1/13) | ✓ 100% (0/0) | X 0% (0/8) | +| script/config/ConfigTokenNames.sol | X 92% (33/36) | X 93% (26/28) | ✓ 100% (0/0) | X 88% (14/16) | +| script/config/autocompounder/ConfigAutoCompounder.sol | ✓ 100% (2/2) | ✓ 100% (1/1) | ✓ 100% (0/0) | ✓ 100% (1/1) | +| script/config/chains/ConfigChain_mainnet.sol | X 10% (2/21) | X 15% (2/13) | ✓ 100% (0/0) | X 0% (0/8) | | script/config/collaterals/ConfigCollateral_fxUSD_mainnet.sol | X 67% (4/6) | X 60% (3/5) | ✓ 100% (0/0) | X 67% (2/3) | -| script/config/collaterals/ConfigCollateral_stETH_mainnet.sol | X 33% (2/6) | X 20% (1/5) | ✓ 100% (0/0) | X 33% (1/3) | +| script/config/collaterals/ConfigCollateral_stETH_mainnet.sol | X 67% (4/6) | X 60% (3/5) | ✓ 100% (0/0) | X 67% (2/3) | | script/config/pegs/ConfigPeg.sol | X 75% (6/8) | X 86% (6/7) | ✓ 100% (0/0) | X 75% (3/4) | | script/config/pegs/ConfigPeg_BTC.sol | X 33% (2/6) | X 33% (1/3) | ✓ 100% (0/0) | X 33% (1/3) | | script/config/pegs/ConfigPeg_ETH.sol | X 67% (4/6) | X 67% (2/3) | ✓ 100% (0/0) | X 67% (2/3) | -| script/config/pegs/ConfigPeg_EUR.sol | X 33% (2/6) | X 33% (1/3) | ✓ 100% (0/0) | X 33% (1/3) | +| script/config/pegs/ConfigPeg_EUR.sol | X 67% (4/6) | X 67% (2/3) | ✓ 100% (0/0) | X 67% (2/3) | | script/config/pegs/ConfigPeg_GOLD.sol | X 33% (2/6) | X 33% (1/3) | ✓ 100% (0/0) | X 33% (1/3) | | script/config/pegs/ConfigPeg_MCAP.sol | X 0% (0/6) | X 0% (0/3) | ✓ 100% (0/0) | X 0% (0/3) | | script/config/pegs/ConfigPeg_SILVER.sol | X 0% (0/6) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/3) | | script/config/stabilitypool/ConfigStabilityPool.sol | ✓ 100% (6/6) | ✓ 100% (3/3) | ✓ 100% (0/0) | ✓ 100% (3/3) | | script/config/stabilitypool/ConfigStabilityPoolManager.sol | ✓ 100% (6/6) | ✓ 100% (3/3) | ✓ 100% (0/0) | ✓ 100% (3/3) | | script/config/stabilitypool/ConfigStabilityPoolManagerCommon.sol | X 0% (0/21) | X 0% (0/17) | ✓ 100% (0/0) | X 0% (0/7) | -| script/config/volatility/ConfigPriceVolatility_105.sol | X 0% (0/65) | X 0% (0/71) | ✓ 100% (0/0) | X 0% (0/2) | +| script/config/volatility/ConfigPriceVolatility_105.sol | ✓ 100% (65/65) | ✓ 100% (71/71) | ✓ 100% (0/0) | ✓ 100% (2/2) | | script/config/volatility/ConfigPriceVolatility_105_stable.sol | X 0% (0/65) | X 0% (0/71) | ✓ 100% (0/0) | X 0% (0/2) | | script/config/volatility/ConfigPriceVolatility_115.sol | X 0% (0/65) | X 0% (0/71) | ✓ 100% (0/0) | X 0% (0/2) | | script/config/volatility/ConfigPriceVolatility_115_stable.sol | X 0% (0/65) | X 0% (0/71) | ✓ 100% (0/0) | X 0% (0/2) | | script/config/volatility/ConfigPriceVolatility_125.sol | X 0% (0/65) | X 0% (0/71) | ✓ 100% (0/0) | X 0% (0/2) | | script/config/volatility/ConfigPriceVolatility_125_stable.sol | X 0% (0/65) | X 0% (0/71) | ✓ 100% (0/0) | X 0% (0/2) | -| script/config/volatility/ConfigPriceVolatility_130.sol | X 0% (0/65) | X 0% (0/71) | ✓ 100% (0/0) | X 0% (0/2) | +| script/config/volatility/ConfigPriceVolatility_130.sol | ✓ 100% (65/65) | ✓ 100% (71/71) | ✓ 100% (0/0) | ✓ 100% (2/2) | | script/config/volatility/ConfigPriceVolatility_130_stable.sol | ✓ 100% (65/65) | ✓ 100% (71/71) | ✓ 100% (0/0) | ✓ 100% (2/2) | | script/src/HarborFactoryDeployer.sol | X 56% (9/16) | X 73% (8/11) | ✓ 100% (0/0) | X 20% (1/5) | | script/src/v2/DeployMintersShared.sol | X 0% (0/84) | X 0% (0/102) | X 0% (0/4) | X 0% (0/9) | @@ -37,19 +38,21 @@ | script/src/v2/contracts/PeggedToken.sol | X 0% (0/24) | X 0% (0/33) | X 0% (0/6) | X 0% (0/1) | | script/src/v2/contracts/StabilityPool.sol | X 0% (0/18) | X 0% (0/25) | ✓ 100% (0/0) | X 0% (0/3) | | script/src/v2/contracts/StabilityPoolManager.sol | X 0% (0/26) | X 0% (0/30) | ✓ 100% (0/0) | X 0% (0/4) | -| script/src/v3/DeployMintersShared.sol | X 86% (86/100) | X 85% (105/123) | X 25% (1/4) | X 80% (8/10) | +| script/src/v3/DeployMintersShared.sol | X 87% (93/107) | X 87% (118/136) | X 25% (1/4) | X 82% (9/11) | | script/src/v3/Deploy_BTC_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | | script/src/v3/Deploy_ETH_Minter.sol | ✓ 100% (4/4) | ✓ 100% (3/3) | ✓ 100% (0/0) | ✓ 100% (1/1) | -| script/src/v3/Deploy_EUR_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | +| script/src/v3/Deploy_EUR_Minter.sol | ✓ 100% (5/5) | ✓ 100% (4/4) | ✓ 100% (0/0) | ✓ 100% (1/1) | | script/src/v3/Deploy_GOLD_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | | script/src/v3/Deploy_MCAP_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | | script/src/v3/Deploy_SILVER_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | +| script/src/v3/contracts/AutoCompounder.sol | ✓ 100% (25/25) | ✓ 100% (34/34) | ✓ 100% (0/0) | ✓ 100% (4/4) | | script/src/v3/contracts/Genesis.sol | X 70% (7/10) | X 67% (8/12) | ✓ 100% (0/0) | X 50% (1/2) | | script/src/v3/contracts/LeveragedToken.sol | ✓ 100% (15/15) | ✓ 100% (23/23) | ✓ 100% (0/0) | ✓ 100% (1/1) | | script/src/v3/contracts/Minter.sol | X 58% (23/40) | X 55% (23/42) | ✓ 100% (0/0) | X 62% (5/8) | | script/src/v3/contracts/PeggedToken.sol | X 83% (20/24) | X 94% (31/33) | X 50% (3/6) | ✓ 100% (1/1) | -| script/src/v3/contracts/StabilityPool.sol | ✓ 100% (34/34) | ✓ 100% (50/50) | ✓ 100% (0/0) | ✓ 100% (4/4) | +| script/src/v3/contracts/StabilityPool.sol | ✓ 100% (26/26) | ✓ 100% (41/41) | ✓ 100% (0/0) | ✓ 100% (3/3) | | script/src/v3/contracts/StabilityPoolManager.sol | X 54% (14/26) | X 50% (15/30) | ✓ 100% (0/0) | X 50% (2/4) | +| src/autocompounding/AutoCompounder_v1.sol | X 96% (73/76) | X 97% (72/74) | X 60% (3/5) | X 94% (16/17) | | src/math/DecrementalFloatingPoint.sol | ✓ 100% (31/31) | ✓ 100% (33/33) | ✓ 100% (9/9) | ✓ 100% (6/6) | | src/minter/Genesis_v1.sol | X 99% (88/89) | ✓ 100% (88/88) | ✓ 100% (14/14) | X 92% (11/12) | | src/minter/Minter_v1.sol | X 0% (0/601) | X 0% (0/649) | X 0% (0/102) | X 0% (0/68) | @@ -57,23 +60,21 @@ | src/minter/Minter_v3.sol | X 99% (609/617) | X 99% (660/667) | X 93% (98/105) | X 99% (70/71) | | src/minter/ReservePool_v1.sol | X 94% (15/16) | ✓ 100% (15/15) | ✓ 100% (3/3) | X 80% (4/5) | | src/minter/StabilityPoolManager_v1.sol | X 99% (165/166) | X 99% (176/177) | X 95% (20/21) | ✓ 100% (24/24) | -| src/minter/StabilityPool_v1.sol | X 61% (124/203) | X 58% (129/223) | X 18% (6/33) | X 73% (16/22) | -| src/minter/StabilityPool_v2.sol | X 66% (131/199) | X 65% (143/219) | X 26% (8/31) | X 64% (14/22) | -| src/minter/StabilityPool_v3.sol | ✓ 100% (260/260) | ✓ 100% (285/285) | ✓ 100% (35/35) | ✓ 100% (34/34) | +| src/minter/StabilityPool_v1.sol | X 0% (0/203) | X 0% (0/223) | X 0% (0/33) | X 0% (0/22) | +| src/minter/StabilityPool_v2.sol | X 61% (122/199) | X 58% (127/219) | X 19% (6/31) | X 73% (16/22) | +| src/minter/StabilityPool_v3.sol | ✓ 100% (251/251) | ✓ 100% (273/273) | ✓ 100% (35/35) | ✓ 100% (32/32) | | src/minter/TokenDistributor_v1.sol | X 96% (94/98) | X 97% (112/116) | X 77% (10/13) | X 93% (14/15) | | src/minter/library/ConfigIncentiveLib.sol | ✓ 100% (24/24) | ✓ 100% (17/17) | ✓ 100% (2/2) | ✓ 100% (9/9) | | src/minter/library/Config_v1.sol | X 96% (76/79) | X 97% (92/95) | X 90% (18/20) | ✓ 100% (6/6) | | src/minter/library/StringPacking_v1.sol | ✓ 100% (26/26) | ✓ 100% (33/33) | ✓ 100% (6/6) | ✓ 100% (2/2) | | src/price/PriceOracle_v1.sol | X 97% (31/32) | X 97% (36/37) | X 85% (11/13) | ✓ 100% (3/3) | | src/price/StakedETHWrappedPriceOracle_v1.sol | X 0% (0/29) | X 0% (0/27) | X 0% (0/5) | X 0% (0/4) | -| src/reward/RewardAlias_v1.sol | ✓ 100% (15/15) | ✓ 100% (12/12) | ✓ 100% (1/1) | ✓ 100% (6/6) | -| src/reward/accumulator/MultipleRewardCompoundingAccumulator.sol | X 93% (127/136) | X 94% (161/171) | X 81% (13/16) | X 95% (20/21) | +| src/reward/accumulator/MultipleRewardCompoundingAccumulator.sol | X 91% (124/136) | X 90% (154/171) | X 75% (12/16) | X 90% (19/21) | | src/reward/accumulator/MultipleRewardCompoundingAccumulator_v2.sol | X 96% (141/147) | X 96% (177/184) | X 83% (15/18) | ✓ 100% (22/22) | -| src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol | X 91% (149/163) | X 91% (180/198) | X 86% (19/22) | X 88% (23/26) | +| src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol | X 96% (145/151) | X 97% (177/183) | X 85% (17/20) | X 96% (24/25) | | src/reward/distributor/LinearMultipleRewardDistributor.sol | ✓ 100% (77/77) | ✓ 100% (86/86) | ✓ 100% (12/12) | ✓ 100% (14/14) | | src/reward/distributor/LinearMultipleRewardDistributor_v2.sol | ✓ 100% (77/77) | ✓ 100% (86/86) | ✓ 100% (12/12) | ✓ 100% (14/14) | -| src/reward/distributor/LinearMultipleRewardDistributor_v3.sol | X 93% (96/103) | X 93% (109/117) | X 62% (8/13) | ✓ 100% (19/19) | | src/reward/distributor/LinearReward.sol | ✓ 100% (25/25) | ✓ 100% (27/27) | ✓ 100% (6/6) | ✓ 100% (2/2) | -| src/util/FmtLib.sol | X 0% (0/19) | X 0% (0/26) | X 0% (0/4) | X 0% (0/1) | +| src/util/FmtLib.sol | X 79% (15/19) | X 73% (19/26) | X 50% (2/4) | ✓ 100% (1/1) | | src/util/WordCodec.sol | ✓ 100% (15/15) | ✓ 100% (14/14) | ✓ 100% (0/0) | ✓ 100% (4/4) | -| Total | X 59% (4762/8116) | X 57% (5052/8793) | X 49% (442/900) | X 61% (713/1173) | +| Total | X 59% (4833/8160) | X 58% (5123/8831) | X 48% (429/891) | X 61% (722/1190) | diff --git a/regression/gas.txt b/regression/gas.txt index d127f9e1..2b29f418 100644 --- a/regression/gas.txt +++ b/regression/gas.txt @@ -1,67 +1,28 @@ -script/config/markets/ConfigMarket_BTC_fxUSD_mainnet.sol:ConfigMarket_BTC_fxUSD_mainnet -| function name | max | -|-----------------|-----------| -| collateral | 4.990e+02 | -| peg | 5.010e+02 | - -script/config/markets/ConfigMarket_BTC_stETH_mainnet.sol:ConfigMarket_BTC_stETH_mainnet -| function name | max | -|-----------------|-----------| -| collateral | 4.990e+02 | -| peg | 5.010e+02 | - -script/config/markets/ConfigMarket_ETH_fxUSD_mainnet.sol:ConfigMarket_ETH_fxUSD_mainnet -| function name | max | -|--------------------------------------|-----------| -| collateral | 4.990e+02 | -| harvestBountyRatio | 2.810e+02 | -| harvestCutRatio | 2.490e+02 | -| leveragedName | 5.704e+03 | -| leveragedSymbol | 6.807e+03 | -| minTotalSupply | 2.810e+02 | -| minterConfig | 1.169e+04 | -| peg | 5.010e+02 | -| rebalanceBountyRatio | 2.360e+02 | -| rebalanceThreshold | 2.590e+02 | -| spCollateralName | 8.085e+03 | -| spCollateralSymbol | 8.061e+03 | -| spLeveragedName | 8.887e+03 | -| spLeveragedSymbol | 8.924e+03 | -| stabilityPoolEarlyWithdrawalFeeRatio | 2.800e+02 | -| stabilityPoolWithdrawalDelay | 3.010e+02 | -| stabilityPoolWithdrawalPeriod | 2.820e+02 | -| wrappedCollateralToken | 2.790e+02 | - -script/config/markets/ConfigMarket_EUR_fxUSD_mainnet.sol:ConfigMarket_EUR_fxUSD_mainnet -| function name | max | -|-----------------|-----------| -| collateral | 4.990e+02 | -| peg | 5.010e+02 | - -script/config/markets/ConfigMarket_EUR_stETH_mainnet.sol:ConfigMarket_EUR_stETH_mainnet -| function name | max | -|-----------------|-----------| -| collateral | 4.990e+02 | -| peg | 5.010e+02 | - -script/config/markets/ConfigMarket_GOLD_fxUSD_mainnet.sol:ConfigMarket_GOLD_fxUSD_mainnet -| function name | max | -|-----------------|-----------| -| collateral | 4.990e+02 | -| peg | 5.010e+02 | - -script/config/markets/ConfigMarket_GOLD_stETH_mainnet.sol:ConfigMarket_GOLD_stETH_mainnet -| function name | max | -|-----------------|-----------| -| collateral | 4.990e+02 | -| peg | 5.010e+02 | - -script/config/pegs/ConfigPeg_ETH.sol:ConfigPeg_ETH -| function name | max | -|-----------------|-----------| -| key | 4.270e+02 | -| name | 6.300e+02 | -| symbol | 1.155e+03 | +src/autocompounding/AutoCompounder_v1.sol:AutoCompounder_v1 +| function name | max | +|-----------------------|-----------| +| MINTER | 3.250e+02 | +| PEGGED_TOKEN | 3.050e+02 | +| STABILITY_POOL | 2.840e+02 | +| WRAPPED_COLLATERAL | 3.050e+02 | +| approveCompoundTokens | 6.985e+04 | +| asset | 2.421e+03 | +| balanceOf | 2.592e+03 | +| compound | 3.943e+05 | +| decimals | 2.880e+02 | +| deposit | 2.114e+05 | +| depositPeggedToken | 3.209e+05 | +| initialize | 1.015e+05 | +| maxFeeRatio | 2.391e+03 | +| name | 1.751e+04 | +| owner | 2.440e+03 | +| previewRedeem | 3.632e+04 | +| redeem | 9.753e+04 | +| setMaxFeeRatio | 2.562e+04 | +| sweep | 4.524e+04 | +| symbol | 1.874e+04 | +| totalAssets | 7.820e+04 | +| transferOwnership | 1.202e+04 | src/minter/Genesis_v1.sol:Genesis_v1 | function name | max | @@ -93,9 +54,9 @@ src/minter/Minter_v3.sol:Minter_v3 | collateralTokenBalance | 2.358e+03 | | config | 4.895e+04 | | feeReceiver | 2.442e+03 | -| freeMintLeveragedToken | 1.438e+05 | +| freeMintLeveragedToken | 1.492e+05 | | freeMintPeggedToken | 1.674e+05 | -| freeRedeemLeveragedToken | 8.668e+04 | +| freeRedeemLeveragedToken | 9.676e+04 | | freeRedeemPeggedToken | 1.346e+05 | | grantRoles | 2.633e+04 | | harvestable | 2.981e+04 | @@ -110,7 +71,7 @@ src/minter/Minter_v3.sol:Minter_v3 | mintLeveragedTokenIncentiveRatio | 3.108e+04 | | mintPeggedToken(uint256,address,uint256) | 1.911e+05 | | mintPeggedToken(uint256,address,uint256,uint256) | 1.251e+05 | -| mintPeggedTokenDryRun(uint256) | 6.401e+04 | +| mintPeggedTokenDryRun(uint256) | 6.399e+04 | | mintPeggedTokenDryRun(uint256,uint256) | 4.556e+04 | | mintPeggedTokenIncentiveRatio | 3.012e+04 | | owner | 2.402e+03 | @@ -151,14 +112,14 @@ src/minter/StabilityPoolManager_v1.sol:StabilityPoolManager_v1 | function name | max | |----------------------------|-----------| | feeReceiver | 2.441e+03 | -| harvest | 4.488e+05 | +| harvest | 4.442e+05 | | harvestBountyRatio | 2.369e+03 | | harvestCutRatio | 2.371e+03 | | harvestable | 3.052e+04 | | hasStabilityPool | 5.370e+02 | | initialize | 7.069e+04 | | owner | 2.423e+03 | -| rebalance | 5.612e+05 | +| rebalance | 5.541e+05 | | rebalanceBountyRatio | 2.354e+03 | | rebalanceThreshold | 2.347e+03 | | rebalanceable | 2.926e+04 | @@ -172,81 +133,71 @@ src/minter/StabilityPoolManager_v1.sol:StabilityPoolManager_v1 | updateRebalanceThreshold | 2.571e+04 | | upgradeToAndCall | 1.087e+04 | -src/minter/StabilityPool_v1.sol:StabilityPool_v1 +src/minter/StabilityPool_v2.sol:StabilityPool_v2 | function name | max | |-----------------------|-----------| | ASSET_TOKEN | 3.270e+02 | | REBALANCER_ROLE | 2.620e+02 | | REWARD_DEPOSITOR_ROLE | 2.840e+02 | | REWARD_MANAGER_ROLE | 3.270e+02 | -| assetBalanceOf | 8.053e+03 | -| claim | 1.209e+05 | -| claimable | 2.096e+04 | -| claimed | 2.848e+03 | -| deposit | 2.693e+05 | +| assetBalanceOf | 8.049e+03 | +| claim | 1.436e+05 | +| claimable | 2.336e+04 | +| claimed | 7.479e+03 | +| deposit | 2.800e+05 | | depositReward | 6.533e+04 | | getWithdrawalRequest | 2.745e+03 | | grantRoles | 2.636e+04 | | initialize | 2.041e+05 | -| notifyLiquidation | 1.373e+05 | +| notifyLiquidation | 1.370e+05 | | registerRewardToken | 7.292e+04 | | requestWithdrawal | 2.504e+04 | | sweep | 4.020e+04 | | totalAssetSupply | 2.489e+03 | | transferOwnership | 1.207e+04 | -| upgradeToAndCall | 1.090e+04 | - -src/minter/StabilityPool_v2.sol:StabilityPool_v2 -| function name | max | -|----------------------|-----------| -| ASSET_TOKEN | 3.270e+02 | -| assetBalanceOf | 8.049e+03 | -| checkpoint | 1.893e+05 | -| claim | 2.672e+05 | -| claimable | 2.550e+04 | -| claimed | 9.798e+03 | -| deposit | 2.800e+05 | -| depositReward | 6.533e+04 | -| getWithdrawalRequest | 2.745e+03 | -| notifyLiquidation | 1.107e+05 | -| proxiableUUID | 3.410e+02 | -| sweep | 4.020e+04 | -| totalAssetSupply | 2.489e+03 | -| withdraw | 3.013e+05 | +| upgradeToAndCall | 1.092e+04 | src/minter/StabilityPool_v3.sol:StabilityPool_v3 | function name | max | |----------------------------------------|-----------| | ASSET_TOKEN | 3.490e+02 | +| EXEMPT_WITHDRAWAL_FEE_ROLE | 2.860e+02 | | LIQUIDATION_TOKEN | 3.500e+02 | | REBALANCER_ROLE | 2.840e+02 | | REWARD_DEPOSITOR_ROLE | 3.060e+02 | -| REWARD_MANAGER_ROLE | 3.270e+02 | -| activeRewardTokens | 1.195e+04 | -| assetBalanceOf | 8.047e+03 | -| checkpoint | 1.788e+05 | -| claim(address) | 2.910e+05 | -| claim(address,address) | 2.200e+05 | -| claim(address,address,address,uint256) | 2.112e+05 | -| claimable | 5.960e+04 | -| claimed | 7.465e+03 | -| deposit | 4.059e+05 | -| depositReward | 8.044e+04 | -| getWithdrawalRequest | 2.761e+03 | -| grantRoles | 2.637e+04 | +| REWARD_MANAGER_ROLE | 2.630e+02 | +| allowance | 2.789e+03 | +| approve | 2.458e+04 | +| assetBalanceOf | 5.834e+03 | +| balanceOf | 5.811e+03 | +| checkpoint | 1.465e+05 | +| claim(address) | 2.246e+05 | +| claim(address,address) | 1.536e+05 | +| claim(address,address,address,uint256) | 2.161e+05 | +| claimable | 2.488e+04 | +| claimed | 7.472e+03 | +| decimals | 2.950e+02 | +| deposit | 2.848e+05 | +| depositReward | 6.726e+04 | +| getWithdrawalRequest | 2.767e+03 | +| grantRoles | 2.638e+04 | | historicalRewardTokens | 5.180e+03 | | initialize | 2.041e+05 | | name | 1.926e+04 | -| notifyLiquidation | 1.198e+05 | +| notifyLiquidation | 1.235e+05 | | owner | 2.446e+03 | -| registerRewardToken(address) | 7.144e+04 | +| proxiableUUID | 3.640e+02 | +| registerRewardToken | 8.852e+04 | | requestWithdrawal | 2.501e+04 | -| sweep | 3.610e+04 | +| sweep | 4.020e+04 | | symbol | 1.950e+04 | | totalAssetSupply | 2.423e+03 | +| totalSupply | 2.424e+03 | +| transfer | 1.880e+05 | +| transferFrom | 1.316e+05 | | transferOwnership | 1.204e+04 | -| unregisterRewardToken | 1.046e+05 | -| withdraw | 2.272e+05 | +| unregisterRewardToken | 9.139e+04 | +| withdraw | 2.585e+05 | src/minter/TokenDistributor_v1.sol:TokenDistributor_v1 | function name | max | @@ -274,111 +225,3 @@ src/minter/library/StringPacking_v1.sol:StringPacking_v1 |-----------------|-----------| | pack64 | 9.370e+02 | | unpack64 | 1.580e+04 | - -src/reward/RewardAlias_v1.sol:RewardAlias_v1 -| function name | max | -|-------------------|-----------| -| initialize | 7.040e+04 | -| owner | 2.371e+03 | -| proxiableUUID | 3.410e+02 | -| supportsInterface | 5.280e+02 | -| transferOwnership | 1.202e+04 | -| underlying | 2.010e+02 | -| upgradeToAndCall | 1.083e+04 | - -test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator.sol:MockMultipleRewardCompoundingAccumulator -| function name | max | -|---------------------------|-----------| -| REWARD_MANAGER_ROLE | 2.830e+02 | -| REWARD_PERIOD_LENGTH | 2.710e+02 | -| activeRewardTokens | 9.672e+03 | -| checkpoint | 2.793e+05 | -| claim() | 2.063e+05 | -| claim(address) | 2.068e+05 | -| claim(address,address) | 2.073e+05 | -| claimable | 1.983e+04 | -| claimed | 2.848e+03 | -| depositReward | 1.442e+05 | -| grantRoles | 4.791e+04 | -| historicalRewardTokens | 2.833e+03 | -| initialize | 9.180e+04 | -| owner | 2.380e+03 | -| reentrantCall | 2.400e+04 | -| registerRewardToken | 9.651e+04 | -| rewardReceiver | 2.683e+03 | -| setRewardReceiver | 4.601e+04 | -| setTotalPoolShare | 6.621e+04 | -| setUserPoolShare | 6.621e+04 | -| tokenToExponentToIntegral | 2.767e+03 | -| unregisterRewardToken | 1.137e+05 | -| userRewardSnapshot | 5.475e+03 | - -test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v2.sol:MockMultipleRewardCompoundingAccumulator_v2 -| function name | max | -|---------------------------|-----------| -| REWARD_MANAGER_ROLE | 2.830e+02 | -| REWARD_PERIOD_LENGTH | 2.710e+02 | -| activeRewardTokens | 9.672e+03 | -| checkpoint | 3.529e+05 | -| claim() | 2.103e+05 | -| claim(address) | 2.108e+05 | -| claim(address,address) | 2.113e+05 | -| claimable | 2.203e+04 | -| claimed | 7.479e+03 | -| depositReward | 1.442e+05 | -| grantRoles | 4.791e+04 | -| historicalRewardTokens | 2.833e+03 | -| initialize | 9.178e+04 | -| owner | 2.380e+03 | -| reentrantCall | 2.400e+04 | -| registerRewardToken | 9.651e+04 | -| rewardData | 2.913e+03 | -| rewardReceiver | 2.683e+03 | -| setRewardReceiver | 4.601e+04 | -| setTotalPoolShare | 6.621e+04 | -| setUserPoolShare | 6.621e+04 | -| tokenToExponentToIntegral | 2.765e+03 | -| unregisterRewardToken | 1.137e+05 | -| userRewardSnapshot | 7.580e+03 | - -test/mocks/reward/distributor/MockLinearMultipleRewardDistributor.sol:MockLinearMultipleRewardDistributor -| function name | max | -|------------------------|-----------| -| REWARD_DEPOSITOR_ROLE | 2.610e+02 | -| REWARD_MANAGER_ROLE | 2.400e+02 | -| REWARD_PERIOD_LENGTH | 2.490e+02 | -| activeRewardTokens | 9.672e+03 | -| depositReward | 1.209e+05 | -| getRewardDataStorage | 2.846e+03 | -| grantRoles | 4.790e+04 | -| hasAnyRole | 2.635e+03 | -| historicalRewardTokens | 9.692e+03 | -| initialize | 9.156e+04 | -| isActiveRewardToken | 2.698e+03 | -| owner | 2.389e+03 | -| pendingRewards | 3.491e+03 | -| registerRewardToken | 9.646e+04 | -| rewardData | 2.890e+03 | -| transferOwnership | 2.868e+04 | -| unregisterRewardToken | 1.138e+05 | - -test/mocks/reward/distributor/MockLinearMultipleRewardDistributor_v2.sol:MockLinearMultipleRewardDistributor_v2 -| function name | max | -|------------------------|-----------| -| REWARD_DEPOSITOR_ROLE | 2.610e+02 | -| REWARD_MANAGER_ROLE | 2.400e+02 | -| REWARD_PERIOD_LENGTH | 2.490e+02 | -| activeRewardTokens | 9.672e+03 | -| depositReward | 1.209e+05 | -| getRewardDataStorage | 2.846e+03 | -| grantRoles | 4.790e+04 | -| hasAnyRole | 2.635e+03 | -| historicalRewardTokens | 9.692e+03 | -| initialize | 9.156e+04 | -| isActiveRewardToken | 2.698e+03 | -| owner | 2.389e+03 | -| pendingRewards | 3.491e+03 | -| registerRewardToken | 9.646e+04 | -| rewardData | 2.890e+03 | -| transferOwnership | 2.868e+04 | -| unregisterRewardToken | 1.138e+05 | diff --git a/regression/sizes.txt b/regression/sizes.txt index 639cfa4d..455a2b47 100644 --- a/regression/sizes.txt +++ b/regression/sizes.txt @@ -1,17 +1,18 @@ | Contract | Runtime Size (B) | Runtime Margin (B) | Initcode Size (B) | Deploy Gas | Deploy Cost ($) | |-----------------------------------|--------------------|----------------------|---------------------|--------------|-------------------| +| AutoCompounder_v1 | 12,167 | 12,409 | 13,776 | 2,571,160 | 257.12 | | ConfigIncentiveLib | 85 | 24,491 | 135 | 18,350 | 1.84 | -| ConfigMarket_BTC_fxUSD_mainnet | 6,231 | 18,345 | 6,259 | 1,308,790 | 130.88 | -| ConfigMarket_BTC_stETH_mainnet | 6,257 | 18,319 | 6,285 | 1,314,250 | 131.43 | -| ConfigMarket_ETH_fxUSD_mainnet | 6,231 | 18,345 | 6,259 | 1,308,790 | 130.88 | -| ConfigMarket_EUR_fxUSD_mainnet | 6,219 | 18,357 | 6,247 | 1,306,270 | 130.63 | -| ConfigMarket_EUR_stETH_mainnet | 6,245 | 18,331 | 6,273 | 1,311,730 | 131.17 | -| ConfigMarket_GOLD_fxUSD_mainnet | 6,235 | 18,341 | 6,263 | 1,309,630 | 130.96 | -| ConfigMarket_GOLD_stETH_mainnet | 6,261 | 18,315 | 6,289 | 1,315,090 | 131.51 | -| ConfigMarket_MCAP_fxUSD_mainnet | 6,237 | 18,339 | 6,265 | 1,310,050 | 131.00 | -| ConfigMarket_MCAP_stETH_mainnet | 6,263 | 18,313 | 6,291 | 1,315,510 | 131.55 | -| ConfigMarket_SILVER_fxUSD_mainnet | 6,231 | 18,345 | 6,259 | 1,308,790 | 130.88 | -| ConfigMarket_SILVER_stETH_mainnet | 6,257 | 18,319 | 6,285 | 1,314,250 | 131.43 | +| ConfigMarket_BTC_fxUSD_mainnet | 6,677 | 17,899 | 6,705 | 1,402,450 | 140.25 | +| ConfigMarket_BTC_stETH_mainnet | 6,703 | 17,873 | 6,731 | 1,407,910 | 140.79 | +| ConfigMarket_ETH_fxUSD_mainnet | 6,677 | 17,899 | 6,705 | 1,402,450 | 140.25 | +| ConfigMarket_EUR_fxUSD_mainnet | 6,665 | 17,911 | 6,693 | 1,399,930 | 139.99 | +| ConfigMarket_EUR_stETH_mainnet | 6,691 | 17,885 | 6,719 | 1,405,390 | 140.54 | +| ConfigMarket_GOLD_fxUSD_mainnet | 6,681 | 17,895 | 6,709 | 1,403,290 | 140.33 | +| ConfigMarket_GOLD_stETH_mainnet | 6,707 | 17,869 | 6,735 | 1,408,750 | 140.88 | +| ConfigMarket_MCAP_fxUSD_mainnet | 6,683 | 17,893 | 6,711 | 1,403,710 | 140.37 | +| ConfigMarket_MCAP_stETH_mainnet | 6,709 | 17,867 | 6,737 | 1,409,170 | 140.92 | +| ConfigMarket_SILVER_fxUSD_mainnet | 6,677 | 17,899 | 6,705 | 1,402,450 | 140.25 | +| ConfigMarket_SILVER_stETH_mainnet | 6,703 | 17,873 | 6,731 | 1,407,910 | 140.79 | | ConfigPeg_BTC | 766 | 23,810 | 794 | 161,140 | 16.11 | | ConfigPeg_ETH | 766 | 23,810 | 794 | 161,140 | 16.11 | | ConfigPeg_EUR | 768 | 23,808 | 796 | 161,560 | 16.16 | @@ -42,11 +43,10 @@ | Minter_v3 | 24,218 | 358 | 26,045 | 5,104,050 | 510.41 | | PriceOracle_v1 | 1,958 | 22,618 | 2,010 | 411,700 | 41.17 | | ReservePool_v1 | 4,760 | 19,816 | 5,009 | 1,002,090 | 100.21 | -| RewardAlias_v1 | 2,974 | 21,602 | 3,358 | 628,380 | 62.84 | | StabilityPoolManager_v1 | 11,209 | 13,367 | 13,057 | 2,372,370 | 237.24 | | StabilityPool_v1 | 21,092 | 3,484 | 23,085 | 4,449,250 | 444.93 | | StabilityPool_v2 | 20,711 | 3,865 | 22,579 | 4,367,990 | 436.80 | -| StabilityPool_v3 | 24,376 | 200 | 27,002 | 5,145,220 | 514.52 | +| StabilityPool_v3 | 23,064 | 1,512 | 25,683 | 4,869,630 | 486.96 | | StakedETHWrappedPriceOracle_v1 | 3,695 | 20,881 | 4,653 | 785,530 | 78.55 | | StringPacking_v1 | 1,218 | 23,358 | 1,270 | 256,300 | 25.63 | | TokenDistributor_v1 | 10,116 | 14,460 | 10,365 | 2,126,850 | 212.69 | diff --git a/results/rebalance_fairness_breakeven.csv b/results/rebalance_fairness_breakeven.csv new file mode 100644 index 00000000..2e29106c --- /dev/null +++ b/results/rebalance_fairness_breakeven.csv @@ -0,0 +1,3 @@ +Pool,BreakEven_fee_pct,Horizon_weeks,PriceDrop_pct,Lev_pct,APR_pct +Coll,0.0000000000000017,12.000000000000000000,10.000000000000000000,25.000000000000000000,10.000000000000000000 +Lev,0.0000000000000060,12.000000000000000000,10.000000000000000000,25.000000000000000000,10.000000000000000000 diff --git a/results/rebalance_fairness_fee_scan.csv b/results/rebalance_fairness_fee_scan.csv new file mode 100644 index 00000000..a5c05bbb --- /dev/null +++ b/results/rebalance_fairness_fee_scan.csv @@ -0,0 +1,11 @@ +Fee_pct,LiquidFrac_pct,Alice_coll_weekly_$,Bob_coll_weekly_$,Coll_gap_pct,Charlie_lev_weekly_$,Dave_lev_weekly_$,Lev_gap_pct +0.000000000000000000,37.500000000000000000,1015.933185404339140925,1112.179487179487025480,8.653846153846151048,695.112179487179389815,1112.179487179487025484,37.500000000000000100 +1.000000000000000000,37.500000000000000000,1018.591358174844607047,1105.268237976172813562,7.842157842157839342,697.770352257684855933,1105.268237976172813566,36.868686868686868787 +2.000000000000000000,37.500000000000000000,1021.269939181591957745,1098.303927358629701746,7.013904462884051888,700.448933264432206628,1098.303927358629701750,36.224489795918367448 +5.000000000000000000,37.500000000000000000,1029.430509277876604826,1077.086445108289619336,4.424522845575474265,708.609503360716853695,1077.086445108289619340,34.210526315789473789 +8.000000000000000000,37.500000000000000000,1037.783470024761282879,1055.368747166389456399,1.666268514094598103,716.962464107601531735,1055.368747166389456403,32.065217391304347934 +10.000000000000000000,37.500000000000000000,1043.462380631554166308,1040.603579588727959483,0.000000000000000000,722.641374714394415154,1040.603579588727959487,30.555555555555555666 +15.000000000000000000,37.500000000000000000,1058.061196282350013102,1002.646658896658757819,0.000000000000000000,737.240190365190261925,1002.646658896658757823,26.470588235294117764 +18.000000000000000000,37.500000000000000000,1067.107088188671366023,979.127339940223240223,0.000000000000000000,746.286082271511614832,979.127339940223240227,23.780487804878048902 +20.000000000000000000,37.500000000000000000,1073.262024949673523681,963.124504361617630312,0.000000000000000000,752.441019032513772481,963.124504361617630316,21.875000000000000124 +25.000000000000000000,37.500000000000000000,1089.102888508252761022,921.938259109311613227,0.000000000000000000,768.281882591093009796,921.938259109311613231,16.666666666666666800 diff --git a/results/rebalance_fairness_fee_scan.gp b/results/rebalance_fairness_fee_scan.gp new file mode 100644 index 00000000..893fc168 --- /dev/null +++ b/results/rebalance_fairness_fee_scan.gp @@ -0,0 +1,36 @@ +# Generated by RebalanceFairnessScan.t.sol - withdrawal fee scan +# +# Design case: 10% price drop, 25% leveraged, 37.5% liquidation fraction. +# Fee is applied to Bob/Dave's withdrawn haETH (burned, not redistributed). +# Income gap = (returner_weekly_$ - stayer_weekly_$) / returner_weekly_$ * 100 +# +# CSV columns: 1=Fee_pct, 2=LiquidFrac_pct, +# 3=Alice_coll_$, 4=Bob_coll_$, 5=Coll_gap_pct, +# 6=Charlie_lev_$, 7=Dave_lev_$, 8=Lev_gap_pct +set datafile separator ',' +set bmargin 7 +set key below spacing 1.3 +set grid + +set terminal pngcairo size 1400,500 enhanced font 'Helvetica,11' +set output './results/rebalance_fairness_fee_scan.png' +set multiplot layout 1,2 title 'Withdrawal Fee Impact - Design Case (10% drop, 37.5% liquidation)' font 'Helvetica,13' + +set xlabel 'Withdrawal fee (%)' +set ylabel 'Income gap (%)' +set title 'Stayer vs returner: income gap' +set xrange [0:*] +set yrange [*:*] +plot '< tail -n+2 ./results/rebalance_fairness_fee_scan.csv' using 1:5 with linespoints lw 2 title 'Coll SP gap', \ + '< tail -n+2 ./results/rebalance_fairness_fee_scan.csv' using 1:8 with linespoints lw 2 title 'Lev SP gap', \ + 0 with lines dt 2 lc rgb 'gray50' title 'fair (0%)' + +set title 'Weekly $ income vs withdrawal fee' +set ylabel 'Weekly income ($)' +set yrange [0:*] +plot '< tail -n+2 ./results/rebalance_fairness_fee_scan.csv' using 1:3 with linespoints lw 2 title 'Alice (Coll stayer)', \ + '< tail -n+2 ./results/rebalance_fairness_fee_scan.csv' using 1:4 with linespoints lw 2 title 'Bob (Coll returner)', \ + '< tail -n+2 ./results/rebalance_fairness_fee_scan.csv' using 1:6 with linespoints lw 2 title 'Charlie (Lev stayer)', \ + '< tail -n+2 ./results/rebalance_fairness_fee_scan.csv' using 1:7 with linespoints lw 2 title 'Dave (Lev returner)' + +unset multiplot diff --git a/results/rebalance_fairness_scan.csv b/results/rebalance_fairness_scan.csv new file mode 100644 index 00000000..dfd6963a --- /dev/null +++ b/results/rebalance_fairness_scan.csv @@ -0,0 +1,26 @@ +PriceDrop_pct,Lev_pct,LiquidFrac_pct,Alice_coll_weekly_$,Bob_coll_weekly_$,Coll_gap_pct,Charlie_lev_weekly_$,Dave_lev_weekly_$,Lev_gap_pct +5.000000000000000000,10.000000000000000000,91.666666666666666600,830.757552164434718305,1053.643724696356202807,21.153846153846151953,87.803643724696349881,1053.643724696356202807,91.666666666666666700 +10.000000000000000000,10.000000000000000000,100.000000000000000000,855.524821250326643163,1069.402283654915470872,19.999719999999996942,0.002138804567309830,1069.402283654915470872,99.999800000000000000 +15.000000000000000000,10.000000000000000000,100.000000000000000000,855.524821250326643163,1069.402283654915470872,19.999719999999996942,0.002138804567309830,1069.402283654915470872,99.999800000000000000 +20.000000000000000000,10.000000000000000000,100.000000000000000000,855.524821250326643163,1069.402283654915470872,19.999719999999996942,0.002138804567309830,1069.402283654915470872,99.999800000000000000 +25.000000000000000000,10.000000000000000000,100.000000000000000000,855.524821250326643163,1069.402283654915470872,19.999719999999996942,0.002138804567309830,1069.402283654915470872,99.999800000000000000 +35.000000000000000000,10.000000000000000000,100.000000000000000000,855.524821250326643163,1069.402283654915470872,19.999719999999996942,0.002138804567309830,1069.402283654915470872,99.999800000000000000 +40.000000000000000000,10.000000000000000000,100.000000000000000000,855.524821250326643163,1069.402283654915470872,19.999719999999996942,0.002138804567309830,1069.402283654915470872,99.999800000000000000 +50.000000000000000000,10.000000000000000000,100.000000000000000000,855.524821250326643163,1069.402283654915470872,19.999719999999996942,0.002138804567309830,1069.402283654915470872,99.999800000000000000 +60.000000000000000000,10.000000000000000000,100.000000000000000000,855.524821250326643163,1069.402283654915470872,19.999719999999996942,0.002138804567309830,1069.402283654915470872,99.999800000000000000 +70.000000000000000000,10.000000000000000000,100.000000000000000000,855.524821250326643163,1069.402283654915470872,19.999719999999996942,0.002138804567309830,1069.402283654915470872,99.999800000000000000 +5.000000000000000000,25.000000000000000000,12.500000000000000000,1023.250155714730488594,1053.643724696356137341,2.884615384615383755,921.938259109311619123,1053.643724696356137345,12.500000000000000100 +10.000000000000000000,25.000000000000000000,37.500000000000000000,1015.933185404339140925,1112.179487179487025480,8.653846153846151048,695.112179487179389815,1112.179487179487025484,37.500000000000000100 +15.000000000000000000,25.000000000000000000,62.500000000000000000,1007.755395057431196048,1177.601809954751032601,14.423076923076921045,441.600678733031636049,1177.601809954751032605,62.500000000000000100 +20.000000000000000000,25.000000000000000000,87.500000000000000000,998.555380917159716911,1251.201923076922985296,20.192307692307690171,156.400240384615371911,1251.201923076922985302,87.500000000000000100 +25.000000000000000000,25.000000000000000000,100.000000000000000000,1026.629785500391971796,1283.282740385898534690,19.999719999999995049,0.002566565480771796,1283.282740385898534690,99.999800000000000000 +35.000000000000000000,25.000000000000000000,100.000000000000000000,1026.629785500391971796,1283.282740385898534690,19.999719999999995049,0.002566565480771796,1283.282740385898534690,99.999800000000000000 +40.000000000000000000,25.000000000000000000,100.000000000000000000,1026.629785500391971796,1283.282740385898534690,19.999719999999995049,0.002566565480771796,1283.282740385898534690,99.999800000000000000 +50.000000000000000000,25.000000000000000000,100.000000000000000000,1026.629785500391971796,1283.282740385898534690,19.999719999999995049,0.002566565480771796,1283.282740385898534690,99.999800000000000000 +60.000000000000000000,25.000000000000000000,100.000000000000000000,1026.629785500391971796,1283.282740385898534690,19.999719999999995049,0.002566565480771796,1283.282740385898534690,99.999800000000000000 +70.000000000000000000,25.000000000000000000,100.000000000000000000,1026.629785500391971796,1283.282740385898534690,19.999719999999995049,0.002566565480771796,1283.282740385898534690,99.999800000000000000 +40.000000000000000000,50.000000000000000000,37.500000000000000000,1523.899778106508783665,1668.269230769230653864,8.653846153846153048,1042.668269230769157000,1668.269230769230653870,37.500000000000000100 +50.000000000000000000,50.000000000000000000,100.000000000000000000,1539.944678250587957695,1924.924110578847877926,19.999719999999998203,0.003849848221157695,1924.924110578847877926,99.999800000000000000 +60.000000000000000000,50.000000000000000000,100.000000000000000000,1539.944678250587957695,1924.924110578847877926,19.999719999999998203,0.003849848221157695,1924.924110578847877926,99.999800000000000000 +70.000000000000000000,50.000000000000000000,100.000000000000000000,1539.944678250587957695,1924.924110578847877926,19.999719999999998203,0.003849848221157695,1924.924110578847877926,99.999800000000000000 +70.000000000000000000,75.000000000000000000,37.500000000000000000,3047.799556213017567331,3336.538461538461307729,8.653846153846153048,2085.336538461538314001,3336.538461538461307741,37.500000000000000100 diff --git a/results/rebalance_fairness_scan.gp b/results/rebalance_fairness_scan.gp new file mode 100644 index 00000000..101345c1 --- /dev/null +++ b/results/rebalance_fairness_scan.gp @@ -0,0 +1,38 @@ +# Generated by RebalanceFairnessScan.t.sol +# +# Income gap = (returner_weekly_$ - stayer_weekly_$) / returner_weekly_$ * 100 +# where weekly_$ = (totalDollars_after_2_weeks - totalDollars_before) / 2. +# This captures harvest income + wCOL appreciation on unclaimed rebalance rewards. +# A 0% gap means the stayer earns the same as the returner (fair). +# A 37.5% gap (at 37.5% liquidation) means the stayer earns 37.5% less per week. +# +# CSV columns: 1=PriceDrop_pct, 2=Lev_pct, 3=LiquidFrac_pct, +# 4=Alice_coll_weekly_$, 5=Bob_coll_weekly_$, 6=Coll_gap_pct, +# 7=Charlie_lev_weekly_$, 8=Dave_lev_weekly_$, 9=Lev_gap_pct +set datafile separator ',' +set bmargin 7 +set key below spacing 1.3 +set grid + +set terminal pngcairo size 1400,500 enhanced font 'Helvetica,11' +set output './results/rebalance_fairness_scan.png' +set multiplot layout 1,2 title 'Rebalance Fairness Gap - Scenario B (dodge attack)' font 'Helvetica,13' + +set xlabel 'Liquidation fraction (%)' +set ylabel 'Income gap (%)' +set title 'Stayer vs returner: $ income gap' +set xrange [0:100] +set yrange [0:100] +plot '< tail -n+2 ./results/rebalance_fairness_scan.csv' using 3:6 with points pt 7 ps 1.2 title 'Coll SP gap', \ + '< tail -n+2 ./results/rebalance_fairness_scan.csv' using 3:9 with points pt 5 ps 1.2 title 'Lev SP gap' + +set title 'Weekly $ income (lev=25%, APR=10%)' +set ylabel 'Weekly income ($)' +set xrange [0:100] +set yrange [0:*] +plot "< awk -F, 'NR>1 && $2==25' ./results/rebalance_fairness_scan.csv" using 3:4 with linespoints title 'Alice (Coll stayer)', \ + "< awk -F, 'NR>1 && $2==25' ./results/rebalance_fairness_scan.csv" using 3:5 with linespoints title 'Bob (Coll returner)', \ + "< awk -F, 'NR>1 && $2==25' ./results/rebalance_fairness_scan.csv" using 3:7 with linespoints title 'Charlie (Lev stayer)', \ + "< awk -F, 'NR>1 && $2==25' ./results/rebalance_fairness_scan.csv" using 3:8 with linespoints title 'Dave (Lev returner)' + +unset multiplot diff --git a/results/rebalance_fairness_timeline.csv b/results/rebalance_fairness_timeline.csv new file mode 100644 index 00000000..3f48710e --- /dev/null +++ b/results/rebalance_fairness_timeline.csv @@ -0,0 +1,27 @@ +Fee_pct,Week,Alice_haXXX_eq,Bob_haXXX_eq,Charlie_haXXX_eq,Dave_haXXX_eq +0.000000000000000000,0.000000000000000000,99.999999999999999999,100.000000000000000000,99.999999999999999900,100.000000000000000000 +0.000000000000000000,1.000000000000000000,100.231370192307692270,100.254807692307692252,100.159254807692307558,100.254807692307692252 +0.000000000000000000,2.000000000000000000,100.491633796184520502,100.514960528607946745,100.419331154726702572,100.514960528607946717 +0.000000000000000000,3.000000000000000000,100.757375508338911262,100.780392465058042572,100.684881668599122422,100.780392465058042596 +0.000000000000000000,4.000000000000000000,101.028673629085458451,101.051152040185433398,100.955984593287933064,101.051152040185433396 +0.000000000000000000,5.000000000000000000,101.300632196113444975,101.322362972192101996,101.227747489074241061,101.322362972192102026 +0.000000000000000000,6.000000000000000000,101.573252566543812668,101.594026085571732191,101.500171712102554313,101.594026085571732188 +0.000000000000000000,7.000000000000000000,101.846536099651002910,101.866142206631664440,101.773258620669331096,101.866142206631664461 +0.000000000000000000,8.000000000000000000,102.120484156866203536,102.138712163496831099,102.047009575226224169,102.138712163496831113 +0.000000000000000000,9.000000000000000000,102.395098101780608681,102.411736786113695643,102.321425938383338596,102.411736786113695635 +0.000000000000000000,10.000000000000000000,102.670379300148691209,102.685216906254195441,102.596509074912501729,102.685216906254195426 +0.000000000000000000,11.000000000000000000,102.946329119891489314,102.959153357519689614,102.872260351750547442,102.959153357519689647 +0.000000000000000000,12.000000000000000000,103.222948931099905597,103.233546975344910231,103.148681138002612645,103.233546975344910234 +10.000000000000000000,0.000000000000000000,99.999999999999999999,90.000000000000000000,99.999999999999999900,90.000000000000000000 +10.000000000000000000,1.000000000000000000,100.237677313404417325,90.238409177456207107,100.165561928789032613,90.238409177456207107 +10.000000000000000000,2.000000000000000000,100.506926232515342240,90.480617100260696127,100.434617138409879754,90.480617100260696136 +10.000000000000000000,3.000000000000000000,100.781858440324768397,90.727732344281960563,100.709351547921891803,90.727732344281960550 +10.000000000000000000,4.000000000000000000,101.062555880912431663,90.979799658510490062,100.989847042450637082,90.979799658510490036 +10.000000000000000000,5.000000000000000000,101.343951113789026381,91.232276907354260589,101.271039827245914266,91.232276907354260622 +10.000000000000000000,6.000000000000000000,101.626045568115882081,91.485164837833531184,101.552931330440851341,91.485164837833531208 +10.000000000000000000,7.000000000000000000,101.908840675216263844,91.738464198682831064,101.835522982328956649,91.738464198682831047 +10.000000000000000000,8.000000000000000000,102.192337868578661021,91.992175740354667782,102.118816215367405114,91.992175740354667792 +10.000000000000000000,9.000000000000000000,102.476538583860092752,92.246300215023238184,102.402812464180341061,92.246300215023238160 +10.000000000000000000,10.000000000000000000,102.761444258889428485,92.500838376588140513,102.687513165562196889,92.500838376588140525 +10.000000000000000000,11.000000000000000000,103.047056333670726483,92.755790980678090272,102.972919758481028614,92.755790980678090281 +10.000000000000000000,12.000000000000000000,103.333376250386587699,93.011158784654637462,103.259033684081867783,93.011158784654637479 diff --git a/results/rebalance_fairness_timeline.gp b/results/rebalance_fairness_timeline.gp new file mode 100644 index 00000000..4172987f --- /dev/null +++ b/results/rebalance_fairness_timeline.gp @@ -0,0 +1,34 @@ +# Generated by RebalanceFairnessScan.t.sol - timeline with weekly compounding +# +# Design case: 10% price drop, 25% lev, 37.5% liquidation. +# Weekly: Eve mints lev (CR recovery), harvest, compound (Alice+Charlie claim wCOL, +# freeMint haXXX, re-deposit). Charlie's hsXXX rebalance reward stays as claimable. +# +# CSV: 1=Fee_pct, 2=Week, 3=Alice_haXXX_eq, 4=Bob_haXXX_eq, +# 5=Charlie_haXXX_eq, 6=Dave_haXXX_eq +set datafile separator ',' +set bmargin 7 +set key below spacing 1.3 +set grid + +set terminal pngcairo size 1400,500 enhanced font 'Helvetica,11' +set output './results/rebalance_fairness_timeline.png' +set multiplot layout 1,2 title 'haXXX-Equivalent Position Over Time (weekly compound)' font 'Helvetica,13' + +set xlabel 'Week' +set ylabel 'haXXX-equivalent' +set title 'Coll SP: Alice (stayer) vs Bob (returner)' +set xrange [0:12] +set yrange [*:*] +plot "< awk -F, 'NR>1 && $1==0' ./results/rebalance_fairness_timeline.csv" using 2:3 with linespoints lw 2 title 'Alice (no fee)', \ + "< awk -F, 'NR>1 && $1==0' ./results/rebalance_fairness_timeline.csv" using 2:4 with linespoints lw 2 title 'Bob (no fee)', \ + "< awk -F, 'NR>1 && $1==10' ./results/rebalance_fairness_timeline.csv" using 2:3 with linespoints lw 2 dt 2 title 'Alice (10% fee)', \ + "< awk -F, 'NR>1 && $1==10' ./results/rebalance_fairness_timeline.csv" using 2:4 with linespoints lw 2 dt 2 title 'Bob (10% fee)' + +set title 'Lev SP: Charlie (stayer) vs Dave (returner)' +plot "< awk -F, 'NR>1 && $1==0' ./results/rebalance_fairness_timeline.csv" using 2:5 with linespoints lw 2 title 'Charlie (no fee)', \ + "< awk -F, 'NR>1 && $1==0' ./results/rebalance_fairness_timeline.csv" using 2:6 with linespoints lw 2 title 'Dave (no fee)', \ + "< awk -F, 'NR>1 && $1==10' ./results/rebalance_fairness_timeline.csv" using 2:5 with linespoints lw 2 dt 2 title 'Charlie (10% fee)', \ + "< awk -F, 'NR>1 && $1==10' ./results/rebalance_fairness_timeline.csv" using 2:6 with linespoints lw 2 dt 2 title 'Dave (10% fee)' + +unset multiplot diff --git a/script/config/ConfigTokenNames.sol b/script/config/ConfigTokenNames.sol index 48d5c22f..0de42c3a 100644 --- a/script/config/ConfigTokenNames.sol +++ b/script/config/ConfigTokenNames.sol @@ -77,4 +77,35 @@ abstract contract ConfigTokenNames { function spLeveragedSymbol() public view returns (string memory symbol) { (, symbol) = _spStrings(Liquidation.Leveraged); } + + // ── Auto-compounder tokens ───────────────────────────────────────── + + function _acStrings(Liquidation liquidation) private view returns (string memory name, string memory symbol) { + string memory liqSymbol = liquidation == Liquidation.Collateral + ? _collateral() + : string.concat("hs", _collateral().upper()); + + name = string.concat("Harbor auto-compounder: ", peggedSymbol(), " (", liqSymbol, ")"); + symbol = string.concat("hc", _peg(), "(", liqSymbol, ")"); + } + + /// @notice Collateral auto-compounder name (e.g., "Harbor auto-compounder: haETH (fxUSD)"). + function acCollateralName() public view returns (string memory name) { + (name, ) = _acStrings(Liquidation.Collateral); + } + + /// @notice Collateral auto-compounder symbol (e.g., "hcETH(fxUSD)"). + function acCollateralSymbol() public view returns (string memory symbol) { + (, symbol) = _acStrings(Liquidation.Collateral); + } + + /// @notice Leveraged auto-compounder name (e.g., "Harbor auto-compounder: haETH (hsFXUSD)"). + function acLeveragedName() public view returns (string memory name) { + (name, ) = _acStrings(Liquidation.Leveraged); + } + + /// @notice Leveraged auto-compounder symbol (e.g., "hcETH(hsFXUSD)"). + function acLeveragedSymbol() public view returns (string memory symbol) { + (, symbol) = _acStrings(Liquidation.Leveraged); + } } diff --git a/script/config/autocompounder/ConfigAutoCompounder.sol b/script/config/autocompounder/ConfigAutoCompounder.sol new file mode 100644 index 00000000..d6007836 --- /dev/null +++ b/script/config/autocompounder/ConfigAutoCompounder.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.28 <0.9.0; + +/// @notice Auto-compounder configuration defaults. +abstract contract ConfigAutoCompounder { + /// @notice Maximum fee ratio for compound minting (18 decimals). + /// @dev 5% default - compound will skip if cumulative fee exceeds this. + function autoCompounderMaxFeeRatio() public pure virtual returns (uint256) { + return 0.05 ether; + } +} diff --git a/script/config/markets/ConfigMarket_BTC_fxUSD_mainnet.sol b/script/config/markets/ConfigMarket_BTC_fxUSD_mainnet.sol index b102c55e..64ff8059 100644 --- a/script/config/markets/ConfigMarket_BTC_fxUSD_mainnet.sol +++ b/script/config/markets/ConfigMarket_BTC_fxUSD_mainnet.sol @@ -9,6 +9,7 @@ import {ConfigStabilityPool} from "../stabilitypool/ConfigStabilityPool.sol"; import {ConfigStabilityPoolManager} from "../stabilitypool/ConfigStabilityPoolManager.sol"; import {Config_MinterMarket} from "../ConfigBase.sol"; import {ConfigTokenNames} from "../ConfigTokenNames.sol"; +import {ConfigAutoCompounder} from "../autocompounder/ConfigAutoCompounder.sol"; /// @notice Market configuration for BTC::fxUSD. contract ConfigMarket_BTC_fxUSD_mainnet is @@ -19,5 +20,6 @@ contract ConfigMarket_BTC_fxUSD_mainnet is ConfigPriceVolatility_130_stable, ConfigStabilityPool, ConfigStabilityPoolManager, - ConfigTokenNames + ConfigTokenNames, + ConfigAutoCompounder {} diff --git a/script/config/markets/ConfigMarket_BTC_stETH_mainnet.sol b/script/config/markets/ConfigMarket_BTC_stETH_mainnet.sol index b75285d7..c9314310 100644 --- a/script/config/markets/ConfigMarket_BTC_stETH_mainnet.sol +++ b/script/config/markets/ConfigMarket_BTC_stETH_mainnet.sol @@ -9,6 +9,7 @@ import {ConfigStabilityPool} from "../stabilitypool/ConfigStabilityPool.sol"; import {ConfigStabilityPoolManager} from "../stabilitypool/ConfigStabilityPoolManager.sol"; import {Config_MinterMarket} from "../ConfigBase.sol"; import {ConfigTokenNames} from "../ConfigTokenNames.sol"; +import {ConfigAutoCompounder} from "../autocompounder/ConfigAutoCompounder.sol"; /// @notice Market configuration for BTC::stETH. contract ConfigMarket_BTC_stETH_mainnet is @@ -19,5 +20,6 @@ contract ConfigMarket_BTC_stETH_mainnet is ConfigPriceVolatility_125_stable, ConfigStabilityPool, ConfigStabilityPoolManager, - ConfigTokenNames + ConfigTokenNames, + ConfigAutoCompounder {} diff --git a/script/config/markets/ConfigMarket_ETH_fxUSD_mainnet.sol b/script/config/markets/ConfigMarket_ETH_fxUSD_mainnet.sol index dd64f4e3..1f128c9f 100644 --- a/script/config/markets/ConfigMarket_ETH_fxUSD_mainnet.sol +++ b/script/config/markets/ConfigMarket_ETH_fxUSD_mainnet.sol @@ -8,6 +8,7 @@ import {ConfigPriceVolatility_130_stable} from "../volatility/ConfigPriceVolatil import {ConfigStabilityPool} from "../stabilitypool/ConfigStabilityPool.sol"; import {ConfigStabilityPoolManager} from "../stabilitypool/ConfigStabilityPoolManager.sol"; import {ConfigTokenNames} from "../ConfigTokenNames.sol"; +import {ConfigAutoCompounder} from "../autocompounder/ConfigAutoCompounder.sol"; import {Config_MinterMarket} from "../ConfigBase.sol"; /// @notice Market configuration for ETH::fxUSD. @@ -19,5 +20,6 @@ contract ConfigMarket_ETH_fxUSD_mainnet is ConfigPriceVolatility_130_stable, ConfigStabilityPool, ConfigStabilityPoolManager, - ConfigTokenNames + ConfigTokenNames, + ConfigAutoCompounder {} diff --git a/script/config/markets/ConfigMarket_EUR_fxUSD_mainnet.sol b/script/config/markets/ConfigMarket_EUR_fxUSD_mainnet.sol index 6dfb657e..2da9ddec 100644 --- a/script/config/markets/ConfigMarket_EUR_fxUSD_mainnet.sol +++ b/script/config/markets/ConfigMarket_EUR_fxUSD_mainnet.sol @@ -9,6 +9,7 @@ import {ConfigStabilityPool} from "../stabilitypool/ConfigStabilityPool.sol"; import {ConfigStabilityPoolManager} from "../stabilitypool/ConfigStabilityPoolManager.sol"; import {Config_MinterMarket} from "../ConfigBase.sol"; import {ConfigTokenNames} from "../ConfigTokenNames.sol"; +import {ConfigAutoCompounder} from "../autocompounder/ConfigAutoCompounder.sol"; /// @notice Market configuration for EUR::fxUSD. contract ConfigMarket_EUR_fxUSD_mainnet is @@ -19,5 +20,6 @@ contract ConfigMarket_EUR_fxUSD_mainnet is ConfigPriceVolatility_105, ConfigStabilityPool, ConfigStabilityPoolManager, - ConfigTokenNames + ConfigTokenNames, + ConfigAutoCompounder {} diff --git a/script/config/markets/ConfigMarket_EUR_stETH_mainnet.sol b/script/config/markets/ConfigMarket_EUR_stETH_mainnet.sol index deafde1c..3fa5c72a 100644 --- a/script/config/markets/ConfigMarket_EUR_stETH_mainnet.sol +++ b/script/config/markets/ConfigMarket_EUR_stETH_mainnet.sol @@ -9,6 +9,7 @@ import {ConfigStabilityPool} from "../stabilitypool/ConfigStabilityPool.sol"; import {ConfigStabilityPoolManager} from "../stabilitypool/ConfigStabilityPoolManager.sol"; import {Config_MinterMarket} from "../ConfigBase.sol"; import {ConfigTokenNames} from "../ConfigTokenNames.sol"; +import {ConfigAutoCompounder} from "../autocompounder/ConfigAutoCompounder.sol"; /// @notice Market configuration for EUR::stETH. contract ConfigMarket_EUR_stETH_mainnet is @@ -19,5 +20,6 @@ contract ConfigMarket_EUR_stETH_mainnet is ConfigPriceVolatility_130, ConfigStabilityPool, ConfigStabilityPoolManager, - ConfigTokenNames + ConfigTokenNames, + ConfigAutoCompounder {} diff --git a/script/config/markets/ConfigMarket_GOLD_fxUSD_mainnet.sol b/script/config/markets/ConfigMarket_GOLD_fxUSD_mainnet.sol index 3fb856af..f7802dc0 100644 --- a/script/config/markets/ConfigMarket_GOLD_fxUSD_mainnet.sol +++ b/script/config/markets/ConfigMarket_GOLD_fxUSD_mainnet.sol @@ -9,6 +9,7 @@ import {ConfigStabilityPool} from "../stabilitypool/ConfigStabilityPool.sol"; import {ConfigStabilityPoolManager} from "../stabilitypool/ConfigStabilityPoolManager.sol"; import {Config_MinterMarket} from "../ConfigBase.sol"; import {ConfigTokenNames} from "../ConfigTokenNames.sol"; +import {ConfigAutoCompounder} from "../autocompounder/ConfigAutoCompounder.sol"; /// @notice Market configuration for GOLD::fxUSD. contract ConfigMarket_GOLD_fxUSD_mainnet is @@ -19,5 +20,6 @@ contract ConfigMarket_GOLD_fxUSD_mainnet is ConfigPriceVolatility_115, ConfigStabilityPool, ConfigStabilityPoolManager, - ConfigTokenNames + ConfigTokenNames, + ConfigAutoCompounder {} diff --git a/script/config/markets/ConfigMarket_GOLD_stETH_mainnet.sol b/script/config/markets/ConfigMarket_GOLD_stETH_mainnet.sol index c22187c1..674268e0 100644 --- a/script/config/markets/ConfigMarket_GOLD_stETH_mainnet.sol +++ b/script/config/markets/ConfigMarket_GOLD_stETH_mainnet.sol @@ -9,6 +9,7 @@ import {ConfigStabilityPool} from "../stabilitypool/ConfigStabilityPool.sol"; import {ConfigStabilityPoolManager} from "../stabilitypool/ConfigStabilityPoolManager.sol"; import {Config_MinterMarket} from "../ConfigBase.sol"; import {ConfigTokenNames} from "../ConfigTokenNames.sol"; +import {ConfigAutoCompounder} from "../autocompounder/ConfigAutoCompounder.sol"; /// @notice Market configuration for GOLD::stETH. contract ConfigMarket_GOLD_stETH_mainnet is @@ -19,5 +20,6 @@ contract ConfigMarket_GOLD_stETH_mainnet is ConfigPriceVolatility_130, ConfigStabilityPool, ConfigStabilityPoolManager, - ConfigTokenNames + ConfigTokenNames, + ConfigAutoCompounder {} diff --git a/script/config/markets/ConfigMarket_MCAP_fxUSD_mainnet.sol b/script/config/markets/ConfigMarket_MCAP_fxUSD_mainnet.sol index 4f6877b0..cf9bc6b9 100644 --- a/script/config/markets/ConfigMarket_MCAP_fxUSD_mainnet.sol +++ b/script/config/markets/ConfigMarket_MCAP_fxUSD_mainnet.sol @@ -9,6 +9,7 @@ import {ConfigStabilityPool} from "../stabilitypool/ConfigStabilityPool.sol"; import {ConfigStabilityPoolManager} from "../stabilitypool/ConfigStabilityPoolManager.sol"; import {Config_MinterMarket} from "../ConfigBase.sol"; import {ConfigTokenNames} from "../ConfigTokenNames.sol"; +import {ConfigAutoCompounder} from "../autocompounder/ConfigAutoCompounder.sol"; /// @notice Market configuration for MCAP::fxUSD. contract ConfigMarket_MCAP_fxUSD_mainnet is @@ -19,5 +20,6 @@ contract ConfigMarket_MCAP_fxUSD_mainnet is ConfigPriceVolatility_130, ConfigStabilityPool, ConfigStabilityPoolManager, - ConfigTokenNames + ConfigTokenNames, + ConfigAutoCompounder {} diff --git a/script/config/markets/ConfigMarket_MCAP_stETH_mainnet.sol b/script/config/markets/ConfigMarket_MCAP_stETH_mainnet.sol index 67ce8d2d..94b3a4e9 100644 --- a/script/config/markets/ConfigMarket_MCAP_stETH_mainnet.sol +++ b/script/config/markets/ConfigMarket_MCAP_stETH_mainnet.sol @@ -9,6 +9,7 @@ import {ConfigStabilityPool} from "../stabilitypool/ConfigStabilityPool.sol"; import {ConfigStabilityPoolManager} from "../stabilitypool/ConfigStabilityPoolManager.sol"; import {Config_MinterMarket} from "../ConfigBase.sol"; import {ConfigTokenNames} from "../ConfigTokenNames.sol"; +import {ConfigAutoCompounder} from "../autocompounder/ConfigAutoCompounder.sol"; /// @notice Market configuration for MCAP::stETH. contract ConfigMarket_MCAP_stETH_mainnet is @@ -19,5 +20,6 @@ contract ConfigMarket_MCAP_stETH_mainnet is ConfigPriceVolatility_130, ConfigStabilityPool, ConfigStabilityPoolManager, - ConfigTokenNames + ConfigTokenNames, + ConfigAutoCompounder {} diff --git a/script/config/markets/ConfigMarket_SILVER_fxUSD_mainnet.sol b/script/config/markets/ConfigMarket_SILVER_fxUSD_mainnet.sol index 0bd45d78..78db7a3f 100644 --- a/script/config/markets/ConfigMarket_SILVER_fxUSD_mainnet.sol +++ b/script/config/markets/ConfigMarket_SILVER_fxUSD_mainnet.sol @@ -9,6 +9,7 @@ import {ConfigStabilityPool} from "../stabilitypool/ConfigStabilityPool.sol"; import {ConfigStabilityPoolManager} from "../stabilitypool/ConfigStabilityPoolManager.sol"; import {Config_MinterMarket} from "../ConfigBase.sol"; import {ConfigTokenNames} from "../ConfigTokenNames.sol"; +import {ConfigAutoCompounder} from "../autocompounder/ConfigAutoCompounder.sol"; /// @notice Market configuration for SILVER::fxUSD. contract ConfigMarket_SILVER_fxUSD_mainnet is @@ -19,5 +20,6 @@ contract ConfigMarket_SILVER_fxUSD_mainnet is ConfigPriceVolatility_125, ConfigStabilityPool, ConfigStabilityPoolManager, - ConfigTokenNames + ConfigTokenNames, + ConfigAutoCompounder {} diff --git a/script/config/markets/ConfigMarket_SILVER_stETH_mainnet.sol b/script/config/markets/ConfigMarket_SILVER_stETH_mainnet.sol index b7657f73..f854d43c 100644 --- a/script/config/markets/ConfigMarket_SILVER_stETH_mainnet.sol +++ b/script/config/markets/ConfigMarket_SILVER_stETH_mainnet.sol @@ -9,6 +9,7 @@ import {ConfigStabilityPool} from "../stabilitypool/ConfigStabilityPool.sol"; import {ConfigStabilityPoolManager} from "../stabilitypool/ConfigStabilityPoolManager.sol"; import {Config_MinterMarket} from "../ConfigBase.sol"; import {ConfigTokenNames} from "../ConfigTokenNames.sol"; +import {ConfigAutoCompounder} from "../autocompounder/ConfigAutoCompounder.sol"; /// @notice Market configuration for SILVER::stETH. contract ConfigMarket_SILVER_stETH_mainnet is @@ -19,5 +20,6 @@ contract ConfigMarket_SILVER_stETH_mainnet is ConfigPriceVolatility_130, ConfigStabilityPool, ConfigStabilityPoolManager, - ConfigTokenNames + ConfigTokenNames, + ConfigAutoCompounder {} diff --git a/script/src/v3/DeployMintersShared.sol b/script/src/v3/DeployMintersShared.sol index 75143f19..5ceed9f7 100644 --- a/script/src/v3/DeployMintersShared.sol +++ b/script/src/v3/DeployMintersShared.sol @@ -9,6 +9,7 @@ import {Minter} from "./contracts/Minter.sol"; import {StabilityPool} from "./contracts/StabilityPool.sol"; import {StabilityPoolManager} from "./contracts/StabilityPoolManager.sol"; import {Genesis} from "./contracts/Genesis.sol"; +import {AutoCompounder, IAutoCompounderMarketConfig} from "./contracts/AutoCompounder.sol"; import {HarborFactoryDeployer} from "script/src/HarborFactoryDeployer.sol"; import {DeploymentState} from "@bao-script/deployment/DeploymentState.sol"; import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; @@ -16,7 +17,6 @@ import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; import {Config_MinterMarket, IMarketConfig, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; import {IMinter} from "src/interfaces/IMinter.sol"; import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; -import {LinearMultipleRewardDistributor_v3} from "src/reward/distributor/LinearMultipleRewardDistributor_v3.sol"; /// @notice Extended market config interface with methods from collateral and chain configs. interface IFullMinterConfig { @@ -45,7 +45,8 @@ abstract contract DeployMintersShared is Minter, StabilityPool, StabilityPoolManager, - Genesis + Genesis, + AutoCompounder { using LibString for string; @@ -166,8 +167,11 @@ abstract contract DeployMintersShared is // Deploy Stability Pools _deployStabilityPools(state, cfg, marketKey); - // Deploy reward aliases and register on SPs - _deployRewardAliases(state, cfg, marketKey); + // Register reward tokens on SPs + _registerRewardTokens(cfg, marketKey); + + // Deploy Auto-Compounders (one per SP) + _deployAutoCompounders(state, cfg, marketKey); // Deploy StabilityPoolManager _deployStabilityPoolManager(state, cfg, marketKey); @@ -217,48 +221,44 @@ abstract contract DeployMintersShared is ); } - function _deployRewardAliases( - DeploymentTypes.State memory state, + function _deployAutoCompounders( + DeploymentTypes.State memory stateData, IFullMinterConfig cfg, string memory marketKey ) internal { - string memory spCollKey = _key(marketKey, StabilityPoolCollateral); - string memory spLevKey = _key(marketKey, StabilityPoolLeveraged); + address minter = _predictAddress(_key(marketKey, "minter")); + address spCollateral = _predictAddress(_key(marketKey, StabilityPoolCollateral)); + address spLeveraged = _predictAddress(_key(marketKey, StabilityPoolLeveraged)); + + deployAutoCompounder( + AutoCompounderCollateral, + stateData, + Config_MinterMarket(address(cfg)), + spCollateral, + minter + ); + + deployAutoCompounder( + AutoCompounderLeveraged, + stateData, + Config_MinterMarket(address(cfg)), + spLeveraged, + minter + ); + } + + function _registerRewardTokens(IFullMinterConfig cfg, string memory marketKey) internal { + address spCollateral = _predictAddress(_key(marketKey, StabilityPoolCollateral)); + address spLeveraged = _predictAddress(_key(marketKey, StabilityPoolLeveraged)); address wrappedCollateral = cfg.wrappedCollateralToken(); address leveragedToken = _predictAddress(_key(marketKey, "leveraged")); - // Collateral SP: wrappedCollateral with harvest + rebalance aliases - deployRewardAlias(state, spCollKey, "harvest", wrappedCollateral); - deployRewardAlias(state, spCollKey, "rebalance", wrappedCollateral); - { - address[] memory collAliases = new address[](2); - collAliases[0] = _predictAddress(_key(spCollKey, "harvest")); - collAliases[1] = _predictAddress(_key(spCollKey, "rebalance")); - LinearMultipleRewardDistributor_v3(_predictAddress(spCollKey)).registerRewardToken( - wrappedCollateral, - collAliases - ); - } + // Collateral SP: wrappedCollateral (receives both harvest + rebalance rewards) + IMultipleRewardDistributor(spCollateral).registerRewardToken(wrappedCollateral); - // Leveraged SP: wrappedCollateral with harvest alias, leveragedToken with rebalance alias - deployRewardAlias(state, spLevKey, "harvest", wrappedCollateral); - deployRewardAlias(state, spLevKey, "rebalance", leveragedToken); - { - address[] memory levHarvestAliases = new address[](1); - levHarvestAliases[0] = _predictAddress(_key(spLevKey, "harvest")); - LinearMultipleRewardDistributor_v3(_predictAddress(spLevKey)).registerRewardToken( - wrappedCollateral, - levHarvestAliases - ); - } - { - address[] memory levRebalAliases = new address[](1); - levRebalAliases[0] = _predictAddress(_key(spLevKey, "rebalance")); - LinearMultipleRewardDistributor_v3(_predictAddress(spLevKey)).registerRewardToken( - leveragedToken, - levRebalAliases - ); - } + // Leveraged SP: wrappedCollateral (harvest) + leveragedToken (rebalance) + IMultipleRewardDistributor(spLeveraged).registerRewardToken(wrappedCollateral); + IMultipleRewardDistributor(spLeveraged).registerRewardToken(leveragedToken); } function _deployStabilityPoolManager( @@ -305,6 +305,25 @@ abstract contract DeployMintersShared is grantStabilityPoolRoles(string.concat(marketKey, "::stabilityPoolCollateral"), spCollateral, spm); grantStabilityPoolRoles(string.concat(marketKey, "::stabilityPoolLeveraged"), spLeveraged, spm); + // Grant SP fee exemption to auto-compounders (using predicted addresses — AC need not be deployed) + { + address acCollateral = _predictAddress(_key(marketKey, AutoCompounderCollateral)); + address acLeveraged = _predictAddress(_key(marketKey, AutoCompounderLeveraged)); + string memory spCollKey = string.concat(marketKey, "::stabilityPoolCollateral"); + string memory spLevKey = string.concat(marketKey, "::stabilityPoolLeveraged"); + grantStabilityPoolAutoCompounderRole(spCollKey, spCollateral, acCollateral, "autoCompounderCollateral"); + grantStabilityPoolAutoCompounderRole(spLevKey, spLeveraged, acLeveraged, "autoCompounderLeveraged"); + } + + // Configure Auto-Compounders (maxFeeRatio, approvals) + { + uint256 maxFeeRatio = IAutoCompounderMarketConfig(address(market)).autoCompounderMaxFeeRatio(); + address acCollateral = _predictAddress(_key(marketKey, AutoCompounderCollateral)); + address acLeveraged = _predictAddress(_key(marketKey, AutoCompounderLeveraged)); + configureAutoCompounder(acCollateral, maxFeeRatio); + configureAutoCompounder(acLeveraged, maxFeeRatio); + } + // Configure StabilityPoolManager configureStabilityPoolManager( spm, diff --git a/script/src/v3/contracts/AutoCompounder.sol b/script/src/v3/contracts/AutoCompounder.sol new file mode 100644 index 00000000..2b826196 --- /dev/null +++ b/script/src/v3/contracts/AutoCompounder.sol @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.28 <0.9.0; + +import {console2 as console} from "forge-std/console2.sol"; +import {HarborFactoryDeployer} from "script/src/HarborFactoryDeployer.sol"; +import {DeploymentState} from "@bao-script/deployment/DeploymentState.sol"; +import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; + +import {AutoCompounder_v1} from "@harbor/autocompounding/AutoCompounder_v1.sol"; +import {Config_MinterMarket, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; +import {ConfigTokenNames} from "script/config/ConfigTokenNames.sol"; + +/// @notice Config interface for auto-compounder deployment parameters. +interface IAutoCompounderMarketConfig { + function autoCompounderMaxFeeRatio() external pure returns (uint256); +} + +interface IStabilityPoolRole { + function EXEMPT_WITHDRAWAL_FEE_ROLE() external view returns (uint256); // solhint-disable-line func-name-mixedcase +} + +/// @notice Harbor AutoCompounder deployment logic. +/// @dev Each market has TWO auto-compounders: Collateral and Leveraged (one per stability pool). +/// Post-deployment: setMaxFeeRatio, approveCompoundTokens. +/// EXEMPT_WITHDRAWAL_FEE_ROLE is granted via grantStabilityPoolAutoCompounderRole (using predicted address). +abstract contract AutoCompounder is HarborFactoryDeployer { + string AutoCompounderCollateral = "autoCompounderCollateral"; + string AutoCompounderLeveraged = "autoCompounderLeveraged"; + + // ========== AUTO-COMPOUNDER DEPLOYMENT ========== + + /// @notice Deploy AutoCompounder impl only, record in state. + function deployAutoCompounderImplementation( + string memory acType, + DeploymentTypes.State memory stateData, + Config_MinterMarket marketConfig, + address stabilityPool, + address minter + ) internal virtual returns (address impl) { + string memory marketKey = MinterMarketConfigLib.salt(marketConfig); + string memory acKey = string.concat(marketKey, "::", acType); + console.log(" > %s", acKey); + + ConfigTokenNames names = ConfigTokenNames(address(marketConfig)); + bool isCollateral = keccak256(bytes(acType)) == keccak256("autoCompounderCollateral"); + string memory tokenName = isCollateral ? names.acCollateralName() : names.acLeveragedName(); + string memory tokenSymbol = isCollateral ? names.acCollateralSymbol() : names.acLeveragedSymbol(); + + impl = address(new AutoCompounder_v1(stabilityPool, minter, tokenName, tokenSymbol)); + console.log(" Impl: %s", impl); + console.log(" Name: %s", tokenName); + console.log(" Symbol: %s", tokenSymbol); + + DeploymentState.recordImplementation( + stateData, + DeploymentTypes.ImplementationRecord({ + proxy: acKey, + contractSource: "@harbor/autocompounding/AutoCompounder_v1.sol", + contractType: "AutoCompounder_v1", + implementation: impl, + deploymentTime: uint64(block.timestamp) + }) + ); + } + + /// @notice Deploy AutoCompounder impl+proxy, record in state. + function deployAutoCompounder( + string memory acType, + DeploymentTypes.State memory stateData, + Config_MinterMarket marketConfig, + address stabilityPool, + address minter + ) internal returns (address proxy) { + string memory marketKey = MinterMarketConfigLib.salt(marketConfig); + string memory acKey = string.concat(marketKey, "::", acType); + + address impl = deployAutoCompounderImplementation(acType, stateData, marketConfig, stabilityPool, minter); + + bytes memory initData = abi.encodeCall(AutoCompounder_v1.initialize, (address(this), owner())); + + proxy = _deployProxyAndRecord(stateData, acKey, impl, initData); + } + + /// @notice Post-deployment configuration: set maxFeeRatio and approve tokens. + function configureAutoCompounder(address acProxy, uint256 maxFeeRatio) internal { + AutoCompounder_v1(acProxy).setMaxFeeRatio(maxFeeRatio); + AutoCompounder_v1(acProxy).approveCompoundTokens(); + } + + /// @notice Grant EXEMPT_WITHDRAWAL_FEE_ROLE on a stability pool to an auto-compounder. + /// @dev Can be called with a predicted (not-yet-deployed) acProxy address. + function grantStabilityPoolAutoCompounderRole( + string memory stabilityPoolKey, + address stabilityPool, + address acProxy, + string memory acLabel + ) internal { + uint256 exemptRole = IStabilityPoolRole(stabilityPool).EXEMPT_WITHDRAWAL_FEE_ROLE(); + _grantRoles(stabilityPoolKey, stabilityPool, acProxy, acLabel, exemptRole, "EXEMPT_WITHDRAWAL_FEE"); + } +} diff --git a/script/src/v3/contracts/StabilityPool.sol b/script/src/v3/contracts/StabilityPool.sol index 976b91ae..cc27931c 100644 --- a/script/src/v3/contracts/StabilityPool.sol +++ b/script/src/v3/contracts/StabilityPool.sol @@ -8,7 +8,6 @@ import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; import {DeploymentState} from "@bao-script/deployment/DeploymentState.sol"; import {StabilityPool_v3} from "@harbor/minter/StabilityPool_v3.sol"; -import {RewardAlias_v1} from "@harbor/reward/RewardAlias_v1.sol"; import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; import {Config_MinterMarket, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; import {ConfigTokenNames} from "script/config/ConfigTokenNames.sol"; @@ -116,36 +115,4 @@ abstract contract StabilityPool is HarborFactoryDeployer { "REBALANCER | REWARD_DEPOSITOR" ); } - - // ========== REWARD ALIAS DEPLOYMENT ========== - - /// @notice Deploy a reward alias at a predictable address. - /// @param stateData Deployment state for recording. - /// @param spKey The stability pool local key (e.g. _key(marketKey, StabilityPoolCollateral)). - /// @param aliasName Alias purpose suffix (e.g. "harvest", "rebalance"). - /// @param underlying The underlying reward token address. - function deployRewardAlias( - DeploymentTypes.State memory stateData, - string memory spKey, - string memory aliasName, - address underlying - ) internal returns (address aliasProxy) { - string memory aliasKey = _key(spKey, aliasName); - console.log(" > %s", aliasKey); - - address impl = address(new RewardAlias_v1(underlying)); - console.log(" Impl: %s", impl); - console.log(" Underlying: %s", underlying); - - bytes memory initData = abi.encodeCall(RewardAlias_v1.initialize, (address(this), owner())); - - aliasProxy = _deployProxyAndRecord( - stateData, - aliasKey, - impl, - "@harbor/reward/RewardAlias_v1.sol", - "RewardAlias_v1", - initData - ); - } } diff --git a/script/src/v3/contracts/StabilityPoolManager.sol b/script/src/v3/contracts/StabilityPoolManager.sol index 155d204c..7c967b26 100644 --- a/script/src/v3/contracts/StabilityPoolManager.sol +++ b/script/src/v3/contracts/StabilityPoolManager.sol @@ -9,7 +9,7 @@ import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; import {StabilityPoolManager_v1} from "@harbor/minter/StabilityPoolManager_v1.sol"; import {TokenDistributor_v1} from "@harbor/minter/TokenDistributor_v1.sol"; -/// @notice Harbor StabilityPoolManager_v1 deployment logic (including SPMFeeReceiver). +/// @notice Harbor StabilityPoolManager deployment logic (including SPMFeeReceiver). /// @dev SPM coordinates the two stability pools per market. /// @dev SPM grants: HARVESTER_ROLE on Minter (obtained via Minter deployment). /// @dev SPM needs: REBALANCER_ROLE, REWARD_DEPOSITOR_ROLE on both stability pools. @@ -25,7 +25,7 @@ abstract contract StabilityPoolManager is HarborFactoryDeployer { // ========== STABILITY POOL MANAGER DEPLOYMENT ========== - /// @notice Deploy StabilityPoolManager impl+proxy, record in state. + /// @notice Deploy StabilityPoolManager_v1 impl+proxy, record in state. function deployStabilityPoolManager( DeploymentTypes.State memory stateData, string memory marketKey, diff --git a/src/autocompounding/AutoCompounder_v1.sol b/src/autocompounding/AutoCompounder_v1.sol index 694715d3..0cf76a77 100644 --- a/src/autocompounding/AutoCompounder_v1.sol +++ b/src/autocompounding/AutoCompounder_v1.sol @@ -6,11 +6,14 @@ import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/U import {ERC4626Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC4626Upgradeable.sol"; import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import {IERC5313} from "@openzeppelin/contracts/interfaces/IERC5313.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {HarborOwnable} from "@bao/HarborOwnable.sol"; +import {Token} from "@bao/Token.sol"; +import {TokenHolder, ITokenHolder} from "@bao/TokenHolder.sol"; import {IAutoCompounder} from "src/interfaces/IAutoCompounder.sol"; import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; @@ -18,18 +21,27 @@ import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumula import {IMultipleRewardAccumulator_v3} from "src/interfaces/IMultipleRewardAccumulator_v3.sol"; import {IMinter} from "src/interfaces/IMinter.sol"; import {IMinter_v3} from "src/interfaces/IMinter_v3.sol"; +import {StringPacking_v1} from "src/minter/library/StringPacking_v1.sol"; /// @title AutoCompounder_v1 /// @notice Level 1 auto-compounder: non-rebasing ERC4626 vault wrapping a rebasing stability pool position. /// @dev The ERC4626 asset is the SP token (rebasing ERC20). Share count is fixed on deposit; share price /// moves as totalAssets changes from harvest rewards, compounding, and rebalance losses. -/// compound() claims wCOLn rewards, mints pegged tokens via the Minter (fee-capped), and redeposits to the SP. -/// totalAssets() includes the SP position plus unclaimed wCOLn valued via Minter dry run. +/// compound() claims wrapped collateral rewards, mints pegged tokens via the Minter (fee-capped), +/// and redeposits to the SP. +/// totalAssets() includes the SP position plus unclaimed wrapped collateral valued via Minter dry run. /// Works for both collateral and leveraged stability pools. // solhint-disable-next-line contract-name-capwords -contract AutoCompounder_v1 is Initializable, UUPSUpgradeable, ERC4626Upgradeable, HarborOwnable, IERC5313, IAutoCompounder { +contract AutoCompounder_v1 is + Initializable, + UUPSUpgradeable, + ERC4626Upgradeable, + HarborOwnable, + TokenHolder, + IERC5313, + IAutoCompounder +{ using SafeERC20 for IERC20; - using Math for uint256; /*////////////////////////////////////////////////////////////////////////// ERRORS @@ -45,16 +57,17 @@ contract AutoCompounder_v1 is Initializable, UUPSUpgradeable, ERC4626Upgradeable EVENTS //////////////////////////////////////////////////////////////////////////*/ - /// @notice Emitted when compound() successfully converts rewards to SP position. + /// @notice Emitted on every compound() call. /// @param caller The address that triggered the compound. - /// @param collateralClaimed The amount of wrapped collateral claimed from the SP. - /// @param peggedMinted The amount of pegged tokens minted from the claimed collateral. - event Compounded(address indexed caller, uint256 collateralClaimed, uint256 peggedMinted); - - /// @notice Emitted when compound() skips because fees exceed the cap. - /// @param caller The address that triggered the compound. - /// @param claimableCollateral The amount of wrapped collateral available but not claimed. - event CompoundSkipped(address indexed caller, uint256 claimableCollateral); + /// @param claimableCollateral The total amount of wrapped collateral available before compound. + /// @param collateralClaimed The amount of wrapped collateral claimed (0 if skipped due to fees). + /// @param peggedMinted The amount of pegged tokens minted (0 if skipped due to fees). + event Compounded( + address indexed caller, + uint256 claimableCollateral, + uint256 collateralClaimed, + uint256 peggedMinted + ); /// @notice Emitted when the max fee ratio is updated. /// @param newMaxFeeRatio The new max fee ratio (18 decimals). @@ -80,6 +93,16 @@ contract AutoCompounder_v1 is Initializable, UUPSUpgradeable, ERC4626Upgradeable /// @custom:oz-upgrades-unsafe-allow state-variable-immutable address public immutable PEGGED_TOKEN; // solhint-disable-line immutable-vars-naming + /// @dev ERC20 name stored as two bytes32 (up to 64 characters) + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + bytes32 private immutable _ERC20_NAME_0; + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + bytes32 private immutable _ERC20_NAME_1; + + /// @dev ERC20 symbol stored as bytes32 (up to 32 characters) + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + bytes32 private immutable _ERC20_SYMBOL; + /*////////////////////////////////////////////////////////////////////////// STORAGE (ERC7201) //////////////////////////////////////////////////////////////////////////*/ @@ -108,7 +131,9 @@ contract AutoCompounder_v1 is Initializable, UUPSUpgradeable, ERC4626Upgradeable /// @custom:oz-upgrades-unsafe-allow constructor constructor( address stabilityPool_, - address minter_ + address minter_, + string memory name_, + string memory symbol_ ) ERC20Upgradeable() ERC4626Upgradeable() { _disableInitializers(); STABILITY_POOL = stabilityPool_; @@ -116,30 +141,18 @@ contract AutoCompounder_v1 is Initializable, UUPSUpgradeable, ERC4626Upgradeable WRAPPED_COLLATERAL = IMinter(minter_).WRAPPED_COLLATERAL_TOKEN(); PEGGED_TOKEN = IMinter(minter_).PEGGED_TOKEN(); assert(IStabilityPool(stabilityPool_).ASSET_TOKEN() == PEGGED_TOKEN); + (_ERC20_NAME_0, _ERC20_NAME_1) = StringPacking_v1.pack64(name_); + // slither-disable-next-line unused-return + (_ERC20_SYMBOL, ) = StringPacking_v1.pack64(symbol_); } /// @notice Initialize the auto-compounder. /// @param deployerOwner_ The initial owner (typically the FactoryDeployer). /// @param pendingOwner_ The final owner (typically the Harbor multisig). - /// @param maxFeeRatio_ The initial max fee ratio for compound minting (18 decimals). - /// @param name_ The ERC20 name for the AC share token. - /// @param symbol_ The ERC20 symbol for the AC share token. - function initialize( - address deployerOwner_, - address pendingOwner_, - uint256 maxFeeRatio_, - string memory name_, - string memory symbol_ - ) external initializer { + function initialize(address deployerOwner_, address pendingOwner_) external initializer { _initializeOwner(deployerOwner_, pendingOwner_); __UUPSUpgradeable_init(); __ERC4626_init(IERC20(STABILITY_POOL)); - __ERC20_init(name_, symbol_); - _getAutoCompounderStorage().maxFeeRatio = maxFeeRatio_; - - // Permanent approvals for compound flow - IERC20(PEGGED_TOKEN).approve(STABILITY_POOL, type(uint256).max); - IERC20(WRAPPED_COLLATERAL).approve(MINTER, type(uint256).max); } /*////////////////////////////////////////////////////////////////////////// @@ -173,15 +186,45 @@ contract AutoCompounder_v1 is Initializable, UUPSUpgradeable, ERC4626Upgradeable return _getAutoCompounderStorage().maxFeeRatio; } + /// @notice Set permanent token approvals for the compound flow. + /// @dev Called by the deployer after proxy creation. Approves the SP to spend pegged tokens + /// and the Minter to spend wrapped collateral. + function approveCompoundTokens() external onlyOwner { + IERC20(PEGGED_TOKEN).approve(STABILITY_POOL, type(uint256).max); + IERC20(WRAPPED_COLLATERAL).approve(MINTER, type(uint256).max); + } + + /*////////////////////////////////////////////////////////////////////////// + ERC20 METADATA (IMMUTABLE) + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice ERC20 name, packed into constructor immutables. + function name() public view override(ERC20Upgradeable, IERC20Metadata) returns (string memory) { + return StringPacking_v1.unpack64(_ERC20_NAME_0, _ERC20_NAME_1); + } + + /// @notice ERC20 symbol, packed into constructor immutables. + function symbol() public view override(ERC20Upgradeable, IERC20Metadata) returns (string memory) { + return StringPacking_v1.unpack64(_ERC20_SYMBOL, bytes32(0)); + } + + /// @dev Decimals match the SP token (18). + function decimals() public pure override(ERC4626Upgradeable) returns (uint8) { + return 18; + } + /*////////////////////////////////////////////////////////////////////////// ERC4626 OVERRIDES //////////////////////////////////////////////////////////////////////////*/ /// @notice Total assets under management, in SP share units. - /// @dev SP.balanceOf(this) + claimable wCOLn valued in pegged token terms via Minter dry run. + /// @dev SP.balanceOf(this) + claimable wrapped collateral valued in pegged token terms via Minter dry run. function totalAssets() public view override returns (uint256) { uint256 spPosition = IERC20(STABILITY_POOL).balanceOf(address(this)); - uint256 claimableCollateral = IMultipleRewardAccumulator(STABILITY_POOL).claimable(address(this), WRAPPED_COLLATERAL); + uint256 claimableCollateral = IMultipleRewardAccumulator(STABILITY_POOL).claimable( + address(this), + WRAPPED_COLLATERAL + ); if (claimableCollateral == 0) { return spPosition; } @@ -189,10 +232,11 @@ contract AutoCompounder_v1 is Initializable, UUPSUpgradeable, ERC4626Upgradeable // price = underlying collateral price in peg terms (18 dec) // rate = wrapped-to-underlying rate (18 dec) // claimableValue = claimableCollateral * rate * price / 1e36 - (,,,, uint256 price, uint256 rate) = - IMinter_v3(MINTER).mintPeggedTokenDryRun(claimableCollateral, type(uint256).max); - uint256 claimableValue = claimableCollateral.mulDiv(rate, 1e18).mulDiv(price, 1e18); - return spPosition + claimableValue; + (, , , , uint256 price, uint256 rate) = IMinter_v3(MINTER).mintPeggedTokenDryRun( + claimableCollateral, + type(uint256).max + ); + return spPosition + Math.mulDiv(claimableCollateral, price * rate, 1e36); } /*////////////////////////////////////////////////////////////////////////// @@ -209,26 +253,30 @@ contract AutoCompounder_v1 is Initializable, UUPSUpgradeable, ERC4626Upgradeable uint256 maxFee = _getAutoCompounderStorage().maxFeeRatio; // Dry run to see how much can be profitably minted within the fee cap - (,, uint256 collateralTaken,,,) = IMinter_v3(MINTER).mintPeggedTokenDryRun(claimable, maxFee); + (, , uint256 collateralTaken, , , ) = IMinter_v3(MINTER).mintPeggedTokenDryRun(claimable, maxFee); if (collateralTaken == 0) { - // Fee too high - skip. wCOLn stays as unclaimed in SP, included in totalAssets via claimable(). - emit CompoundSkipped(msg.sender, claimable); + // Fee too high - skip. Wrapped collateral stays as unclaimed in SP, + // included in totalAssets via claimable(). + emit Compounded(msg.sender, claimable, 0, 0); return; } // Fractional claim: only take what can be profitably minted IMultipleRewardAccumulator_v3(STABILITY_POOL).claim( - address(this), address(this), WRAPPED_COLLATERAL, collateralTaken + address(this), + address(this), + WRAPPED_COLLATERAL, + collateralTaken ); // Mint pegged tokens from the claimed collateral - (uint256 minted,) = IMinter_v3(MINTER).mintPeggedToken(collateralTaken, address(this), 0, maxFee); + (uint256 minted, ) = IMinter_v3(MINTER).mintPeggedToken(collateralTaken, address(this), 0, maxFee); // Deposit minted pegged tokens back into the SP IStabilityPool(STABILITY_POOL).deposit(minted, address(this), 0); - emit Compounded(msg.sender, collateralTaken, minted); + emit Compounded(msg.sender, claimable, collateralTaken, minted); } /*////////////////////////////////////////////////////////////////////////// @@ -237,22 +285,35 @@ contract AutoCompounder_v1 is Initializable, UUPSUpgradeable, ERC4626Upgradeable /// @inheritdoc IAutoCompounder function depositPeggedToken(uint256 peggedAmount, address receiver) external returns (uint256 shares) { - // Transfer pegged tokens from caller - IERC20(PEGGED_TOKEN).safeTransferFrom(msg.sender, address(this), peggedAmount); + peggedAmount = Token.allOf(msg.sender, PEGGED_TOKEN, peggedAmount); - // Deposit to SP - AC receives rebasing SP position + // Snapshot exchange rate BEFORE the SP deposit changes totalAssets + uint256 assetsBefore = totalAssets(); + uint256 supplyBefore = totalSupply(); + + // Transfer pegged tokens from caller, deposit to SP + IERC20(PEGGED_TOKEN).safeTransferFrom(msg.sender, address(this), peggedAmount); uint256 spBalanceBefore = IERC20(STABILITY_POOL).balanceOf(address(this)); IStabilityPool(STABILITY_POOL).deposit(peggedAmount, address(this), 0); uint256 spReceived = IERC20(STABILITY_POOL).balanceOf(address(this)) - spBalanceBefore; - // Mint AC shares for the SP shares received - shares = previewDeposit(spReceived); + // Compute shares at the pre-deposit exchange rate (matches ERC4626._convertToShares) + shares = Math.mulDiv(spReceived, supplyBefore + 1, assetsBefore + 1); if (shares == 0) { revert DepositPeggedTokenZeroShares(); } _mint(receiver, shares); } + /*////////////////////////////////////////////////////////////////////////// + SWEEP + //////////////////////////////////////////////////////////////////////////*/ + + /// @inheritdoc TokenHolder + function _checkSweeper() internal view override(TokenHolder) { + _checkOwner(); + } + /*////////////////////////////////////////////////////////////////////////// INTERNAL OVERRIDES //////////////////////////////////////////////////////////////////////////*/ @@ -266,10 +327,13 @@ contract AutoCompounder_v1 is Initializable, UUPSUpgradeable, ERC4626Upgradeable /// @dev Withdraw SP tokens from the vault to receiver. /// Uses SP.transfer (not SP.withdraw) - the user receives the rebasing SP token directly. - function _withdraw(address caller, address receiver, address tokenOwner, uint256 assets, uint256 shares) - internal - override - { + function _withdraw( + address caller, + address receiver, + address tokenOwner, + uint256 assets, + uint256 shares + ) internal override { if (caller != tokenOwner) { _spendAllowance(tokenOwner, caller, shares); } @@ -277,9 +341,4 @@ contract AutoCompounder_v1 is Initializable, UUPSUpgradeable, ERC4626Upgradeable IERC20(STABILITY_POOL).safeTransfer(receiver, assets); emit Withdraw(caller, receiver, tokenOwner, assets, shares); } - - /// @dev Decimals match the SP token (18). - function decimals() public pure override returns (uint8) { - return 18; - } } diff --git a/src/interfaces/IAutoCompounder.sol b/src/interfaces/IAutoCompounder.sol index 50273a20..07fb8ac7 100644 --- a/src/interfaces/IAutoCompounder.sol +++ b/src/interfaces/IAutoCompounder.sol @@ -5,7 +5,7 @@ pragma solidity >=0.8.28 <0.9.0; /// @notice Interface for the Level 1 Auto-Compounder (ERC4626). /// @dev Wraps a rebasing StabilityPool into a non-rebasing StabilityPool share. interface IAutoCompounder { - /// @notice Compound pending rewards: claim wCOLn, mint pegged tokens, redeposit to SP. + /// @notice Compound pending rewards: claim wrapped collateral, mint pegged tokens, redeposit to SP. /// Only claims what can be profitably minted. Remainder stays as unclaimed in SP. /// Permissionless - anyone can trigger. function compound() external; diff --git a/src/interfaces/IMultipleRewardDistributor.sol b/src/interfaces/IMultipleRewardDistributor.sol index 0f84157c..51a88fb8 100644 --- a/src/interfaces/IMultipleRewardDistributor.sol +++ b/src/interfaces/IMultipleRewardDistributor.sol @@ -48,9 +48,6 @@ interface IMultipleRewardDistributor { /// @dev Thrown when period length is non-zero and outside the range 1 day to 28 day (inclusive). error InvalidPeriodLength(uint40 periodLength); - /// @dev Thrown when an alias's underlying() does not match the expected underlying token. - error AliasUnderlyingMismatch(); - /************************* * Public View Functions * *************************/ diff --git a/src/interfaces/IRewardAlias.sol b/src/interfaces/IRewardAlias.sol deleted file mode 100644 index 7d92bb7e..00000000 --- a/src/interfaces/IRewardAlias.sol +++ /dev/null @@ -1,15 +0,0 @@ -// SPDX-License-Identifier: MIT - -pragma solidity >=0.8.28 <0.9.0; - -/// @notice Interface for a reward token alias. -/// @dev If a reward token address implements this interface and returns a non-zero underlying, -/// the reward system treats it as an alias: integrals track under the alias address, -/// but token transfers use the underlying address. -interface IRewardAlias { - error ZeroAddress(); - - /// @notice Returns the underlying token this alias represents. - /// @return The underlying token address. address(0) means not an alias. - function underlying() external view returns (address); -} diff --git a/src/interfaces/IStabilityPool_v3.sol b/src/interfaces/IStabilityPool_v3.sol deleted file mode 100644 index fe338c4e..00000000 --- a/src/interfaces/IStabilityPool_v3.sol +++ /dev/null @@ -1,10 +0,0 @@ -// SPDX-License-Identifier: MIT - -pragma solidity >=0.8.28 <0.9.0; - -import {IMultipleRewardAccumulator_v3} from "src/interfaces/IMultipleRewardAccumulator_v3.sol"; - -/// @notice StabilityPool v3 additions: unified claim interface. -/// @dev Does NOT inherit IStabilityPool — the SP_v3 contract inherits both separately. -// solhint-disable-next-line contract-name-capwords,no-empty-blocks -interface IStabilityPool_v3 is IMultipleRewardAccumulator_v3 {} diff --git a/src/minter/StabilityPool_v3.sol b/src/minter/StabilityPool_v3.sol index 1e7f4a6e..aed06f29 100644 --- a/src/minter/StabilityPool_v3.sol +++ b/src/minter/StabilityPool_v3.sol @@ -16,7 +16,7 @@ import {MultipleRewardCompoundingAccumulator_v3} from "src/reward/accumulator/Mu import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; import {IMinter} from "src/interfaces/IMinter.sol"; import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; -import {IStabilityPool_v3} from "src/interfaces/IStabilityPool_v3.sol"; +import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; import {StringPacking_v1} from "src/minter/library/StringPacking_v1.sol"; // solhint-disable not-rely-on-time // slither-disable-start timestamp @@ -42,8 +42,7 @@ contract StabilityPool_v3 is MultipleRewardCompoundingAccumulator_v3, TokenHolder, IStabilityPool, - IERC20Metadata, - IStabilityPool_v3 + IERC20Metadata { using SafeERC20 for IERC20; using DecrementalFloatingPoint for uint128; @@ -159,6 +158,8 @@ contract StabilityPool_v3 is mapping(address => WithdrawalRequest) withdrawalRequests; /// @dev Packed fee configuration (address + uint96) FeePayment feePayment; + /// @dev ERC20 allowances: owner => spender => amount + mapping(address => mapping(address => uint256)) allowances; } // chisel eval 'keccak256(abi.encode(uint256(keccak256("bao.storage.StabilityPool")) - 1)) & ~bytes32(uint256(0xff))' @@ -173,22 +174,6 @@ contract StabilityPool_v3 is } } - /// @custom:storage-location erc7201:bao.storage.StabilityPool_v3 - struct StabilityPoolERC20AllowancesStorage { - /// @dev ERC20 allowances: owner => spender => amount - mapping(address => mapping(address => uint256)) allowances; - } - - // chisel eval 'keccak256(abi.encode(uint256(keccak256("bao.storage.StabilityPool_v3")) - 1)) & ~bytes32(uint256(0xff))' - bytes32 private constant _V3_STORAGE = 0xb4346888fe08dd20fe3aa583577b90a0e39bc6ca623364fcc9a4cf38a1ec7f00; - - function _getERC20Storage() internal pure returns (StabilityPoolERC20AllowancesStorage storage $) { - // solhint-disable-next-line no-inline-assembly - assembly { - $.slot := _V3_STORAGE - } - } - /********** * Errors * **********/ @@ -640,46 +625,40 @@ contract StabilityPool_v3 is // ERC20 View Functions // ═══════════════════════════════════════════════════════════════════════ + /// @inheritdoc IERC20Metadata function name() external view returns (string memory) { return StringPacking_v1.unpack64(_ERC20_NAME_0, _ERC20_NAME_1); } + /// @inheritdoc IERC20Metadata function symbol() external view returns (string memory) { return StringPacking_v1.unpack64(_ERC20_SYMBOL, bytes32(0)); } + /// @inheritdoc IERC20Metadata function decimals() external view returns (uint8) { return _ERC20_DECIMALS; } - function balanceOf(address account) external view returns (uint256) { + /// @inheritdoc IERC20 + function allowance(address owner_, address spender) external view returns (uint256) { StabilityPoolStorage storage $ = _getStabilityPoolStorage(); - TokenBalance memory balance = $.assetBalances[account]; - return _getCompoundedBalance(balance.amount, balance.product, $.totalAssetSupply.product); + return $.allowances[owner_][spender]; } - function totalSupply() external view returns (uint256) { + /// @inheritdoc IERC20 + function balanceOf(address account) external view returns (uint256 amount) { StabilityPoolStorage storage $ = _getStabilityPoolStorage(); - return $.totalAssetSupply.amount; + amount = _getCompoundedBalance( + $.assetBalances[account].amount, + $.assetBalances[account].product, + $.totalAssetSupply.product + ); } - function allowance(address owner_, address spender) external view returns (uint256) { - StabilityPoolERC20AllowancesStorage storage $ = _getERC20Storage(); - return $.allowances[owner_][spender]; - } - - // ═══════════════════════════════════════════════════════════════════════ - // Alias-Aware Claimable - // ═══════════════════════════════════════════════════════════════════════ - - /// @notice Returns claimable for a token. If the token has aliases, sums all aliases' claimable. - /// @dev Overrides the accumulator's claimable to aggregate across aliases. - function claimable(address account, address token) external view override returns (uint256 total) { - total = _claimable(account, token, true); - address[] memory aliases = _getAliases(token); - for (uint256 i = 0; i < aliases.length; i++) { - total += _claimable(account, aliases[i], true); - } + /// @inheritdoc IERC20 + function totalSupply() external view returns (uint256 totalSupply_) { + totalSupply_ = _getStabilityPoolStorage().totalAssetSupply.amount; } // ═══════════════════════════════════════════════════════════════════════ @@ -692,7 +671,7 @@ contract StabilityPool_v3 is } function transferFrom(address from, address to, uint256 amount) external nonReentrant returns (bool) { - StabilityPoolERC20AllowancesStorage storage $ = _getERC20Storage(); + StabilityPoolStorage storage $ = _getStabilityPoolStorage(); address spender = _msgSender(); uint256 currentAllowance = $.allowances[from][spender]; if (currentAllowance != type(uint256).max) { @@ -708,7 +687,7 @@ contract StabilityPool_v3 is } function approve(address spender, uint256 amount) external returns (bool) { - StabilityPoolERC20AllowancesStorage storage $ = _getERC20Storage(); + StabilityPoolStorage storage $ = _getStabilityPoolStorage(); $.allowances[_msgSender()][spender] = amount; emit Approval(_msgSender(), spender, amount); return true; diff --git a/src/reward/RewardAlias_v1.sol b/src/reward/RewardAlias_v1.sol deleted file mode 100644 index c328f993..00000000 --- a/src/reward/RewardAlias_v1.sol +++ /dev/null @@ -1,58 +0,0 @@ -// SPDX-License-Identifier: MIT - -pragma solidity 0.8.30; - -import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; -import {IERC5313} from "@openzeppelin/contracts/interfaces/IERC5313.sol"; -import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; -import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; - -import {HarborOwnable} from "@bao/HarborOwnable.sol"; -import {IRewardAlias} from "src/interfaces/IRewardAlias.sol"; - -/// @title RewardAlias_v1 -/// @notice A minimal UUPS-upgradeable contract that identifies itself as an alias for an underlying reward token. -/// @dev Deploy via BaoFactory (CREATE3) at a predictable address. -/// The reward system detects aliases via IRewardAlias.underlying() during registration. -/// The alias address is used for integral tracking; the underlying is used for token transfers. -// solhint-disable-next-line contract-name-capwords -contract RewardAlias_v1 is Initializable, UUPSUpgradeable, HarborOwnable, IERC5313, IRewardAlias { - /// @notice The underlying reward token this alias represents. - /// @custom:oz-upgrades-unsafe-allow state-variable-immutable - address internal immutable UNDERLYING; // solhint-disable-line immutable-vars-naming - - /// @custom:oz-upgrades-unsafe-allow constructor - constructor(address underlying_) { - _disableInitializers(); - if (underlying_ == address(0)) { - revert ZeroAddress(); - } - UNDERLYING = underlying_; - } - - /// @notice Initialize ownership. - /// @param deployerOwner_ The initial (temporary) owner — typically the FactoryDeployer contract. - /// @param pendingOwner_ The final owner — typically the Harbor multisig. - function initialize(address deployerOwner_, address pendingOwner_) external initializer { - _initializeOwner(deployerOwner_, pendingOwner_); - __UUPSUpgradeable_init(); - } - - /// @inheritdoc IERC5313 - function owner() public view override(HarborOwnable, IERC5313) returns (address owner_) { - owner_ = HarborOwnable.owner(); - } - - /// @inheritdoc IRewardAlias - function underlying() external view returns (address) { - return UNDERLYING; - } - - /// @inheritdoc IERC165 - function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { - return interfaceId == type(IERC5313).interfaceId || super.supportsInterface(interfaceId); - } - - /// @notice Authorize upgrades — only owner can upgrade. - function _authorizeUpgrade(address) internal override onlyOwner {} // solhint-disable-line no-empty-blocks -} diff --git a/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol b/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol index a53a96fb..92c5684b 100644 --- a/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol +++ b/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol @@ -11,7 +11,7 @@ import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumula import {IMultipleRewardAccumulator_v3} from "src/interfaces/IMultipleRewardAccumulator_v3.sol"; import {DecrementalFloatingPoint} from "src/math/DecrementalFloatingPoint.sol"; -import {LinearMultipleRewardDistributor_v3} from "src/reward/distributor/LinearMultipleRewardDistributor_v3.sol"; +import {LinearMultipleRewardDistributor} from "src/reward/distributor/LinearMultipleRewardDistributor_v2.sol"; // solhint-disable not-rely-on-time @@ -115,7 +115,7 @@ import {LinearMultipleRewardDistributor_v3} from "src/reward/distributor/LinearM // solhint-disable-next-line contract-name-capwords abstract contract MultipleRewardCompoundingAccumulator_v3 is ReentrancyGuardTransientUpgradeable, - LinearMultipleRewardDistributor_v3, + LinearMultipleRewardDistributor, IMultipleRewardAccumulator, IMultipleRewardAccumulator_v3 { @@ -273,7 +273,7 @@ abstract contract MultipleRewardCompoundingAccumulator_v3 is uint256 rewardManagerRole, uint256 rewardDepositorRole, uint40 periodLength - ) LinearMultipleRewardDistributor_v3(rewardManagerRole, rewardDepositorRole, periodLength) {} + ) LinearMultipleRewardDistributor(rewardManagerRole, rewardDepositorRole, periodLength) {} /************************* * Public View Functions * @@ -537,12 +537,10 @@ abstract contract MultipleRewardCompoundingAccumulator_v3 is } /// @dev Internal function to claim up to maxAmount of a single reward token. - /// If token has registered aliases, drains them in order first, then the token's own pending. - /// If token is an alias (no aliases of its own), claims only from that alias. /// Caller should make sure `_checkpoint` is called before this function. /// /// @param account The address of user to claim. - /// @param token The address of reward token (underlying or alias). + /// @param token The address of reward token. /// @param receiver The address of recipient of the reward token. /// @param maxAmount The maximum amount to claim. Use type(uint256).max for all. function _claimSingle( @@ -551,31 +549,6 @@ abstract contract MultipleRewardCompoundingAccumulator_v3 is address receiver, uint256 maxAmount ) internal virtual returns (uint256) { - address[] memory aliases = _getAliases(token); - uint256 totalClaimed; - // Drain aliases in registration order - for (uint256 i = 0; i < aliases.length; i++) { - if (maxAmount == 0) { - break; - } - uint256 aliasAmount = _claimFromToken(account, aliases[i], receiver, maxAmount); - totalClaimed += aliasAmount; - maxAmount -= aliasAmount; - } - // Then drain the token's own pending - if (maxAmount > 0) { - totalClaimed += _claimFromToken(account, token, receiver, maxAmount); - } - return totalClaimed; - } - - /// @dev Claim up to maxAmount from a single token address (no alias traversal). - function _claimFromToken( - address account, - address token, - address receiver, - uint256 maxAmount - ) private returns (uint256) { (uint64 ts, uint256 integral, uint128 pending, uint128 claimed_) = _getUserRewardSnapshot(account, token); uint256 amount = pending; if (amount > maxAmount) { @@ -584,14 +557,14 @@ abstract contract MultipleRewardCompoundingAccumulator_v3 is if (amount > 0) { _setUserRewardSnapshot(account, token, ts, integral, pending - uint128(amount), claimed_ + uint128(amount)); - IERC20(_resolveUnderlying(token)).safeTransfer(receiver, amount); + IERC20(token).safeTransfer(receiver, amount); emit Claim(account, token, receiver, amount); } return amount; } - /// @inheritdoc LinearMultipleRewardDistributor_v3 + /// @inheritdoc LinearMultipleRewardDistributor function _accumulateReward(address token, uint256 amount) internal virtual override { // slither-disable-next-line incorrect-equality if (amount == 0) { diff --git a/src/reward/distributor/LinearMultipleRewardDistributor_v3.sol b/src/reward/distributor/LinearMultipleRewardDistributor_v3.sol deleted file mode 100644 index 5a1444a5..00000000 --- a/src/reward/distributor/LinearMultipleRewardDistributor_v3.sol +++ /dev/null @@ -1,339 +0,0 @@ -// SPDX-License-Identifier: MIT - -pragma solidity 0.8.30; - -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import {ContextUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ContextUpgradeable.sol"; -import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; -import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; - -import {BaoOwnableRoles} from "@bao/BaoOwnableRoles.sol"; - -import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; -import {IRewardAlias} from "src/interfaces/IRewardAlias.sol"; -import {LinearReward} from "./LinearReward.sol"; - -// solhint-disable no-empty-blocks -// solhint-disable not-rely-on-time - -/// @title Linear Multiple Reward Distributor -/// @dev A base contract for distributing multiple reward tokens linearly over time. -/// -/// This contract manages the registration, tracking, and linear distribution of -/// multiple reward tokens. It maintains a list of active and historical reward tokens, -/// associates distributors using roles based access, and calculates distribution rates -/// over defined time periods. -/// -/// Key features: -/// - Register and unregister reward tokens -/// - Configure linear reward distribution with customizable period lengths -/// - Track pending and distributed rewards -/// - Manage active and historical reward tokens -/// -/// The contract uses a role-based access control system to manage distributors -/// and supports immediate or time-based reward distribution depending on the -/// configured period length. -// solhint-disable-next-line contract-name-capwords -abstract contract LinearMultipleRewardDistributor_v3 is - Initializable, - ContextUpgradeable, - BaoOwnableRoles, - IMultipleRewardDistributor -{ - using EnumerableSet for EnumerableSet.AddressSet; - using SafeERC20 for IERC20; - - using LinearReward for LinearReward.RewardData; - - /************* - * Constants * - *************/ - - /// @notice The role used to manage rewards. - /// @custom:oz-upgrades-unsafe-allow state-variable-immutable - uint256 public immutable REWARD_MANAGER_ROLE; - - /// @custom:oz-upgrades-unsafe-allow state-variable-immutable - uint256 public immutable REWARD_DEPOSITOR_ROLE; - - /// @notice The length of reward period in seconds. - /// @dev If the value is zero, the reward will be distributed immediately. - /// @dev It is either zero or at least 1 day (which is 86400). - /// @custom:oz-upgrades-unsafe-allow state-variable-immutable - uint40 public immutable REWARD_PERIOD_LENGTH; - - /************* - * Variables * - *************/ - - struct LinearMultipleRewardDistributorStorage { - /// @notice Mapping from reward token address to linear distribution reward data. - mapping(address => LinearReward.RewardData) rewardData; - /// @dev The list of active reward tokens. - EnumerableSet.AddressSet activeRewardTokens; - /// @dev The list of historical reward tokens. - EnumerableSet.AddressSet historicalRewardTokens; - /// @dev Alias address => underlying token address. Set at registration, used for token transfers. - mapping(address => address) aliasToUnderlying; - /// @dev Underlying token => ordered list of aliases (drain order for claimSingle(underlying)). - mapping(address => address[]) aliases; - } - - // chisel eval 'keccak256(abi.encode(uint256(keccak256("bao.storage.LinearMultipleRewardDistributor")) - 1)) & ~bytes32(uint256(0xff))' - bytes32 private constant _LINEARMULTIPLEREWARDDISTRIBUTOR_STORAGE = - 0xe9dd8489e2940f6fb582767a094c112cfce2739b7a5f3357b085cab0a6a7d300; - - function _getLinearMultipleRewardDistributorStorage() - private - pure - returns (LinearMultipleRewardDistributorStorage storage $) - { - // solhint-disable-next-line no-inline-assembly - assembly { - $.slot := _LINEARMULTIPLEREWARDDISTRIBUTOR_STORAGE - } - } - - /*************** - * Constructor * - ***************/ - /// @dev there is no need for an initializer - /// @dev abstract classes should not define role numbers, so pass them in - /// @custom:oz-upgrades-unsafe-allow constructor - constructor(uint256 rewardManagerRole, uint256 rewardDepositorRole, uint40 periodLength_) { - REWARD_MANAGER_ROLE = rewardManagerRole; - - if (periodLength_ != 0 && (periodLength_ < 1 days || periodLength_ > 28 days)) { - revert InvalidPeriodLength(periodLength_); - } - REWARD_PERIOD_LENGTH = periodLength_; - REWARD_DEPOSITOR_ROLE = rewardDepositorRole; - } - - /************************* - * Public View Functions * - *************************/ - - /// @inheritdoc IMultipleRewardDistributor - function rewardData( - address token - ) external view returns (uint256 lastUpdate, uint256 finishAt, uint256 rate, uint256 queued) { - LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); - LinearReward.RewardData memory data = $.rewardData[token]; - return (data.lastUpdate, data.finishAt, data.rate, data.queued); - } - - /// @inheritdoc IMultipleRewardDistributor - // slither-disable-next-line shadowing-local // this isn't shadowing, it's implementing an interface - function activeRewardTokens() public view override returns (address[] memory rewardTokens) { - LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); - rewardTokens = $.activeRewardTokens.values(); - } - - /// @inheritdoc IMultipleRewardDistributor - function isActiveRewardToken(address token) public view returns (bool isActive) { - LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); - isActive = $.activeRewardTokens.contains(token); - } - - /// @inheritdoc IMultipleRewardDistributor - function historicalRewardTokens() public view override returns (address[] memory rewardTokens) { - LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); - rewardTokens = $.historicalRewardTokens.values(); - } - - /// @inheritdoc IMultipleRewardDistributor - function pendingRewards( - address token - ) external view override returns (uint256 distributable, uint256 undistributed) { - (distributable, undistributed) = _pendingRewards(token); - } - - /**************************** - * Public Mutator Functions * - ****************************/ - - /// @inheritdoc IMultipleRewardDistributor - function depositReward(address token, uint256 amount) external override onlyOwnerOrRoles(REWARD_DEPOSITOR_ROLE) { - address _distributor = _msgSender(); - LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); - - if (!$.activeRewardTokens.contains(token)) { - revert NotActiveRewardToken(); - } - if (amount > 0) { - IERC20(_resolveUnderlying(token)).safeTransferFrom(_distributor, address(this), amount); - } - - _distributePendingReward(); - - _notifyReward(token, amount); - - emit DepositReward(token, amount); - } - - /************************ - * Restricted Functions * - ************************/ - - /// @inheritdoc IMultipleRewardDistributor - function registerRewardToken(address token) external onlyOwnerOrRoles(REWARD_MANAGER_ROLE) { - _registerRewardToken(token); - } - - /// @notice Register a reward token with an ordered list of aliases. - /// @dev Each alias must implement IRewardAlias.underlying() returning `token`. - /// Aliases are registered as active tokens with their own integrals. - /// claimSingle(underlying) drains aliases in this order, then underlying's own. - /// @param token The underlying reward token. - /// @param tokenAliases Ordered list of alias addresses (drain order). - function registerRewardToken( - address token, - address[] calldata tokenAliases - ) external onlyOwnerOrRoles(REWARD_MANAGER_ROLE) { - _registerRewardToken(token); - - LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); - for (uint256 i = 0; i < tokenAliases.length; i++) { - address alias_ = tokenAliases[i]; - // Reverts if alias doesn't implement underlying() or returns wrong address - // slither-disable-next-line calls-loop - if (IRewardAlias(alias_).underlying() != token) { - revert AliasUnderlyingMismatch(); - } - _registerRewardToken(alias_); - $.aliasToUnderlying[alias_] = token; - $.aliases[token].push(alias_); - } - } - - function _registerRewardToken(address token) internal { - if (token == address(0)) { - revert RewardTokenIsZero(); - } - LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); - - if (!$.activeRewardTokens.add(token)) { - revert DuplicatedRewardToken(); - } - // slither-disable-next-line unused-return we don't care if the the token was already in the set - $.historicalRewardTokens.remove(token); // wake-disable-line unchecked-return-value - - emit RegisterRewardToken(token); - } - - /// @inheritdoc IMultipleRewardDistributor - function unregisterRewardToken(address token) external onlyOwnerOrRoles(REWARD_MANAGER_ROLE) { - _unregisterRewardToken(token); - - // If token has aliases, unregister them too (they're a unit) - LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); - address[] storage tokenAliases = $.aliases[token]; - for (uint256 i = 0; i < tokenAliases.length; i++) { - address alias_ = tokenAliases[i]; - _unregisterRewardToken(alias_); - delete $.aliasToUnderlying[alias_]; - } - delete $.aliases[token]; - } - - function _unregisterRewardToken(address token) internal { - LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); - - if (!$.activeRewardTokens.remove(token)) { - revert NotActiveRewardToken(); - } - LinearReward.RewardData memory _data = $.rewardData[token]; - unchecked { - (uint256 _distributable, uint256 _undistributed) = _data.pending(); - if (_data.queued < REWARD_PERIOD_LENGTH) { - _data.queued = 0; // ignore round error - } - if (_data.queued + _distributable + _undistributed > 0) { - revert RewardDistributionNotFinished(); - } - } - - // slither-disable-next-line unused-return - $.historicalRewardTokens.add(token); // wake-disable-line unchecked-return-value - emit UnregisterRewardToken(token); - } - - /********************** - * Internal Functions * - **********************/ - - /// @dev Internal function to notify new rewards. - /// - /// @param token The address of token. - /// @param amount The amount of new rewards. - function _notifyReward(address token, uint256 amount) internal { - LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); - - if (REWARD_PERIOD_LENGTH == 0) { - _accumulateReward(token, amount); - } else { - LinearReward.RewardData memory data = $.rewardData[token]; - data.increase(REWARD_PERIOD_LENGTH, amount); - $.rewardData[token] = data; - } - } - - /// @dev Internal function to distribute all pending reward tokens. - function _distributePendingReward() internal { - LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); - - // If the reward period length is zero, we distribute rewards immediately. - // If there are no active reward tokens, we do nothing. - if (REWARD_PERIOD_LENGTH == 0 || $.activeRewardTokens.length() == 0) { - return; - } - address[] memory activeRewardTokens_ = $.activeRewardTokens.values(); - for (uint256 i = 0; i < activeRewardTokens_.length; i++) { - address token = activeRewardTokens_[i]; - - // slither-disable-next-line unused-return - (uint256 pending, ) = $.rewardData[token].pending(); - - $.rewardData[token].lastUpdate = uint40(block.timestamp); - - if (pending > 0) { - _accumulateReward(token, pending); - } - } - } - - function _getRewardData(address token) internal view returns (LinearReward.RewardData storage) { - LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); - return $.rewardData[token]; - } - - /// @dev Internal function to accumulate distributed rewards. - /// @dev derived contracts should implement this - /// @param token The address of token. - /// @param amount The amount of rewards to accumulate. - function _accumulateReward(address token, uint256 amount) internal virtual; - - function _pendingRewards(address token) internal view returns (uint256 distributable, uint256 undistributed) { - LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); - (distributable, undistributed) = $.rewardData[token].pending(); - } - - // ═══════════════════════════════════════════════════════════════════════ - // Alias support - // ═══════════════════════════════════════════════════════════════════════ - - /// @dev Returns the underlying token for transfers. If not an alias, returns the token itself. - function _resolveUnderlying(address token) internal view returns (address) { - LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); - address underlying = $.aliasToUnderlying[token]; - return underlying != address(0) ? underlying : token; - } - - /// @dev Returns the ordered alias list for an underlying token. - function _getAliases(address token) internal view returns (address[] memory) { - LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); - return $.aliases[token]; - } -} diff --git a/test/GraphsLiquidate.t.sol b/test/GraphsLiquidate.t.sol index 9fa34e48..24d5948e 100644 --- a/test/GraphsLiquidate.t.sol +++ b/test/GraphsLiquidate.t.sol @@ -297,8 +297,8 @@ contract TestGraphsLiquidate is TestGraphs, TestCollateralRatioRangeSetUp { m.stabilityPoolLeveragedLeveraged = IERC20(leveragedToken).balanceOf(stabilityPoolLeveraged); m.userCollateral = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user, wrappedCollateralToken); m.userLeveraged = IMultipleRewardAccumulator(stabilityPoolLeveraged).claimable(user, leveragedToken); - m.userBalanceSPCollateral = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user); - m.userBalanceSPLeveraged = IStabilityPool(stabilityPoolLeveraged).assetBalanceOf(user); + m.userBalanceSPCollateral = IERC20(stabilityPoolCollateral).balanceOf(user); + m.userBalanceSPLeveraged = IERC20(stabilityPoolLeveraged).balanceOf(user); m.leveragedTokenPrice = IMinter(minter).leveragedTokenPrice(); } diff --git a/test/StabilityPool.t.sol b/test/StabilityPool.t.sol index e1850857..38b4804a 100644 --- a/test/StabilityPool.t.sol +++ b/test/StabilityPool.t.sol @@ -172,7 +172,7 @@ contract TestStabilityPoolSetUp is TestMinterFeeSetUp { assertEq(StabilityPool_v3(sp).owner(), owner); assertEq(IStabilityPool(sp).ASSET_TOKEN(), peggedToken); assertEq(IStabilityPool(sp).LIQUIDATION_TOKEN(), liquidateTo); - assertEq(IStabilityPool(sp).totalAssetSupply(), 0); + assertEq(IERC20(sp).totalSupply(), 0); } } @@ -339,7 +339,7 @@ contract TestStabilityPoolDepositWithdraw is TestStabilityPoolSetUp { // 2 deposit ------------------------------------------------------------------------------ assertEq(deposited, 2 * price, "returned value"); assertEq(IERC20(peggedToken).balanceOf(stabilityPoolCollateral), 2 * price); - assertEq(IStabilityPool(stabilityPoolCollateral).assetBalanceOf(receiver), 2 * price); + assertEq(IERC20(stabilityPoolCollateral).balanceOf(receiver), 2 * price); assertEq(IERC20(peggedToken).balanceOf(user1), 8 * price); // $3 withdrawal @@ -350,7 +350,7 @@ contract TestStabilityPoolDepositWithdraw is TestStabilityPoolSetUp { ); IStabilityPool(stabilityPoolCollateral).withdraw(3 * price, receiver, 0); // 1 withdraw --------------------------------------------- - assertEq(IStabilityPool(stabilityPoolCollateral).assetBalanceOf(receiver), 2 * price); + assertEq(IERC20(stabilityPoolCollateral).balanceOf(receiver), 2 * price); // $5 second deposit vm.prank(user1); @@ -358,7 +358,7 @@ contract TestStabilityPoolDepositWithdraw is TestStabilityPoolSetUp { // 3 deposit ------------------------------------------------------------ assertEq(deposited, 5 * price, "returned value 5"); assertEq(IERC20(peggedToken).balanceOf(stabilityPoolCollateral), 7 * price); - assertEq(IStabilityPool(stabilityPoolCollateral).assetBalanceOf(receiver), 7 * price); + assertEq(IERC20(stabilityPoolCollateral).balanceOf(receiver), 7 * price); // withdraw some _beginWithdrawal(user1); @@ -367,7 +367,7 @@ contract TestStabilityPoolDepositWithdraw is TestStabilityPoolSetUp { // 2 withdraw --------------------------------------------------------------------------- assertEq(withdrawn, 4 * price, "withdraw 4"); assertEq(IERC20(peggedToken).balanceOf(stabilityPoolCollateral), 3 * price); - assertEq(IStabilityPool(stabilityPoolCollateral).assetBalanceOf(receiver), 3 * price); + assertEq(IERC20(stabilityPoolCollateral).balanceOf(receiver), 3 * price); // withdraw rest _beginWithdrawal(user1); @@ -376,7 +376,7 @@ contract TestStabilityPoolDepositWithdraw is TestStabilityPoolSetUp { // 3 withdraw --------------------------------------------------------------------------- assertEq(withdrawn, 3 * price - 1 ether, "withdraw 3 (-1)"); // include the minimum pool size assertEq(IERC20(peggedToken).balanceOf(stabilityPoolCollateral), 1 ether); - assertEq(IStabilityPool(stabilityPoolCollateral).assetBalanceOf(receiver), 1 ether); + assertEq(IERC20(stabilityPoolCollateral).balanceOf(receiver), 1 ether); // deposit -1 vm.prank(user1); @@ -384,7 +384,7 @@ contract TestStabilityPoolDepositWithdraw is TestStabilityPoolSetUp { // 4 deposit ------------------------------------------------------------------------------ assertEq(deposited, 10 * price - 1 ether, "returned value 10"); assertEq(IERC20(peggedToken).balanceOf(stabilityPoolCollateral), 10 * price); - assertEq(IStabilityPool(stabilityPoolCollateral).assetBalanceOf(receiver), 10 * price); + assertEq(IERC20(stabilityPoolCollateral).balanceOf(receiver), 10 * price); assertEq(IERC20(peggedToken).balanceOf(user1), 0); // check min deposit amount diff --git a/test/StabilityPoolClaimable.t.sol b/test/StabilityPoolClaimable.t.sol index c2a88466..ee91dda9 100644 --- a/test/StabilityPoolClaimable.t.sol +++ b/test/StabilityPoolClaimable.t.sol @@ -163,9 +163,9 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { uint256 initialUser2 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user2, rewardToken1); uint256 initialUser3 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user3, rewardToken1); - uint256 user1Balance = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1); - uint256 user2Balance = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user2); - uint256 user3Balance = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user3); + uint256 user1Balance = IERC20(stabilityPoolCollateral).balanceOf(user1); + uint256 user2Balance = IERC20(stabilityPoolCollateral).balanceOf(user2); + uint256 user3Balance = IERC20(stabilityPoolCollateral).balanceOf(user3); // User2 withdraws half their deposit vm.prank(user2); @@ -179,9 +179,9 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { vm.warp(block.timestamp + 1 hours); // Verify balances after withdrawal - assertEq(IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1), user1Balance); - assertEq(IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user2), user2Balance - DEPOSIT_AMOUNT / 2); - assertEq(IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user3), user3Balance); + assertEq(IERC20(stabilityPoolCollateral).balanceOf(user1), user1Balance); + assertEq(IERC20(stabilityPoolCollateral).balanceOf(user2), user2Balance - DEPOSIT_AMOUNT / 2); + assertEq(IERC20(stabilityPoolCollateral).balanceOf(user3), user3Balance); // Distribute more rewards - should be split proportionally to current deposits _depositRewardAndWait(rewardToken1, rewardAmount); @@ -564,7 +564,7 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { uint256 rewardAmount = 300 ether; // Rebalancer sweeps some asset tokens - this should trigger _notifyLoss - _liquidate(IStabilityPool(stabilityPoolCollateral).totalAssetSupply()); + _liquidate(IERC20(stabilityPoolCollateral).totalSupply()); // Distribute more rewards after loss _depositRewardAndWait(rewardToken1, rewardAmount); @@ -587,7 +587,7 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { vm.startPrank(rebalancer); ITokenHolder(stabilityPoolCollateral).sweep( peggedToken, - IStabilityPool(stabilityPoolCollateral).totalAssetSupply(), + IERC20(stabilityPoolCollateral).totalSupply(), rebalancer ); vm.stopPrank(); @@ -885,379 +885,3 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(user1, receiver, rewardToken1, 50 ether); } } - -// ═══════════════════════════════════════════════════════════════════════════ -// Reward Alias Tests -// ═══════════════════════════════════════════════════════════════════════════ - -import {RewardAlias_v1} from "src/reward/RewardAlias_v1.sol"; -import {IMultipleRewardAccumulator_v3} from "src/interfaces/IMultipleRewardAccumulator_v3.sol"; -import {LinearMultipleRewardDistributor_v3} from "src/reward/distributor/LinearMultipleRewardDistributor_v3.sol"; - -contract TestRewardAlias is TestStabilityPoolRebalanceSetUp { - MockERC20 aliasUnderlying; - RewardAlias_v1 harvestAlias; - RewardAlias_v1 boostAlias; - - uint256 constant DEPOSIT_AMOUNT = 10 ether; - - function setUp() public override { - super.setUp(); - - // Create a reward token and two aliases for it - aliasUnderlying = new MockERC20("Reward", "RWD", 18); - vm.label(address(aliasUnderlying), "AliasUnderlying"); - harvestAlias = new RewardAlias_v1(address(aliasUnderlying)); - vm.label(address(harvestAlias), "HARVEST_ALIAS"); - boostAlias = new RewardAlias_v1(address(aliasUnderlying)); - vm.label(address(boostAlias), "BOOST_ALIAS"); - - // Register underlying with both aliases (drain order: harvest first, then boost) - address[] memory aliases = new address[](2); - aliases[0] = address(harvestAlias); - aliases[1] = address(boostAlias); - vm.prank(rewardManager); - LinearMultipleRewardDistributor_v3(stabilityPoolCollateral).registerRewardToken( - address(aliasUnderlying), - aliases - ); - - // Fund the depositor with the underlying reward token - aliasUnderlying.mint(rewardDepositor, 1000 ether); - vm.prank(rewardDepositor); - aliasUnderlying.approve(stabilityPoolCollateral, type(uint256).max); - - // Deposit for users - deal(peggedToken, user1, DEPOSIT_AMOUNT * 10); - deal(peggedToken, user2, DEPOSIT_AMOUNT * 10); - setUp_collateral(100 ether, 100 ether); - - vm.prank(user1); - IStabilityPool(stabilityPoolCollateral).deposit(DEPOSIT_AMOUNT, user1, 0); - vm.prank(user2); - IStabilityPool(stabilityPoolCollateral).deposit(DEPOSIT_AMOUNT, user2, 0); - } - - function _depositRewardAndWait(address alias_, uint256 amount) internal { - vm.prank(rewardDepositor); - IMultipleRewardDistributor(stabilityPoolCollateral).depositReward(alias_, amount); - skip(8 days); - } - - // ── Registration ──────────────────────────────────────────────────── - - function testAlias_registeredAsActiveToken() public view { - address[] memory active = IMultipleRewardDistributor(stabilityPoolCollateral).activeRewardTokens(); - bool foundHarvest; - bool foundBoost; - for (uint256 i = 0; i < active.length; i++) { - if (active[i] == address(harvestAlias)) { - foundHarvest = true; - } - if (active[i] == address(boostAlias)) { - foundBoost = true; - } - } - assertTrue(foundHarvest, "harvest alias registered"); - assertTrue(foundBoost, "boost alias registered"); - } - - // ── Deposit via alias ─────────────────────────────────────────────── - - function testAlias_depositTransfersUnderlying() public { - uint256 spBalBefore = aliasUnderlying.balanceOf(stabilityPoolCollateral); - uint256 depositorBalBefore = aliasUnderlying.balanceOf(rewardDepositor); - - vm.prank(rewardDepositor); - IMultipleRewardDistributor(stabilityPoolCollateral).depositReward(address(harvestAlias), 100 ether); - - // The underlying token was transferred, not the alias - assertEq(aliasUnderlying.balanceOf(stabilityPoolCollateral) - spBalBefore, 100 ether, "SP received underlying"); - assertEq( - depositorBalBefore - aliasUnderlying.balanceOf(rewardDepositor), - 100 ether, - "depositor sent underlying" - ); - } - - // ── Claimable per alias ───────────────────────────────────────────── - - function testAlias_claimableTrackedSeparately() public { - _depositRewardAndWait(address(harvestAlias), 100 ether); - _depositRewardAndWait(address(boostAlias), 200 ether); - - uint256 claimHarvest = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( - user1, - address(harvestAlias) - ); - uint256 claimBoost = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(boostAlias)); - - // user1 has 50% of the pool → gets 50% of each alias's reward - assertApproxEqAbs( - claimHarvest, - 50 ether, - 2 * 604800, - "harvest claimable ~50 (tolerance: 2 periods of rate truncation)" - ); - assertApproxEqAbs( - claimBoost, - 100 ether, - 2 * 604800, - "boost claimable ~100 (tolerance: 2 periods of rate truncation)" - ); - } - - // ── Claim via alias → transfers underlying ────────────────────────── - - function testAlias_claimSingleTransfersUnderlying() public { - _depositRewardAndWait(address(harvestAlias), 100 ether); - - uint256 claimable = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(harvestAlias)); - assertGt(claimable, 0, "has claimable"); - - uint256 rwdBefore = aliasUnderlying.balanceOf(user1); - vm.prank(user1); - IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim( - user1, - address(0), - address(harvestAlias), - type(uint256).max - ); - - // User received the underlying token, not the alias - assertEq(aliasUnderlying.balanceOf(user1) - rwdBefore, claimable, "received underlying"); - } - - // ── Claim one alias doesn't affect another ────────────────────────── - - function testAlias_claimOneDoesNotAffectOther() public { - _depositRewardAndWait(address(harvestAlias), 100 ether); - _depositRewardAndWait(address(boostAlias), 200 ether); - - uint256 boostBefore = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(boostAlias)); - - // Claim only harvest - vm.prank(user1); - IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim( - user1, - address(0), - address(harvestAlias), - type(uint256).max - ); - - // Boost should be unchanged - uint256 boostAfter = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(boostAlias)); - assertEq(boostAfter, boostBefore, "boost unaffected by harvest claim"); - } - - // ── claim() claims all aliases, transferring underlying ───────────── - - function testAlias_claimAllTransfersUnderlying() public { - _depositRewardAndWait(address(harvestAlias), 100 ether); - _depositRewardAndWait(address(boostAlias), 200 ether); - - uint256 rwdBefore = aliasUnderlying.balanceOf(user1); - vm.prank(user1); - IMultipleRewardAccumulator(stabilityPoolCollateral).claim(user1); - - uint256 received = aliasUnderlying.balanceOf(user1) - rwdBefore; - // Should have received harvest + boost combined (~150 ether for 50% of pool) - assertApproxEqAbs( - received, - 150 ether, - 4 * 604800, - "received total from both aliases (tolerance: 4 periods of rate truncation)" - ); - - // Both should be zero after claim - assertEq( - IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(harvestAlias)), - 0, - "harvest zeroed" - ); - assertEq( - IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(boostAlias)), - 0, - "boost zeroed" - ); - } - - // ── RewardAlias_v1 contract ──────────────────────────────────────────── - - function testAlias_underlyingReturnsCorrectToken() public view { - assertEq(harvestAlias.underlying(), address(aliasUnderlying), "harvest underlying"); - assertEq(boostAlias.underlying(), address(aliasUnderlying), "boost underlying"); - } - - function testAlias_differentAliasesDifferentAddresses() public view { - assertTrue(address(harvestAlias) != address(boostAlias), "different addresses"); - } - - // ── Aggregation: claimable(raw token) sums aliases ────────────────── - - function testAlias_claimableAggregatesAliases() public { - _depositRewardAndWait(address(harvestAlias), 100 ether); - _depositRewardAndWait(address(boostAlias), 200 ether); - - // Claimable for each alias individually - uint256 harvestOnly = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( - user1, - address(harvestAlias) - ); - uint256 boostOnly = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(boostAlias)); - - // Claimable for the raw underlying — should sum both aliases - uint256 aggregated = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( - user1, - address(aliasUnderlying) - ); - - assertEq(aggregated, harvestOnly + boostOnly, "aggregated = harvest + boost"); - assertGt(aggregated, 0, "non-zero aggregated"); - } - - function testAlias_claimableRawTokenWithNoAliases() public { - // Register a plain token (no alias) and deposit to it - MockERC20 plainToken = new MockERC20("Plain", "PLN", 18); - vm.prank(rewardManager); - IMultipleRewardDistributor(stabilityPoolCollateral).registerRewardToken(address(plainToken)); - plainToken.mint(rewardDepositor, 100 ether); - vm.prank(rewardDepositor); - plainToken.approve(stabilityPoolCollateral, 100 ether); - vm.prank(rewardDepositor); - IMultipleRewardDistributor(stabilityPoolCollateral).depositReward(address(plainToken), 100 ether); - skip(8 days); - - // Claimable for a plain token with no aliases — should return its own claimable only - uint256 claimable = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(plainToken)); - assertGt(claimable, 0, "plain token has claimable"); - - // No aliases exist for this token, so aggregation adds nothing - uint256 aliasCount = 0; - address[] memory active = IMultipleRewardDistributor(stabilityPoolCollateral).activeRewardTokens(); - for (uint256 i = 0; i < active.length; i++) { - if (active[i] == address(plainToken)) { - aliasCount++; - } - } - assertEq(aliasCount, 1, "plain token registered once"); - } - - // ── Fractional claim with aliases ────────────────────────────────── - - function testAlias_fractionalClaim_partialFromAlias() public { - _depositRewardAndWait(address(harvestAlias), 100 ether); - - uint256 claimable = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(harvestAlias)); - assertGt(claimable, 0, "should have claimable via alias"); - - // Partial claim via alias — should receive underlying token - uint256 half = claimable / 2; - uint256 balBefore = IERC20(address(aliasUnderlying)).balanceOf(user1); - vm.prank(user1); - IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(user1, address(0), address(harvestAlias), half); - - assertEq(IERC20(address(aliasUnderlying)).balanceOf(user1) - balBefore, half, "received underlying"); - - // Remainder still claimable via alias - uint256 remaining = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(harvestAlias)); - assertApproxEqAbs(remaining, claimable - half, 1, "remainder via alias"); - } - - function testAlias_fractionalClaim_partialFromOneAlias_otherUnaffected() public { - _depositRewardAndWait(address(harvestAlias), 60 ether); - _depositRewardAndWait(address(boostAlias), 40 ether); - - uint256 claimableHarvest = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( - user1, - address(harvestAlias) - ); - uint256 claimableBoost = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( - user1, - address(boostAlias) - ); - - // Partial claim from harvestAlias only - vm.prank(user1); - IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim( - user1, - address(0), - address(harvestAlias), - claimableHarvest / 3 - ); - - // boostAlias claimable unchanged - assertEq( - IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(boostAlias)), - claimableBoost, - "boost alias unaffected" - ); - - // harvestAlias reduced - uint256 remainHarvest = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( - user1, - address(harvestAlias) - ); - assertApproxEqAbs(remainHarvest, claimableHarvest - claimableHarvest / 3, 1, "harvest alias reduced"); - } - - function testAlias_fractionalClaim_thenClaimUnderlying_aggregated() public { - _depositRewardAndWait(address(harvestAlias), 60 ether); - _depositRewardAndWait(address(boostAlias), 40 ether); - - // Aggregated claimable for underlying = sum of both aliases - uint256 aggregated = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( - user1, - address(aliasUnderlying) - ); - uint256 claimableHarvest = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( - user1, - address(harvestAlias) - ); - uint256 claimableBoost = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( - user1, - address(boostAlias) - ); - assertApproxEqAbs(aggregated, claimableHarvest + claimableBoost, 2, "aggregated = sum of aliases"); - - // Partial claim from harvestAlias - vm.prank(user1); - IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim( - user1, - address(0), - address(harvestAlias), - claimableHarvest / 2 - ); - - // Aggregated drops by the claimed amount - uint256 newAggregated = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( - user1, - address(aliasUnderlying) - ); - assertApproxEqAbs(newAggregated, aggregated - claimableHarvest / 2, 2, "aggregated reduced by partial claim"); - } - - function testAlias_fractionalClaim_withReceiver() public { - _depositRewardAndWait(address(harvestAlias), 100 ether); - - uint256 claimable = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(harvestAlias)); - address receiver = makeAddr("aliasReceiver"); - uint256 partialAmount = claimable / 4; - - vm.prank(user1); - IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim( - user1, - receiver, - address(harvestAlias), - partialAmount - ); - - // Receiver gets the underlying token, not the alias - assertEq(IERC20(address(aliasUnderlying)).balanceOf(receiver), partialAmount, "receiver got underlying"); - assertEq(IERC20(address(aliasUnderlying)).balanceOf(user1), 0, "user got nothing"); - - // Remainder - uint256 remaining = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(harvestAlias)); - assertApproxEqAbs(remaining, claimable - partialAmount, 1, "remainder after partial alias claim"); - } -} diff --git a/test/StabilityPoolExtras.t.sol b/test/StabilityPoolExtras.t.sol index 1c21c0ff..4cabd43c 100644 --- a/test/StabilityPoolExtras.t.sol +++ b/test/StabilityPoolExtras.t.sol @@ -21,8 +21,8 @@ contract TestStabilityPoolExtra1 is TestStabilityPoolRebalanceSetUp { uint256 constant TINY_DEPOSIT = 1 ether; // Extremely small deposit to test edge cases uint256 constant REWARD_AMOUNT = 50 ether; - // Test for totalSupplyHistory getter (coverage for function 236) - function testTotalSupplyHistory() public { + // Test for totalAssetSupplyHistory getter (coverage for function 236) + function testtotalAssetSupplyHistory() public { // Store the current timestamp for reference uint256 initialTimestamp = block.timestamp; @@ -82,7 +82,7 @@ contract TestStabilityPoolExtra1 is TestStabilityPoolRebalanceSetUp { // After "complete" liquidation, user retains MIN_TOTAL_ASSET_SUPPLY due to protection assertEq( - IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1), + IERC20(stabilityPoolCollateral).balanceOf(user1), MIN_TOTAL_ASSET_SUPPLY, "Balance should be MIN_TOTAL_ASSET_SUPPLY after complete liquidation due to protection" ); @@ -93,7 +93,7 @@ contract TestStabilityPoolExtra1 is TestStabilityPoolRebalanceSetUp { // Test that total supply is now MIN_TOTAL_ASSET_SUPPLY + new deposit assertEq( - IStabilityPool(stabilityPoolCollateral).totalAssetSupply(), + IERC20(stabilityPoolCollateral).totalSupply(), MIN_TOTAL_ASSET_SUPPLY + DEPOSIT_AMOUNT, "Total supply should be protection minimum plus new deposit" ); @@ -110,7 +110,7 @@ contract TestStabilityPoolExtra1 is TestStabilityPoolRebalanceSetUp { IStabilityPool(stabilityPoolCollateral).deposit(DEPOSIT_AMOUNT, user4, 0); // Liquidate 99.9% but respect MIN_TOTAL_ASSET_SUPPLY protection - uint256 totalSupply = IStabilityPool(stabilityPoolCollateral).totalAssetSupply(); + uint256 totalSupply = IERC20(stabilityPoolCollateral).totalSupply(); uint256 maxLiquidatable = totalSupply > MIN_TOTAL_ASSET_SUPPLY ? totalSupply - MIN_TOTAL_ASSET_SUPPLY : 0; if (maxLiquidatable > 0) { _liquidate((maxLiquidatable * 999) / 1000); @@ -119,7 +119,7 @@ contract TestStabilityPoolExtra1 is TestStabilityPoolRebalanceSetUp { // Test that balance approaches protection minimum after multiple liquidations assertLe( - IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user3), + IERC20(stabilityPoolCollateral).balanceOf(user3), MIN_TOTAL_ASSET_SUPPLY * 2, // Increased tolerance since TINY_DEPOSIT is now 1 ether "Small balance should approach protection minimum after multiple liquidations" ); @@ -134,7 +134,7 @@ contract TestStabilityPoolExtra1 is TestStabilityPoolRebalanceSetUp { IStabilityPool(stabilityPoolCollateral).deposit(DEPOSIT_AMOUNT, user1, 0); // Verify initial balance before liquidation - uint256 initialBalance = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1); + uint256 initialBalance = IERC20(stabilityPoolCollateral).balanceOf(user1); assertEq(initialBalance, DEPOSIT_AMOUNT, "Initial balance should match deposit amount"); // Perform a "full" liquidation (limited by MIN_TOTAL_ASSET_SUPPLY protection) @@ -142,12 +142,12 @@ contract TestStabilityPoolExtra1 is TestStabilityPoolRebalanceSetUp { // Check final balance is MIN_TOTAL_ASSET_SUPPLY due to protection assertEq( - IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1), + IERC20(stabilityPoolCollateral).balanceOf(user1), MIN_TOTAL_ASSET_SUPPLY, "Balance should be MIN_TOTAL_ASSET_SUPPLY after full liquidation due to protection" ); assertEq( - IStabilityPool(stabilityPoolCollateral).totalAssetSupply(), + IERC20(stabilityPoolCollateral).totalSupply(), MIN_TOTAL_ASSET_SUPPLY, "Total supply should be MIN_TOTAL_ASSET_SUPPLY after full liquidation due to protection" ); @@ -175,12 +175,12 @@ contract TestStabilityPoolExtra1 is TestStabilityPoolRebalanceSetUp { // Check final balance is MIN_TOTAL_ASSET_SUPPLY due to protection assertEq( - IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1), + IERC20(stabilityPoolCollateral).balanceOf(user1), MIN_TOTAL_ASSET_SUPPLY, "Balance should be MIN_TOTAL_ASSET_SUPPLY after excess liquidation due to protection" ); assertEq( - IStabilityPool(stabilityPoolCollateral).totalAssetSupply(), + IERC20(stabilityPoolCollateral).totalSupply(), MIN_TOTAL_ASSET_SUPPLY, "Total supply should be MIN_TOTAL_ASSET_SUPPLY after excess liquidation due to protection" ); @@ -272,7 +272,7 @@ contract TestStabilityPoolExtra1 is TestStabilityPoolRebalanceSetUp { // Verify deposit amount assertEq(deposited, initialPeggedBalance, "Should deposit entire balance"); assertEq( - IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1), + IERC20(stabilityPoolCollateral).balanceOf(user1), initialPeggedBalance, "Balance should match deposit" ); @@ -400,7 +400,7 @@ contract TestStabilityPoolExtra1 is TestStabilityPoolRebalanceSetUp { IStabilityPool(stabilityPoolCollateral).deposit(DEPOSIT_AMOUNT * 5, user3, 0); // Check user1's balance after multiple exponent changes - uint256 user1Balance = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1); + uint256 user1Balance = IERC20(stabilityPoolCollateral).balanceOf(user1); assertLt( user1Balance, DEPOSIT_AMOUNT / 1000, @@ -420,11 +420,7 @@ contract TestStabilityPoolExtra1 is TestStabilityPoolRebalanceSetUp { // Verify deposit assertEq(deposited, exactAmount, "Should deposit exact balance amount"); - assertEq( - IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1), - exactAmount, - "Balance should match deposit" - ); + assertEq(IERC20(stabilityPoolCollateral).balanceOf(user1), exactAmount, "Balance should match deposit"); assertEq(IERC20(peggedToken).balanceOf(user1), 0, "Pegged token balance should be 0"); } @@ -450,7 +446,7 @@ contract TestStabilityPoolExtra1 is TestStabilityPoolRebalanceSetUp { // Check total balance is correct assertEq( - IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1), + IERC20(stabilityPoolCollateral).balanceOf(user1), smallAmount * 5, "Balance should be sum of all deposits" ); @@ -467,14 +463,14 @@ contract TestStabilityPoolExtra1 is TestStabilityPoolRebalanceSetUp { vm.prank(user1); IStabilityPool(stabilityPoolCollateral).deposit(DEPOSIT_AMOUNT, user1, 0); - uint256 initialBalance = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1); + uint256 initialBalance = IERC20(stabilityPoolCollateral).balanceOf(user1); // Sweep a tiny amount of asset tokens uint256 tinyAmount = 1; _liquidate(tinyAmount); // Verify the impact on user balance - uint256 finalBalance = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1); + uint256 finalBalance = IERC20(stabilityPoolCollateral).balanceOf(user1); assertLt(finalBalance, initialBalance, "Balance should decrease after liquidate"); // Check that lastAssetLossError was updated @@ -497,12 +493,12 @@ contract TestStabilityPoolExtra1 is TestStabilityPoolRebalanceSetUp { // Verify protection prevents complete depletion assertEq( - IStabilityPool(stabilityPoolCollateral).totalAssetSupply(), + IERC20(stabilityPoolCollateral).totalSupply(), MIN_TOTAL_ASSET_SUPPLY, "Total supply should be MIN_TOTAL_ASSET_SUPPLY due to protection, not 0" ); assertEq( - IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1), + IERC20(stabilityPoolCollateral).balanceOf(user1), MIN_TOTAL_ASSET_SUPPLY, "User balance should be MIN_TOTAL_ASSET_SUPPLY due to protection, not 0" ); diff --git a/test/StabilityPoolExtras2.t.sol b/test/StabilityPoolExtras2.t.sol index 6a8d6540..046958ff 100644 --- a/test/StabilityPoolExtras2.t.sol +++ b/test/StabilityPoolExtras2.t.sol @@ -83,11 +83,7 @@ contract TestStabilityPoolExtra2 is TestStabilityPoolSetUp { "User's pegged token balance should remain unchanged" ); - assertEq( - IStabilityPool(stabilityPoolCollateral).assetBalanceOf(address(0)), - 0, - "Zero address should have no tokens" - ); + assertEq(IERC20(stabilityPoolCollateral).balanceOf(address(0)), 0, "Zero address should have no tokens"); } // Test withdraw with receiver = address(0) @@ -106,7 +102,7 @@ contract TestStabilityPoolExtra2 is TestStabilityPoolSetUp { // Verify balances remain unchanged assertEq( - IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1), + IERC20(stabilityPoolCollateral).balanceOf(user1), DEPOSIT_AMOUNT, "User's LP token balance should remain unchanged" ); @@ -139,11 +135,11 @@ contract TestStabilityPoolExtra2 is TestStabilityPoolSetUp { IStabilityPool(stabilityPoolCollateral).deposit(DEPOSIT_AMOUNT, user1, 0); // Get exact balance - uint256 exactBalance = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1); + uint256 exactBalance = IERC20(stabilityPoolCollateral).balanceOf(user1); // Calculate maximum withdrawable amount considering MIN_TOTAL_ASSET_SUPPLY protection uint256 MIN_TOTAL_ASSET_SUPPLY = 1 ether; - uint256 totalSupply = IStabilityPool(stabilityPoolCollateral).totalAssetSupply(); + uint256 totalSupply = IERC20(stabilityPoolCollateral).totalSupply(); uint256 maxWithdrawable = totalSupply > MIN_TOTAL_ASSET_SUPPLY ? totalSupply - MIN_TOTAL_ASSET_SUPPLY : 0; // When trying to withdraw the exact balance, the pool will limit it to maxWithdrawable @@ -165,21 +161,21 @@ contract TestStabilityPoolExtra2 is TestStabilityPoolSetUp { // The remaining balance should be the original balance minus what was actually withdrawn uint256 expectedRemainingBalance = exactBalance - expectedWithdrawal; assertEq( - IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1), + IERC20(stabilityPoolCollateral).balanceOf(user1), expectedRemainingBalance, "Balance should reflect actual withdrawal amount" ); // Pool total supply should not go below MIN_TOTAL_ASSET_SUPPLY assertGe( - IStabilityPool(stabilityPoolCollateral).totalAssetSupply(), + IERC20(stabilityPoolCollateral).totalSupply(), MIN_TOTAL_ASSET_SUPPLY, "Pool should maintain minimum total asset supply" ); } - // Test totalSupplyHistory with invalid index - function testTotalSupplyHistoryInvalidIndex() public view { + // Test totalAssetSupplyHistory with invalid index + function testtotalAssetSupplyHistoryInvalidIndex() public view { // Get total supply history with invalid index (uint40 atDay, uint256 amount) = IStabilityPool(stabilityPoolCollateral).totalAssetSupplyHistory(999); @@ -207,7 +203,7 @@ contract TestStabilityPoolExtra2 is TestStabilityPoolSetUp { // Check final balances assertEq( - IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1), + IERC20(stabilityPoolCollateral).balanceOf(user1), DEPOSIT_AMOUNT + DEPOSIT_AMOUNT - DEPOSIT_AMOUNT / 2, "User1 balance should reflect all operations" ); diff --git a/test/StabilityPoolFeatures.t.sol b/test/StabilityPoolFeatures.t.sol index 4a7cb553..0fe54618 100644 --- a/test/StabilityPoolFeatures.t.sol +++ b/test/StabilityPoolFeatures.t.sol @@ -360,7 +360,7 @@ contract StabilityPoolFeatures is TestStabilityPoolSetUp { vm.prank(user1); uint256 withdrawn = IStabilityPool(stabilityPoolCollateral).withdraw(type(uint256).max, user1, 0); - uint256 supplyAfter = IStabilityPool(stabilityPoolCollateral).totalAssetSupply(); + uint256 supplyAfter = IERC20(stabilityPoolCollateral).totalSupply(); assertEq(supplyAfter, 1 ether, "supply at MIN"); // Fee should have been trimmed to 0 @@ -389,7 +389,7 @@ contract StabilityPoolFeatures is TestStabilityPoolSetUp { vm.prank(user1); uint256 withdrawn = IStabilityPool(stabilityPoolCollateral).withdraw(type(uint256).max, user1, 0); - uint256 supplyAfter = IStabilityPool(stabilityPoolCollateral).totalAssetSupply(); + uint256 supplyAfter = IERC20(stabilityPoolCollateral).totalSupply(); assertEq(supplyAfter, 1 ether, "supply at MIN"); uint256 feeCollected = IERC20(peggedToken).balanceOf(FEE_ADDRESS) - feeReceiverBefore; diff --git a/test/StabilityPoolLoss.t.sol b/test/StabilityPoolLoss.t.sol index 3c738fe8..d01ec039 100644 --- a/test/StabilityPoolLoss.t.sol +++ b/test/StabilityPoolLoss.t.sol @@ -38,15 +38,15 @@ contract TestStabilityPoolLoss is TestStabilityPoolBaseSetUp { IStabilityPool(pool).deposit(depositAmount, user1, 0); vm.stopPrank(); - uint256 initialTotalAssets = IStabilityPool(pool).totalAssetSupply(); + uint256 initialTotalAssets = IERC20(pool).totalSupply(); assertEq(initialTotalAssets, depositAmount); // Action: Simulate loss through sweep _liquidate(pool, lossAmount); // Get resulting balances - uint256 totalAssetSupply = IStabilityPool(pool).totalAssetSupply(); - uint256 userBalance = IStabilityPool(pool).assetBalanceOf(user1); + uint256 totalAssetSupply = IERC20(pool).totalSupply(); + uint256 userBalance = IERC20(pool).balanceOf(user1); // Assertions - with proper tolerance for rounding uint256 expectedRemainingSupply = depositAmount - lossAmount; @@ -80,7 +80,7 @@ contract TestStabilityPoolLoss is TestStabilityPoolBaseSetUp { IStabilityPool(pool).deposit(user2Deposit_, user2, 0); // Pre-loss checks - assertEq(IStabilityPool(pool).totalAssetSupply(), totalDeposit); + assertEq(IERC20(pool).totalSupply(), totalDeposit); // Action: Simulate loss through sweep _liquidate(pool, lossAmount); @@ -90,20 +90,12 @@ contract TestStabilityPoolLoss is TestStabilityPoolBaseSetUp { uint256 expectedUser2Loss = lossAmount - expectedUser1Loss; // Account for rounding // Check proportional loss distribution with appropriate tolerance - assertApproxEqAbs( - IStabilityPool(pool).assetBalanceOf(user1), - user1Deposit_ - expectedUser1Loss, - TOLERANCE_LARGE - ); + assertApproxEqAbs(IERC20(pool).balanceOf(user1), user1Deposit_ - expectedUser1Loss, TOLERANCE_LARGE); - assertApproxEqAbs( - IStabilityPool(pool).assetBalanceOf(user2), - user2Deposit_ - expectedUser2Loss, - TOLERANCE_LARGE - ); + assertApproxEqAbs(IERC20(pool).balanceOf(user2), user2Deposit_ - expectedUser2Loss, TOLERANCE_LARGE); // Total assets check - assertApproxEqAbs(IStabilityPool(pool).totalAssetSupply(), totalDeposit - lossAmount, 10); + assertApproxEqAbs(IERC20(pool).totalSupply(), totalDeposit - lossAmount, 10); } /// @notice Test withdrawals after loss with varying amounts @@ -127,7 +119,7 @@ contract TestStabilityPoolLoss is TestStabilityPoolBaseSetUp { // Action: Simulate loss through sweep _liquidate(pool, lossAmount); - uint256 remainingBalance = IStabilityPool(pool).assetBalanceOf(user1); + uint256 remainingBalance = IERC20(pool).balanceOf(user1); assertApproxEqAbs(remainingBalance, depositAmount - lossAmount, TOLERANCE_LARGE); // Action: User withdraws @@ -143,11 +135,7 @@ contract TestStabilityPoolLoss is TestStabilityPoolBaseSetUp { // Assert correct withdrawal with tolerance assertApproxEqAbs(IERC20(peggedToken).balanceOf(user1), initialAssetBalance + withdrawAmount, TOLERANCE_SMALL); - assertApproxEqAbs( - IStabilityPool(pool).assetBalanceOf(user1), - remainingBalance - withdrawAmount, - TOLERANCE_LARGE - ); + assertApproxEqAbs(IERC20(pool).balanceOf(user1), remainingBalance - withdrawAmount, TOLERANCE_LARGE); } /// @notice Test scenario with near-total or total loss @@ -190,9 +178,9 @@ contract TestStabilityPoolLoss is TestStabilityPoolBaseSetUp { } // Assertions with appropriate tolerance - assertApproxEqAbs(IStabilityPool(pool).totalAssetSupply(), expectedRemaining, 10); + assertApproxEqAbs(IERC20(pool).totalSupply(), expectedRemaining, 10); - uint256 remainingBalance = IStabilityPool(pool).assetBalanceOf(user1); + uint256 remainingBalance = IERC20(pool).balanceOf(user1); assertApproxEqAbs(remainingBalance, expectedRemaining, TOLERANCE_LARGE); // Test withdrawal after near-total loss if there's anything left @@ -215,7 +203,7 @@ contract TestStabilityPoolLoss is TestStabilityPoolBaseSetUp { ); // Should be approximately MIN_TOTAL_ASSET_SUPPLY left - assertApproxEqAbs(IStabilityPool(pool).assetBalanceOf(user1), MIN_TOTAL_ASSET_SUPPLY, TOLERANCE_SMALL); + assertApproxEqAbs(IERC20(pool).balanceOf(user1), MIN_TOTAL_ASSET_SUPPLY, TOLERANCE_SMALL); } } @@ -296,9 +284,9 @@ contract TestStabilityPoolLoss is TestStabilityPoolBaseSetUp { remainingBalance -= lossAmounts[i]; // Verify balance after each loss with appropriate tolerance - assertApproxEqAbs(IStabilityPool(pool).totalAssetSupply(), remainingBalance, TOLERANCE_SMALL); + assertApproxEqAbs(IERC20(pool).totalSupply(), remainingBalance, TOLERANCE_SMALL); - assertApproxEqAbs(IStabilityPool(pool).assetBalanceOf(user1), remainingBalance, TOLERANCE_LARGE); + assertApproxEqAbs(IERC20(pool).balanceOf(user1), remainingBalance, TOLERANCE_LARGE); } } @@ -322,19 +310,19 @@ contract TestStabilityPoolLoss is TestStabilityPoolBaseSetUp { uint256 expectedUser2LossFirst = firstLoss - expectedUser1LossFirst; assertApproxEqAbs( - IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1), + IERC20(stabilityPoolCollateral).balanceOf(user1), user1Deposit - expectedUser1LossFirst, TOLERANCE_LARGE ); assertApproxEqAbs( - IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user2), + IERC20(stabilityPoolCollateral).balanceOf(user2), user2Deposit - expectedUser2LossFirst, TOLERANCE_LARGE ); // User1 withdraws half - uint256 user1RemainingBalance = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1); + uint256 user1RemainingBalance = IERC20(stabilityPoolCollateral).balanceOf(user1); uint256 user1WithdrawAmount = user1RemainingBalance / 2; vm.prank(user1); @@ -355,7 +343,7 @@ contract TestStabilityPoolLoss is TestStabilityPoolBaseSetUp { _liquidate(stabilityPoolCollateral, secondLoss); // Check final balances - uint256 totalAssetsAfterAll = IStabilityPool(stabilityPoolCollateral).totalAssetSupply(); + uint256 totalAssetsAfterAll = IERC20(stabilityPoolCollateral).totalSupply(); uint256 expectedTotalAssets = user1Deposit + user2Deposit + user3Deposit - @@ -366,9 +354,9 @@ contract TestStabilityPoolLoss is TestStabilityPoolBaseSetUp { assertApproxEqAbs(totalAssetsAfterAll, expectedTotalAssets, TOLERANCE_LARGE); // Ensure all users can withdraw remaining balances (considering MIN_TOTAL_ASSET_SUPPLY protection) - uint256 user1FinalBalance = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1); - uint256 user2FinalBalance = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user2); - uint256 user3FinalBalance = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user3); + uint256 user1FinalBalance = IERC20(stabilityPoolCollateral).balanceOf(user1); + uint256 user2FinalBalance = IERC20(stabilityPoolCollateral).balanceOf(user2); + uint256 user3FinalBalance = IERC20(stabilityPoolCollateral).balanceOf(user3); // Calculate total withdrawable amount (total balances minus MIN_TOTAL_ASSET_SUPPLY protection) uint256 totalUserBalances = user1FinalBalance + user2FinalBalance + user3FinalBalance; @@ -408,11 +396,7 @@ contract TestStabilityPoolLoss is TestStabilityPoolBaseSetUp { } // Pool should be left with approximately MIN_TOTAL_ASSET_SUPPLY due to protection - assertApproxEqAbs( - IStabilityPool(stabilityPoolCollateral).totalAssetSupply(), - MIN_TOTAL_ASSET_SUPPLY, - TOLERANCE_LARGE - ); + assertApproxEqAbs(IERC20(stabilityPoolCollateral).totalSupply(), MIN_TOTAL_ASSET_SUPPLY, TOLERANCE_LARGE); } } @@ -513,9 +497,9 @@ contract TestStabilityPoolRewardsAndLoss is TestStabilityPoolBaseSetUp { uint256 startTime = block.timestamp; // Verify initial state - assertEq(IStabilityPool(pool).totalAssetSupply(), user1Deposit + user2Deposit); - assertEq(IStabilityPool(pool).assetBalanceOf(user1), user1Deposit); - assertEq(IStabilityPool(pool).assetBalanceOf(user2), user2Deposit); + assertEq(IERC20(pool).totalSupply(), user1Deposit + user2Deposit); + assertEq(IERC20(pool).balanceOf(user1), user1Deposit); + assertEq(IERC20(pool).balanceOf(user2), user2Deposit); _checkRewards("initial"); _checkRewards("initial", user1, 0, 0); @@ -545,14 +529,14 @@ contract TestStabilityPoolRewardsAndLoss is TestStabilityPoolBaseSetUp { daycount = 2; vm.warp(startTime + daycount * 1 days); // 2/7 of the reward period - uint256 totalSupply = IStabilityPool(pool).totalAssetSupply(); + uint256 totalSupply = IERC20(pool).totalSupply(); vm.prank(rebalancer); uint256 immediateAmount = _liquidate(totalSupply / 2); // 1 notifyLiquidation -------------------------------------------------------- - assertEq(IStabilityPool(pool).totalAssetSupply(), totalSupply / 2, "Pool should be half emptied"); - assertEq(IStabilityPool(pool).assetBalanceOf(user1), user1Deposit / 2, "User1 balance halved"); - assertEq(IStabilityPool(pool).assetBalanceOf(user2), user2Deposit / 2, "User2 balance halved"); + assertEq(IERC20(pool).totalSupply(), totalSupply / 2, "Pool should be half emptied"); + assertEq(IERC20(pool).balanceOf(user1), user1Deposit / 2, "User1 balance halved"); + assertEq(IERC20(pool).balanceOf(user2), user2Deposit / 2, "User2 balance halved"); // Test liquidation rewards and delayed rewards preservation _checkRewards("2 days, half"); @@ -561,7 +545,7 @@ contract TestStabilityPoolRewardsAndLoss is TestStabilityPoolBaseSetUp { // Phase 3: Complete liquidation ///////////////////////////////// - totalSupply = IStabilityPool(pool).totalAssetSupply(); + totalSupply = IERC20(pool).totalSupply(); prevdaycount = daycount; daycount = 4; vm.warp(startTime + daycount * 1 days); // 4/7 of the reward period @@ -571,8 +555,8 @@ contract TestStabilityPoolRewardsAndLoss is TestStabilityPoolBaseSetUp { // 2 notifyLiquidation --------------------------------------------- // Calculate expected user balances after complete liquidation - assertApproxEqAbs(IStabilityPool(pool).assetBalanceOf(user1), uint256(1 ether) / 3, 100, "User1 1/3 share"); - assertApproxEqAbs(IStabilityPool(pool).assetBalanceOf(user2), uint256(2 ether) / 3, 100, "User2 2/3 share"); + assertApproxEqAbs(IERC20(pool).balanceOf(user1), uint256(1 ether) / 3, 100, "User1 1/3 share"); + assertApproxEqAbs(IERC20(pool).balanceOf(user2), uint256(2 ether) / 3, 100, "User2 2/3 share"); // Test liquidation rewards preservation and delayed reward preservation _checkRewards("4 days, full"); @@ -651,11 +635,11 @@ contract TestStabilityPoolRewardsAndLoss is TestStabilityPoolBaseSetUp { IStabilityPool(pool).deposit(user3Deposit, user3, 0); assertEq( - IStabilityPool(pool).totalAssetSupply(), + IERC20(pool).totalSupply(), user3Deposit + 1 ether, // 1 ether is the MIN_TOTAL_ASSET_SUPPLY "Pool should accept new deposits after emptying" ); - assertEq(IStabilityPool(pool).assetBalanceOf(user3), user3Deposit, "User3 new deposit balance"); + assertEq(IERC20(pool).balanceOf(user3), user3Deposit, "User3 new deposit balance"); // rewards change when a deposit is made because it triggers distribution of pending new delayed rewards _checkRewards("new deposit"); diff --git a/test/StabilityPoolRebalance.t.sol b/test/StabilityPoolRebalance.t.sol index a4a302dd..d642e37a 100644 --- a/test/StabilityPoolRebalance.t.sol +++ b/test/StabilityPoolRebalance.t.sol @@ -104,9 +104,9 @@ contract TestStabilityPoolRebalance is TestStabilityPoolRebalanceSetUp { IStabilityPool(stabilityPoolCollateral).deposit(DEPOSIT_AMOUNT * 3, user3, 0); // Verify initial balances - uint256 user1InitialBalance = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1); - uint256 user2InitialBalance = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user2); - uint256 user3InitialBalance = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user3); + uint256 user1InitialBalance = IERC20(stabilityPoolCollateral).balanceOf(user1); + uint256 user2InitialBalance = IERC20(stabilityPoolCollateral).balanceOf(user2); + uint256 user3InitialBalance = IERC20(stabilityPoolCollateral).balanceOf(user3); assertEq(user1InitialBalance, DEPOSIT_AMOUNT, "User1 initial balance should match deposit"); assertEq(user2InitialBalance, DEPOSIT_AMOUNT * 2, "User2 initial balance should match deposit"); assertEq(user3InitialBalance, DEPOSIT_AMOUNT * 3, "User3 initial balance should match deposit"); @@ -117,8 +117,8 @@ contract TestStabilityPoolRebalance is TestStabilityPoolRebalanceSetUp { _liquidate(DEPOSIT_AMOUNT); // After first liquidation, balances should be reduced by ~16.67% - uint256 user1AfterLiquidation1 = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1); - uint256 user3AfterLiquidation1 = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user3); + uint256 user1AfterLiquidation1 = IERC20(stabilityPoolCollateral).balanceOf(user1); + uint256 user3AfterLiquidation1 = IERC20(stabilityPoolCollateral).balanceOf(user3); uint256 expectedUser1BalanceAfterLiq1 = (user1InitialBalance * 5) / 6; uint256 expectedUser3BalanceAfterLiq1 = (user3InitialBalance * 5) / 6; @@ -154,8 +154,8 @@ contract TestStabilityPoolRebalance is TestStabilityPoolRebalanceSetUp { IStabilityPool(stabilityPoolCollateral).withdraw(DEPOSIT_AMOUNT / 4, user1, 0); // After withdrawal, user1's balance should be reduced by DEPOSIT_AMOUNT/4 - uint256 user1AfterWithdraw = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1); - uint256 user3AfterUser1Withdraw = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user3); + uint256 user1AfterWithdraw = IERC20(stabilityPoolCollateral).balanceOf(user1); + uint256 user3AfterUser1Withdraw = IERC20(stabilityPoolCollateral).balanceOf(user3); uint256 expectedUser1BalanceAfterWithdraw = user1AfterLiquidation1 - (DEPOSIT_AMOUNT / 4); @@ -190,10 +190,10 @@ contract TestStabilityPoolRebalance is TestStabilityPoolRebalanceSetUp { _liquidate(DEPOSIT_AMOUNT / 2); // Check final balances - uint256 user1FinalBalance = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1); - uint256 user2FinalBalance = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user2); - uint256 user3FinalBalance = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user3); - uint256 user4FinalBalance = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user4); + uint256 user1FinalBalance = IERC20(stabilityPoolCollateral).balanceOf(user1); + uint256 user2FinalBalance = IERC20(stabilityPoolCollateral).balanceOf(user2); + uint256 user3FinalBalance = IERC20(stabilityPoolCollateral).balanceOf(user3); + uint256 user4FinalBalance = IERC20(stabilityPoolCollateral).balanceOf(user4); // All balances should be positive assertTrue(user1FinalBalance > 0, "User1 should have balance > 0"); @@ -246,9 +246,9 @@ contract TestStabilityPoolRebalance is TestStabilityPoolRebalanceSetUp { IStabilityPool(stabilityPoolCollateral).deposit(DEPOSIT_AMOUNT * 3, user3, 0); // Verify initial balances - uint256 user1InitialBalance = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1); - uint256 user2InitialBalance = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user2); - uint256 user3InitialBalance = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user3); + uint256 user1InitialBalance = IERC20(stabilityPoolCollateral).balanceOf(user1); + uint256 user2InitialBalance = IERC20(stabilityPoolCollateral).balanceOf(user2); + uint256 user3InitialBalance = IERC20(stabilityPoolCollateral).balanceOf(user3); assertEq(user1InitialBalance, DEPOSIT_AMOUNT, "User1 initial balance should match deposit"); assertEq(user2InitialBalance, DEPOSIT_AMOUNT * 2, "User2 initial balance should match deposit"); assertEq(user3InitialBalance, DEPOSIT_AMOUNT * 3, "User3 initial balance should match deposit"); @@ -257,9 +257,9 @@ contract TestStabilityPoolRebalance is TestStabilityPoolRebalanceSetUp { _liquidate(DEPOSIT_AMOUNT); // Capture actual values after first liquidation - uint256 user1AfterLiquidation1 = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1); - uint256 user2AfterLiquidation1 = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user2); - uint256 user3AfterLiquidation1 = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user3); + uint256 user1AfterLiquidation1 = IERC20(stabilityPoolCollateral).balanceOf(user1); + uint256 user2AfterLiquidation1 = IERC20(stabilityPoolCollateral).balanceOf(user2); + uint256 user3AfterLiquidation1 = IERC20(stabilityPoolCollateral).balanceOf(user3); // Verify actual values match expected - this step documents the actual behavior // From the trace, we can see user1 has 83333333333333333300 @@ -280,7 +280,7 @@ contract TestStabilityPoolRebalance is TestStabilityPoolRebalanceSetUp { IStabilityPool(stabilityPoolCollateral).withdraw(DEPOSIT_AMOUNT / 4, user1, 0); // Capture actual values after withdrawal - uint256 user1AfterWithdraw = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1); + uint256 user1AfterWithdraw = IERC20(stabilityPoolCollateral).balanceOf(user1); // From the trace, we know this is 58333333333333333300 assertEq(user1AfterWithdraw, 58333333333333333300, "User1 balance after withdrawal"); @@ -299,10 +299,10 @@ contract TestStabilityPoolRebalance is TestStabilityPoolRebalanceSetUp { _liquidate(DEPOSIT_AMOUNT / 2); // Get final balances - uint256 user1FinalBalance = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1); - uint256 user2FinalBalance = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user2); - uint256 user3FinalBalance = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user3); - uint256 user4FinalBalance = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user4); + uint256 user1FinalBalance = IERC20(stabilityPoolCollateral).balanceOf(user1); + uint256 user2FinalBalance = IERC20(stabilityPoolCollateral).balanceOf(user2); + uint256 user3FinalBalance = IERC20(stabilityPoolCollateral).balanceOf(user3); + uint256 user4FinalBalance = IERC20(stabilityPoolCollateral).balanceOf(user4); // Verify exact values based on trace data assertEq(user1FinalBalance, 41399999999999999990, "User1 final balance"); @@ -352,26 +352,26 @@ contract TestStabilityPoolRebalance is TestStabilityPoolRebalanceSetUp { // sweepAmount = 1; // 1 wei deal(peggedToken, user1, depositAmount); - assertEq(IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1), 0); + assertEq(IERC20(stabilityPoolCollateral).balanceOf(user1), 0); vm.prank(user1); IStabilityPool(stabilityPoolCollateral).deposit(depositAmount, user1, 0); - uint256 initialBalance = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1); + uint256 initialBalance = IERC20(stabilityPoolCollateral).balanceOf(user1); assertEq(initialBalance, depositAmount, "Initial balance should match deposit"); // Verify initial lastAssetLossError assertEq(IStabilityPool(stabilityPoolCollateral).lastAssetLossError(), 0, "lastAssetLossError should be 0"); // Sweep a tiny amount (1 wei) - uint256 totalSupplyBefore = IStabilityPool(stabilityPoolCollateral).totalAssetSupply(); + uint256 totalSupplyBefore = IERC20(stabilityPoolCollateral).totalSupply(); vm.expectEmit(stabilityPoolCollateral); emit ITokenHolder.Swept(peggedToken, sweepAmount, rebalancer); vm.expectEmit(peggedToken); emit IERC20.Transfer(stabilityPoolCollateral, rebalancer, sweepAmount); _liquidate(sweepAmount); assertLe( - IStabilityPool(stabilityPoolCollateral).totalAssetSupply(), + IERC20(stabilityPoolCollateral).totalSupply(), totalSupplyBefore, "Total supply should decrease by at most the sweep amount" ); @@ -381,7 +381,7 @@ contract TestStabilityPoolRebalance is TestStabilityPoolRebalanceSetUp { // Only check for reasonable lower bound if there's enough margin to avoid underflow if (totalSupplyBefore >= sweepAmount + 1000) { assertGe( - IStabilityPool(stabilityPoolCollateral).totalAssetSupply(), + IERC20(stabilityPoolCollateral).totalSupply(), totalSupplyBefore - sweepAmount - 1000, // Allow for error correction "Total supply should not decrease by much more than sweep amount" ); @@ -390,7 +390,7 @@ contract TestStabilityPoolRebalance is TestStabilityPoolRebalanceSetUp { // and remains reasonable (could be as low as MIN_TOTAL_ASSET_SUPPLY) uint256 minSupply = IStabilityPool(stabilityPoolCollateral).MIN_TOTAL_ASSET_SUPPLY(); assertGe( - IStabilityPool(stabilityPoolCollateral).totalAssetSupply(), + IERC20(stabilityPoolCollateral).totalSupply(), minSupply, "Total supply should not go below minimum supply" ); @@ -399,7 +399,7 @@ contract TestStabilityPoolRebalance is TestStabilityPoolRebalanceSetUp { // Complete liquidation case - supply should be minimum supply uint256 minSupply = IStabilityPool(stabilityPoolCollateral).MIN_TOTAL_ASSET_SUPPLY(); assertEq( - IStabilityPool(stabilityPoolCollateral).totalAssetSupply(), + IERC20(stabilityPoolCollateral).totalSupply(), minSupply, "Total supply should be minimum supply after complete liquidation" ); @@ -422,7 +422,7 @@ contract TestStabilityPoolRebalance is TestStabilityPoolRebalanceSetUp { // For very small losses, the balance may not change if completely absorbed by error correction if (sweepAmount < totalSupplyBefore / 1000) { // Very small loss - balance may not change if absorbed by accumulated error - uint256 finalBalance = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1); + uint256 finalBalance = IERC20(stabilityPoolCollateral).balanceOf(user1); // Balance should not increase (that would be clearly wrong) assertLe(finalBalance, initialBalance, "Balance should not increase after sweep"); @@ -431,7 +431,7 @@ contract TestStabilityPoolRebalance is TestStabilityPoolRebalanceSetUp { assertGt(finalBalance, 0, "Balance should remain positive"); } else { // Larger loss - existing logic for handling medium to large losses - uint256 finalBalance = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1); + uint256 finalBalance = IERC20(stabilityPoolCollateral).balanceOf(user1); // Balance should not increase (that would be clearly wrong) assertLe(finalBalance, initialBalance, "Balance should not increase after sweep"); @@ -461,7 +461,7 @@ contract TestStabilityPoolRebalance is TestStabilityPoolRebalanceSetUp { IStabilityPool(stabilityPoolCollateral).deposit(initialDeposit, user1, 0); // Verify initial state - uint256 initialBalance = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1); + uint256 initialBalance = IERC20(stabilityPoolCollateral).balanceOf(user1); assertEq(IStabilityPool(stabilityPoolCollateral).lastAssetLossError(), 0, "Initial loss error should be 0"); // Create a very small loss (1 wei) @@ -469,7 +469,7 @@ contract TestStabilityPoolRebalance is TestStabilityPoolRebalanceSetUp { _liquidate(tinyLossAmount); // Get post-loss state - uint256 newBalance = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1); + uint256 newBalance = IERC20(stabilityPoolCollateral).balanceOf(user1); uint256 newLossError = IStabilityPool(stabilityPoolCollateral).lastAssetLossError(); uint256 balanceReduction = initialBalance - newBalance; @@ -490,7 +490,7 @@ contract TestStabilityPoolRebalance is TestStabilityPoolRebalanceSetUp { // A second tiny sweep should behave similarly but account for existing error _liquidate(tinyLossAmount); - uint256 finalBalance = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1); + uint256 finalBalance = IERC20(stabilityPoolCollateral).balanceOf(user1); assertEq( IStabilityPool(stabilityPoolCollateral).lastAssetLossError(), newLossError - 1 ether, @@ -516,7 +516,7 @@ contract TestStabilityPoolRebalance is TestStabilityPoolRebalanceSetUp { IStabilityPool(stabilityPoolCollateral).deposit(initialDeposit, user1, 0); // Verify initial state - uint256 initialBalance = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1); + uint256 initialBalance = IERC20(stabilityPoolCollateral).balanceOf(user1); uint256 initialLossError = IStabilityPool(stabilityPoolCollateral).lastAssetLossError(); assertEq(initialLossError, 0, "Initial loss error should be 0"); @@ -525,7 +525,7 @@ contract TestStabilityPoolRebalance is TestStabilityPoolRebalanceSetUp { _liquidate(tinyLossAmount); // Get post-loss state - uint256 newBalance = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1); + uint256 newBalance = IERC20(stabilityPoolCollateral).balanceOf(user1); uint256 newLossError = IStabilityPool(stabilityPoolCollateral).lastAssetLossError(); uint256 balanceReduction = initialBalance - newBalance; @@ -549,7 +549,7 @@ contract TestStabilityPoolRebalance is TestStabilityPoolRebalanceSetUp { _liquidate(largerLossAmount); - uint256 finalBalance = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1); + uint256 finalBalance = IERC20(stabilityPoolCollateral).balanceOf(user1); uint256 finalLossError = IStabilityPool(stabilityPoolCollateral).lastAssetLossError(); assertApproxEqAbs( finalLossError, @@ -592,10 +592,10 @@ contract TestStabilityPoolRebalance is TestStabilityPoolRebalanceSetUp { IStabilityPool(stabilityPoolCollateral).deposit(DEPOSIT_AMOUNT / 2, user3, 0); // 2. Verify initial balances - uint256 user1InitialBalance = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1); - uint256 user2InitialBalance = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user2); - uint256 user3InitialBalance = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user3); - uint256 totalSupply = IStabilityPool(stabilityPoolCollateral).totalAssetSupply(); + uint256 user1InitialBalance = IERC20(stabilityPoolCollateral).balanceOf(user1); + uint256 user2InitialBalance = IERC20(stabilityPoolCollateral).balanceOf(user2); + uint256 user3InitialBalance = IERC20(stabilityPoolCollateral).balanceOf(user3); + uint256 totalSupply = IERC20(stabilityPoolCollateral).totalSupply(); assertEq(user1InitialBalance, DEPOSIT_AMOUNT, "User1 initial balance incorrect"); assertEq(user2InitialBalance, DEPOSIT_AMOUNT * 2, "User2 initial balance incorrect"); @@ -615,9 +615,9 @@ contract TestStabilityPoolRebalance is TestStabilityPoolRebalanceSetUp { uint256 theoreticalUser3Balance = ((DEPOSIT_AMOUNT / 2) * minSupply) / totalOriginalDeposits; // (50 * 1e18) / 350 // Get actual balances - uint256 actualUser1Balance = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1); - uint256 actualUser2Balance = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user2); - uint256 actualUser3Balance = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user3); + uint256 actualUser1Balance = IERC20(stabilityPoolCollateral).balanceOf(user1); + uint256 actualUser2Balance = IERC20(stabilityPoolCollateral).balanceOf(user2); + uint256 actualUser3Balance = IERC20(stabilityPoolCollateral).balanceOf(user3); // Verify theoretical vs actual with precision tolerance assertApproxEqAbs( @@ -682,7 +682,7 @@ contract TestStabilityPoolRebalance is TestStabilityPoolRebalanceSetUp { // User1's balance should remain unchanged after failed withdrawal assertEq( - IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1), + IERC20(stabilityPoolCollateral).balanceOf(user1), actualUser1Balance, "User1 balance should remain unchanged after failed withdrawal due to MIN_TOTAL_ASSET_SUPPLY constraint" ); @@ -693,12 +693,12 @@ contract TestStabilityPoolRebalance is TestStabilityPoolRebalanceSetUp { // 8. Verify the new deposit worked correctly assertEq( - IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user4), + IERC20(stabilityPoolCollateral).balanceOf(user4), DEPOSIT_AMOUNT * 5, "User4 deposit after liquidation failed" ); assertEq( - IStabilityPool(stabilityPoolCollateral).totalAssetSupply(), + IERC20(stabilityPoolCollateral).totalSupply(), DEPOSIT_AMOUNT * 5 + minSupply, // User1's withdrawal returned 0, so no tokens were removed "Total supply incorrect after new deposit" ); @@ -712,13 +712,13 @@ contract TestStabilityPoolRebalance is TestStabilityPoolRebalanceSetUp { uint256 expectedUser4Balance = (DEPOSIT_AMOUNT * 5 * (DEPOSIT_AMOUNT * 4 + minSupply)) / (DEPOSIT_AMOUNT * 5 + minSupply); // No subtraction since no tokens were withdrawn assertApproxEqRel( - IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user4), + IERC20(stabilityPoolCollateral).balanceOf(user4), expectedUser4Balance, 0.01e18, // 1% tolerance for rounding "User4 balance after partial liquidation should be proportionally reduced" ); assertEq( - IStabilityPool(stabilityPoolCollateral).totalAssetSupply(), + IERC20(stabilityPoolCollateral).totalSupply(), DEPOSIT_AMOUNT * 4 + minSupply, // No subtraction since no tokens were withdrawn "Total supply after partial liquidation incorrect" ); @@ -735,10 +735,10 @@ contract TestStabilityPoolRebalance is TestStabilityPoolRebalanceSetUp { // The important property is that the error system prevents precision loss accumulation // by ensuring total user balances + error account for all precision differences - uint256 totalUserBalancesAfterLoss = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1) + - IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user2) + - IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user3) + - IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user4); + uint256 totalUserBalancesAfterLoss = IERC20(stabilityPoolCollateral).balanceOf(user1) + + IERC20(stabilityPoolCollateral).balanceOf(user2) + + IERC20(stabilityPoolCollateral).balanceOf(user3) + + IERC20(stabilityPoolCollateral).balanceOf(user4); // The error system ensures system integrity is maintained assertGe(totalUserBalancesAfterLoss, minSupply, "System should maintain minimum viable balance"); @@ -746,7 +746,7 @@ contract TestStabilityPoolRebalance is TestStabilityPoolRebalanceSetUp { function testNotifyLossWithZeroSupply() public { // 1. Verify the pool starts with zero supply - uint256 initialSupply = IStabilityPool(stabilityPoolCollateral).totalAssetSupply(); + uint256 initialSupply = IERC20(stabilityPoolCollateral).totalSupply(); assertEq(initialSupply, 0, "Pool should start with zero supply"); // 2. Verify initial lastAssetLossError @@ -773,7 +773,7 @@ contract TestStabilityPoolRebalance is TestStabilityPoolRebalanceSetUp { assertEq(poolBalanceAfter, 0, "Pool should have zero balance after sweep"); // 7. Verify supply remains at zero - uint256 finalSupply = IStabilityPool(stabilityPoolCollateral).totalAssetSupply(); + uint256 finalSupply = IERC20(stabilityPoolCollateral).totalSupply(); assertEq(finalSupply, 0, "Pool supply should remain zero"); // 8. Verify lastAssetLossError remains at zero @@ -785,7 +785,7 @@ contract TestStabilityPoolRebalance is TestStabilityPoolRebalanceSetUp { IStabilityPool(stabilityPoolCollateral).deposit(DEPOSIT_AMOUNT, user1, 0); assertEq( - IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1), + IERC20(stabilityPoolCollateral).balanceOf(user1), DEPOSIT_AMOUNT, "User should be able to deposit after zero-supply sweep" ); @@ -799,7 +799,7 @@ contract TestStabilityPoolRebalance is TestStabilityPoolRebalanceSetUp { IStabilityPool(stabilityPoolCollateral).deposit(depositAmount, user1, 0); // Record initial balance - uint256 initialBalance = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1); + uint256 initialBalance = IERC20(stabilityPoolCollateral).balanceOf(user1); assertEq(initialBalance, depositAmount, "Initial balance should match deposit"); // 2. Create a significant loss (99.9%) to trigger an exponent change @@ -807,7 +807,7 @@ contract TestStabilityPoolRebalance is TestStabilityPoolRebalanceSetUp { _liquidate(sweepAmount); // 3. Check balance after exponent change - should trigger exponentDiff == 1 branch - uint256 balanceAfterExponentChange = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1); + uint256 balanceAfterExponentChange = IERC20(stabilityPoolCollateral).balanceOf(user1); // Verify the expected relationship between initial and final balance uint256 expectedRemainingBalance = depositAmount - sweepAmount; @@ -823,7 +823,7 @@ contract TestStabilityPoolRebalance is TestStabilityPoolRebalanceSetUp { assertEq(actualRatio, expectedRatio, "Balance reduction ratio should match sweep percentage"); // 4. Check balance again to ensure the calculation is stable - uint256 secondBalanceCheck = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1); + uint256 secondBalanceCheck = IERC20(stabilityPoolCollateral).balanceOf(user1); assertEq(secondBalanceCheck, balanceAfterExponentChange, "Balance should be stable across multiple checks"); } @@ -831,14 +831,14 @@ contract TestStabilityPoolRebalance is TestStabilityPoolRebalanceSetUp { // 1. Initial setup with multiple users vm.prank(user1); IStabilityPool(stabilityPoolCollateral).deposit(DEPOSIT_AMOUNT, user1, 0); - assertEq(IStabilityPool(stabilityPoolCollateral).totalAssetSupply(), DEPOSIT_AMOUNT, "tas#1"); + assertEq(IERC20(stabilityPoolCollateral).totalSupply(), DEPOSIT_AMOUNT, "tas#1"); vm.prank(user2); IStabilityPool(stabilityPoolCollateral).deposit(DEPOSIT_AMOUNT, user2, 0); - assertEq(IStabilityPool(stabilityPoolCollateral).totalAssetSupply(), DEPOSIT_AMOUNT * 2, "tas#2"); + assertEq(IERC20(stabilityPoolCollateral).totalSupply(), DEPOSIT_AMOUNT * 2, "tas#2"); // 2. Record initial product value - uint256 initialTotalSupply = IStabilityPool(stabilityPoolCollateral).totalAssetSupply(); + uint256 initialTotalSupply = IERC20(stabilityPoolCollateral).totalSupply(); uint128 initialProduct = MockStabilityPool(stabilityPoolCollateral).__totalSupply().product; assertEq(initialProduct, 1e36, "Initial product should be 1 ether ether"); @@ -846,7 +846,7 @@ contract TestStabilityPoolRebalance is TestStabilityPoolRebalanceSetUp { _liquidate(initialTotalSupply); // 4. Verify pool state after liquidation - uint256 postLiquidationSupply = IStabilityPool(stabilityPoolCollateral).totalAssetSupply(); + uint256 postLiquidationSupply = IERC20(stabilityPoolCollateral).totalSupply(); uint128 postLiquidationProduct = MockStabilityPool(stabilityPoolCollateral).__totalSupply().product; // With these assertions assertEq(postLiquidationSupply, 1 ether, "Supply should be small after complete liquidation"); @@ -868,11 +868,11 @@ contract TestStabilityPoolRebalance is TestStabilityPoolRebalanceSetUp { IStabilityPool(stabilityPoolCollateral).deposit(DEPOSIT_AMOUNT, user3, 0); // After complete liquidation, MIN_TOTAL_ASSET_SUPPLY remains, so total = DEPOSIT_AMOUNT + MIN_TOTAL_ASSET_SUPPLY uint256 minSupply = IStabilityPool(stabilityPoolCollateral).MIN_TOTAL_ASSET_SUPPLY(); - assertEq(IStabilityPool(stabilityPoolCollateral).totalAssetSupply(), DEPOSIT_AMOUNT + minSupply, "tas#3"); + assertEq(IERC20(stabilityPoolCollateral).totalSupply(), DEPOSIT_AMOUNT + minSupply, "tas#3"); vm.prank(user4); IStabilityPool(stabilityPoolCollateral).deposit(DEPOSIT_AMOUNT * 2, user4, 0); - assertEq(IStabilityPool(stabilityPoolCollateral).totalAssetSupply(), DEPOSIT_AMOUNT * 3 + minSupply, "tas#4"); + assertEq(IERC20(stabilityPoolCollateral).totalSupply(), DEPOSIT_AMOUNT * 3 + minSupply, "tas#4"); // 6. Verify product continues from reduced state after new deposits uint128 newEpochProduct = MockStabilityPool(stabilityPoolCollateral).__totalSupply().product; @@ -889,19 +889,19 @@ contract TestStabilityPoolRebalance is TestStabilityPoolRebalanceSetUp { // After complete liquidation, users retain proportional shares of MIN_TOTAL_ASSET_SUPPLY uint256 user1ExpectedBalance = (DEPOSIT_AMOUNT * minSupply) / (DEPOSIT_AMOUNT * 2); // 50% of minSupply assertEq( - IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1), + IERC20(stabilityPoolCollateral).balanceOf(user1), user1ExpectedBalance, "User1 balance should be proportional share of MIN_TOTAL_ASSET_SUPPLY" ); assertEq( - IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user2), + IERC20(stabilityPoolCollateral).balanceOf(user2), user1ExpectedBalance, "User2 balance should be proportional share of MIN_TOTAL_ASSET_SUPPLY" ); // 8. Test partial liquidation in new epoch _liquidate(DEPOSIT_AMOUNT); - assertEq(IStabilityPool(stabilityPoolCollateral).totalAssetSupply(), DEPOSIT_AMOUNT * 2 + minSupply, "tas#5"); + assertEq(IERC20(stabilityPoolCollateral).totalSupply(), DEPOSIT_AMOUNT * 2 + minSupply, "tas#5"); // 9. Verify product changed appropriately uint128 productAfterPartialLiquidation = MockStabilityPool(stabilityPoolCollateral).__totalSupply().product; @@ -921,14 +921,14 @@ contract TestStabilityPoolRebalance is TestStabilityPoolRebalanceSetUp { vm.prank(user3); IStabilityPool(stabilityPoolCollateral).withdraw(DEPOSIT_AMOUNT / 2, owner, 0); assertEq( - IStabilityPool(stabilityPoolCollateral).totalAssetSupply(), + IERC20(stabilityPoolCollateral).totalSupply(), DEPOSIT_AMOUNT * 2 + minSupply - DEPOSIT_AMOUNT / 2, // Account for MIN_TOTAL_ASSET_SUPPLY "tas#6" ); // 11. Verify final state is consistent assertEq( - IStabilityPool(stabilityPoolCollateral).totalAssetSupply(), + IERC20(stabilityPoolCollateral).totalSupply(), (DEPOSIT_AMOUNT * 3) / 2 + minSupply, // Account for MIN_TOTAL_ASSET_SUPPLY "Final supply should be correct" ); diff --git a/test/StabilityPoolSpec.t.sol b/test/StabilityPoolSpec.t.sol index 8c1da554..65443c7e 100644 --- a/test/StabilityPoolSpec.t.sol +++ b/test/StabilityPoolSpec.t.sol @@ -37,10 +37,10 @@ contract TestStabilityPoolSpec is TestStabilityPoolRebalanceSetUp { function testInitialState() public view { // Check initial state - assertEq(IStabilityPool(stabilityPoolCollateral).totalAssetSupply(), 0); - assertEq(IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1), 0); - assertEq(IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user2), 0); - assertEq(IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user3), 0); + assertEq(IERC20(stabilityPoolCollateral).totalSupply(), 0); + assertEq(IERC20(stabilityPoolCollateral).balanceOf(user1), 0); + assertEq(IERC20(stabilityPoolCollateral).balanceOf(user2), 0); + assertEq(IERC20(stabilityPoolCollateral).balanceOf(user3), 0); } function testDeposit() public { @@ -51,8 +51,8 @@ contract TestStabilityPoolSpec is TestStabilityPoolRebalanceSetUp { // Check deposit results assertEq(deposited, DEPOSIT_AMOUNT); - assertEq(IStabilityPool(stabilityPoolCollateral).totalAssetSupply(), DEPOSIT_AMOUNT); - assertEq(IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1), DEPOSIT_AMOUNT); + assertEq(IERC20(stabilityPoolCollateral).totalSupply(), DEPOSIT_AMOUNT); + assertEq(IERC20(stabilityPoolCollateral).balanceOf(user1), DEPOSIT_AMOUNT); } function testDepositWithMin() public { @@ -63,7 +63,7 @@ contract TestStabilityPoolSpec is TestStabilityPoolRebalanceSetUp { // Check deposit results assertEq(deposited, DEPOSIT_AMOUNT); - assertEq(IStabilityPool(stabilityPoolCollateral).totalAssetSupply(), DEPOSIT_AMOUNT); + assertEq(IERC20(stabilityPoolCollateral).totalSupply(), DEPOSIT_AMOUNT); } function testDepositFailsWithMinTooHigh() public { @@ -88,8 +88,8 @@ contract TestStabilityPoolSpec is TestStabilityPoolRebalanceSetUp { // Check deposit results assertEq(deposited, INITIAL_BALANCE); - assertEq(IStabilityPool(stabilityPoolCollateral).totalAssetSupply(), INITIAL_BALANCE); - assertEq(IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1), INITIAL_BALANCE); + assertEq(IERC20(stabilityPoolCollateral).totalSupply(), INITIAL_BALANCE); + assertEq(IERC20(stabilityPoolCollateral).balanceOf(user1), INITIAL_BALANCE); } function testWithdraw() public { @@ -108,8 +108,8 @@ contract TestStabilityPoolSpec is TestStabilityPoolRebalanceSetUp { // Check withdrawal results assertEq(withdrawn, DEPOSIT_AMOUNT / 2); - assertEq(IStabilityPool(stabilityPoolCollateral).totalAssetSupply(), DEPOSIT_AMOUNT / 2); - assertEq(IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1), DEPOSIT_AMOUNT / 2); + assertEq(IERC20(stabilityPoolCollateral).totalSupply(), DEPOSIT_AMOUNT / 2); + assertEq(IERC20(stabilityPoolCollateral).balanceOf(user1), DEPOSIT_AMOUNT / 2); } function testWithdrawAll() public { @@ -132,13 +132,9 @@ contract TestStabilityPoolSpec is TestStabilityPoolRebalanceSetUp { // Check withdrawal results - should withdraw all except the minimum uint256 expectedWithdrawn = DEPOSIT_AMOUNT - minTotalAssetSupply; assertEq(withdrawn, expectedWithdrawn, "Should withdraw all except minimum"); + assertEq(IERC20(stabilityPoolCollateral).totalSupply(), minTotalAssetSupply, "Should leave minimum in pool"); assertEq( - IStabilityPool(stabilityPoolCollateral).totalAssetSupply(), - minTotalAssetSupply, - "Should leave minimum in pool" - ); - assertEq( - IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1), + IERC20(stabilityPoolCollateral).balanceOf(user1), minTotalAssetSupply, "User should have minimum balance" ); @@ -200,19 +196,19 @@ contract TestStabilityPoolSpec is TestStabilityPoolRebalanceSetUp { IStabilityPool(stabilityPoolCollateral).deposit(DEPOSIT_AMOUNT, user1, 0); // Record initial balance - uint256 initialBalance = IStabilityPool(stabilityPoolCollateral).totalAssetSupply(); + uint256 initialBalance = IERC20(stabilityPoolCollateral).totalSupply(); // Rebalancer sweeps some assets _liquidate(DEPOSIT_AMOUNT / 4); // Check balances after sweep assertEq( - IStabilityPool(stabilityPoolCollateral).totalAssetSupply(), + IERC20(stabilityPoolCollateral).totalSupply(), initialBalance - DEPOSIT_AMOUNT / 4, "totalAssetSupply dropped by the correct amount" ); assertApproxEqRel( - IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1), + IERC20(stabilityPoolCollateral).balanceOf(user1), DEPOSIT_AMOUNT - DEPOSIT_AMOUNT / 4, 0, "User1 should have reduced balance after sweep" @@ -223,7 +219,7 @@ contract TestStabilityPoolSpec is TestStabilityPoolRebalanceSetUp { "Rebalancer should receive swept assets" ); assertEq( - IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1), + IERC20(stabilityPoolCollateral).balanceOf(user1), initialBalance - DEPOSIT_AMOUNT / 4, "User1 should have reduced balance" ); @@ -274,13 +270,10 @@ contract TestStabilityPoolSpec is TestStabilityPoolRebalanceSetUp { IStabilityPool(stabilityPoolCollateral).deposit(DEPOSIT_AMOUNT, user1, 0); // Check final balances - assertEq( - IStabilityPool(stabilityPoolCollateral).totalAssetSupply(), - DEPOSIT_AMOUNT / 2 + DEPOSIT_AMOUNT + DEPOSIT_AMOUNT - ); - assertEq(IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1), DEPOSIT_AMOUNT + DEPOSIT_AMOUNT / 2); - assertEq(IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user2), 0); - assertEq(IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user3), DEPOSIT_AMOUNT); + assertEq(IERC20(stabilityPoolCollateral).totalSupply(), DEPOSIT_AMOUNT / 2 + DEPOSIT_AMOUNT + DEPOSIT_AMOUNT); + assertEq(IERC20(stabilityPoolCollateral).balanceOf(user1), DEPOSIT_AMOUNT + DEPOSIT_AMOUNT / 2); + assertEq(IERC20(stabilityPoolCollateral).balanceOf(user2), 0); + assertEq(IERC20(stabilityPoolCollateral).balanceOf(user3), DEPOSIT_AMOUNT); } function testRewardsAfterMultipleDeposits() public { diff --git a/test/StabilityPoolUpgradeMigration.t.sol b/test/StabilityPoolUpgradeMigration.t.sol index b61081da..e8037b3b 100644 --- a/test/StabilityPoolUpgradeMigration.t.sol +++ b/test/StabilityPoolUpgradeMigration.t.sol @@ -4,70 +4,37 @@ pragma solidity >=0.8.28 <0.9.0; import {UnsafeUpgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; -import {IMintableRole} from "@bao/interfaces/IMintableRole.sol"; -import {IMintable} from "@bao/interfaces/IMintable.sol"; import {ITokenHolder} from "@bao/TokenHolder.sol"; -import {MintableBurnableERC20_v1} from "@bao/MintableBurnableERC20_v1.sol"; -import {IMinter} from "src/interfaces/IMinter.sol"; import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; import {IWrappedPriceOracle} from "src/interfaces/IWrappedPriceOracle.sol"; -import {StabilityPool_v1} from "src/minter/StabilityPool_v1.sol"; import {StabilityPool_v2} from "src/minter/StabilityPool_v2.sol"; +import {StabilityPool_v3} from "src/minter/StabilityPool_v3.sol"; import {TestStabilityPoolSetUp} from "test/StabilityPool.t.sol"; /// @title TestStabilityPoolUpgradeMigration -/// @notice Tests that upgrading StabilityPool_v1 → StabilityPool_v2 via UUPS proxy preserves +/// @notice Tests that upgrading StabilityPool_v2 → StabilityPool_v3 via UUPS proxy preserves /// all state and produces identical results at every lifecycle stage. /// Each scenario is run with 3 liquidation variants: none, partial, complete. contract TestStabilityPoolUpgradeMigration is TestStabilityPoolSetUp { uint256 price; - /// @dev Override to deploy with StabilityPool_v1 implementation instead of MockStabilityPool (v2) + /// @dev Override to deploy with StabilityPool_v2 implementation (matching production) function _setupStabilityPool(address liquidationToken) internal override returns (address stabilityPool) { - string memory liquidation = IERC20Metadata(liquidationToken).symbol(); - string memory pegged = IERC20Metadata(IMinter(minter).PEGGED_TOKEN()).symbol(); - string memory wrappedCollateral = IERC20Metadata(IMinter(minter).WRAPPED_COLLATERAL_TOKEN()).symbol(); - - string memory SPName = string.concat(pegged, "x", wrappedCollateral, "~", liquidation); - address stabilityPoolToken = address( - UnsafeUpgrades.deployUUPSProxy( - address(new MintableBurnableERC20_v1()), - abi.encodeCall( - MintableBurnableERC20_v1.initialize, - (owner, "StabilityPool Token", string.concat("lp", SPName)) - ) - ) - ); - vm.label(stabilityPoolToken, string.concat("lp", SPName)); - - // Deploy with StabilityPool_v1 implementation (NOT MockStabilityPool which is v2) + // Deploy with StabilityPool_v2 implementation — this is what production proxies currently run stabilityPool = UnsafeUpgrades.deployUUPSProxy( address( - new StabilityPool_v1( - minter, - liquidationToken, - EARLY_WITHDRAWAL_FEE, - FEE_ADDRESS, - WITHDRAWAL_START_DELAY, - WITHDRAWAL_END_WINDOW, - 1 ether - ) + new StabilityPool_v2(minter, liquidationToken, WITHDRAWAL_START_DELAY, WITHDRAWAL_END_WINDOW, 1 ether) ), - abi.encodeCall(StabilityPool_v1.initialize, (owner, EARLY_WITHDRAWAL_FEE, FEE_ADDRESS)) + abi.encodeCall(StabilityPool_v2.initialize, (owner, EARLY_WITHDRAWAL_FEE, FEE_ADDRESS)) ); - vm.label(stabilityPool, SPName); - - IBaoRoles(stabilityPoolToken).grantRoles(address(this), IMintableRole(stabilityPoolToken).MINTER_ROLE()); - IMintable(stabilityPoolToken).mint(stabilityPool, 1 ether); IBaoRoles(stabilityPool).grantRoles( rewardManager, @@ -85,7 +52,6 @@ contract TestStabilityPoolUpgradeMigration is TestStabilityPoolSetUp { IMultipleRewardDistributor(stabilityPool).registerRewardToken(wrappedCollateralToken); } - IBaoOwnable(stabilityPoolToken).transferOwnership(owner); IBaoOwnable(stabilityPool).transferOwnership(owner); } @@ -133,23 +99,31 @@ contract TestStabilityPoolUpgradeMigration is TestStabilityPoolSetUp { IStabilityPool(stabilityPoolCollateral).deposit(amount, user, 0); } - /// @dev Deploy v2 implementation and upgrade the proxy - function _upgradeToV2() internal { + /// @dev Deploy v3 implementation and upgrade the proxy + function _upgradeToV3() internal { // Deploy impl BEFORE prank — constructor makes external calls that consume prank - address v2Impl = address( - new StabilityPool_v2(minter, wrappedCollateralToken, WITHDRAWAL_START_DELAY, WITHDRAWAL_END_WINDOW, 1 ether) + address v3Impl = address( + new StabilityPool_v3( + minter, + wrappedCollateralToken, + WITHDRAWAL_START_DELAY, + WITHDRAWAL_END_WINDOW, + 1 ether, + "StabilityPool", + "SP" + ) ); vm.prank(owner); - UUPSUpgradeable(stabilityPoolCollateral).upgradeToAndCall(v2Impl, ""); + UUPSUpgradeable(stabilityPoolCollateral).upgradeToAndCall(v3Impl, ""); } // ═══════════════════════════════════════════════════════════════════════ // 1. FreshPool — single test (liquidation N/A for empty pool) // ═══════════════════════════════════════════════════════════════════════ - function test_upgradeFromV1_FreshPool() public { + function test_upgradeFromV2_FreshPool() public { // Upgrade empty pool - _upgradeToV2(); + _upgradeToV3(); // Post-upgrade: all operations should work assertEq(IStabilityPool(stabilityPoolCollateral).totalAssetSupply(), 0, "Empty pool after upgrade"); @@ -190,7 +164,7 @@ contract TestStabilityPoolUpgradeMigration is TestStabilityPoolSetUp { // 2. AfterDeposits — 3 liquidation variants // ═══════════════════════════════════════════════════════════════════════ - function _test_upgradeFromV1_AfterDeposits(bool doPartialLiq, bool doCompleteLiq) internal { + function _test_upgradeFromV2_AfterDeposits(bool doPartialLiq, bool doCompleteLiq) internal { // Build state on v1 _deposit(user1, 100 ether); _deposit(user2, 50 ether); @@ -210,7 +184,7 @@ contract TestStabilityPoolUpgradeMigration is TestStabilityPoolSetUp { // Revert and upgrade vm.revertToState(snap); - _upgradeToV2(); + _upgradeToV3(); // Record v2 results uint256 v2_bal1 = IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1); @@ -241,23 +215,23 @@ contract TestStabilityPoolUpgradeMigration is TestStabilityPoolSetUp { } } - function test_upgradeFromV1_AfterDeposits_NoLiquidation() public { - _test_upgradeFromV1_AfterDeposits(false, false); + function test_upgradeFromV2_AfterDeposits_NoLiquidation() public { + _test_upgradeFromV2_AfterDeposits(false, false); } - function test_upgradeFromV1_AfterDeposits_PartialLiquidation() public { - _test_upgradeFromV1_AfterDeposits(true, false); + function test_upgradeFromV2_AfterDeposits_PartialLiquidation() public { + _test_upgradeFromV2_AfterDeposits(true, false); } - function test_upgradeFromV1_AfterDeposits_CompleteLiquidation() public { - _test_upgradeFromV1_AfterDeposits(false, true); + function test_upgradeFromV2_AfterDeposits_CompleteLiquidation() public { + _test_upgradeFromV2_AfterDeposits(false, true); } // ═══════════════════════════════════════════════════════════════════════ // 3. AfterRewards — 3 liquidation variants // ═══════════════════════════════════════════════════════════════════════ - function _test_upgradeFromV1_AfterRewards(bool doPartialLiq, bool doCompleteLiq) internal { + function _test_upgradeFromV2_AfterRewards(bool doPartialLiq, bool doCompleteLiq) internal { // Build state on v1 _deposit(user1, 100 ether); _depositReward(steam, 10 ether); @@ -283,7 +257,7 @@ contract TestStabilityPoolUpgradeMigration is TestStabilityPoolSetUp { // Revert and upgrade vm.revertToState(snap); - _upgradeToV2(); + _upgradeToV3(); // Record v2 results uint256 v2_claimSteam = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, steam); @@ -313,23 +287,23 @@ contract TestStabilityPoolUpgradeMigration is TestStabilityPoolSetUp { } } - function test_upgradeFromV1_AfterRewards_NoLiquidation() public { - _test_upgradeFromV1_AfterRewards(false, false); + function test_upgradeFromV2_AfterRewards_NoLiquidation() public { + _test_upgradeFromV2_AfterRewards(false, false); } - function test_upgradeFromV1_AfterRewards_PartialLiquidation() public { - _test_upgradeFromV1_AfterRewards(true, false); + function test_upgradeFromV2_AfterRewards_PartialLiquidation() public { + _test_upgradeFromV2_AfterRewards(true, false); } - function test_upgradeFromV1_AfterRewards_CompleteLiquidation() public { - _test_upgradeFromV1_AfterRewards(false, true); + function test_upgradeFromV2_AfterRewards_CompleteLiquidation() public { + _test_upgradeFromV2_AfterRewards(false, true); } // ═══════════════════════════════════════════════════════════════════════ // 4. AfterPartialClaim — 3 liquidation variants // ═══════════════════════════════════════════════════════════════════════ - function _test_upgradeFromV1_AfterPartialClaim(bool doPartialLiq, bool doCompleteLiq) internal { + function _test_upgradeFromV2_AfterPartialClaim(bool doPartialLiq, bool doCompleteLiq) internal { // Build state on v1 _deposit(user1, 100 ether); @@ -364,7 +338,7 @@ contract TestStabilityPoolUpgradeMigration is TestStabilityPoolSetUp { // Revert and upgrade vm.revertToState(snap); - _upgradeToV2(); + _upgradeToV3(); // Record v2 results uint256 v2_claimed = IMultipleRewardAccumulator(stabilityPoolCollateral).claimed(user1, steam); @@ -390,23 +364,23 @@ contract TestStabilityPoolUpgradeMigration is TestStabilityPoolSetUp { } } - function test_upgradeFromV1_AfterPartialClaim_NoLiquidation() public { - _test_upgradeFromV1_AfterPartialClaim(false, false); + function test_upgradeFromV2_AfterPartialClaim_NoLiquidation() public { + _test_upgradeFromV2_AfterPartialClaim(false, false); } - function test_upgradeFromV1_AfterPartialClaim_PartialLiquidation() public { - _test_upgradeFromV1_AfterPartialClaim(true, false); + function test_upgradeFromV2_AfterPartialClaim_PartialLiquidation() public { + _test_upgradeFromV2_AfterPartialClaim(true, false); } - function test_upgradeFromV1_AfterPartialClaim_CompleteLiquidation() public { - _test_upgradeFromV1_AfterPartialClaim(false, true); + function test_upgradeFromV2_AfterPartialClaim_CompleteLiquidation() public { + _test_upgradeFromV2_AfterPartialClaim(false, true); } // ═══════════════════════════════════════════════════════════════════════ // 5. MidRewardPeriod — 3 liquidation variants // ═══════════════════════════════════════════════════════════════════════ - function _test_upgradeFromV1_MidRewardPeriod(bool doPartialLiq, bool doCompleteLiq) internal { + function _test_upgradeFromV2_MidRewardPeriod(bool doPartialLiq, bool doCompleteLiq) internal { // Build state on v1 _deposit(user1, 100 ether); _depositReward(steam, 7 ether); // ~1 ether/day over 1-week period @@ -432,7 +406,7 @@ contract TestStabilityPoolUpgradeMigration is TestStabilityPoolSetUp { // Revert and upgrade vm.revertToState(snap); - _upgradeToV2(); + _upgradeToV3(); // Record v2 results uint256 v2_claimable = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, steam); @@ -455,23 +429,23 @@ contract TestStabilityPoolUpgradeMigration is TestStabilityPoolSetUp { assertGt(finalClaimable, v2_claimable, "More rewards after remaining period"); } - function test_upgradeFromV1_MidRewardPeriod_NoLiquidation() public { - _test_upgradeFromV1_MidRewardPeriod(false, false); + function test_upgradeFromV2_MidRewardPeriod_NoLiquidation() public { + _test_upgradeFromV2_MidRewardPeriod(false, false); } - function test_upgradeFromV1_MidRewardPeriod_PartialLiquidation() public { - _test_upgradeFromV1_MidRewardPeriod(true, false); + function test_upgradeFromV2_MidRewardPeriod_PartialLiquidation() public { + _test_upgradeFromV2_MidRewardPeriod(true, false); } - function test_upgradeFromV1_MidRewardPeriod_CompleteLiquidation() public { - _test_upgradeFromV1_MidRewardPeriod(false, true); + function test_upgradeFromV2_MidRewardPeriod_CompleteLiquidation() public { + _test_upgradeFromV2_MidRewardPeriod(false, true); } // ═══════════════════════════════════════════════════════════════════════ // 6. MultiUserLazyMigration — 3 liquidation variants // ═══════════════════════════════════════════════════════════════════════ - function _test_upgradeFromV1_MultiUserLazyMigration(bool doPartialLiq, bool doCompleteLiq) internal { + function _test_upgradeFromV2_MultiUserLazyMigration(bool doPartialLiq, bool doCompleteLiq) internal { // Build state on v1 — equal deposits for easy comparison _deposit(user1, 100 ether); _deposit(user2, 100 ether); @@ -489,7 +463,7 @@ contract TestStabilityPoolUpgradeMigration is TestStabilityPoolSetUp { } // Upgrade to v2 (no snapshot/revert — testing post-upgrade behavior directly) - _upgradeToV2(); + _upgradeToV3(); // user1 interacts → triggers V1→V2 migration vm.prank(user1); @@ -548,16 +522,16 @@ contract TestStabilityPoolUpgradeMigration is TestStabilityPoolSetUp { } } - function test_upgradeFromV1_MultiUserLazyMigration_NoLiquidation() public { - _test_upgradeFromV1_MultiUserLazyMigration(false, false); + function test_upgradeFromV2_MultiUserLazyMigration_NoLiquidation() public { + _test_upgradeFromV2_MultiUserLazyMigration(false, false); } - function test_upgradeFromV1_MultiUserLazyMigration_PartialLiquidation() public { - _test_upgradeFromV1_MultiUserLazyMigration(true, false); + function test_upgradeFromV2_MultiUserLazyMigration_PartialLiquidation() public { + _test_upgradeFromV2_MultiUserLazyMigration(true, false); } - function test_upgradeFromV1_MultiUserLazyMigration_CompleteLiquidation() public { - _test_upgradeFromV1_MultiUserLazyMigration(false, true); + function test_upgradeFromV2_MultiUserLazyMigration_CompleteLiquidation() public { + _test_upgradeFromV2_MultiUserLazyMigration(false, true); } // ═══════════════════════════════════════════════════════════════════════ @@ -568,7 +542,7 @@ contract TestStabilityPoolUpgradeMigration is TestStabilityPoolSetUp { /// This occurs when a user is checkpointed at a new exponent (after complete /// liquidation shifts the exponent) where no rewards have been distributed yet. /// Verifies: Path 3->2 migration, Path 2 read, Path 2->1 transition, Path 1 read. - function test_upgradeFromV1_RarePath_ZeroIntegralAfterExponentShift() public { + function test_upgradeFromV2_RarePath_ZeroIntegralAfterExponentShift() public { // Build state on v1: deposit, earn rewards, claim _deposit(user1, 100 ether); _depositReward(steam, 10 ether); @@ -583,7 +557,7 @@ contract TestStabilityPoolUpgradeMigration is TestStabilityPoolSetUp { _liquidate(IStabilityPool(stabilityPoolCollateral).totalAssetSupply()); // Upgrade to v2 BEFORE re-depositing - V1 data still untouched - _upgradeToV2(); + _upgradeToV3(); // Re-deposit triggers _checkpoint on v2: // - Reads V1 data via Path 3 (V2 mapping empty) @@ -636,7 +610,7 @@ contract TestStabilityPoolUpgradeMigration is TestStabilityPoolSetUp { /// @notice Tests that a pending withdrawal request initiated on v1 survives /// the upgrade and can be completed on v2 with correct amounts. - function test_upgradeFromV1_MidWithdrawal() public { + function test_upgradeFromV2_MidWithdrawal() public { // Build state on v1: deposit and earn rewards _deposit(user1, 100 ether); _depositReward(steam, 10 ether); @@ -663,7 +637,7 @@ contract TestStabilityPoolUpgradeMigration is TestStabilityPoolSetUp { // Revert and upgrade vm.revertToState(snap); - _upgradeToV2(); + _upgradeToV3(); // Verify withdrawal request preserved (uint64 v2Start, uint64 v2End) = IStabilityPool(stabilityPoolCollateral).getWithdrawalRequest(user1); @@ -713,7 +687,7 @@ contract TestStabilityPoolUpgradeMigration is TestStabilityPoolSetUp { /// @notice Tests that rewards accumulated across multiple exponent shifts (complete /// liquidations) on v1 are correctly preserved through upgrade. Exercises the /// _claimableFrom loop that sums integrals across exponent boundaries. - function test_upgradeFromV1_MultipleExponentShifts() public { + function test_upgradeFromV2_MultipleExponentShifts() public { // Exponent 0: deposit and earn rewards _deposit(user1, 100 ether); _depositReward(steam, 10 ether); @@ -750,7 +724,7 @@ contract TestStabilityPoolUpgradeMigration is TestStabilityPoolSetUp { // Revert and upgrade vm.revertToState(snap); - _upgradeToV2(); + _upgradeToV3(); // Assert identical assertEq( @@ -794,7 +768,7 @@ contract TestStabilityPoolUpgradeMigration is TestStabilityPoolSetUp { /// creating a product that differs from their initial deposit product. /// The re-deposit triggers a v1 checkpoint that updates the user's product, /// so the upgrade must handle this intermediate product state correctly. - function test_upgradeFromV1_ReDepositAfterPartialLiquidation() public { + function test_upgradeFromV2_ReDepositAfterPartialLiquidation() public { // Initial deposit on v1 _deposit(user1, 100 ether); _depositReward(steam, 10 ether); @@ -826,7 +800,7 @@ contract TestStabilityPoolUpgradeMigration is TestStabilityPoolSetUp { // Revert and upgrade vm.revertToState(snap); - _upgradeToV2(); + _upgradeToV3(); // Assert identical assertEq( diff --git a/test/StabilityPool_v3_ERC20.t.sol b/test/StabilityPool_v3_ERC20.t.sol index e6776b79..df91cd31 100644 --- a/test/StabilityPool_v3_ERC20.t.sol +++ b/test/StabilityPool_v3_ERC20.t.sol @@ -5,37 +5,72 @@ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; +import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; import {StabilityPool_v3} from "src/minter/StabilityPool_v3.sol"; import {StringPacking_v1} from "src/minter/library/StringPacking_v1.sol"; -import {TestStabilityPoolSetUp} from "test/StabilityPool.t.sol"; +import {DeployEURSetUp} from "test/deployment/DeployEURSetUp.t.sol"; /// @title TestStabilityPool_v3_ERC20 -/// @notice Coverage tests for StabilityPool_v3 ERC20 functions. +/// @notice Coverage tests for StabilityPool_v3 ERC20 functions and transfer equivalence. /// Uses IERC20/IERC20Metadata interfaces per CLAUDE.md. -contract TestStabilityPool_v3_ERC20 is TestStabilityPoolSetUp { +/// Inherits production deployment infrastructure (DeployEURSetUp) for realistic test setup. +contract TestStabilityPool_v3_ERC20 is DeployEURSetUp { + address user1; + address user2; + address user3; + + // Short names pointing into the EUR::fxUSD market. + address sp; + address peggedToken; + address wrappedCollateralToken; + + function setUp() public virtual override { + super.setUp(); + user1 = makeAddr("user1"); + user2 = makeAddr("user2"); + user3 = makeAddr("user3"); + + sp = spCollFxUSD; + peggedToken = pegged; + wrappedCollateralToken = wrappedCollateralFxUSD; + } + + /// @dev Mint pegged tokens to `user` and deposit them into the SP. function _deposit(address user, uint256 amount) internal { - deal(peggedToken, user, amount); + _mintPegged(minterFxUSD, user, amount); + vm.prank(user); + IERC20(peggedToken).approve(sp, amount); vm.prank(user); - IStabilityPool(stabilityPoolCollateral).deposit(amount, user, 0); + IStabilityPool(sp).deposit(amount, user, 0); + } + + /// @dev Apply a loss to the SP via notifyLiquidation. Returns the wCol used as the liquidation reward. + function _applyLoss(uint256 liquidated, uint256 returned) internal { + deal(wrappedCollateralToken, sp, IERC20(wrappedCollateralToken).balanceOf(sp) + returned); + vm.prank(spmFxUSD); + IStabilityPool(sp).notifyLiquidation(liquidated, returned); } // ═══════════════════════════════════════════════════════════════════════ // Metadata: name, symbol, decimals // ═══════════════════════════════════════════════════════════════════════ + /// Intent: name() returns a non-empty string from immutable storage. function test_name() public view { - string memory n = IERC20Metadata(stabilityPoolCollateral).name(); + string memory n = IERC20Metadata(sp).name(); assertGt(bytes(n).length, 0, "name not empty"); } + /// Intent: symbol() returns a non-empty string from immutable storage. function test_symbol() public view { - string memory s = IERC20Metadata(stabilityPoolCollateral).symbol(); + string memory s = IERC20Metadata(sp).symbol(); assertGt(bytes(s).length, 0, "symbol not empty"); } + /// Intent: decimals() matches the underlying pegged token (18). function test_decimals() public view { - uint8 d = IERC20Metadata(stabilityPoolCollateral).decimals(); + uint8 d = IERC20Metadata(sp).decimals(); assertEq(d, 18, "decimals"); } @@ -43,101 +78,135 @@ contract TestStabilityPool_v3_ERC20 is TestStabilityPoolSetUp { // String packing: StringTooLong, short strings, medium strings // ═══════════════════════════════════════════════════════════════════════ + /// Intent: constructor reverts if name exceeds 64 characters (StringPacking_v1 limit). function test_stringTooLong_name_reverts() public { // 65-char string exceeds 64-char limit string memory longName = "12345678901234567890123456789012345678901234567890123456789012345"; vm.expectRevert(StringPacking_v1.StringTooLong.selector); - new StabilityPool_v3(minter, wrappedCollateralToken, 3600, 90000, 1 ether, longName, "s"); + new StabilityPool_v3(minterFxUSD, wrappedCollateralToken, 3600, 90000, 1 ether, longName, "s"); } + /// Intent: constructor reverts if symbol exceeds 64 characters (StringPacking_v1 limit). function test_stringTooLong_symbol_reverts() public { string memory longSymbol = "12345678901234567890123456789012345678901234567890123456789012345"; vm.expectRevert(StringPacking_v1.StringTooLong.selector); - new StabilityPool_v3(minter, wrappedCollateralToken, 3600, 90000, 1 ether, "n", longSymbol); + new StabilityPool_v3(minterFxUSD, wrappedCollateralToken, 3600, 90000, 1 ether, "n", longSymbol); } + /// Intent: short strings (<32 chars) round-trip through StringPacking_v1 correctly. function test_name_shortString() public { - // < 32 chars - StabilityPool_v3 sp = new StabilityPool_v3(minter, wrappedCollateralToken, 3600, 90000, 1 ether, "Short", "S"); - assertEq(sp.name(), "Short", "short name"); - assertEq(sp.symbol(), "S", "short symbol"); + StabilityPool_v3 sp_ = new StabilityPool_v3( + minterFxUSD, + wrappedCollateralToken, + 3600, + 90000, + 1 ether, + "Short", + "S" + ); + assertEq(sp_.name(), "Short", "short name"); + assertEq(sp_.symbol(), "S", "short symbol"); } + /// Intent: 32-char strings round-trip correctly (single bytes32 boundary). function test_name_exactly32chars() public { - // Exactly 32 chars string memory name32 = "12345678901234567890123456789012"; assertEq(bytes(name32).length, 32, "sanity"); - StabilityPool_v3 sp = new StabilityPool_v3(minter, wrappedCollateralToken, 3600, 90000, 1 ether, name32, "S"); - assertEq(sp.name(), name32, "32-char name"); + StabilityPool_v3 sp_ = new StabilityPool_v3( + minterFxUSD, + wrappedCollateralToken, + 3600, + 90000, + 1 ether, + name32, + "S" + ); + assertEq(sp_.name(), name32, "32-char name"); } + /// Intent: 33-64 char strings (need 2 bytes32 slots) round-trip correctly. function test_name_between32and64chars() public { - // 40 chars (between 32 and 64) string memory name40 = "1234567890123456789012345678901234567890"; assertEq(bytes(name40).length, 40, "sanity"); - StabilityPool_v3 sp = new StabilityPool_v3(minter, wrappedCollateralToken, 3600, 90000, 1 ether, name40, "S"); - assertEq(sp.name(), name40, "40-char name"); + StabilityPool_v3 sp_ = new StabilityPool_v3( + minterFxUSD, + wrappedCollateralToken, + 3600, + 90000, + 1 ether, + name40, + "S" + ); + assertEq(sp_.name(), name40, "40-char name"); } + /// Intent: 64-char strings (max length) round-trip correctly. function test_name_exactly64chars() public { string memory name64 = "1234567890123456789012345678901234567890123456789012345678901234"; assertEq(bytes(name64).length, 64, "sanity"); - StabilityPool_v3 sp = new StabilityPool_v3(minter, wrappedCollateralToken, 3600, 90000, 1 ether, name64, "S"); - assertEq(sp.name(), name64, "64-char name"); + StabilityPool_v3 sp_ = new StabilityPool_v3( + minterFxUSD, + wrappedCollateralToken, + 3600, + 90000, + 1 ether, + name64, + "S" + ); + assertEq(sp_.name(), name64, "64-char name"); } // ═══════════════════════════════════════════════════════════════════════ // balanceOf / totalSupply // ═══════════════════════════════════════════════════════════════════════ - function test_balanceOf_matchesAssetBalanceOf() public { + /// Intent: ERC20 balanceOf returns the depositor's compounded position. + function test_balanceOf_matchesDeposit() public { _deposit(user1, 10 ether); - assertEq( - IERC20(stabilityPoolCollateral).balanceOf(user1), - IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1), - "balanceOf == assetBalanceOf" - ); + assertEq(IERC20(sp).balanceOf(user1), 10 ether, "balanceOf == deposit (no loss)"); } + /// Intent: a user with no deposits has zero balance. function test_balanceOf_zeroForNewUser() public view { - assertEq(IERC20(stabilityPoolCollateral).balanceOf(user1), 0, "zero for new user"); + assertEq(IERC20(sp).balanceOf(user1), 0, "zero for new user"); } - function test_totalSupply_matchesTotalAssetSupply() public { + /// Intent: ERC20 totalSupply matches the sum of deposits (no loss). + function test_totalSupply_matchesDeposits() public { + uint256 supplyBefore = IERC20(sp).totalSupply(); _deposit(user1, 10 ether); - assertEq( - IERC20(stabilityPoolCollateral).totalSupply(), - IStabilityPool(stabilityPoolCollateral).totalAssetSupply(), - "totalSupply == totalAssetSupply" - ); + assertEq(IERC20(sp).totalSupply(), supplyBefore + 10 ether, "totalSupply increased by deposit"); } // ═══════════════════════════════════════════════════════════════════════ - // transfer + // transfer (basic ERC20 mechanics) // ═══════════════════════════════════════════════════════════════════════ + /// Intent: transfer moves balance from sender to receiver and returns true. function test_transfer() public { _deposit(user1, 10 ether); _deposit(user2, 5 ether); vm.prank(user1); - bool success = IERC20(stabilityPoolCollateral).transfer(user2, 3 ether); + bool success = IERC20(sp).transfer(user2, 3 ether); assertTrue(success, "returns true"); - assertEq(IERC20(stabilityPoolCollateral).balanceOf(user1), 7 ether, "sender"); - assertEq(IERC20(stabilityPoolCollateral).balanceOf(user2), 8 ether, "receiver"); + assertEq(IERC20(sp).balanceOf(user1), 7 ether, "sender"); + assertEq(IERC20(sp).balanceOf(user2), 8 ether, "receiver"); } + /// Intent: transferring entire balance leaves sender with zero. function test_transfer_entireBalance() public { _deposit(user1, 10 ether); _deposit(user2, 5 ether); vm.prank(user1); - IERC20(stabilityPoolCollateral).transfer(user2, 10 ether); + IERC20(sp).transfer(user2, 10 ether); - assertEq(IERC20(stabilityPoolCollateral).balanceOf(user1), 0, "sender zero"); + assertEq(IERC20(sp).balanceOf(user1), 0, "sender zero"); } + /// Intent: transferring more than balance reverts with TransferExceedsBalance. function test_transfer_exceedsBalance_reverts() public { _deposit(user1, 10 ether); @@ -145,25 +214,28 @@ contract TestStabilityPool_v3_ERC20 is TestStabilityPoolSetUp { vm.expectRevert( abi.encodeWithSelector(StabilityPool_v3.TransferExceedsBalance.selector, user1, 11 ether, 10 ether) ); - IERC20(stabilityPoolCollateral).transfer(user2, 11 ether); + IERC20(sp).transfer(user2, 11 ether); } + /// Intent: transfer to zero address reverts with InvalidReceiver. function test_transfer_toZeroAddress_reverts() public { _deposit(user1, 10 ether); vm.prank(user1); vm.expectRevert(abi.encodeWithSelector(IStabilityPool.InvalidReceiver.selector, address(0))); - IERC20(stabilityPoolCollateral).transfer(address(0), 1 ether); + IERC20(sp).transfer(address(0), 1 ether); } + /// Intent: transfer to self reverts with InvalidReceiver. function test_transfer_toSelf_reverts() public { _deposit(user1, 10 ether); vm.prank(user1); vm.expectRevert(abi.encodeWithSelector(IStabilityPool.InvalidReceiver.selector, user1)); - IERC20(stabilityPoolCollateral).transfer(user1, 1 ether); + IERC20(sp).transfer(user1, 1 ether); } + /// Intent: transfer emits the standard Transfer event. function test_transfer_emitsEvent() public { _deposit(user1, 10 ether); @@ -171,78 +243,258 @@ contract TestStabilityPool_v3_ERC20 is TestStabilityPoolSetUp { emit IERC20.Transfer(user1, user2, 3 ether); vm.prank(user1); - IERC20(stabilityPoolCollateral).transfer(user2, 3 ether); + IERC20(sp).transfer(user2, 3 ether); } + /// Intent: transfer from zero address (msg.sender = address(0)) reverts. function test_transfer_fromZeroAddress_reverts() public { _deposit(user1, 10 ether); vm.prank(address(0)); vm.expectRevert(abi.encodeWithSelector(IStabilityPool.InvalidReceiver.selector, address(0))); - IERC20(stabilityPoolCollateral).transfer(user1, 1 ether); + IERC20(sp).transfer(user1, 1 ether); } // ═══════════════════════════════════════════════════════════════════════ // approve / allowance // ═══════════════════════════════════════════════════════════════════════ + /// Intent: approve sets the allowance and returns true. function test_approve_and_allowance() public { vm.prank(user1); - bool success = IERC20(stabilityPoolCollateral).approve(user2, 5 ether); + bool success = IERC20(sp).approve(user2, 5 ether); assertTrue(success, "returns true"); - assertEq(IERC20(stabilityPoolCollateral).allowance(user1, user2), 5 ether, "allowance"); + assertEq(IERC20(sp).allowance(user1, user2), 5 ether, "allowance"); } + /// Intent: approve emits the standard Approval event. function test_approve_emitsEvent() public { vm.expectEmit(true, true, false, true); emit IERC20.Approval(user1, user2, 5 ether); vm.prank(user1); - IERC20(stabilityPoolCollateral).approve(user2, 5 ether); + IERC20(sp).approve(user2, 5 ether); } // ═══════════════════════════════════════════════════════════════════════ // transferFrom // ═══════════════════════════════════════════════════════════════════════ + /// Intent: transferFrom moves balance and decrements the allowance. function test_transferFrom() public { _deposit(user1, 10 ether); vm.prank(user1); - IERC20(stabilityPoolCollateral).approve(user2, 5 ether); + IERC20(sp).approve(user2, 5 ether); vm.prank(user2); - bool success = IERC20(stabilityPoolCollateral).transferFrom(user1, user2, 3 ether); + bool success = IERC20(sp).transferFrom(user1, user2, 3 ether); assertTrue(success, "returns true"); - assertEq(IERC20(stabilityPoolCollateral).balanceOf(user1), 7 ether, "sender"); - assertEq(IERC20(stabilityPoolCollateral).balanceOf(user2), 3 ether, "receiver"); - assertEq(IERC20(stabilityPoolCollateral).allowance(user1, user2), 2 ether, "allowance decreased"); + assertEq(IERC20(sp).balanceOf(user1), 7 ether, "sender"); + assertEq(IERC20(sp).balanceOf(user2), 3 ether, "receiver"); + assertEq(IERC20(sp).allowance(user1, user2), 2 ether, "allowance decreased"); } + /// Intent: transferFrom with type(uint256).max allowance does not deduct from the allowance. function test_transferFrom_infiniteAllowance() public { _deposit(user1, 10 ether); vm.prank(user1); - IERC20(stabilityPoolCollateral).approve(user2, type(uint256).max); + IERC20(sp).approve(user2, type(uint256).max); vm.prank(user2); - IERC20(stabilityPoolCollateral).transferFrom(user1, user2, 3 ether); + IERC20(sp).transferFrom(user1, user2, 3 ether); - assertEq(IERC20(stabilityPoolCollateral).allowance(user1, user2), type(uint256).max, "infinite not deducted"); + assertEq(IERC20(sp).allowance(user1, user2), type(uint256).max, "infinite not deducted"); } + /// Intent: transferFrom with insufficient allowance reverts with InsufficientAllowance. function test_transferFrom_insufficientAllowance_reverts() public { _deposit(user1, 10 ether); vm.prank(user1); - IERC20(stabilityPoolCollateral).approve(user2, 2 ether); + IERC20(sp).approve(user2, 2 ether); vm.prank(user2); vm.expectRevert( abi.encodeWithSelector(StabilityPool_v3.InsufficientAllowance.selector, user2, 2 ether, 3 ether) ); - IERC20(stabilityPoolCollateral).transferFrom(user1, user2, 3 ether); + IERC20(sp).transferFrom(user1, user2, 3 ether); + } + + // ═══════════════════════════════════════════════════════════════════════ + // Transfer equivalence — transfer must behave identically to withdraw + deposit + // (B.3.1a — exposes the bug where _transferBalance operates on stored balance, + // not compounded balance, so transfers move the wrong amount after a loss) + // ═══════════════════════════════════════════════════════════════════════ + + /// Intent: with no prior loss, transfer X from user1 to user2 should produce the same + /// end balances as user1 transferring (sanity check, no bug expected here). + function test_transfer_equivalence_noLoss() public { + _deposit(user1, 100 ether); + + uint256 user1Before = IERC20(sp).balanceOf(user1); + uint256 user2Before = IERC20(sp).balanceOf(user2); + + vm.prank(user1); + IERC20(sp).transfer(user2, 30 ether); + + assertEq(IERC20(sp).balanceOf(user1), user1Before - 30 ether, "user1 -30"); + assertEq(IERC20(sp).balanceOf(user2), user2Before + 30 ether, "user2 +30"); + } + + /// Intent: after a loss, transferring X stored-units must move X compounded-balance, + /// not X stored-balance. Transfer X from user1 should reduce user1's compounded + /// balance by exactly X and increase user2's by exactly X. The bug: current + /// implementation reduces user1's stored amount by X, which equals more or less + /// than X compounded depending on the product. + function test_transfer_equivalence_afterLoss() public { + // user1 deposits 100 at fresh product + _deposit(user1, 100 ether); + // ensure CR is healthy enough that the loss is small relative to pool, but real + // Apply a 25% loss to the pool (25 of 100) + _applyLoss(25 ether, 25 ether); + + // After the loss, user1's compounded balance is 75 (75% of original) + uint256 user1Compounded = IERC20(sp).balanceOf(user1); + assertApproxEqAbs(user1Compounded, 75 ether, 1, "user1 75 after loss"); + + // Transfer 30 (compounded) from user1 to user2 + vm.prank(user1); + IERC20(sp).transfer(user2, 30 ether); + + // user1 should have 45, user2 should have 30 + assertApproxEqAbs(IERC20(sp).balanceOf(user1), 45 ether, 1, "user1 45"); + assertApproxEqAbs(IERC20(sp).balanceOf(user2), 30 ether, 1, "user2 30"); + } + + /// Intent: round-trip transfer A->B then B->A leaves both balances unchanged (within rounding). + function test_transfer_roundTrip_noLoss() public { + _deposit(user1, 100 ether); + + uint256 user1Before = IERC20(sp).balanceOf(user1); + uint256 user2Before = IERC20(sp).balanceOf(user2); + + vm.prank(user1); + IERC20(sp).transfer(user2, 30 ether); + vm.prank(user2); + IERC20(sp).transfer(user1, 30 ether); + + assertEq(IERC20(sp).balanceOf(user1), user1Before, "user1 unchanged"); + assertEq(IERC20(sp).balanceOf(user2), user2Before, "user2 unchanged"); + } + + /// Intent: round-trip transfer A->B then B->A after a loss leaves both balances unchanged. + function test_transfer_roundTrip_afterLoss() public { + _deposit(user1, 100 ether); + _applyLoss(25 ether, 25 ether); + + uint256 user1Before = IERC20(sp).balanceOf(user1); + uint256 user2Before = IERC20(sp).balanceOf(user2); + + vm.prank(user1); + IERC20(sp).transfer(user2, 30 ether); + vm.prank(user2); + IERC20(sp).transfer(user1, 30 ether); + + assertApproxEqAbs(IERC20(sp).balanceOf(user1), user1Before, 1, "user1 unchanged"); + assertApproxEqAbs(IERC20(sp).balanceOf(user2), user2Before, 1, "user2 unchanged"); + } + + /// Intent: transferring entire compounded balance after multiple losses leaves sender empty + /// and receiver with the full transferred amount. + function test_transfer_full_afterMultipleLosses() public { + _deposit(user1, 200 ether); + _applyLoss(20 ether, 20 ether); // 10% loss + _applyLoss(18 ether, 18 ether); // ~10% of remaining + _applyLoss(16 ether, 16 ether); // ~10% again + + uint256 user1Compounded = IERC20(sp).balanceOf(user1); + assertGt(user1Compounded, 0, "user1 has some balance"); + + vm.prank(user1); + IERC20(sp).transfer(user2, user1Compounded); + + assertApproxEqAbs(IERC20(sp).balanceOf(user1), 0, 1, "user1 empty"); + assertApproxEqAbs(IERC20(sp).balanceOf(user2), user1Compounded, 1, "user2 has full"); + } + + /// Intent: a transfer should not affect the sender's pending rewards. Reward accrual up to + /// the transfer point belongs to the sender; future rewards accrue per new balances. + function test_transfer_preservesPendingRewards() public { + _deposit(user1, 100 ether); + _deposit(user2, 100 ether); + + // Accrue rewards (simulating SPM harvest deposit) + _depositReward(sp, wrappedCollateralToken, wrappedCollateralToken, 10 ether); + skip(2 weeks); // let rewards fully drip + + // Snapshot pending rewards before transfer + uint256 user1ClaimableBefore = IMultipleRewardAccumulator(sp).claimable(user1, wrappedCollateralToken); + uint256 user2ClaimableBefore = IMultipleRewardAccumulator(sp).claimable(user2, wrappedCollateralToken); + assertGt(user1ClaimableBefore, 0, "user1 has rewards"); + assertGt(user2ClaimableBefore, 0, "user2 has rewards"); + + // Transfer half of user1's balance to user2 + vm.prank(user1); + IERC20(sp).transfer(user2, 50 ether); + + // Pending rewards should be preserved (within tiny rounding) + assertApproxEqAbs( + IMultipleRewardAccumulator(sp).claimable(user1, wrappedCollateralToken), + user1ClaimableBefore, + 1, + "user1 rewards preserved" + ); + assertApproxEqAbs( + IMultipleRewardAccumulator(sp).claimable(user2, wrappedCollateralToken), + user2ClaimableBefore, + 1, + "user2 rewards preserved" + ); + } + + /// Intent: after a transfer, future rewards should accrue to user1 and user2 proportional + /// to their NEW compounded balances (not their pre-transfer balances). + function test_transfer_futureRewardsProportionalToCompoundedBalance() public { + _deposit(user1, 100 ether); + _deposit(user2, 100 ether); + + // Transfer 50 from user1 to user2 — now user1 has 50, user2 has 150 + vm.prank(user1); + IERC20(sp).transfer(user2, 50 ether); + + // Accrue new rewards + _depositReward(sp, wrappedCollateralToken, wrappedCollateralToken, 20 ether); + skip(2 weeks); + + uint256 user1Claimable = IMultipleRewardAccumulator(sp).claimable(user1, wrappedCollateralToken); + uint256 user2Claimable = IMultipleRewardAccumulator(sp).claimable(user2, wrappedCollateralToken); + + // user2's claim should be ~3x user1's (150 vs 50) + assertApproxEqRel(user2Claimable, user1Claimable * 3, 0.01 ether, "user2 ~3x user1"); + } + + /// Intent: a transfer followed by a loss should apply the loss to both parties based on + /// their POST-TRANSFER compounded balances. After the transfer, both have equal + /// balances; after the loss, both should still be equal (each losing the same fraction). + function test_transfer_thenLoss_applies_proportionally() public { + _deposit(user1, 200 ether); + + // Transfer 100 from user1 to user2 — both have 100 + vm.prank(user1); + IERC20(sp).transfer(user2, 100 ether); + + assertApproxEqAbs(IERC20(sp).balanceOf(user1), 100 ether, 1, "user1 100 after transfer"); + assertApproxEqAbs(IERC20(sp).balanceOf(user2), 100 ether, 1, "user2 100 after transfer"); + + // Apply a 50% loss to the pool + _applyLoss(100 ether, 100 ether); + + // Both should have 50 (half each) + assertApproxEqAbs(IERC20(sp).balanceOf(user1), 50 ether, 1, "user1 50 after loss"); + assertApproxEqAbs(IERC20(sp).balanceOf(user2), 50 ether, 1, "user2 50 after loss"); } } diff --git a/test/deployment/AutoCompounderTest.t.sol b/test/deployment/AutoCompounderTest.t.sol new file mode 100644 index 00000000..99773308 --- /dev/null +++ b/test/deployment/AutoCompounderTest.t.sol @@ -0,0 +1,517 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.28 <0.9.0; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; +import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; +import {IAutoCompounder} from "src/interfaces/IAutoCompounder.sol"; +import {AutoCompounder_v1} from "src/autocompounding/AutoCompounder_v1.sol"; +import {DeployEURSetUp} from "test/deployment/DeployEURSetUp.t.sol"; + +/// @title AutoCompounder tests using EUR peg (fxUSD + stETH collateral). +/// Run: forge test --mc AutoCompounderTest --fork-url mainnet -vv +contract AutoCompounderTest is DeployEURSetUp { + address alice = makeAddr("alice"); + address bob = makeAddr("bob"); + + // ── Deployment verification ──────────────────────────────────────── + + function test_deployment_immutables() public view { + assertEq(AutoCompounder_v1(acCollFxUSD).STABILITY_POOL(), spCollFxUSD); + assertEq(AutoCompounder_v1(acCollFxUSD).MINTER(), minterFxUSD); + assertEq(AutoCompounder_v1(acCollFxUSD).WRAPPED_COLLATERAL(), wrappedCollateralFxUSD); + assertEq(AutoCompounder_v1(acCollFxUSD).PEGGED_TOKEN(), pegged); + + assertEq(AutoCompounder_v1(acCollStETH).STABILITY_POOL(), spCollStETH); + assertEq(AutoCompounder_v1(acCollStETH).MINTER(), minterStETH); + assertEq(AutoCompounder_v1(acCollStETH).WRAPPED_COLLATERAL(), wrappedCollateralStETH); + assertEq(AutoCompounder_v1(acCollStETH).PEGGED_TOKEN(), pegged); + } + + function test_deployment_metadata() public view { + assertGt(bytes(IERC4626(acCollFxUSD).name()).length, 0, "fxUSD AC name"); + assertGt(bytes(IERC4626(acCollFxUSD).symbol()).length, 0, "fxUSD AC symbol"); + assertEq(IERC4626(acCollFxUSD).decimals(), 18); + + assertGt(bytes(IERC4626(acCollStETH).name()).length, 0, "stETH AC name"); + assertGt(bytes(IERC4626(acCollStETH).symbol()).length, 0, "stETH AC symbol"); + assertEq(IERC4626(acCollStETH).decimals(), 18); + } + + function test_deployment_maxFeeRatio() public view { + assertEq(AutoCompounder_v1(acCollFxUSD).maxFeeRatio(), 0.05 ether, "fxUSD AC maxFeeRatio"); + assertEq(AutoCompounder_v1(acCollStETH).maxFeeRatio(), 0.05 ether, "stETH AC maxFeeRatio"); + } + + function test_deployment_asset() public view { + assertEq(IERC4626(acCollFxUSD).asset(), spCollFxUSD, "fxUSD AC asset is SP"); + assertEq(IERC4626(acCollStETH).asset(), spCollStETH, "stETH AC asset is SP"); + } + + // ── Deposit / Withdraw round-trip ────────────────────────────────── + + function test_depositWithdraw_roundTrip() public { + // Alice deposits pegged -> SP -> gets SP tokens -> deposits to AC + _mintAndDepositToSP(minterFxUSD, spCollFxUSD, alice, 10 ether); + uint256 spBalance = IERC20(spCollFxUSD).balanceOf(alice); + assertGt(spBalance, 0, "alice has SP tokens"); + + // Approve AC and deposit SP tokens + vm.startPrank(alice); + IERC20(spCollFxUSD).approve(acCollFxUSD, spBalance); + uint256 shares = IERC4626(acCollFxUSD).deposit(spBalance, alice); + vm.stopPrank(); + + assertGt(shares, 0, "alice got AC shares"); + assertEq(IERC20(spCollFxUSD).balanceOf(alice), 0, "SP tokens moved to AC"); + assertEq(IERC4626(acCollFxUSD).balanceOf(alice), shares, "AC shares in alice's balance"); + + // Redeem all AC shares -> get SP tokens back + vm.prank(alice); + uint256 spReturned = IERC4626(acCollFxUSD).redeem(shares, alice, alice); + + assertEq(spReturned, spBalance, "got same SP tokens back"); + assertEq(IERC4626(acCollFxUSD).balanceOf(alice), 0, "no AC shares left"); + assertEq(IERC20(spCollFxUSD).balanceOf(alice), spBalance, "SP tokens returned"); + } + + // ── depositPeggedToken ───────────────────────────────────────────── + + function test_depositPeggedToken() public { + uint256 peggedAmount = 10 ether; + _mintPegged(minterFxUSD, alice, peggedAmount); + + vm.startPrank(alice); + IERC20(pegged).approve(acCollFxUSD, peggedAmount); + uint256 shares = IAutoCompounder(acCollFxUSD).depositPeggedToken(peggedAmount, alice); + vm.stopPrank(); + + assertGt(shares, 0, "alice got AC shares"); + assertEq(IERC20(pegged).balanceOf(alice), 0, "pegged tokens consumed"); + assertGt(IERC4626(acCollFxUSD).totalAssets(), 0, "AC has assets"); + } + + function test_depositPeggedToken_equivalentToDeposit() public { + uint256 amount = 10 ether; + + // Alice deposits via depositPeggedToken (pegged -> SP -> AC in one call) + _mintPegged(minterFxUSD, alice, amount); + vm.startPrank(alice); + IERC20(pegged).approve(acCollFxUSD, amount); + uint256 sharesPegged = IAutoCompounder(acCollFxUSD).depositPeggedToken(amount, alice); + vm.stopPrank(); + + // Bob deposits via deposit (pegged -> SP manually, then SP tokens -> AC) + _mintAndDepositToSP(minterFxUSD, spCollFxUSD, bob, amount); + uint256 spBal = IERC20(spCollFxUSD).balanceOf(bob); + vm.startPrank(bob); + IERC20(spCollFxUSD).approve(acCollFxUSD, spBal); + uint256 sharesDeposit = IERC4626(acCollFxUSD).deposit(spBal, bob); + vm.stopPrank(); + + // Same collateral amount should produce same shares (second depositor buys at same rate) + assertEq(sharesPegged, sharesDeposit, "depositPeggedToken and deposit produce equal shares"); + + // Both should redeem to the same SP token amount + uint256 redeemAlice = IERC4626(acCollFxUSD).previewRedeem(sharesPegged); + uint256 redeemBob = IERC4626(acCollFxUSD).previewRedeem(sharesDeposit); + assertEq(redeemAlice, redeemBob, "equal redemption value"); + } + + function test_depositPeggedToken_doesNotAffectExistingUsers() public { + // Charlie is an existing depositor + address charlie = makeAddr("charlie"); + _mintAndDepositToSP(minterFxUSD, spCollFxUSD, charlie, 50 ether); + uint256 spBalCharlie = IERC20(spCollFxUSD).balanceOf(charlie); + vm.prank(charlie); + IERC20(spCollFxUSD).approve(acCollFxUSD, spBalCharlie); + vm.prank(charlie); + IERC4626(acCollFxUSD).deposit(spBalCharlie, charlie); + + uint256 charlieRedeemBefore = IERC4626(acCollFxUSD).previewRedeem(IERC4626(acCollFxUSD).balanceOf(charlie)); + + // Alice enters via depositPeggedToken + _mintPegged(minterFxUSD, alice, 10 ether); + vm.startPrank(alice); + IERC20(pegged).approve(acCollFxUSD, 10 ether); + IAutoCompounder(acCollFxUSD).depositPeggedToken(10 ether, alice); + vm.stopPrank(); + + uint256 charlieRedeemAfterPegged = IERC4626(acCollFxUSD).previewRedeem( + IERC4626(acCollFxUSD).balanceOf(charlie) + ); + + // Bob enters via deposit (SP tokens) + _mintAndDepositToSP(minterFxUSD, spCollFxUSD, bob, 10 ether); + uint256 spBalBob = IERC20(spCollFxUSD).balanceOf(bob); + vm.prank(bob); + IERC20(spCollFxUSD).approve(acCollFxUSD, spBalBob); + vm.prank(bob); + IERC4626(acCollFxUSD).deposit(spBalBob, bob); + + uint256 charlieRedeemAfterBoth = IERC4626(acCollFxUSD).previewRedeem(IERC4626(acCollFxUSD).balanceOf(charlie)); + + // Charlie's redemption value should be unchanged by either deposit path + assertEq(charlieRedeemAfterPegged, charlieRedeemBefore, "depositPeggedToken did not dilute charlie"); + assertEq(charlieRedeemAfterBoth, charlieRedeemBefore, "deposit did not dilute charlie"); + } + + function test_depositPeggedToken_maxAmount() public { + uint256 peggedAmount = 10 ether; + _mintPegged(minterFxUSD, alice, peggedAmount); + + vm.startPrank(alice); + IERC20(pegged).approve(acCollFxUSD, type(uint256).max); + uint256 shares = IAutoCompounder(acCollFxUSD).depositPeggedToken(type(uint256).max, alice); + vm.stopPrank(); + + assertGt(shares, 0, "alice got AC shares"); + assertEq(IERC20(pegged).balanceOf(alice), 0, "all pegged tokens consumed"); + } + + // ── Compound: full mint ──────────────────────────────────────────── + + function test_compound_fullMint() public { + // Setup: healthy CR via leveraged tokens, so minting pegged during compound has low fee + _setupHealthyMarket(minterFxUSD, spCollFxUSD, alice, 100 ether, 100 ether); + uint256 spBal = IERC20(spCollFxUSD).balanceOf(alice); + vm.startPrank(alice); + IERC20(spCollFxUSD).approve(acCollFxUSD, spBal); + IERC4626(acCollFxUSD).deposit(spBal, alice); + vm.stopPrank(); + + uint256 totalAssetsBefore = IERC4626(acCollFxUSD).totalAssets(); + + // Deposit small reward (0.5% of pool - keeps CR impact minimal) + _depositReward(spCollFxUSD, wrappedCollateralFxUSD, wrappedCollateralFxUSD, 5 ether); + skip(2 weeks); // let rewards fully accrue + + // Verify claimable exists + uint256 claimable = IMultipleRewardAccumulator(spCollFxUSD).claimable(acCollFxUSD, wrappedCollateralFxUSD); + assertGt(claimable, 0, "AC has claimable rewards"); + + // totalAssets should include claimable value + uint256 totalAssetsWithRewards = IERC4626(acCollFxUSD).totalAssets(); + assertGt(totalAssetsWithRewards, totalAssetsBefore, "totalAssets includes claimable"); + + // Compound - anyone can call + vm.prank(bob); + IAutoCompounder(acCollFxUSD).compound(); + + // After compound: all claimable consumed, SP position grew + uint256 claimableAfter = IMultipleRewardAccumulator(spCollFxUSD).claimable(acCollFxUSD, wrappedCollateralFxUSD); + assertEq(claimableAfter, 0, "all rewards claimed"); + uint256 totalAssetsAfter = IERC4626(acCollFxUSD).totalAssets(); + // totalAssets preserved (claimable converted to SP position, minus small minting fee) + assertApproxEqRel(totalAssetsAfter, totalAssetsWithRewards, 0.05 ether, "totalAssets preserved"); + } + + // ── Compound: fee too high -> skip ────────────────────────────────── + + function test_compound_feeTooHigh_skips() public { + // Setup: healthy CR + _setupHealthyMarket(minterFxUSD, spCollFxUSD, alice, 100 ether, 100 ether); + uint256 spBal = IERC20(spCollFxUSD).balanceOf(alice); + vm.startPrank(alice); + IERC20(spCollFxUSD).approve(acCollFxUSD, spBal); + IERC4626(acCollFxUSD).deposit(spBal, alice); + vm.stopPrank(); + + // Deposit rewards + _depositReward(spCollFxUSD, wrappedCollateralFxUSD, wrappedCollateralFxUSD, 5 ether); + skip(2 weeks); + + // Set maxFeeRatio to 0 - nothing should be profitable + vm.prank(HARBOR_MULTISIG); + AutoCompounder_v1(acCollFxUSD).setMaxFeeRatio(0); + + uint256 claimableBefore = IMultipleRewardAccumulator(spCollFxUSD).claimable( + acCollFxUSD, + wrappedCollateralFxUSD + ); + assertGt(claimableBefore, 0, "rewards exist"); + + // Compound should skip (not revert) + IAutoCompounder(acCollFxUSD).compound(); + + // Claimable unchanged - nothing was claimed + uint256 claimableAfter = IMultipleRewardAccumulator(spCollFxUSD).claimable(acCollFxUSD, wrappedCollateralFxUSD); + assertEq(claimableAfter, claimableBefore, "claimable unchanged - compound skipped"); + } + + // ── Compound: nothing to compound -> revert ───────────────────────── + + function test_compound_nothingToCompound_reverts() public { + // Setup: alice deposits, no rewards + _mintAndDepositToSP(minterFxUSD, spCollFxUSD, alice, 10 ether); + uint256 spBal = IERC20(spCollFxUSD).balanceOf(alice); + vm.startPrank(alice); + IERC20(spCollFxUSD).approve(acCollFxUSD, spBal); + IERC4626(acCollFxUSD).deposit(spBal, alice); + vm.stopPrank(); + + vm.expectRevert(AutoCompounder_v1.NothingToCompound.selector); + IAutoCompounder(acCollFxUSD).compound(); + } + + // ── Share price increases after compound ──────────────────────────── + + function test_compound_sharePriceUp() public { + // Healthy CR, then alice and bob deposit equal amounts + _mintLeveraged(minterFxUSD, address(this), 200 ether); + _mintAndDepositToSP(minterFxUSD, spCollFxUSD, alice, 50 ether); + _mintAndDepositToSP(minterFxUSD, spCollFxUSD, bob, 50 ether); + + uint256 spBalAlice = IERC20(spCollFxUSD).balanceOf(alice); + uint256 spBalBob = IERC20(spCollFxUSD).balanceOf(bob); + + vm.prank(alice); + IERC20(spCollFxUSD).approve(acCollFxUSD, spBalAlice); + vm.prank(alice); + uint256 sharesAlice = IERC4626(acCollFxUSD).deposit(spBalAlice, alice); + + vm.prank(bob); + IERC20(spCollFxUSD).approve(acCollFxUSD, spBalBob); + vm.prank(bob); + uint256 sharesBob = IERC4626(acCollFxUSD).deposit(spBalBob, bob); + + assertEq(sharesAlice, sharesBob, "equal deposits -> equal shares"); + + uint256 previewBefore = IERC4626(acCollFxUSD).previewRedeem(sharesAlice); + + // Deposit rewards and compound + _depositReward(spCollFxUSD, wrappedCollateralFxUSD, wrappedCollateralFxUSD, 10 ether); + skip(2 weeks); + IAutoCompounder(acCollFxUSD).compound(); + + uint256 previewAfter = IERC4626(acCollFxUSD).previewRedeem(sharesAlice); + assertGt(previewAfter, previewBefore, "share price increased after compound"); + } + + // ── No dilution on deposit when queue non-empty ──────────────────── + + function test_noDilution_withPendingRewards() public { + // Healthy CR, alice deposits first + _mintLeveraged(minterFxUSD, address(this), 200 ether); + _mintAndDepositToSP(minterFxUSD, spCollFxUSD, alice, 50 ether); + uint256 spBalAlice = IERC20(spCollFxUSD).balanceOf(alice); + vm.prank(alice); + IERC20(spCollFxUSD).approve(acCollFxUSD, spBalAlice); + vm.prank(alice); + IERC4626(acCollFxUSD).deposit(spBalAlice, alice); + + // Rewards accrue + _depositReward(spCollFxUSD, wrappedCollateralFxUSD, wrappedCollateralFxUSD, 10 ether); + skip(2 weeks); + + // Bob deposits AFTER rewards accrued but BEFORE compound + _mintAndDepositToSP(minterFxUSD, spCollFxUSD, bob, 50 ether); + uint256 spBalBob = IERC20(spCollFxUSD).balanceOf(bob); + vm.prank(bob); + IERC20(spCollFxUSD).approve(acCollFxUSD, spBalBob); + vm.prank(bob); + uint256 sharesBob = IERC4626(acCollFxUSD).deposit(spBalBob, bob); + + // Bob should get FEWER shares than alice (alice's shares are worth more due to pending rewards) + uint256 sharesAlice = IERC4626(acCollFxUSD).balanceOf(alice); + assertLt(sharesBob, sharesAlice, "bob gets fewer shares - no dilution"); + } + + // ── Two collaterals: independent compounding ─────────────────────── + + function test_twoCollaterals_independentCompound() public { + // Healthy CR for both markets, deposit to both + _mintLeveraged(minterFxUSD, address(this), 100 ether); + _mintLeveraged(minterStETH, address(this), 100 ether); + _mintAndDepositToSP(minterFxUSD, spCollFxUSD, alice, 50 ether); + _mintAndDepositToSP(minterStETH, spCollStETH, alice, 50 ether); + + uint256 spBalFx = IERC20(spCollFxUSD).balanceOf(alice); + uint256 spBalSt = IERC20(spCollStETH).balanceOf(alice); + + vm.startPrank(alice); + IERC20(spCollFxUSD).approve(acCollFxUSD, spBalFx); + IERC4626(acCollFxUSD).deposit(spBalFx, alice); + IERC20(spCollStETH).approve(acCollStETH, spBalSt); + IERC4626(acCollStETH).deposit(spBalSt, alice); + vm.stopPrank(); + + // Deposit rewards only to fxUSD SP + _depositReward(spCollFxUSD, wrappedCollateralFxUSD, wrappedCollateralFxUSD, 5 ether); + skip(2 weeks); + + // Compound fxUSD AC - should succeed + IAutoCompounder(acCollFxUSD).compound(); + + // stETH AC - nothing to compound + vm.expectRevert(AutoCompounder_v1.NothingToCompound.selector); + IAutoCompounder(acCollStETH).compound(); + } + + // ── totalAssets consistency ───────────────────────────────────────── + + function test_totalAssets_withClaimable() public { + _setupHealthyMarket(minterFxUSD, spCollFxUSD, alice, 100 ether, 100 ether); + uint256 spBal = IERC20(spCollFxUSD).balanceOf(alice); + vm.prank(alice); + IERC20(spCollFxUSD).approve(acCollFxUSD, spBal); + vm.prank(alice); + IERC4626(acCollFxUSD).deposit(spBal, alice); + + uint256 totalAssetsBefore = IERC4626(acCollFxUSD).totalAssets(); + + // Deposit rewards + _depositReward(spCollFxUSD, wrappedCollateralFxUSD, wrappedCollateralFxUSD, 10 ether); + skip(2 weeks); + + uint256 totalAssetsAfter = IERC4626(acCollFxUSD).totalAssets(); + + // totalAssets should have increased by ~reward amount (at 1:1 price/rate) + assertGt(totalAssetsAfter, totalAssetsBefore, "totalAssets increased"); + assertApproxEqRel(totalAssetsAfter - totalAssetsBefore, 10 ether, 0.01 ether, "increase ~= reward amount"); + } + + // ── totalAssets zero claimable -> just SP position ────────────────── + + function test_totalAssets_noClaimable() public { + _mintAndDepositToSP(minterFxUSD, spCollFxUSD, alice, 10 ether); + uint256 spBal = IERC20(spCollFxUSD).balanceOf(alice); + vm.prank(alice); + IERC20(spCollFxUSD).approve(acCollFxUSD, spBal); + vm.prank(alice); + IERC4626(acCollFxUSD).deposit(spBal, alice); + + uint256 totalAssets = IERC4626(acCollFxUSD).totalAssets(); + uint256 spPosition = IERC20(spCollFxUSD).balanceOf(acCollFxUSD); + assertEq(totalAssets, spPosition, "totalAssets == SP position when no claimable"); + } + + // ── Sweep ────────────────────────────────────────────────────────── + + function test_sweep_rescuesStuckTokens() public { + // Accidentally send some tokens to the AC + deal(wrappedCollateralFxUSD, acCollFxUSD, 1 ether); + + address sweepReceiver = makeAddr("sweepReceiver"); + vm.prank(HARBOR_MULTISIG); + AutoCompounder_v1(acCollFxUSD).sweep(wrappedCollateralFxUSD, 1 ether, sweepReceiver); + + assertEq(IERC20(wrappedCollateralFxUSD).balanceOf(sweepReceiver), 1 ether, "swept to receiver"); + } +} + +/// @title AutoCompounder tests for alias and liquidation reward paths. +/// @dev Inherits AutoCompounderTest setup; tests reward flows through harvest and liquidation. +/// +/// Reward paths for collateral SP: +/// - Harvest: depositReward(wrappedCollateral, amount) -> linear distribution over period +/// - Rebalance: notifyLiquidation(liquidated, returned) -> _accumulateReward(wrappedCollateral) -> instant +/// - Both flow through claimable(AC, wrappedCollateral) and are drained by compound() +/// +/// Run: forge test --mc AutoCompounderRewardTest --fork-url mainnet -vv +contract AutoCompounderRewardTest is AutoCompounderTest { + // ── Helpers ──────────────────────────────────────────────────────── + + /// @dev Simulate a liquidation on the collateral SP: burns pegged, distributes wCOL as reward. + function _simulateLiquidation( + address sp, + address spm, + address wCol, + uint256 peggedLiquidated, + uint256 collateralReturned + ) internal { + deal(wCol, sp, IERC20(wCol).balanceOf(sp) + collateralReturned); + vm.prank(spm); + IStabilityPool(sp).notifyLiquidation(peggedLiquidated, collateralReturned); + } + + // ── Compound via depositReward ──────────────────────────────────── + + function test_compound_viaDepositReward() public { + _setupHealthyMarket(minterFxUSD, spCollFxUSD, alice, 100 ether, 100 ether); + uint256 spBal = IERC20(spCollFxUSD).balanceOf(alice); + vm.prank(alice); + IERC20(spCollFxUSD).approve(acCollFxUSD, spBal); + vm.prank(alice); + IERC4626(acCollFxUSD).deposit(spBal, alice); + + uint256 totalAssetsBefore = IERC4626(acCollFxUSD).totalAssets(); + + _depositReward(spCollFxUSD, wrappedCollateralFxUSD, wrappedCollateralFxUSD, 5 ether); + skip(2 weeks); + + uint256 claimable = IMultipleRewardAccumulator(spCollFxUSD).claimable(acCollFxUSD, wrappedCollateralFxUSD); + assertGt(claimable, 0, "AC has claimable"); + + IAutoCompounder(acCollFxUSD).compound(); + + uint256 claimableAfter = IMultipleRewardAccumulator(spCollFxUSD).claimable(acCollFxUSD, wrappedCollateralFxUSD); + assertEq(claimableAfter, 0, "all rewards claimed"); + assertGt(IERC4626(acCollFxUSD).totalAssets(), totalAssetsBefore, "totalAssets grew"); + } + + // ── Compound via liquidation (notifyLiquidation -> _accumulateReward) ── + + function test_compound_viaLiquidation() public { + _setupHealthyMarket(minterFxUSD, spCollFxUSD, alice, 100 ether, 100 ether); + uint256 spBal = IERC20(spCollFxUSD).balanceOf(alice); + vm.prank(alice); + IERC20(spCollFxUSD).approve(acCollFxUSD, spBal); + vm.prank(alice); + IERC4626(acCollFxUSD).deposit(spBal, alice); + + uint256 totalAssetsBefore = IERC4626(acCollFxUSD).totalAssets(); + + // Simulate liquidation: burn 10 pegged, return 10 wrappedCollateral (instant, not linear) + _simulateLiquidation(spCollFxUSD, spmFxUSD, wrappedCollateralFxUSD, 10 ether, 10 ether); + + // Claimable should be available immediately (no skip needed) + uint256 claimable = IMultipleRewardAccumulator(spCollFxUSD).claimable(acCollFxUSD, wrappedCollateralFxUSD); + assertGt(claimable, 0, "AC has claimable from liquidation"); + + // totalAssets reflects the claimable (minus the pegged loss from liquidation) + // The SP position dropped by ~10 ether (loss), but gained ~10 ether claimable wCOLn + // At price=1, rate=1 these roughly cancel out + uint256 totalAssetsAfterLiq = IERC4626(acCollFxUSD).totalAssets(); + assertApproxEqRel( + totalAssetsAfterLiq, + totalAssetsBefore, + 0.01 ether, + "totalAssets roughly preserved through liquidation" + ); + + IAutoCompounder(acCollFxUSD).compound(); + + uint256 claimableAfter = IMultipleRewardAccumulator(spCollFxUSD).claimable(acCollFxUSD, wrappedCollateralFxUSD); + assertEq(claimableAfter, 0, "liquidation rewards compounded"); + } + + // ── Compound: harvest + liquidation combined ─────────────────────── + + function test_compound_harvestPlusLiquidation() public { + _setupHealthyMarket(minterFxUSD, spCollFxUSD, alice, 100 ether, 100 ether); + uint256 spBal = IERC20(spCollFxUSD).balanceOf(alice); + vm.prank(alice); + IERC20(spCollFxUSD).approve(acCollFxUSD, spBal); + vm.prank(alice); + IERC4626(acCollFxUSD).deposit(spBal, alice); + + // Harvest reward (linear) + _depositReward(spCollFxUSD, wrappedCollateralFxUSD, wrappedCollateralFxUSD, 3 ether); + skip(2 weeks); + + // Liquidation reward (instant) + _simulateLiquidation(spCollFxUSD, spmFxUSD, wrappedCollateralFxUSD, 5 ether, 5 ether); + + // Both should be claimable + uint256 claimable = IMultipleRewardAccumulator(spCollFxUSD).claimable(acCollFxUSD, wrappedCollateralFxUSD); + assertGt(claimable, 3 ether, "claimable includes harvest + liquidation"); + + // Single compound drains everything + IAutoCompounder(acCollFxUSD).compound(); + + uint256 claimableAfter = IMultipleRewardAccumulator(spCollFxUSD).claimable(acCollFxUSD, wrappedCollateralFxUSD); + assertEq(claimableAfter, 0, "single compound drained all reward sources"); + } +} diff --git a/test/deployment/DeployEURSetUp.t.sol b/test/deployment/DeployEURSetUp.t.sol new file mode 100644 index 00000000..a26dabe1 --- /dev/null +++ b/test/deployment/DeployEURSetUp.t.sol @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.28 <0.9.0; + +import {BaoTest} from "@bao-test/BaoTest.sol"; +import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; +import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; +import {Deploy_EUR_Minter} from "script/src/v3/Deploy_EUR_Minter.sol"; +import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; +import {Config_MinterMarket} from "script/config/ConfigBase.sol"; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IMinter} from "src/interfaces/IMinter.sol"; +import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; +import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; +import {MockWrappedPriceOracle} from "test/mocks/MockWrappedPriceOracle.sol"; + +/// @title Common deployment setup for EUR market tests. +/// @dev Deploys EUR peg with two collaterals (fxUSD, stETH), each with collateral + leveraged SPs and ACs. +/// Forks mainnet at a pinned block, deploys all market infrastructure via production scripts, +/// grants test contract free-mint and reward-depositor roles, sets mock oracles to price=rate=1. +abstract contract DeployEURSetUp is BaoTest, Deploy_EUR_Minter { + // ── EUR::fxUSD market ────────────────────────────────────────────── + address minterFxUSD; + address spCollFxUSD; + address spLevFxUSD; + address spmFxUSD; + address acCollFxUSD; + address acLevFxUSD; + + // ── EUR::stETH market ────────────────────────────────────────────── + address minterStETH; + address spCollStETH; + address spLevStETH; + address spmStETH; + address acCollStETH; + address acLevStETH; + + // ── Shared ───────────────────────────────────────────────────────── + address pegged; // haEUR - shared across markets + address wrappedCollateralFxUSD; + address wrappedCollateralStETH; + + MockWrappedPriceOracle mockOracleFxUSD; + MockWrappedPriceOracle mockOracleStETH; + + function _shouldPersistState() internal pure override returns (bool) { + return false; + } + + function setUp() public virtual { + address factory = _ensureBaoFactory(); + vm.createSelectFork(vm.rpcUrl("mainnet"), 24699497); + + vm.prank(IBaoFactory(factory).owner()); + IBaoFactory(factory).setOperator(address(this), 365 days); + + (ConfigPeg peg_, Config_MinterMarket[] memory mktConfigs) = createEURMintersConfig(); + deployForPeg("test_eur", peg_, mktConfigs, "mainnet", true, mktConfigs); + + _setSaltPrefix("test_eur"); + + // EUR::fxUSD + string memory mkFx = "EUR::fxUSD"; + minterFxUSD = _predictAddress(_key(mkFx, "minter")); + spCollFxUSD = _predictAddress(_key(mkFx, "stabilityPoolCollateral")); + spLevFxUSD = _predictAddress(_key(mkFx, "stabilityPoolLeveraged")); + spmFxUSD = _predictAddress(_key(mkFx, "stabilityPoolManager")); + acCollFxUSD = _predictAddress(_key(mkFx, "autoCompounderCollateral")); + acLevFxUSD = _predictAddress(_key(mkFx, "autoCompounderLeveraged")); + wrappedCollateralFxUSD = IMinter(minterFxUSD).WRAPPED_COLLATERAL_TOKEN(); + + // EUR::stETH + string memory mkSt = "EUR::stETH"; + minterStETH = _predictAddress(_key(mkSt, "minter")); + spCollStETH = _predictAddress(_key(mkSt, "stabilityPoolCollateral")); + spLevStETH = _predictAddress(_key(mkSt, "stabilityPoolLeveraged")); + spmStETH = _predictAddress(_key(mkSt, "stabilityPoolManager")); + acCollStETH = _predictAddress(_key(mkSt, "autoCompounderCollateral")); + acLevStETH = _predictAddress(_key(mkSt, "autoCompounderLeveraged")); + wrappedCollateralStETH = IMinter(minterStETH).WRAPPED_COLLATERAL_TOKEN(); + + // Shared pegged token + pegged = _predictAddress(_key("EUR", "pegged")); + + // Mock oracles (price=1, rate=1 for simple accounting) + mockOracleFxUSD = new MockWrappedPriceOracle(); + mockOracleFxUSD.setLatestAnswer(1 ether, 1 ether); + mockOracleStETH = new MockWrappedPriceOracle(); + mockOracleStETH.setLatestAnswer(1 ether, 1 ether); + + vm.startPrank(HARBOR_MULTISIG); + IMinter(minterFxUSD).updatePriceOracle(address(mockOracleFxUSD)); + IMinter(minterStETH).updatePriceOracle(address(mockOracleStETH)); + // Grant free mint role for test helpers + IBaoRoles(minterFxUSD).grantRoles(address(this), IMinter(minterFxUSD).ZERO_FEE_ROLE()); + IBaoRoles(minterStETH).grantRoles(address(this), IMinter(minterStETH).ZERO_FEE_ROLE()); + // Grant reward depositor role on collateral SPs + IBaoRoles(spCollFxUSD).grantRoles( + address(this), + IMultipleRewardDistributor(spCollFxUSD).REWARD_DEPOSITOR_ROLE() + ); + IBaoRoles(spCollStETH).grantRoles( + address(this), + IMultipleRewardDistributor(spCollStETH).REWARD_DEPOSITOR_ROLE() + ); + vm.stopPrank(); + } + + // ── Helpers ──────────────────────────────────────────────────────── + + function _mintPegged( + address minter_, + address to, + uint256 collateralAmount + ) internal returns (uint256 peggedMinted) { + address wCol = IMinter(minter_).WRAPPED_COLLATERAL_TOKEN(); + deal(wCol, address(this), collateralAmount); + IERC20(wCol).approve(minter_, collateralAmount); + peggedMinted = IMinter(minter_).freeMintPeggedToken(collateralAmount, to); + } + + function _mintLeveraged( + address minter_, + address to, + uint256 collateralAmount + ) internal returns (uint256 leveragedMinted) { + address wCol = IMinter(minter_).WRAPPED_COLLATERAL_TOKEN(); + deal(wCol, address(this), collateralAmount); + IERC20(wCol).approve(minter_, collateralAmount); + leveragedMinted = IMinter(minter_).freeMintLeveragedToken(collateralAmount, to); + } + + /// @dev Set up a market with a healthy collateral ratio. + /// Mints pegged tokens (into SP) and leveraged tokens to achieve target CR. + /// CR = total_collateral_value / pegged_supply. Leveraged adds collateral without adding pegged. + /// With price=1, rate=1: CR = (peggedCollateral + leveragedCollateral) / peggedSupply + function _setupHealthyMarket( + address minter_, + address sp, + address user, + uint256 peggedCollateral, + uint256 leveragedCollateral + ) internal { + _mintAndDepositToSP(minter_, sp, user, peggedCollateral); + if (leveragedCollateral > 0) { + _mintLeveraged(minter_, user, leveragedCollateral); + } + } + + function _mintAndDepositToSP(address minter_, address sp, address user, uint256 amount) internal { + uint256 peggedMinted = _mintPegged(minter_, user, amount); + vm.startPrank(user); + IERC20(pegged).approve(sp, peggedMinted); + IStabilityPool(sp).deposit(peggedMinted, user, 0); + vm.stopPrank(); + } + + function _depositReward(address sp, address wCol, address rewardAlias, uint256 amount) internal { + deal(wCol, address(this), amount); + IERC20(wCol).approve(sp, amount); + IMultipleRewardDistributor(sp).depositReward(rewardAlias, amount); + } +} diff --git a/test/deployment/RebalanceFairness.t.sol b/test/deployment/RebalanceFairness.t.sol index 91e7e7fc..51e997bb 100644 --- a/test/deployment/RebalanceFairness.t.sol +++ b/test/deployment/RebalanceFairness.t.sol @@ -18,6 +18,7 @@ import {StabilityPoolManager_v1} from "src/minter/StabilityPoolManager_v1.sol"; import {MockWrappedPriceOracle} from "test/mocks/MockWrappedPriceOracle.sol"; import {console2} from "forge-std/console2.sol"; +import {FmtLib} from "src/util/FmtLib.sol"; /// @title RebalanceFairnessTest /// @notice Worked example from doc/ideas/sp-dynamic-fees.md using real contract code @@ -40,13 +41,14 @@ contract RebalanceFairnessSetUp is BaoTest, Deploy_ETH_Minter { uint256 oraclePrice; uint256 oracleRate; - // Cast — 6 actors, equal amounts, 2 per pool initially (equal pool sizes) + // Cast — 6 SP actors + Eve who holds only leveraged tokens address alice; // Stays in Collateral SP address bob; // Withdraws from Coll SP before rebalance, re-deposits after address charlie; // Stays in Leveraged SP address dave; // Withdraws from Lev SP before rebalance, re-deposits after address fred; // Outside SPs, deposits into Coll SP after rebalance address george; // Outside SPs, deposits into Lev SP after rebalance + address eve; // Holds only leveraged tokens (the market maker / leveraged-side liquidity) function _shouldPersistState() internal pure override returns (bool) { return false; @@ -86,9 +88,9 @@ contract RebalanceFairnessSetUp is BaoTest, Deploy_ETH_Minter { // The deployment script sets the oracle to a predicted address that doesn't exist yet // (oracles are deployed separately). Override it with our mock. mockOracle = new MockWrappedPriceOracle(); - // Price = 1 so collateral and pegged amounts are in the same units (simplifies balance sheets) - // Rate = 1 means 1 fxSAVE = 1 fxUSD (no yield accrued yet) - oraclePrice = 1 ether; + // Price = 1/4000 ETH per fxUSD (i.e. 4000 fxUSD per ETH, ETH ≈ $4000). + // Rate = 1 means 1 fxSAVE = 1 fxUSD (no yield accrued yet). + oraclePrice = 1 ether / 4000; oracleRate = 1 ether; mockOracle.setLatestAnswer(oraclePrice, oracleRate); @@ -109,8 +111,9 @@ contract RebalanceFairnessSetUp is BaoTest, Deploy_ETH_Minter { dave = makeAddr("dave"); fred = makeAddr("fred"); george = makeAddr("george"); + eve = makeAddr("eve"); - // Approve both pools for all actors + // Approve both pools for the 6 SP actors (Eve doesn't deposit into pools) address[6] memory actors = [alice, bob, charlie, dave, fred, george]; for (uint256 i = 0; i < actors.length; i++) { vm.startPrank(actors[i]); @@ -166,28 +169,162 @@ contract RebalanceFairnessSetUp is BaoTest, Deploy_ETH_Minter { IStabilityPool(pool).withdraw(type(uint256).max, who, 0); } + /// @notice Bump the oracle rate by 0.1% (~5.2% APY weekly equivalent) and trigger a harvest. function _triggerHarvest() internal returns (uint256 harvested) { - // Increase rate by 5% to simulate yield accrual - oracleRate = (oracleRate * 105) / 100; + oracleRate = (oracleRate * 1001) / 1000; mockOracle.setLatestAnswer(oraclePrice, oracleRate); harvested = IStabilityPoolManager(stabilityPoolManager).harvest(makeAddr("bountyReceiver"), 0); } + /// @dev Convert a fxSAVE amount to fxUSD using the current oracle rate. + /// Rate has units fxUSD-per-fxSAVE, so 1 fxSAVE = `rate` fxUSD. + function _fxSAVEToFxUSD(uint256 fxSAVEamount) internal view returns (uint256) { + return (fxSAVEamount * oracleRate) / 1 ether; + } + + /// @dev Convert a haETH (pegged) amount to fxUSD using the current oracle price. + /// Price has units ETH-per-fxUSD (since CR = collateral_fxUSD × price / pegged_haETH is dimensionless), + /// so 1 haETH = 1 ETH = 1/price fxUSD. + function _haETHToFxUSD(uint256 peggedAmount) internal view returns (uint256) { + return (peggedAmount * 1 ether) / oraclePrice; + } + + /// @dev Convert a leveraged-token amount to fxUSD via the Minter's `leveragedTokenPrice()`. + /// `leveragedTokenPrice()` returns NAV in pegged-token (haETH) units, so: + /// $ = lev × levPrice / oraclePrice (haETH-equivalent → fxUSD) + function _levToFxUSD(uint256 levAmount) internal view returns (uint256) { + if (levAmount == 0) { + return 0; + } + uint256 levPrice = IMinter(minter).leveragedTokenPrice(); + return (levAmount * levPrice) / oraclePrice; + } + + /// @dev Compute an actor's total dollar value across wallet, both SPs, and claimable rewards. + /// Position (haETH) → $ via price, fxSAVE rewards → $ via rate, lev tokens → $ via levTokenPrice. + function _totalDollars(address who) internal view returns (uint256) { + uint256 peggedColl = IERC20(stabilityPoolCollateral).balanceOf(who); + uint256 peggedLev = IERC20(stabilityPoolLeveraged).balanceOf(who); + uint256 peggedWallet = IERC20(pegged).balanceOf(who); + uint256 fxSAVEcoll = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(who, wrappedCollateral); + uint256 fxSAVElev = IMultipleRewardAccumulator(stabilityPoolLeveraged).claimable(who, wrappedCollateral); + uint256 levWallet = IERC20(leveraged).balanceOf(who); + uint256 levClaimable = IMultipleRewardAccumulator(stabilityPoolLeveraged).claimable(who, leveraged); + return + _haETHToFxUSD(peggedColl + peggedLev + peggedWallet) + + _fxSAVEToFxUSD(fxSAVEcoll + fxSAVElev) + + _levToFxUSD(levWallet + levClaimable); + } + + /// @dev Emit one row for the rebalance-fairness doc table. + /// `Position` is the actor's pegged + lev-token holdings. + /// `Reb` is the rebalance reward delta (postRebal - preRebal) in both fxSAVE and lev tokens. + /// `Harv` is the harvest accumulation since postRebal (fxSAVE only). + function _logTableRow( + string memory name, + address who, + ClaimableSnapshot memory preRebalSnap, + ClaimableSnapshot memory postRebalSnap + ) internal view { + { + uint256 pegPos = IERC20(pegged).balanceOf(who) + + IERC20(stabilityPoolCollateral).balanceOf(who) + + IERC20(stabilityPoolLeveraged).balanceOf(who); + console2.log( + string.concat( + " ", + name, + "\n", + " pos_haETH=", + FmtLib.sci(pegPos), + " pos_lev=", + FmtLib.sci(IERC20(leveraged).balanceOf(who)) + ) + ); + } + { + uint256 preFxsave = preRebalSnap.fxSAVE_collSP + preRebalSnap.fxSAVE_levSP; + uint256 postRebFxsave = postRebalSnap.fxSAVE_collSP + postRebalSnap.fxSAVE_levSP; + ClaimableSnapshot memory cur = _snapshotClaimable(who); + uint256 curFxsave = cur.fxSAVE_collSP + cur.fxSAVE_levSP; + console2.log( + string.concat( + " reb_fxSAVE=", + FmtLib.sci(postRebFxsave - preFxsave), + " reb_lev=", + FmtLib.sci(postRebalSnap.levToken_levSP - preRebalSnap.levToken_levSP), + " harv_fxSAVE=", + FmtLib.sci(curFxsave - postRebFxsave), + "\n total_$=", + FmtLib.sci(_totalDollars(who)) + ) + ); + } + } + + /// @dev Emit a labelled stage table for all 6 SP actors plus Eve. + /// Eve never deposits, so her snapshots are always zero. + function _logStageTable( + string memory label, + ClaimableSnapshot[6] memory preRebalSnaps, + ClaimableSnapshot[6] memory postRebalSnaps + ) internal view { + console2.log( + string.concat( + "\n=== STAGE TABLE: ", + label, + " ===\n", + "price=", + FmtLib.sci(oraclePrice), + " rate=", + FmtLib.sci(oracleRate) + ) + ); + _logTableRow("Alice", alice, preRebalSnaps[0], postRebalSnaps[0]); + _logTableRow("Bob", bob, preRebalSnaps[1], postRebalSnaps[1]); + _logTableRow("Charlie", charlie, preRebalSnaps[2], postRebalSnaps[2]); + _logTableRow("Dave", dave, preRebalSnaps[3], postRebalSnaps[3]); + _logTableRow("Fred", fred, preRebalSnaps[4], postRebalSnaps[4]); + _logTableRow("George", george, preRebalSnaps[5], postRebalSnaps[5]); + ClaimableSnapshot memory zero; + _logTableRow("Eve", eve, zero, zero); + } + // ═══════════════════════════════════════════════════════════════ // Logging // ═══════════════════════════════════════════════════════════════ function _logState(string memory label) internal view { - console2.log(""); - console2.log("=== %s ===", label); - console2.log("Minter CR: %e", IMinter(minter).collateralRatio()); - console2.log("Minter harvestable: %e", IMinter(minter).harvestable()); - console2.log("Minter wstETH: %e", IERC20(wrappedCollateral).balanceOf(minter)); - console2.log("Coll SP pegged bal: %e", IERC20(pegged).balanceOf(stabilityPoolCollateral)); - console2.log("Lev SP pegged bal: %e", IERC20(pegged).balanceOf(stabilityPoolLeveraged)); - console2.log("Coll SP wstETH bal: %e", IERC20(wrappedCollateral).balanceOf(stabilityPoolCollateral)); - console2.log("Lev SP lev token bal: %e", IERC20(leveraged).balanceOf(stabilityPoolLeveraged)); - console2.log("Rebalance threshold: %e", IStabilityPoolManager(stabilityPoolManager).rebalanceThreshold()); + console2.log( + string.concat( + "\n=== ", + label, + " ===\n", + "Minter CR: ", + FmtLib.sci(IMinter(minter).collateralRatio()), + "\n", + "Minter harvestable: ", + FmtLib.sci(IMinter(minter).harvestable()), + "\n", + "Minter fxSAVE: ", + FmtLib.sci(IERC20(wrappedCollateral).balanceOf(minter)), + "\n", + "Coll SP pegged bal: ", + FmtLib.sci(IERC20(pegged).balanceOf(stabilityPoolCollateral)), + "\n", + "Lev SP pegged bal: ", + FmtLib.sci(IERC20(pegged).balanceOf(stabilityPoolLeveraged)), + "\n", + "Coll SP fxSAVE bal: ", + FmtLib.sci(IERC20(wrappedCollateral).balanceOf(stabilityPoolCollateral)), + "\n", + "Lev SP lev token bal: ", + FmtLib.sci(IERC20(leveraged).balanceOf(stabilityPoolLeveraged)), + "\n", + "Rebalance threshold: ", + FmtLib.sci(IStabilityPoolManager(stabilityPoolManager).rebalanceThreshold()) + ) + ); } // ═══════════════════════════════════════════════════════════════ @@ -207,19 +344,37 @@ contract RebalanceFairnessSetUp is BaoTest, Deploy_ETH_Minter { } function _logActor(string memory name, address who) internal view { - console2.log("--- %s ---", name); - console2.log(" pegged (wallet): %e", IERC20(pegged).balanceOf(who)); + ClaimableSnapshot memory c = _snapshotClaimable(who); console2.log( - " Coll SP deposit: %e", - IStabilityPool(stabilityPoolCollateral).assetBalanceOf(who) + string.concat( + "--- ", + name, + " ---\n", + " pegged (wallet): ", + FmtLib.sci(IERC20(pegged).balanceOf(who)), + "\n", + " Coll SP deposit: ", + FmtLib.sci(IERC20(stabilityPoolCollateral).balanceOf(who)), + "\n", + " Lev SP deposit: ", + FmtLib.sci(IERC20(stabilityPoolLeveraged).balanceOf(who)), + "\n", + " fxSAVE (wallet): ", + FmtLib.sci(IERC20(wrappedCollateral).balanceOf(who)), + "\n", + " leveraged (wallet): ", + FmtLib.sci(IERC20(leveraged).balanceOf(who)), + "\n", + " claimable fxSAVE (coll SP): ", + FmtLib.sci(c.fxSAVE_collSP), + "\n", + " claimable fxSAVE (lev SP): ", + FmtLib.sci(c.fxSAVE_levSP), + "\n", + " claimable lev tokens (lev SP): ", + FmtLib.sci(c.levToken_levSP) + ) ); - console2.log(" Lev SP deposit: %e", IStabilityPool(stabilityPoolLeveraged).assetBalanceOf(who)); - console2.log(" fxSAVE (wallet): %e", IERC20(wrappedCollateral).balanceOf(who)); - console2.log(" leveraged (wallet): %e", IERC20(leveraged).balanceOf(who)); - ClaimableSnapshot memory c = _snapshotClaimable(who); - console2.log(" claimable fxSAVE (coll SP): %e", c.fxSAVE_collSP); - console2.log(" claimable fxSAVE (lev SP): %e", c.fxSAVE_levSP); - console2.log(" claimable lev tokens (lev SP): %e", c.levToken_levSP); } function _logAllActors() internal view { @@ -242,8 +397,7 @@ contract RebalanceFairnessSetUp is BaoTest, Deploy_ETH_Minter { ClaimableSnapshot[6] memory current, bool isHarvestDelta ) internal pure { - console2.log(""); - console2.log("=== %s ===", label); + console2.log(string.concat("\n=== ", label, " ===")); string[6] memory names = [ "Alice (Coll, stays)", "Bob (Coll, returns)", @@ -263,12 +417,18 @@ contract RebalanceFairnessSetUp is BaoTest, Deploy_ETH_Minter { uint256 levToken = isHarvestDelta ? current[i].levToken_levSP - preRebal[i].levToken_levSP : current[i].levToken_levSP; - console2.log(" %s", names[i]); console2.log( - " fxSAVE (coll SP): %e | fxSAVE (lev SP): %e | lev tokens: %e", - fxSAVE_coll, - fxSAVE_lev, - levToken + string.concat( + " ", + names[i], + "\n", + " fxSAVE (coll SP): ", + FmtLib.sci(fxSAVE_coll), + " | fxSAVE (lev SP): ", + FmtLib.sci(fxSAVE_lev), + " | lev tokens: ", + FmtLib.sci(levToken) + ) ); } } @@ -282,23 +442,25 @@ contract RebalanceFairnessSetUp is BaoTest, Deploy_ETH_Minter { } contract RebalanceFairnessScenarios is RebalanceFairnessSetUp { - address marketMaker; - - /// @notice Bootstrap the system: mint pegged + leveraged at healthy CR, distribute to actors, - /// then drop price to push CR below threshold. - /// Each actor gets 100 pegged. Market maker keeps the leveraged tokens. + /// @notice Bootstrap the system: mint pegged + leveraged at healthy CR, distribute to actors. + /// @dev Does NOT drop the price — call `_dropPriceBelowRebalanceThreshold()` separately so + /// the test can capture a Stage 0 snapshot at the healthy CR. + /// Each actor gets 100 haETH. Eve keeps all 200 leveraged tokens. + /// At price = 1/4000 (ETH per fxUSD = 4000 fxUSD per ETH, ETH ≈ $4000): + /// 600 haETH × $4000 = $2.4M obligations + /// 200 leveraged tokens minted from 800,000 fxSAVE + /// Total collateral in Minter = 3,200,000 fxSAVE = $3.2M, CR = 1.333 (healthy) function _bootstrap() internal returns (uint256 each) { - marketMaker = makeAddr("marketMaker"); each = 100 ether; - // Mint 600 pegged (for 6 actors × 100) and 200 leveraged (for market maker) - // At price=1: 600 fxSAVE → 600 pegged, 200 fxSAVE → 200 leveraged - // Total collateral = 800, pegged = 600, CR = 800/600 = 1.333 (healthy) - _mintPegged(marketMaker, 600 ether); - _mintLeveraged(marketMaker, 200 ether); + // 600 haETH from 600 × 4000 = 2,400,000 fxSAVE. + // 200 leveraged tokens from 200 × 4000 = 800,000 fxSAVE. + // Total Minter collateral = 3,200,000 fxSAVE; pegged = 600; CR = 1.333. + _mintPegged(eve, 2_400_000 ether); + _mintLeveraged(eve, 800_000 ether); - // Market maker distributes pegged to actors - vm.startPrank(marketMaker); + // Eve distributes pegged to the 6 SP actors and keeps the leveraged tokens herself. + vm.startPrank(eve); IERC20(pegged).transfer(alice, each); IERC20(pegged).transfer(bob, each); IERC20(pegged).transfer(charlie, each); @@ -306,22 +468,37 @@ contract RebalanceFairnessScenarios is RebalanceFairnessSetUp { IERC20(pegged).transfer(fred, each); IERC20(pegged).transfer(george, each); vm.stopPrank(); + } - // Drop price by 10%: CR = 800 * 0.9 / 600 = 1.20 (below 1.30 threshold) - oraclePrice = 0.9 ether; + /// @notice Multiply oracle price by 0.9 so CR drops 10% (1.333 → 1.20), below the rebalance + /// threshold (1.30) but well above the depeg point (1.00). Equivalently: ETH appreciates + /// by ~11.11% relative to fxUSD. + function _dropPriceBelowRebalanceThreshold() internal { + oraclePrice = (oraclePrice * 9) / 10; mockOracle.setLatestAnswer(oraclePrice, oracleRate); } /// @notice Scenario A: Everyone stays through the rebalance (baseline). + /// @dev Uses 0.1% rate bump per week applied for 2 weeks. function test_scenarioA_everyoneStays() public { uint256 each = _bootstrap(); - // Deposit into pools: Coll SP = Alice + Bob, Lev SP = Charlie + Dave (equal pool sizes) + // Deposit into pools: Coll SP = Alice + Bob, Lev SP = Charlie + Dave (equal pool sizes). + // Fred and George stay in their wallets. Eve holds the leveraged tokens, no SP. _deposit(stabilityPoolCollateral, alice, each); _deposit(stabilityPoolCollateral, bob, each); _deposit(stabilityPoolLeveraged, charlie, each); _deposit(stabilityPoolLeveraged, dave, each); - // Fred and George hold pegged outside SPs + + // ── Stage 0: post-deposit, healthy CR=1.333, price=1/4000, rate=1 ── + ClaimableSnapshot[6] memory zeroSnaps; // no rebalance has happened yet + _logStageTable("scenarioA Stage 0 - After initial deposit (CR=1.333, rate=1)", zeroSnaps, zeroSnaps); + + // Drop the oracle price 10% so CR falls below the rebalance threshold + _dropPriceBelowRebalanceThreshold(); + + // ── Stage 1: post price drop, CR=1.20, no rebalance yet ── + _logStageTable("scenarioA Stage 1 - After price drop (CR=1.20, rate=1)", zeroSnaps, zeroSnaps); _logState("BEFORE REBALANCE - Scenario A"); @@ -341,6 +518,58 @@ contract RebalanceFairnessScenarios is RebalanceFairnessSetUp { console2.log("From Coll SP: %e", collLiquidated); console2.log("From Lev SP: %e", levLiquidated); + // ── Asserts: rebalance ────────────────────────────────────── + // Total liquidated to bring CR from 1.20 → 1.30: 75 pegged (37.5 from each pool) + assertApproxEqAbs(liquidated, 75 ether, 1e16, "scenarioA: total liquidated == 75"); + assertApproxEqAbs(collLiquidated, 37.5 ether, 1e16, "scenarioA: coll liquidated == 37.5"); + assertApproxEqAbs(levLiquidated, 37.5 ether, 1e16, "scenarioA: lev liquidated == 37.5"); + + // Each Coll SP depositor lost 18.75 pegged → 81.25 remaining + assertApproxEqAbs( + IERC20(stabilityPoolCollateral).balanceOf(alice), + 81.25 ether, + 1e15, + "scenarioA: alice 81.25 after rebalance" + ); + assertApproxEqAbs( + IERC20(stabilityPoolCollateral).balanceOf(bob), + 81.25 ether, + 1e15, + "scenarioA: bob 81.25 after rebalance" + ); + + // Each Coll SP depositor receives 18.75 haETH / price fxSAVE rebalance reward + // (since rate = 1.0). At price = 0.9/4000, that's 18.75 / (0.9/4000) ≈ 83,333.33 fxSAVE. + // Each Lev SP depositor receives 31.25 lev tokens (price-invariant — the lev mint formula + // has price in both numerator and denominator). + { + uint256 expectedCollRebal = (18.75 ether * 1 ether) / oraclePrice; + assertApproxEqAbs( + _snapshotClaimable(alice).fxSAVE_collSP - preRebal[0].fxSAVE_collSP, + expectedCollRebal, + 1e15, + "scenarioA: alice rebalance fxSAVE == 83333.33" + ); + assertApproxEqAbs( + _snapshotClaimable(bob).fxSAVE_collSP - preRebal[1].fxSAVE_collSP, + expectedCollRebal, + 1e15, + "scenarioA: bob rebalance fxSAVE == 83333.33" + ); + assertApproxEqAbs( + _snapshotClaimable(charlie).levToken_levSP - preRebal[2].levToken_levSP, + 31.25 ether, + 1e15, + "scenarioA: charlie rebalance lev tokens == 31.25" + ); + assertApproxEqAbs( + _snapshotClaimable(dave).levToken_levSP - preRebal[3].levToken_levSP, + 31.25 ether, + 1e15, + "scenarioA: dave rebalance lev tokens == 31.25" + ); + } + // Snapshot after rebalance — delta from preRebal = rebalance rewards only ClaimableSnapshot[6] memory postRebal = _snapshotAll(); _logBreakdown("REBALANCE REWARDS (static, one-off)", preRebal, postRebal, true); @@ -348,23 +577,92 @@ contract RebalanceFairnessScenarios is RebalanceFairnessSetUp { _logState("AFTER REBALANCE - Scenario A"); _logAllActors(); - // Trigger harvest - skip(1 days); - uint256 harvested = _triggerHarvest(); - console2.log("Harvested: %e", harvested); + // ── Stage 2: post-rebalance (CR back to 1.30, rate=1) ── + _logStageTable("scenarioA Stage 2 - After rebalance (CR=1.30, rate=1)", preRebal, postRebal); + + // ── Week 1 harvest (0.1% bump) ────────────────────────────── + ClaimableSnapshot[6] memory postHarvest1; + { + skip(1 days); + uint256 harvested1 = _triggerHarvest(); + console2.log("Week 1 harvested: %e", harvested1); + skip(8 days); // full reward distribution period - // Wait for full reward distribution period - skip(8 days); + // Expected: Minter wstETH after rebalance ≈ 3,033,333 fxSAVE × 0.001 ≈ 3030 fxSAVE. + // (Was 0.7576 ether in the old 1× test → scaled by 4000.) + assertApproxEqRel(harvested1, 3030 ether, 0.01 ether, "scenarioA: week 1 harvest ~= 3030"); - // Snapshot after harvest — delta from postRebal = harvest rewards only - ClaimableSnapshot[6] memory postHarvest = _snapshotAll(); - _logBreakdown("HARVEST REWARDS (streamed, ongoing)", postRebal, postHarvest, true); - _logBreakdown("TOTAL CLAIMABLE (rebalance + harvest)", preRebal, postHarvest, false); + postHarvest1 = _snapshotAll(); + _logBreakdown("WEEK 1 HARVEST REWARDS", postRebal, postHarvest1, true); - _logState("AFTER HARVEST - Scenario A"); + _assertScenarioAWeek1(postRebal, postHarvest1); + + // ── Stage 3: after week 1 harvest (rate = 1.001) ── + _logStageTable("scenarioA Stage 3 - After week 1 harvest (CR=1.30, rate=1.001)", preRebal, postRebal); + } + + // ── Week 2 harvest (another 0.1% bump) ────────────────────── + { + uint256 harvested2 = _triggerHarvest(); + console2.log("Week 2 harvested: %e", harvested2); + skip(8 days); + + // Week 2 harvest is slightly less because the Minter's wCOL was reduced by week 1 harvest. + // Was 0.7568 ether in old test → scaled by 4000. + assertApproxEqRel(harvested2, 3027 ether, 0.01 ether, "scenarioA: week 2 harvest ~= 3027"); + + ClaimableSnapshot[6] memory postHarvest2 = _snapshotAll(); + _logBreakdown("WEEK 2 HARVEST REWARDS", postHarvest1, postHarvest2, true); + _logBreakdown("TOTAL CLAIMABLE (rebalance + 2 weeks harvest)", preRebal, postHarvest2, false); + + _assertScenarioATotals(postRebal, postHarvest2); + + // ── Stage 4: after week 2 harvest (rate = 1.002001) ── + _logStageTable("scenarioA Stage 4 - After week 2 harvest (CR=1.30, rate=1.002001)", preRebal, postRebal); + } + + _logState("AFTER 2 WEEKS HARVEST - Scenario A"); _logAllActors(); } + /// @dev Per-actor week 1 harvest assertions for Scenario A. + /// Each SP depositor gets ~758 fxSAVE: total weekly harvest 3030 → 50% to each pool (1515) → + /// 50% to each depositor within the pool (757.5). Was 0.190 in the old 1× test, scaled by 4000. + function _assertScenarioAWeek1( + ClaimableSnapshot[6] memory postRebal, + ClaimableSnapshot[6] memory postHarvest1 + ) internal pure { + uint256 alice_w1 = postHarvest1[0].fxSAVE_collSP - postRebal[0].fxSAVE_collSP; + uint256 bob_w1 = postHarvest1[1].fxSAVE_collSP - postRebal[1].fxSAVE_collSP; + uint256 charlie_w1 = postHarvest1[2].fxSAVE_levSP - postRebal[2].fxSAVE_levSP; + uint256 dave_w1 = postHarvest1[3].fxSAVE_levSP - postRebal[3].fxSAVE_levSP; + + assertApproxEqRel(alice_w1, 758 ether, 0.01 ether, "scenarioA: alice w1 harvest ~= 758"); + assertApproxEqRel(bob_w1, 758 ether, 0.01 ether, "scenarioA: bob w1 harvest ~= 758"); + assertApproxEqRel(charlie_w1, 758 ether, 0.01 ether, "scenarioA: charlie w1 harvest ~= 758"); + assertApproxEqRel(dave_w1, 758 ether, 0.01 ether, "scenarioA: dave w1 harvest ~= 758"); + + // Equal harvest for all 4 SP depositors (proportional to equal deposit size) + assertEq(alice_w1, bob_w1, "scenarioA: alice == bob w1 harvest"); + assertEq(charlie_w1, dave_w1, "scenarioA: charlie == dave w1 harvest"); + + // Fred and George get nothing (not in SPs) + assertEq(postHarvest1[4].fxSAVE_collSP, 0, "scenarioA: fred no harvest"); + assertEq(postHarvest1[5].fxSAVE_levSP, 0, "scenarioA: george no harvest"); + } + + /// @dev 2-week per-actor harvest totals for Scenario A. + /// Was 0.379 fxSAVE in old test → scaled to ~1515 in the 4000× test. + function _assertScenarioATotals( + ClaimableSnapshot[6] memory postRebal, + ClaimableSnapshot[6] memory postHarvest2 + ) internal pure { + uint256 alice_total = postHarvest2[0].fxSAVE_collSP - postRebal[0].fxSAVE_collSP; + uint256 charlie_total = postHarvest2[2].fxSAVE_levSP - postRebal[2].fxSAVE_levSP; + assertApproxEqRel(alice_total, 1515 ether, 0.01 ether, "scenarioA: alice 2wk harvest ~= 1515"); + assertApproxEqRel(charlie_total, 1515 ether, 0.01 ether, "scenarioA: charlie 2wk harvest ~= 1515"); + } + /// @notice Scenario B: Bob and Dave withdraw before rebalance, then re-deposit after. /// Fred and George also deposit after rebalance. function test_scenarioB_leaversReturn() public { @@ -376,13 +674,26 @@ contract RebalanceFairnessScenarios is RebalanceFairnessSetUp { _deposit(stabilityPoolLeveraged, charlie, each); _deposit(stabilityPoolLeveraged, dave, each); + // ── Stage 0: post-deposit, healthy CR=1.333, price=1/4000, rate=1 ── + ClaimableSnapshot[6] memory zeroSnaps; + _logStageTable("scenarioB Stage 0 - After initial deposit (CR=1.333, rate=1)", zeroSnaps, zeroSnaps); + + // Drop the oracle price 10% so CR falls below the rebalance threshold + _dropPriceBelowRebalanceThreshold(); + + // ── Stage 1: post price drop, CR=1.20, no rebalance yet, no withdrawals yet ── + _logStageTable("scenarioB Stage 1 - After price drop (CR=1.20, rate=1)", zeroSnaps, zeroSnaps); + _logState("BEFORE WITHDRAWALS - Scenario B"); _logAllActors(); - // Step 1: Bob and Dave withdraw before rebalance + // Step 1: Bob and Dave withdraw before rebalance ("the dodge") _withdrawAll(stabilityPoolCollateral, bob); _withdrawAll(stabilityPoolLeveraged, dave); + // ── Stage 2: after Bob/Dave withdraw, before rebalance ── + _logStageTable("scenarioB Stage 2 - After Bob/Dave withdraw (CR=1.20, rate=1)", zeroSnaps, zeroSnaps); + _logState("AFTER WITHDRAWALS - Scenario B"); _logAllActors(); @@ -406,6 +717,44 @@ contract RebalanceFairnessScenarios is RebalanceFairnessSetUp { _logState("AFTER REBALANCE - Scenario B"); + // ── Asserts: rebalance ────────────────────────────────────── + // Same total liquidation as Scenario A — Alice and Charlie alone absorb everything + assertApproxEqAbs(liquidated, 75 ether, 1e16, "scenarioB: total liquidated == 75"); + assertApproxEqAbs(collLiquidated, 37.5 ether, 1e16, "scenarioB: coll liquidated == 37.5"); + assertApproxEqAbs(levLiquidated, 37.5 ether, 1e16, "scenarioB: lev liquidated == 37.5"); + + // Alice and Charlie each lose 37.5 → 62.5 remaining + assertApproxEqAbs( + IERC20(stabilityPoolCollateral).balanceOf(alice), + 62.5 ether, + 1e15, + "scenarioB: alice 62.5 after rebalance" + ); + assertApproxEqAbs( + IERC20(stabilityPoolLeveraged).balanceOf(charlie), + 62.5 ether, + 1e15, + "scenarioB: charlie 62.5 after rebalance" + ); + + // Alice gets the full Coll SP rebal reward = 37.5 / price fxSAVE ≈ 166,666.67 fxSAVE. + // Charlie gets the full Lev SP rebal reward = 62.5 lev tokens (price-invariant). + { + uint256 expectedAliceRebal = (37.5 ether * 1 ether) / oraclePrice; + assertApproxEqAbs( + postRebal[0].fxSAVE_collSP - preRebal[0].fxSAVE_collSP, + expectedAliceRebal, + 1e15, + "scenarioB: alice rebalance fxSAVE == 166666.67" + ); + assertApproxEqAbs( + postRebal[2].levToken_levSP - preRebal[2].levToken_levSP, + 62.5 ether, + 1e15, + "scenarioB: charlie rebalance lev tokens == 62.5" + ); + } + // Step 3: Re-deposits + new entrants uint256 bobPegged = IERC20(pegged).balanceOf(bob); _deposit(stabilityPoolCollateral, bob, bobPegged); @@ -418,19 +767,111 @@ contract RebalanceFairnessScenarios is RebalanceFairnessSetUp { _logState("AFTER RE-DEPOSITS - Scenario B"); - // Step 4: Harvest - skip(1 days); - uint256 harvested = _triggerHarvest(); - console2.log("Harvested: %e", harvested); + // ── Stage 3: after rebalance + re-deposits (CR=1.30, rate=1) ── + _logStageTable("scenarioB Stage 3 - After rebalance + re-deposits (CR=1.30, rate=1)", preRebal, postRebal); + + // ── Asserts: pool composition after re-deposits ───────────── + // Coll SP: Alice 62.5 + Bob 100 + Fred 100 = 262.5 + assertApproxEqAbs( + IERC20(pegged).balanceOf(stabilityPoolCollateral), + 262.5 ether, + 1e15, + "scenarioB: coll SP total == 262.5" + ); + // Lev SP: Charlie 62.5 + Dave 100 + George 100 = 262.5 + assertApproxEqAbs( + IERC20(pegged).balanceOf(stabilityPoolLeveraged), + 262.5 ether, + 1e15, + "scenarioB: lev SP total == 262.5" + ); - // Wait for full distribution - skip(8 days); + // ── Week 1 harvest (0.1% bump) ────────────────────────────── + ClaimableSnapshot[6] memory postHarvest1; + { + skip(1 days); + uint256 harvested1 = _triggerHarvest(); + console2.log("Week 1 harvested: %e", harvested1); + skip(8 days); - ClaimableSnapshot[6] memory postHarvest = _snapshotAll(); - _logBreakdown("REBALANCE REWARDS (static, one-off)", preRebal, postRebal, true); - _logBreakdown("HARVEST REWARDS (streamed, ongoing)", postRebal, postHarvest, true); - _logBreakdown("TOTAL CLAIMABLE (rebalance + harvest)", preRebal, postHarvest, false); + // Same Minter wstETH after rebalance as Sc A (same total liquidation), so harvest is identical + assertApproxEqRel(harvested1, 3030 ether, 0.01 ether, "scenarioB: week 1 harvest ~= 3030"); + + postHarvest1 = _snapshotAll(); + _logBreakdown("WEEK 1 HARVEST REWARDS", postRebal, postHarvest1, true); + + _assertScenarioBWeek1(postRebal, postHarvest1); + + // ── Stage 4: after week 1 harvest (rate = 1.001) ── + _logStageTable("scenarioB Stage 4 - After week 1 harvest (CR=1.30, rate=1.001)", preRebal, postRebal); + } + + // ── Week 2 harvest ────────────────────────────────────────── + { + uint256 harvested2 = _triggerHarvest(); + console2.log("Week 2 harvested: %e", harvested2); + skip(8 days); - _logState("AFTER HARVEST - Scenario B"); + assertApproxEqRel(harvested2, 3027 ether, 0.01 ether, "scenarioB: week 2 harvest ~= 3027"); + + ClaimableSnapshot[6] memory postHarvest2 = _snapshotAll(); + _logBreakdown("WEEK 2 HARVEST REWARDS", postHarvest1, postHarvest2, true); + _logBreakdown("TOTAL CLAIMABLE (rebalance + 2 weeks harvest)", preRebal, postHarvest2, false); + + _assertScenarioBTotals(postRebal, postHarvest2); + + // ── Stage 5: after week 2 harvest (rate = 1.002001) ── + _logStageTable("scenarioB Stage 5 - After week 2 harvest (CR=1.30, rate=1.002001)", preRebal, postRebal); + } + + _logState("AFTER 2 WEEKS HARVEST - Scenario B"); + } + + /// @dev Per-actor harvest assertions for Scenario B week 1. + /// Each pool has 262.5 pegged after re-deposits (Alice 62.5 + Bob 100 + Fred 100; Charlie/Dave/George same). + /// Coll SP gets 50% of 3030 = 1515 fxSAVE harvest. Alice 62.5/262.5 × 1515 ≈ 361; Bob/Fred 100/262.5 × 1515 ≈ 577. + /// (Was 0.0903 / 0.1444 in the old 1× test → scaled by 4000.) + function _assertScenarioBWeek1( + ClaimableSnapshot[6] memory postRebal, + ClaimableSnapshot[6] memory postHarvest1 + ) internal pure { + uint256 alice_w1 = postHarvest1[0].fxSAVE_collSP - postRebal[0].fxSAVE_collSP; + uint256 bob_w1 = postHarvest1[1].fxSAVE_collSP - postRebal[1].fxSAVE_collSP; + uint256 fred_w1 = postHarvest1[4].fxSAVE_collSP - postRebal[4].fxSAVE_collSP; + uint256 charlie_w1 = postHarvest1[2].fxSAVE_levSP - postRebal[2].fxSAVE_levSP; + uint256 dave_w1 = postHarvest1[3].fxSAVE_levSP - postRebal[3].fxSAVE_levSP; + uint256 george_w1 = postHarvest1[5].fxSAVE_levSP - postRebal[5].fxSAVE_levSP; + + assertApproxEqRel(alice_w1, 361 ether, 0.01 ether, "scenarioB: alice w1 harvest ~= 361"); + assertApproxEqRel(bob_w1, 577 ether, 0.01 ether, "scenarioB: bob w1 harvest ~= 577"); + assertApproxEqRel(fred_w1, 577 ether, 0.01 ether, "scenarioB: fred w1 harvest ~= 577"); + assertApproxEqRel(charlie_w1, 361 ether, 0.01 ether, "scenarioB: charlie w1 harvest ~= 361"); + assertApproxEqRel(dave_w1, 577 ether, 0.01 ether, "scenarioB: dave w1 harvest ~= 577"); + assertApproxEqRel(george_w1, 577 ether, 0.01 ether, "scenarioB: george w1 harvest ~= 577"); + + // Bob/Fred/Dave/George should all earn the same harvest (equal balance, no boost) + assertEq(bob_w1, fred_w1, "scenarioB: bob == fred w1 harvest"); + assertEq(dave_w1, george_w1, "scenarioB: dave == george w1 harvest"); + + // Alice/Charlie earn LESS than Bob/Dave despite staying — the unfairness + assertLt(alice_w1, bob_w1, "scenarioB: alice < bob (unfairness)"); + assertLt(charlie_w1, dave_w1, "scenarioB: charlie < dave (unfairness)"); + } + + /// @dev 2-week per-actor harvest totals for Scenario B. + /// Was 0.181 / 0.289 in old 1× test → scaled by 4000. + function _assertScenarioBTotals( + ClaimableSnapshot[6] memory postRebal, + ClaimableSnapshot[6] memory postHarvest2 + ) internal pure { + uint256 alice_total = postHarvest2[0].fxSAVE_collSP - postRebal[0].fxSAVE_collSP; + uint256 bob_total = postHarvest2[1].fxSAVE_collSP - postRebal[1].fxSAVE_collSP; + uint256 charlie_total = postHarvest2[2].fxSAVE_levSP - postRebal[2].fxSAVE_levSP; + uint256 dave_total = postHarvest2[3].fxSAVE_levSP - postRebal[3].fxSAVE_levSP; + + assertApproxEqRel(alice_total, 722 ether, 0.01 ether, "scenarioB: alice 2wk harvest ~= 722"); + assertApproxEqRel(bob_total, 1155 ether, 0.01 ether, "scenarioB: bob 2wk harvest ~= 1155"); + assertApproxEqRel(charlie_total, 722 ether, 0.01 ether, "scenarioB: charlie 2wk harvest ~= 722"); + assertApproxEqRel(dave_total, 1155 ether, 0.01 ether, "scenarioB: dave 2wk harvest ~= 1155"); } } diff --git a/test/deployment/RebalanceFairnessScan.t.sol b/test/deployment/RebalanceFairnessScan.t.sol new file mode 100644 index 00000000..438f16b5 --- /dev/null +++ b/test/deployment/RebalanceFairnessScan.t.sol @@ -0,0 +1,1005 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.28 <0.9.0; + +import {RebalanceFairnessSetUp} from "./RebalanceFairness.t.sol"; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IMinter} from "src/interfaces/IMinter.sol"; +import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; +import {IStabilityPoolManager} from "src/interfaces/IStabilityPoolManager.sol"; +import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; +import {IMultipleRewardAccumulator_v3} from "src/interfaces/IMultipleRewardAccumulator_v3.sol"; + +import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; +import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; +import {Useful} from "test/Useful.sol"; +import {console2} from "forge-std/console2.sol"; + +/// @title Fairness gap scan over liquidation severity × leveraged fraction +/// @notice Scans the Scenario B (dodge) harvest fairness gap across a grid of +/// (price drop %, leveraged %) points. Outputs CSV + gnuplot files to ./results/. +/// +/// Two scan dimensions: +/// - **Price drop %** (5–25%): determines liquidation severity — how much haETH the +/// stayer loses in the rebalance, and thus the pool-share imbalance afterward. +/// - **Leveraged %** (10–75%): fraction of total Minter wCOL that backs leveraged tokens. +/// Higher leveraged % → more total wCOL → harvest income is larger relative to Alice's +/// private wCOL appreciation → the Coll SP gap closes less. +/// +/// APR is fixed at 10% — the gap % is APR-invariant because both the harvest and the +/// wCOL appreciation on the rebalance reward scale linearly with the rate multiplier. +/// (The harvest comes from yield on the *entire* Minter wCOL pool, while Alice's private +/// appreciation comes from yield on *only her* rebalance reward. Both scale with rate, so +/// the ratio — and hence the gap % — is constant across APR.) +/// +/// The Lev SP gap is always equal to the raw harvest-share gap (Charlie's lev token reward +/// doesn't appreciate), so it depends only on liquidation severity and pool proportions. +contract RebalanceFairnessScan is RebalanceFairnessSetUp { + string constant CSV_FILE = "./results/rebalance_fairness_scan.csv"; + string constant GP_FILE = "./results/rebalance_fairness_scan.gp"; + + uint256 constant FIXED_APR_PCT = 10; // 10% APR for concrete $ numbers + uint256 constant PEGGED_COLLATERAL = 2_400_000 ether; // always 600 haETH at price=1/4000 + + // ── Scan parameters ──────────────────────────────────────────────── + + uint256[] internal priceDropPctValues; + uint256[] internal leveragedPctValues; + + function _initScanParams() internal { + // Price drop in %: determines liquidation severity. + // Must be large enough for the starting CR to fall below the 1.30 threshold. + // CR_start = 1 / (1 - levPct/100). Required drop > 1 - 1.30/CR_start. + // lev=10% → CR=1.111 → already below threshold, any drop triggers + // lev=25% → CR=1.333 → need > 2.5% + // lev=50% → CR=2.000 → need > 35% + // lev=75% → CR=4.000 → need > 67.5% + // Points where CR stays above threshold are skipped (no rebalance, no gap). + priceDropPctValues.push(5); + priceDropPctValues.push(10); + priceDropPctValues.push(15); + priceDropPctValues.push(20); + priceDropPctValues.push(25); + priceDropPctValues.push(35); + priceDropPctValues.push(40); + priceDropPctValues.push(50); + priceDropPctValues.push(60); + priceDropPctValues.push(70); + + // Leveraged fraction of total Minter collateral (%) + // 10% → tiny lev side, harvest pool barely above pegged backing + // 25% → current test setup (800K lev / 3.2M total) + // 50% → equal lev/pegged split (2.4M lev / 4.8M total) + // 75% → lev-dominated system (7.2M lev / 9.6M total) + leveragedPctValues.push(10); + leveragedPctValues.push(25); + leveragedPctValues.push(50); + leveragedPctValues.push(75); + } + + // ── CSV output ───────────────────────────────────────────────────── + + function _openCSV() internal { + if (vm.exists(CSV_FILE)) vm.removeFile(CSV_FILE); + vm.writeLine( + CSV_FILE, + "PriceDrop_pct,Lev_pct,LiquidFrac_pct," + "Alice_coll_weekly_$,Bob_coll_weekly_$,Coll_gap_pct," + "Charlie_lev_weekly_$,Dave_lev_weekly_$,Lev_gap_pct" + ); + } + + function _writeRow( + uint256 priceDropPct, + uint256 levPct, + uint256 liquidFracPct, + uint256 aliceWeekly, + uint256 bobWeekly, + uint256 collGapPct, + uint256 charlieWeekly, + uint256 daveWeekly, + uint256 levGapPct + ) internal { + string[] memory cols = new string[](9); + cols[0] = Useful.toStringScaled(priceDropPct * 1e18, 18); + cols[1] = Useful.toStringScaled(levPct * 1e18, 18); + cols[2] = Useful.toStringScaled(liquidFracPct, 18); + cols[3] = Useful.toStringScaled(aliceWeekly, 18); + cols[4] = Useful.toStringScaled(bobWeekly, 18); + cols[5] = Useful.toStringScaled(collGapPct, 18); + cols[6] = Useful.toStringScaled(charlieWeekly, 18); + cols[7] = Useful.toStringScaled(daveWeekly, 18); + cols[8] = Useful.toStringScaled(levGapPct, 18); + vm.writeLine(CSV_FILE, Useful.join(cols, ",")); + } + + // ── Gnuplot output ───────────────────────────────────────────────── + + function _writeGnuplot() internal { + if (vm.exists(GP_FILE)) vm.removeFile(GP_FILE); + + vm.writeLine(GP_FILE, "# Generated by RebalanceFairnessScan.t.sol"); + vm.writeLine(GP_FILE, "#"); + vm.writeLine(GP_FILE, "# Income gap = (returner_weekly_$ - stayer_weekly_$) / returner_weekly_$ * 100"); + vm.writeLine(GP_FILE, "# where weekly_$ = (totalDollars_after_2_weeks - totalDollars_before) / 2."); + vm.writeLine(GP_FILE, "# This captures harvest income + wCOL appreciation on unclaimed rebalance rewards."); + vm.writeLine(GP_FILE, "# A 0% gap means the stayer earns the same as the returner (fair)."); + vm.writeLine(GP_FILE, "# A 37.5% gap (at 37.5% liquidation) means the stayer earns 37.5% less per week."); + vm.writeLine(GP_FILE, "#"); + vm.writeLine(GP_FILE, "# CSV columns: 1=PriceDrop_pct, 2=Lev_pct, 3=LiquidFrac_pct,"); + vm.writeLine(GP_FILE, "# 4=Alice_coll_weekly_$, 5=Bob_coll_weekly_$, 6=Coll_gap_pct,"); + vm.writeLine(GP_FILE, "# 7=Charlie_lev_weekly_$, 8=Dave_lev_weekly_$, 9=Lev_gap_pct"); + vm.writeLine(GP_FILE, "set datafile separator ','"); + vm.writeLine(GP_FILE, "set bmargin 7"); + vm.writeLine(GP_FILE, "set key below spacing 1.3"); + vm.writeLine(GP_FILE, "set grid"); + vm.writeLine(GP_FILE, ""); + vm.writeLine(GP_FILE, "set terminal pngcairo size 1400,500 enhanced font 'Helvetica,11'"); + vm.writeLine(GP_FILE, "set output './results/rebalance_fairness_scan.png'"); + vm.writeLine( + GP_FILE, + "set multiplot layout 1,2 title " + "'Rebalance Fairness Gap - Scenario B (dodge attack)' font 'Helvetica,13'" + ); + + // ── Plot 1: Coll SP vs Lev SP gap vs liquidation fraction ── + // Use all data (gap depends only on liquidation fraction, not lev%) + vm.writeLine(GP_FILE, ""); + vm.writeLine(GP_FILE, "set xlabel 'Liquidation fraction (%)'"); + vm.writeLine(GP_FILE, "set ylabel 'Income gap (%)'"); + vm.writeLine(GP_FILE, "set title 'Stayer vs returner: $ income gap'"); + vm.writeLine(GP_FILE, "set xrange [0:100]"); + vm.writeLine(GP_FILE, "set yrange [0:100]"); + vm.writeLine( + GP_FILE, + string.concat( + "plot '< tail -n+2 ", + CSV_FILE, + "' using 3:6 with points pt 7 ps 1.2 title 'Coll SP gap', \\\n", + " '< tail -n+2 ", + CSV_FILE, + "' using 3:9 with points pt 5 ps 1.2 title 'Lev SP gap'" + ) + ); + + // ── Plot 2: Absolute $ weekly income at lev=25% ── + vm.writeLine(GP_FILE, ""); + vm.writeLine(GP_FILE, "set title 'Weekly $ income (lev=25%, APR=10%)'"); + vm.writeLine(GP_FILE, "set ylabel 'Weekly income ($)'"); + vm.writeLine(GP_FILE, "set xrange [0:100]"); + vm.writeLine(GP_FILE, "set yrange [0:*]"); + // Use double quotes for the gnuplot string so awk's single-quoted + // expression doesn't conflict with gnuplot's string delimiters. + string memory awkFilter = string.concat("\"< awk -F, 'NR>1 && $2==25' ", CSV_FILE, '"'); + vm.writeLine( + GP_FILE, + string.concat( + "plot ", + awkFilter, + " using 3:4 with linespoints title 'Alice (Coll stayer)', \\\n", + " ", + awkFilter, + " using 3:5 with linespoints title 'Bob (Coll returner)', \\\n", + " ", + awkFilter, + " using 3:7 with linespoints title 'Charlie (Lev stayer)', \\\n", + " ", + awkFilter, + " using 3:8 with linespoints title 'Dave (Lev returner)'" + ) + ); + + vm.writeLine(GP_FILE, ""); + vm.writeLine(GP_FILE, "unset multiplot"); + } + + // ── Core scan logic ──────────────────────────────────────────────── + + /// @dev Set up Scenario B up to the post-rebalance state, BEFORE re-deposits. + /// Returns 0 if the rebalance didn't trigger (CR above threshold). + function _setupToPostRebalance(uint256 priceDropPct, uint256 levPct) internal returns (uint256 liquidFracE18) { + uint256 each = 100 ether; + + uint256 levCollateral = (PEGGED_COLLATERAL * levPct) / (100 - levPct); + + _mintPegged(eve, PEGGED_COLLATERAL); + _mintLeveraged(eve, levCollateral); + + vm.startPrank(eve); + IERC20(pegged).transfer(alice, each); + IERC20(pegged).transfer(bob, each); + IERC20(pegged).transfer(charlie, each); + IERC20(pegged).transfer(dave, each); + IERC20(pegged).transfer(fred, each); + IERC20(pegged).transfer(george, each); + vm.stopPrank(); + + _deposit(stabilityPoolCollateral, alice, each); + _deposit(stabilityPoolCollateral, bob, each); + _deposit(stabilityPoolLeveraged, charlie, each); + _deposit(stabilityPoolLeveraged, dave, each); + + oraclePrice = (oraclePrice * (100 - priceDropPct)) / 100; + mockOracle.setLatestAnswer(oraclePrice, oracleRate); + + _withdrawAll(stabilityPoolCollateral, bob); + _withdrawAll(stabilityPoolLeveraged, dave); + + uint256 collBefore = IERC20(pegged).balanceOf(stabilityPoolCollateral); + try IStabilityPoolManager(stabilityPoolManager).rebalance(makeAddr("bounty"), 0) { + uint256 collAfter = IERC20(pegged).balanceOf(stabilityPoolCollateral); + liquidFracE18 = ((collBefore - collAfter) * 1 ether) / collBefore; + } catch { + liquidFracE18 = 0; + } + } + + /// @dev Apply withdrawal fee (burn feePct % of Bob's and Dave's pegged balance) then + /// re-deposit everyone. + function _applyFeeAndRedeposit(uint256 feePct) internal { + uint256 each = 100 ether; + + // Apply fee to Bob and Dave's withdrawn haETH (burn it — conservative estimate, + // sending to depositors would help fairness even more). + if (feePct > 0) { + uint256 bobBal = IERC20(pegged).balanceOf(bob); + uint256 daveBal = IERC20(pegged).balanceOf(dave); + deal(pegged, bob, (bobBal * (100 - feePct)) / 100); + deal(pegged, dave, (daveBal * (100 - feePct)) / 100); + } + + _deposit(stabilityPoolCollateral, bob, IERC20(pegged).balanceOf(bob)); + _deposit(stabilityPoolLeveraged, dave, IERC20(pegged).balanceOf(dave)); + _deposit(stabilityPoolCollateral, fred, each); + _deposit(stabilityPoolLeveraged, george, each); + } + + /// @dev Run 2 harvest weeks at the fixed APR and return per-week $ income for each key actor. + function _measureIncome() + internal + returns (uint256 aliceWeekly, uint256 bobWeekly, uint256 charlieWeekly, uint256 daveWeekly) + { + uint256 rateMultiplier = 1 ether + (FIXED_APR_PCT * 1 ether) / 5200; + + uint256 aliceBefore = _totalDollars(alice); + uint256 bobBefore = _totalDollars(bob); + uint256 charlieBefore = _totalDollars(charlie); + uint256 daveBefore = _totalDollars(dave); + + skip(1 days); + oracleRate = (oracleRate * rateMultiplier) / 1 ether; + mockOracle.setLatestAnswer(oraclePrice, oracleRate); + IStabilityPoolManager(stabilityPoolManager).harvest(makeAddr("bountyReceiver"), 0); + skip(8 days); + + oracleRate = (oracleRate * rateMultiplier) / 1 ether; + mockOracle.setLatestAnswer(oraclePrice, oracleRate); + IStabilityPoolManager(stabilityPoolManager).harvest(makeAddr("bountyReceiver"), 0); + skip(8 days); + + aliceWeekly = (_totalDollars(alice) - aliceBefore) / 2; + bobWeekly = (_totalDollars(bob) - bobBefore) / 2; + charlieWeekly = (_totalDollars(charlie) - charlieBefore) / 2; + daveWeekly = (_totalDollars(dave) - daveBefore) / 2; + } + + function _gapPct(uint256 higher, uint256 lower) internal pure returns (uint256) { + return higher > 0 ? ((higher - lower) * 100 ether) / higher : 0; + } + + // ── Test 1: Existing gap scan (no fee) ───────────────────────────── + + function test_fairnessGapScan() public { + _initScanParams(); + _openCSV(); + + for (uint256 l = 0; l < leveragedPctValues.length; l++) { + uint256 levPct = leveragedPctValues[l]; + + for (uint256 p = 0; p < priceDropPctValues.length; p++) { + uint256 priceDropPct = priceDropPctValues[p]; + uint256 snap = vm.snapshot(); + + uint256 liquidFracE18 = _setupToPostRebalance(priceDropPct, levPct); + + if (liquidFracE18 > 0) { + _applyFeeAndRedeposit(0); // no fee + uint256 liquidFracPct = liquidFracE18 * 100; + + (uint256 aliceW, uint256 bobW, uint256 charlieW, uint256 daveW) = _measureIncome(); + + _writeRow( + priceDropPct, + levPct, + liquidFracPct, + aliceW, + bobW, + _gapPct(bobW, aliceW), + charlieW, + daveW, + _gapPct(daveW, charlieW) + ); + + console2.log( + string.concat( + " drop=", + Useful.toString(priceDropPct), + "% ", + "lev=", + Useful.toString(levPct), + "% ", + "liqFrac=", + Useful.toStringScaled(liquidFracPct, 18), + "% | ", + "coll_gap=", + Useful.toStringScaled(_gapPct(bobW, aliceW), 18), + "% ", + "lev_gap=", + Useful.toStringScaled(_gapPct(daveW, charlieW), 18), + "%" + ) + ); + } else { + console2.log( + string.concat( + " drop=", + Useful.toString(priceDropPct), + "% ", + "lev=", + Useful.toString(levPct), + "% ", + "-- CR still above threshold, no rebalance --" + ) + ); + } + + vm.revertTo(snap); + } + } + + _writeGnuplot(); + console2.log(""); + console2.log("CSV: %s", CSV_FILE); + console2.log("Gnuplot: %s", GP_FILE); + } + + // ── Test 2: Withdrawal fee scan ──────────────────────────────────── + // + // Realistic worst case ("design event"): 10% price drop at 25% leveraged fraction. + // + // Justification: the rebalance threshold (e.g. 1.30 for ETH) represents the historically + // largest expected 1-day price move. The rebalance bot fires as soon as the oracle updates + // past the threshold — typically within 1 block (~12s). In that window, price may undershoot + // further. The 95th-percentile intra-hour move for ETH is ~5-10%. + // + // With starting CR=1.333 and threshold=1.30: + // - 2.5% drop: CR=1.30 (threshold, minimal liquidation) + // - 5% drop: CR=1.27 (12.5% liquidation) + // - 10% drop: CR=1.20 (37.5% liquidation) ← design case + // - 15% drop: CR=1.13 (62.5% liquidation, severe) + // + // The fee scan uses this design case and varies the withdrawal fee from 0% to 25%, + // measuring how the income gap changes. The fee is burned (conservative — sending it + // to remaining depositors would help fairness even more). + + string constant FEE_CSV = "./results/rebalance_fairness_fee_scan.csv"; + string constant FEE_GP = "./results/rebalance_fairness_fee_scan.gp"; + + uint256 constant DESIGN_PRICE_DROP = 10; + uint256 constant DESIGN_LEV_PCT = 25; + + function _openFeeCSV() internal { + if (vm.exists(FEE_CSV)) vm.removeFile(FEE_CSV); + vm.writeLine( + FEE_CSV, + "Fee_pct,LiquidFrac_pct," + "Alice_coll_weekly_$,Bob_coll_weekly_$,Coll_gap_pct," + "Charlie_lev_weekly_$,Dave_lev_weekly_$,Lev_gap_pct" + ); + } + + function _writeFeeRow( + uint256 feePct, + uint256 liquidFracPct, + uint256 aliceWeekly, + uint256 bobWeekly, + uint256 collGapPct, + uint256 charlieWeekly, + uint256 daveWeekly, + uint256 levGapPct + ) internal { + string[] memory cols = new string[](8); + cols[0] = Useful.toStringScaled(feePct, 18); + cols[1] = Useful.toStringScaled(liquidFracPct, 18); + cols[2] = Useful.toStringScaled(aliceWeekly, 18); + cols[3] = Useful.toStringScaled(bobWeekly, 18); + cols[4] = Useful.toStringScaled(collGapPct, 18); + cols[5] = Useful.toStringScaled(charlieWeekly, 18); + cols[6] = Useful.toStringScaled(daveWeekly, 18); + cols[7] = Useful.toStringScaled(levGapPct, 18); + vm.writeLine(FEE_CSV, Useful.join(cols, ",")); + } + + function _writeFeeGnuplot() internal { + if (vm.exists(FEE_GP)) vm.removeFile(FEE_GP); + vm.writeLine(FEE_GP, "# Generated by RebalanceFairnessScan.t.sol - withdrawal fee scan"); + vm.writeLine(FEE_GP, "#"); + vm.writeLine(FEE_GP, "# Design case: 10% price drop, 25% leveraged, 37.5% liquidation fraction."); + vm.writeLine(FEE_GP, "# Fee is applied to Bob/Dave's withdrawn haETH (burned, not redistributed)."); + vm.writeLine(FEE_GP, "# Income gap = (returner_weekly_$ - stayer_weekly_$) / returner_weekly_$ * 100"); + vm.writeLine(FEE_GP, "#"); + vm.writeLine(FEE_GP, "# CSV columns: 1=Fee_pct, 2=LiquidFrac_pct,"); + vm.writeLine(FEE_GP, "# 3=Alice_coll_$, 4=Bob_coll_$, 5=Coll_gap_pct,"); + vm.writeLine(FEE_GP, "# 6=Charlie_lev_$, 7=Dave_lev_$, 8=Lev_gap_pct"); + vm.writeLine(FEE_GP, "set datafile separator ','"); + vm.writeLine(FEE_GP, "set bmargin 7"); + vm.writeLine(FEE_GP, "set key below spacing 1.3"); + vm.writeLine(FEE_GP, "set grid"); + vm.writeLine(FEE_GP, ""); + vm.writeLine(FEE_GP, "set terminal pngcairo size 1400,500 enhanced font 'Helvetica,11'"); + vm.writeLine(FEE_GP, "set output './results/rebalance_fairness_fee_scan.png'"); + vm.writeLine( + FEE_GP, + "set multiplot layout 1,2 title " + "'Withdrawal Fee Impact - Design Case (10% drop, 37.5% liquidation)' font 'Helvetica,13'" + ); + + // ── Plot 1: Gap vs fee ── + vm.writeLine(FEE_GP, ""); + vm.writeLine(FEE_GP, "set xlabel 'Withdrawal fee (%)'"); + vm.writeLine(FEE_GP, "set ylabel 'Income gap (%)'"); + vm.writeLine(FEE_GP, "set title 'Stayer vs returner: income gap'"); + vm.writeLine(FEE_GP, "set xrange [0:*]"); + vm.writeLine(FEE_GP, "set yrange [*:*]"); + vm.writeLine( + FEE_GP, + string.concat( + "plot '< tail -n+2 ", + FEE_CSV, + "' using 1:5 with linespoints lw 2 title 'Coll SP gap', \\\n", + " '< tail -n+2 ", + FEE_CSV, + "' using 1:8 with linespoints lw 2 title 'Lev SP gap', \\\n", + " 0 with lines dt 2 lc rgb 'gray50' title 'fair (0%)'" + ) + ); + + // ── Plot 2: Absolute $ income vs fee ── + vm.writeLine(FEE_GP, ""); + vm.writeLine(FEE_GP, "set title 'Weekly $ income vs withdrawal fee'"); + vm.writeLine(FEE_GP, "set ylabel 'Weekly income ($)'"); + vm.writeLine(FEE_GP, "set yrange [0:*]"); + vm.writeLine( + FEE_GP, + string.concat( + "plot '< tail -n+2 ", + FEE_CSV, + "' using 1:3 with linespoints lw 2 title 'Alice (Coll stayer)', \\\n", + " '< tail -n+2 ", + FEE_CSV, + "' using 1:4 with linespoints lw 2 title 'Bob (Coll returner)', \\\n", + " '< tail -n+2 ", + FEE_CSV, + "' using 1:6 with linespoints lw 2 title 'Charlie (Lev stayer)', \\\n", + " '< tail -n+2 ", + FEE_CSV, + "' using 1:7 with linespoints lw 2 title 'Dave (Lev returner)'" + ) + ); + + vm.writeLine(FEE_GP, ""); + vm.writeLine(FEE_GP, "unset multiplot"); + } + + function test_withdrawalFeeScan() public { + _openFeeCSV(); + + // Fee values to scan: 0% to 25% in steps + // At 37.5% liquidation, the "fair fee" (= liquidation fraction) is 37.5%. + // We scan up to 25% to show the trend without reaching the extreme. + uint256[10] memory feePctValues = [uint256(0), 1, 2, 5, 8, 10, 15, 18, 20, 25]; + + for (uint256 f = 0; f < feePctValues.length; f++) { + uint256 feePct = feePctValues[f]; + uint256 snap = vm.snapshot(); + + uint256 liquidFracE18 = _setupToPostRebalance(DESIGN_PRICE_DROP, DESIGN_LEV_PCT); + require(liquidFracE18 > 0, "design case must trigger rebalance"); + + _applyFeeAndRedeposit(feePct); + + (uint256 aliceW, uint256 bobW, uint256 charlieW, uint256 daveW) = _measureIncome(); + + // At high fees, Bob may earn less than Alice — gap goes negative (Alice is better off). + // Use signed gap: positive = Bob earns more, negative = Alice earns more. + uint256 collGapPct; + uint256 levGapPct; + if (bobW >= aliceW) { + collGapPct = _gapPct(bobW, aliceW); + } else { + // Negative gap: encode as 0 for now (Alice is winning — fee overshot) + collGapPct = 0; + } + if (daveW >= charlieW) { + levGapPct = _gapPct(daveW, charlieW); + } else { + levGapPct = 0; + } + + uint256 liquidFracPct = liquidFracE18 * 100; + _writeFeeRow(feePct * 1 ether, liquidFracPct, aliceW, bobW, collGapPct, charlieW, daveW, levGapPct); + + console2.log( + string.concat( + " fee=", + Useful.toString(feePct), + "% | ", + "coll_gap=", + Useful.toStringScaled(collGapPct, 18), + "% ", + "lev_gap=", + Useful.toStringScaled(levGapPct, 18), + "%" + ) + ); + + vm.revertTo(snap); + } + + _writeFeeGnuplot(); + console2.log(""); + console2.log("Fee CSV: %s", FEE_CSV); + console2.log("Fee Gnuplot: %s", FEE_GP); + } + + // ── Test 3: Timeline with weekly compounding ─────────────────────── + // + // Shows haXXX-equivalent position over 12 weeks for all actors. Every week: + // 1. Eve mints leveraged (CR recovery towards 1.40) + // 2. Rate bumps, harvest fires + // 3. Alice compounds (claim wCOL, freeMint haXXX, re-deposit) + // 4. Charlie compounds wCOL harvest only (hsXXX rebalance reward stays claimable) + // + // Two scenarios: fee=0% and fee=10% on Bob/Dave's withdrawal. + // haXXX-equivalent = deposit + wCOL-in-haXXX + hsXXX-in-haXXX. + + string constant TL_CSV = "./results/rebalance_fairness_timeline.csv"; + string constant TL_GP = "./results/rebalance_fairness_timeline.gp"; + + uint256 constant TOTAL_WEEKS = 12; + uint256 constant TARGET_CR = 1.40 ether; + uint256 constant CR_RECOVERY_WEEKS = 4; + + // ── haXXX-equivalent valuation ───────────────────────────────────── + + function _levToHaXXX(uint256 levAmount) internal view returns (uint256) { + if (levAmount == 0) return 0; + return (levAmount * IMinter(minter).leveragedTokenPrice()) / 1 ether; + } + + function _haXXXEquivalent(address who) internal view returns (uint256) { + uint256 peggedBal = IERC20(pegged).balanceOf(who) + + IERC20(stabilityPoolCollateral).balanceOf(who) + + IERC20(stabilityPoolLeveraged).balanceOf(who); + uint256 wcolColl = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(who, wrappedCollateral); + uint256 wcolLev = IMultipleRewardAccumulator(stabilityPoolLeveraged).claimable(who, wrappedCollateral); + uint256 wcolWallet = IERC20(wrappedCollateral).balanceOf(who); + // wCOL → COL (× rate) → haXXX (× price): combined × rate × price / 1e36 + uint256 wcolInHaXXX = ((((wcolColl + wcolLev + wcolWallet) * oracleRate) / 1 ether) * oraclePrice) / 1 ether; + uint256 levInHaXXX = _levToHaXXX( + IERC20(leveraged).balanceOf(who) + + IMultipleRewardAccumulator(stabilityPoolLeveraged).claimable(who, leveraged) + ); + return peggedBal + wcolInHaXXX + levInHaXXX; + } + + // ── CR recovery ──────────────────────────────────────────────────── + + function _recoverCR(uint256 targetCR) internal { + uint256 currentCR = IMinter(minter).collateralRatio(); + if (currentCR >= targetCR) return; + + // wCOL needed ≈ pegged × (targetCR - currentCR) / price + uint256 peggedSupply = IERC20(pegged).totalSupply(); + uint256 wcolNeeded = (peggedSupply * (targetCR - currentCR)) / oraclePrice; + + deal(wrappedCollateral, address(this), wcolNeeded); + IERC20(wrappedCollateral).approve(minter, wcolNeeded); + uint256 zeroFeeRole = IMinter(minter).ZERO_FEE_ROLE(); + vm.prank(IBaoOwnable(minter).owner()); + IBaoRoles(minter).grantRoles(address(this), zeroFeeRole); + IMinter(minter).freeMintLeveragedToken(wcolNeeded, eve); + } + + // ── Compound ─────────────────────────────────────────────────────── + + /// @dev Compound an actor's rewards from a pool back into haXXX deposit. + /// + /// Two paths: + /// 1. wCOL (harvest + coll rebalance reward): claim wCOL → freeMint haXXX → deposit + /// 2. hsXXX (lev rebalance reward): claim hsXXX → freeRedeem → wCOL → freeMint haXXX → deposit + /// + /// Both use free (zero-fee) operations for simulation clarity. + function _compoundActor(address who, address pool) internal { + uint256 zeroFeeRole = IMinter(minter).ZERO_FEE_ROLE(); + vm.prank(IBaoOwnable(minter).owner()); + IBaoRoles(minter).grantRoles(who, zeroFeeRole); + + // Step 1: Claim and convert hsXXX (if any) → wCOL via freeRedeem + uint256 levClaimable = IMultipleRewardAccumulator(pool).claimable(who, leveraged); + if (levClaimable > 0) { + vm.startPrank(who); + IMultipleRewardAccumulator_v3(pool).claim(who, who, leveraged, levClaimable); + uint256 levBal = IERC20(leveraged).balanceOf(who); + IERC20(leveraged).approve(minter, levBal); + IMinter(minter).freeRedeemLeveragedToken(levBal, who); // → wCOL to who + vm.stopPrank(); + } + + // Step 2: Claim wCOL (harvest + any coll rebalance reward) + uint256 wcolClaimable = IMultipleRewardAccumulator(pool).claimable(who, wrappedCollateral); + if (wcolClaimable > 0) { + vm.prank(who); + IMultipleRewardAccumulator_v3(pool).claim(who, who, wrappedCollateral, wcolClaimable); + } + + // Step 3: Convert all wCOL in wallet → haXXX → deposit + uint256 wcolBal = IERC20(wrappedCollateral).balanceOf(who); + if (wcolBal > 0) { + vm.startPrank(who); + IERC20(wrappedCollateral).approve(minter, wcolBal); + uint256 peggedMinted = IMinter(minter).freeMintPeggedToken(wcolBal, who); + IERC20(pegged).approve(pool, peggedMinted); + IStabilityPool(pool).deposit(peggedMinted, who, 0); + vm.stopPrank(); + } + } + + // ── CSV + Gnuplot ────────────────────────────────────────────────── + + function _openTimelineCSV() internal { + if (vm.exists(TL_CSV)) vm.removeFile(TL_CSV); + vm.writeLine(TL_CSV, "Fee_pct,Week,Alice_haXXX_eq,Bob_haXXX_eq,Charlie_haXXX_eq,Dave_haXXX_eq"); + } + + function _writeTimelineRow( + uint256 feePct, + uint256 week, + uint256 aliceEq, + uint256 bobEq, + uint256 charlieEq, + uint256 daveEq + ) internal { + string[] memory cols = new string[](6); + cols[0] = Useful.toStringScaled(feePct * 1 ether, 18); + cols[1] = Useful.toStringScaled(week * 1 ether, 18); + cols[2] = Useful.toStringScaled(aliceEq, 18); + cols[3] = Useful.toStringScaled(bobEq, 18); + cols[4] = Useful.toStringScaled(charlieEq, 18); + cols[5] = Useful.toStringScaled(daveEq, 18); + vm.writeLine(TL_CSV, Useful.join(cols, ",")); + } + + function _writeTimelineGnuplot() internal { + if (vm.exists(TL_GP)) vm.removeFile(TL_GP); + vm.writeLine(TL_GP, "# Generated by RebalanceFairnessScan.t.sol - timeline with weekly compounding"); + vm.writeLine(TL_GP, "#"); + vm.writeLine(TL_GP, "# Design case: 10% price drop, 25% lev, 37.5% liquidation."); + vm.writeLine(TL_GP, "# Weekly: Eve mints lev (CR recovery), harvest, compound (Alice+Charlie claim wCOL,"); + vm.writeLine(TL_GP, "# freeMint haXXX, re-deposit). Charlie's hsXXX rebalance reward stays as claimable."); + vm.writeLine(TL_GP, "#"); + vm.writeLine(TL_GP, "# CSV: 1=Fee_pct, 2=Week, 3=Alice_haXXX_eq, 4=Bob_haXXX_eq,"); + vm.writeLine(TL_GP, "# 5=Charlie_haXXX_eq, 6=Dave_haXXX_eq"); + vm.writeLine(TL_GP, "set datafile separator ','"); + vm.writeLine(TL_GP, "set bmargin 7"); + vm.writeLine(TL_GP, "set key below spacing 1.3"); + vm.writeLine(TL_GP, "set grid"); + vm.writeLine(TL_GP, ""); + vm.writeLine(TL_GP, "set terminal pngcairo size 1400,500 enhanced font 'Helvetica,11'"); + vm.writeLine(TL_GP, "set output './results/rebalance_fairness_timeline.png'"); + vm.writeLine( + TL_GP, + "set multiplot layout 1,2 title " + "'haXXX-Equivalent Position Over Time (weekly compound)' font 'Helvetica,13'" + ); + + string memory noFee = string.concat("\"< awk -F, 'NR>1 && $1==0' ", TL_CSV, '"'); + string memory fee10 = string.concat("\"< awk -F, 'NR>1 && $1==10' ", TL_CSV, '"'); + + vm.writeLine(TL_GP, ""); + vm.writeLine(TL_GP, "set xlabel 'Week'"); + vm.writeLine(TL_GP, "set ylabel 'haXXX-equivalent'"); + vm.writeLine(TL_GP, "set title 'Coll SP: Alice (stayer) vs Bob (returner)'"); + vm.writeLine(TL_GP, "set xrange [0:12]"); + vm.writeLine(TL_GP, "set yrange [*:*]"); + vm.writeLine( + TL_GP, + string.concat( + "plot ", + noFee, + " using 2:3 with linespoints lw 2 title 'Alice (no fee)', \\\n", + " ", + noFee, + " using 2:4 with linespoints lw 2 title 'Bob (no fee)', \\\n", + " ", + fee10, + " using 2:3 with linespoints lw 2 dt 2 title 'Alice (10% fee)', \\\n", + " ", + fee10, + " using 2:4 with linespoints lw 2 dt 2 title 'Bob (10% fee)'" + ) + ); + + vm.writeLine(TL_GP, ""); + vm.writeLine(TL_GP, "set title 'Lev SP: Charlie (stayer) vs Dave (returner)'"); + vm.writeLine( + TL_GP, + string.concat( + "plot ", + noFee, + " using 2:5 with linespoints lw 2 title 'Charlie (no fee)', \\\n", + " ", + noFee, + " using 2:6 with linespoints lw 2 title 'Dave (no fee)', \\\n", + " ", + fee10, + " using 2:5 with linespoints lw 2 dt 2 title 'Charlie (10% fee)', \\\n", + " ", + fee10, + " using 2:6 with linespoints lw 2 dt 2 title 'Dave (10% fee)'" + ) + ); + + vm.writeLine(TL_GP, ""); + vm.writeLine(TL_GP, "unset multiplot"); + } + + // ── Shared weekly cycle ────────────────────────────────────────── + + /// @dev Run `weeks` weekly cycles (CR recovery + harvest + compound) and return + /// the final haXXX-equivalent for Bob and Dave. Also returns Alice and Charlie + /// for CSV output. + struct WeeklyResult { + uint256 aliceEq; + uint256 bobEq; + uint256 charlieEq; + uint256 daveEq; + } + + function _runWeeks(uint256 weeks_) internal returns (WeeklyResult memory r) { + uint256 rateMultiplier = 1 ether + (FIXED_APR_PCT * 1 ether) / 5200; + uint256 crStart = 1.30 ether; + + for (uint256 w = 1; w <= weeks_; w++) { + if (w <= CR_RECOVERY_WEEKS) { + _recoverCR(crStart + ((TARGET_CR - crStart) * w) / CR_RECOVERY_WEEKS); + } + skip(1 days); + oracleRate = (oracleRate * rateMultiplier) / 1 ether; + mockOracle.setLatestAnswer(oraclePrice, oracleRate); + IStabilityPoolManager(stabilityPoolManager).harvest(makeAddr("bountyReceiver"), 0); + skip(8 days); + _compoundActor(alice, stabilityPoolCollateral); + _compoundActor(charlie, stabilityPoolLeveraged); + } + r.aliceEq = _haXXXEquivalent(alice); + r.bobEq = _haXXXEquivalent(bob); + r.charlieEq = _haXXXEquivalent(charlie); + r.daveEq = _haXXXEquivalent(dave); + } + + // ── Test 3: Timeline ─────────────────────────────────────────────── + + function test_timelineWithCompounding() public { + _openTimelineCSV(); + + uint256 rateMultiplier = 1 ether + (FIXED_APR_PCT * 1 ether) / 5200; + uint256 crStart = 1.30 ether; + + uint256[2] memory feePctValues = [uint256(0), 10]; + + for (uint256 f = 0; f < feePctValues.length; f++) { + uint256 feePct = feePctValues[f]; + uint256 snap = vm.snapshot(); + + uint256 liquidFracE18 = _setupToPostRebalance(DESIGN_PRICE_DROP, DESIGN_LEV_PCT); + require(liquidFracE18 > 0, "design case must trigger rebalance"); + _applyFeeAndRedeposit(feePct); + + _writeTimelineRow( + feePct, + 0, + _haXXXEquivalent(alice), + _haXXXEquivalent(bob), + _haXXXEquivalent(charlie), + _haXXXEquivalent(dave) + ); + + for (uint256 w = 1; w <= TOTAL_WEEKS; w++) { + if (w <= CR_RECOVERY_WEEKS) { + _recoverCR(crStart + ((TARGET_CR - crStart) * w) / CR_RECOVERY_WEEKS); + } + skip(1 days); + oracleRate = (oracleRate * rateMultiplier) / 1 ether; + mockOracle.setLatestAnswer(oraclePrice, oracleRate); + IStabilityPoolManager(stabilityPoolManager).harvest(makeAddr("bountyReceiver"), 0); + skip(8 days); + _compoundActor(alice, stabilityPoolCollateral); + _compoundActor(charlie, stabilityPoolLeveraged); + + WeeklyResult memory r; + r.aliceEq = _haXXXEquivalent(alice); + r.bobEq = _haXXXEquivalent(bob); + r.charlieEq = _haXXXEquivalent(charlie); + r.daveEq = _haXXXEquivalent(dave); + + _writeTimelineRow(feePct, w, r.aliceEq, r.bobEq, r.charlieEq, r.daveEq); + + console2.log( + string.concat( + " fee=", + Useful.toString(feePct), + "% wk=", + Useful.toString(w), + " | alice=", + Useful.toStringScaled(r.aliceEq, 18), + " bob=", + Useful.toStringScaled(r.bobEq, 18), + " charlie=", + Useful.toStringScaled(r.charlieEq, 18), + " dave=", + Useful.toStringScaled(r.daveEq, 18) + ) + ); + } + + vm.revertTo(snap); + } + + _writeTimelineGnuplot(); + console2.log(""); + console2.log("Timeline CSV: %s", TL_CSV); + console2.log("Timeline Gnuplot: %s", TL_GP); + } + + // ── Test 4: Break-even fee ───────────────────────────────────────── + // + // Binary-search for the minimum withdrawal fee that makes dodging unprofitable + // at a given time horizon (12 weeks). "Unprofitable" = Bob's haXXX-eq at week 12 + // is ≤ what he'd have had if he'd stayed (= Alice's haXXX-eq at week 12 with fee=0). + // + // We also find the break-even fee for the Lev SP (Charlie vs Dave). + + string constant BE_CSV = "./results/rebalance_fairness_breakeven.csv"; + + function test_breakEvenFee() public { + // Baseline: Scenario A (everyone stays) — run 12 weeks with compounding. + // In Scenario A there is no dodge, so Bob stays in the pool through the rebalance. + // We measure Bob's haXXX-eq at week 12 as the "stayed" reference. + // The break-even fee is the minimum fee that makes Scenario B Bob's haXXX-eq ≤ Scenario A Bob's. + uint256 snap0 = vm.snapshot(); + WeeklyResult memory baseline; + { + uint256 each = 100 ether; + _mintPegged(eve, PEGGED_COLLATERAL); + _mintLeveraged(eve, (PEGGED_COLLATERAL * DESIGN_LEV_PCT) / (100 - DESIGN_LEV_PCT)); + vm.startPrank(eve); + IERC20(pegged).transfer(alice, each); + IERC20(pegged).transfer(bob, each); + IERC20(pegged).transfer(charlie, each); + IERC20(pegged).transfer(dave, each); + IERC20(pegged).transfer(fred, each); + IERC20(pegged).transfer(george, each); + vm.stopPrank(); + _deposit(stabilityPoolCollateral, alice, each); + _deposit(stabilityPoolCollateral, bob, each); + _deposit(stabilityPoolLeveraged, charlie, each); + _deposit(stabilityPoolLeveraged, dave, each); + // Price drop + rebalance (everyone stays) + oraclePrice = (oraclePrice * (100 - DESIGN_PRICE_DROP)) / 100; + mockOracle.setLatestAnswer(oraclePrice, oracleRate); + IStabilityPoolManager(stabilityPoolManager).rebalance(makeAddr("bounty"), 0); + // Fred/George deposit after rebalance (same as Scenario B) + _deposit(stabilityPoolCollateral, fred, each); + _deposit(stabilityPoolLeveraged, george, each); + baseline = _runWeeks(TOTAL_WEEKS); + } + vm.revertTo(snap0); + + console2.log( + string.concat( + "Baseline (Scenario A, wk=12): bob=", + Useful.toStringScaled(baseline.bobEq, 18), + " dave=", + Useful.toStringScaled(baseline.daveEq, 18) + ) + ); + + // Binary search: min fee where Scenario B bob_12wk ≤ Scenario A bob_12wk + uint256 collBreakEven = _findBreakEvenFee(baseline.bobEq, true); + // Binary search: min fee where Scenario B dave_12wk ≤ Scenario A dave_12wk + uint256 levBreakEven = _findBreakEvenFee(baseline.daveEq, false); + + // feeBps is in basis points: 1 bp = 0.01%. Display as X.XX% + console2.log( + string.concat( + "Break-even fee (12 weeks, Coll SP): ", + Useful.toStringScaled(collBreakEven * 1e14, 16), + "% (", + Useful.toString(collBreakEven), + " bp)" + ) + ); + console2.log( + string.concat( + "Break-even fee (12 weeks, Lev SP): ", + Useful.toStringScaled(levBreakEven * 1e14, 16), + "% (", + Useful.toString(levBreakEven), + " bp)" + ) + ); + + // Write to CSV for reference + if (vm.exists(BE_CSV)) vm.removeFile(BE_CSV); + vm.writeLine(BE_CSV, "Pool,BreakEven_fee_pct,Horizon_weeks,PriceDrop_pct,Lev_pct,APR_pct"); + string[] memory cols = new string[](6); + + cols[0] = "Coll"; + cols[1] = Useful.toStringScaled(collBreakEven, 16); + cols[2] = Useful.toStringScaled(TOTAL_WEEKS * 1 ether, 18); + cols[3] = Useful.toStringScaled(DESIGN_PRICE_DROP * 1 ether, 18); + cols[4] = Useful.toStringScaled(DESIGN_LEV_PCT * 1 ether, 18); + cols[5] = Useful.toStringScaled(FIXED_APR_PCT * 1 ether, 18); + vm.writeLine(BE_CSV, Useful.join(cols, ",")); + + cols[0] = "Lev"; + cols[1] = Useful.toStringScaled(levBreakEven, 16); + vm.writeLine(BE_CSV, Useful.join(cols, ",")); + + console2.log(""); + console2.log("Break-even CSV: %s", BE_CSV); + } + + /// @dev Binary search over fee % (0–100, in basis points for precision) to find the + /// minimum fee where the dodger's 12-week haXXX-eq ≤ stayerBaseline. + /// `isColl` selects Bob (Coll SP) or Dave (Lev SP). + /// Returns fee in basis points (1 bp = 0.01%). + function _findBreakEvenFee(uint256 stayerBaseline, bool isColl) internal returns (uint256 feeBps) { + uint256 lo = 0; // 0 bp + uint256 hi = 10000; // 100% in bp + + for (uint256 i = 0; i < 20; i++) { + // 20 iterations → precision < 0.01 bp + uint256 mid = (lo + hi) / 2; + uint256 snap = vm.snapshot(); + + _setupToPostRebalance(DESIGN_PRICE_DROP, DESIGN_LEV_PCT); + // _applyFeeAndRedeposit takes fee in whole %, but we need bp precision. + // Apply fee manually: deal reduced balance to bob/dave. + { + uint256 bobBal = IERC20(pegged).balanceOf(bob); + uint256 daveBal = IERC20(pegged).balanceOf(dave); + deal(pegged, bob, (bobBal * (10000 - mid)) / 10000); + deal(pegged, dave, (daveBal * (10000 - mid)) / 10000); + } + uint256 each = 100 ether; + _deposit(stabilityPoolCollateral, bob, IERC20(pegged).balanceOf(bob)); + _deposit(stabilityPoolLeveraged, dave, IERC20(pegged).balanceOf(dave)); + _deposit(stabilityPoolCollateral, fred, each); + _deposit(stabilityPoolLeveraged, george, each); + + WeeklyResult memory r = _runWeeks(TOTAL_WEEKS); + uint256 dodgerEq = isColl ? r.bobEq : r.daveEq; + + if (dodgerEq > stayerBaseline) { + lo = mid + 1; // fee too low, dodging still profitable + } else { + hi = mid; // fee sufficient or overshooting + } + + vm.revertTo(snap); + } + feeBps = hi; // smallest fee that makes dodging unprofitable + } +} diff --git a/test/deployment/RewardSystem.t.sol b/test/deployment/RewardSystem.t.sol index e940ff87..e26692f3 100644 --- a/test/deployment/RewardSystem.t.sol +++ b/test/deployment/RewardSystem.t.sol @@ -9,19 +9,14 @@ import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; import {Config_MinterMarket} from "script/config/ConfigBase.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; -import {IERC5313} from "@openzeppelin/contracts/interfaces/IERC5313.sol"; -import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import {IMinter} from "src/interfaces/IMinter.sol"; import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; import {IMultipleRewardAccumulator_v3} from "src/interfaces/IMultipleRewardAccumulator_v3.sol"; import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; -import {IRewardAlias} from "src/interfaces/IRewardAlias.sol"; -import {RewardAlias_v1} from "src/reward/RewardAlias_v1.sol"; import {MockWrappedPriceOracle} from "test/mocks/MockWrappedPriceOracle.sol"; -/// @title Reward system tests — aliases, accumulator, distributor — using deployment framework +/// @title Reward system tests — accumulator, distributor — using deployment framework contract RewardSystemSetUp is BaoTest, Deploy_ETH_Minter { address minter; address stabilityPoolCollateral; @@ -31,9 +26,6 @@ contract RewardSystemSetUp is BaoTest, Deploy_ETH_Minter { address leveraged; address wrappedCollateral; - address collHarvestAlias; - address collRebalanceAlias; - MockWrappedPriceOracle mockOracle; function _shouldPersistState() internal pure override returns (bool) { @@ -63,9 +55,6 @@ contract RewardSystemSetUp is BaoTest, Deploy_ETH_Minter { leveraged = _predictAddress(_key(marketKey, "leveraged")); wrappedCollateral = IMinter(minter).WRAPPED_COLLATERAL_TOKEN(); - collHarvestAlias = _predictAddress(_key(marketKey, "stabilityPoolCollateral", "harvest")); - collRebalanceAlias = _predictAddress(_key(marketKey, "stabilityPoolCollateral", "rebalance")); - mockOracle = new MockWrappedPriceOracle(); mockOracle.setLatestAnswer(1 ether, 1 ether); @@ -95,41 +84,6 @@ contract RewardSystemSetUp is BaoTest, Deploy_ETH_Minter { } } -// ═══════════════════════════════════════════════════════════════ -// RewardAlias_v1 contract coverage -// ═══════════════════════════════════════════════════════════════ - -contract RewardAliasTest is RewardSystemSetUp { - function test_constructorRevertsZeroAddress() public { - vm.expectRevert(); - new RewardAlias_v1(address(0)); - } - - function test_supportsInterface() public view { - assertTrue(IERC165(collHarvestAlias).supportsInterface(type(IERC5313).interfaceId), "IERC5313"); - assertTrue(IERC165(collHarvestAlias).supportsInterface(type(IERC165).interfaceId), "IERC165"); - assertFalse(IERC165(collHarvestAlias).supportsInterface(0xdeadbeef), "random"); - } - - function test_upgradeByOwner() public { - RewardAlias_v1 newImpl = new RewardAlias_v1(wrappedCollateral); - - // Owner (multisig after transferAllOwnerships) can upgrade - vm.prank(HARBOR_MULTISIG); - UUPSUpgradeable(collHarvestAlias).upgradeToAndCall(address(newImpl), ""); - - // Underlying unchanged (immutable in new impl) - assertEq(IRewardAlias(collHarvestAlias).underlying(), wrappedCollateral, "underlying preserved"); - } - - function test_upgradeRevertsNotOwner() public { - RewardAlias_v1 newImpl = new RewardAlias_v1(wrappedCollateral); - vm.prank(makeAddr("attacker")); - vm.expectRevert(); - UUPSUpgradeable(collHarvestAlias).upgradeToAndCall(address(newImpl), ""); - } -} - // ═══════════════════════════════════════════════════════════════ // Accumulator v3 coverage (via SP deployed with deployment scripts) // ═══════════════════════════════════════════════════════════════ @@ -239,7 +193,7 @@ contract AccumulatorTest is RewardSystemSetUp { // ── claimHistorical ──────────────────────────────────────── function test_claimHistorical() public { - _depositReward(collHarvestAlias, 30 ether); + _depositReward(wrappedCollateral, 30 ether); skip(8 days); // Checkpoint alice to update her pending — but don't claim @@ -252,7 +206,7 @@ contract AccumulatorTest is RewardSystemSetUp { IMultipleRewardAccumulator(stabilityPoolCollateral).claim(carol); // Flush any remaining queued dust - _depositReward(collHarvestAlias, 1); + _depositReward(wrappedCollateral, 1); skip(8 days); vm.prank(bob); IMultipleRewardAccumulator(stabilityPoolCollateral).claim(bob); @@ -264,13 +218,13 @@ contract AccumulatorTest is RewardSystemSetUp { uint256 managerRole = IMultipleRewardDistributor(stabilityPoolCollateral).REWARD_MANAGER_ROLE(); vm.prank(HARBOR_MULTISIG); IBaoRoles(stabilityPoolCollateral).grantRoles(address(this), managerRole); - IMultipleRewardDistributor(stabilityPoolCollateral).unregisterRewardToken(collHarvestAlias); + IMultipleRewardDistributor(stabilityPoolCollateral).unregisterRewardToken(wrappedCollateral); // Verify it's historical address[] memory historical = IMultipleRewardDistributor(stabilityPoolCollateral).historicalRewardTokens(); bool found; for (uint256 i = 0; i < historical.length; i++) { - if (historical[i] == collHarvestAlias) { + if (historical[i] == wrappedCollateral) { found = true; } } @@ -278,7 +232,7 @@ contract AccumulatorTest is RewardSystemSetUp { // Alice claims via claimHistorical — her pending should still be there address[] memory tokens = new address[](1); - tokens[0] = collHarvestAlias; + tokens[0] = wrappedCollateral; uint256 balBefore = IERC20(wrappedCollateral).balanceOf(alice); vm.prank(alice); IMultipleRewardAccumulator(stabilityPoolCollateral).claimHistorical(tokens); @@ -286,7 +240,7 @@ contract AccumulatorTest is RewardSystemSetUp { } function test_claimHistorical_forAccount() public { - _depositReward(collHarvestAlias, 30 ether); + _depositReward(wrappedCollateral, 30 ether); skip(8 days); // Checkpoint alice but don't claim @@ -297,7 +251,7 @@ contract AccumulatorTest is RewardSystemSetUp { IMultipleRewardAccumulator(stabilityPoolCollateral).claim(bob); vm.prank(carol); IMultipleRewardAccumulator(stabilityPoolCollateral).claim(carol); - _depositReward(collHarvestAlias, 1); + _depositReward(wrappedCollateral, 1); skip(8 days); vm.prank(bob); IMultipleRewardAccumulator(stabilityPoolCollateral).claim(bob); @@ -307,11 +261,11 @@ contract AccumulatorTest is RewardSystemSetUp { uint256 managerRole = IMultipleRewardDistributor(stabilityPoolCollateral).REWARD_MANAGER_ROLE(); vm.prank(HARBOR_MULTISIG); IBaoRoles(stabilityPoolCollateral).grantRoles(address(this), managerRole); - IMultipleRewardDistributor(stabilityPoolCollateral).unregisterRewardToken(collHarvestAlias); + IMultipleRewardDistributor(stabilityPoolCollateral).unregisterRewardToken(wrappedCollateral); // Bob triggers historical claim for alice — tokens go to alice address[] memory tokens = new address[](1); - tokens[0] = collHarvestAlias; + tokens[0] = wrappedCollateral; uint256 balBefore = IERC20(wrappedCollateral).balanceOf(alice); vm.prank(bob); IMultipleRewardAccumulator(stabilityPoolCollateral).claimHistorical(alice, tokens); @@ -354,7 +308,7 @@ contract DistributorTest is RewardSystemSetUp { _mintAndDeposit(alice, 100 ether); // Deposit reward that hasn't fully distributed - _depositReward(collHarvestAlias, 10 ether); + _depositReward(wrappedCollateral, 10 ether); // Don't wait — rewards still pending uint256 managerRole = IMultipleRewardDistributor(stabilityPoolCollateral).REWARD_MANAGER_ROLE(); @@ -362,14 +316,14 @@ contract DistributorTest is RewardSystemSetUp { IBaoRoles(stabilityPoolCollateral).grantRoles(address(this), managerRole); vm.expectRevert(); - IMultipleRewardDistributor(stabilityPoolCollateral).unregisterRewardToken(collHarvestAlias); + IMultipleRewardDistributor(stabilityPoolCollateral).unregisterRewardToken(wrappedCollateral); } function test_unregisterAndReregister() public { address alice = makeAddr("alice"); _mintAndDeposit(alice, 100 ether); - _depositReward(collHarvestAlias, 10 ether); + _depositReward(wrappedCollateral, 10 ether); skip(8 days); // Wait for full distribution // Claim all so pending is zero @@ -381,14 +335,14 @@ contract DistributorTest is RewardSystemSetUp { IBaoRoles(stabilityPoolCollateral).grantRoles(address(this), managerRole); // Unregister - IMultipleRewardDistributor(stabilityPoolCollateral).unregisterRewardToken(collHarvestAlias); + IMultipleRewardDistributor(stabilityPoolCollateral).unregisterRewardToken(wrappedCollateral); // Historical should contain it address[] memory historical = IMultipleRewardDistributor(stabilityPoolCollateral).historicalRewardTokens(); assertEq(historical.length, 1, "one historical token"); // Re-register — moves from historical back to active - IMultipleRewardDistributor(stabilityPoolCollateral).registerRewardToken(collHarvestAlias); + IMultipleRewardDistributor(stabilityPoolCollateral).registerRewardToken(wrappedCollateral); // Historical should be empty again historical = IMultipleRewardDistributor(stabilityPoolCollateral).historicalRewardTokens(); diff --git a/test/deployment/StabilityPoolAliasDeployment.t.sol b/test/deployment/StabilityPoolAliasDeployment.t.sol deleted file mode 100644 index ac4d4bd8..00000000 --- a/test/deployment/StabilityPoolAliasDeployment.t.sol +++ /dev/null @@ -1,280 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.28 <0.9.0; - -import {BaoTest} from "@bao-test/BaoTest.sol"; -import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; -import {Deploy_ETH_Minter} from "script/src/v3/Deploy_ETH_Minter.sol"; -import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; -import {Config_MinterMarket, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; - -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {IMinter} from "src/interfaces/IMinter.sol"; -import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; -import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; -import {IMultipleRewardAccumulator_v3} from "src/interfaces/IMultipleRewardAccumulator_v3.sol"; -import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; -import {IRewardAlias} from "src/interfaces/IRewardAlias.sol"; -import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; -import {MockWrappedPriceOracle} from "test/mocks/MockWrappedPriceOracle.sol"; - -/// @title StabilityPoolAliasDeploymentTest -/// @notice Tests that v3 stability pools deployed via the production deployment scripts -/// have reward aliases correctly registered and functional. -contract StabilityPoolAliasDeploymentSetUp is BaoTest, Deploy_ETH_Minter { - using MinterMarketConfigLib for Config_MinterMarket; - - // Deployed contract addresses - address minter; - address stabilityPoolCollateral; - address stabilityPoolLeveraged; - address stabilityPoolManager; - address pegged; - address leveraged; - address wrappedCollateral; - - // Alias addresses - address collHarvestAlias; - address collRebalanceAlias; - address levHarvestAlias; - address levRebalanceAlias; - - // Mock oracle - MockWrappedPriceOracle mockOracle; - - function _shouldPersistState() internal pure override returns (bool) { - return false; - } - - function setUp() public virtual { - // Deploy BaoFactory locally - address factory = _ensureBaoFactory(); - - // Fork mainnet so real token contracts (fxSAVE, fxUSD, etc.) exist - // Pinned after latest Harbor deployment (SPL remediation, 2026-03-25) for caching - vm.createSelectFork(vm.rpcUrl("mainnet"), 24699497); - - // Register as factory operator - vm.prank(IBaoFactory(factory).owner()); - IBaoFactory(factory).setOperator(address(this), 365 days); - - // Deploy ETH::fxUSD market via production deployment scripts - (ConfigPeg peg, Config_MinterMarket[] memory mktConfigs) = createETHMintersConfig(); - Config_MinterMarket[] memory toDeploy = new Config_MinterMarket[](1); - toDeploy[0] = mktConfigs[0]; - deployForPeg("alias_test", peg, mktConfigs, "mainnet", true, toDeploy); - - // Resolve deployed addresses - _setSaltPrefix("alias_test"); - string memory marketKey = "ETH::fxUSD"; - minter = _predictAddress(_key(marketKey, "minter")); - stabilityPoolCollateral = _predictAddress(_key(marketKey, "stabilityPoolCollateral")); - stabilityPoolLeveraged = _predictAddress(_key(marketKey, "stabilityPoolLeveraged")); - stabilityPoolManager = _predictAddress(_key(marketKey, "stabilityPoolManager")); - pegged = _predictAddress(_key("ETH", "pegged")); - leveraged = _predictAddress(_key(marketKey, "leveraged")); - wrappedCollateral = IMinter(minter).WRAPPED_COLLATERAL_TOKEN(); - - // Resolve alias addresses - collHarvestAlias = _predictAddress(_key(marketKey, "stabilityPoolCollateral", "harvest")); - collRebalanceAlias = _predictAddress(_key(marketKey, "stabilityPoolCollateral", "rebalance")); - levHarvestAlias = _predictAddress(_key(marketKey, "stabilityPoolLeveraged", "harvest")); - levRebalanceAlias = _predictAddress(_key(marketKey, "stabilityPoolLeveraged", "rebalance")); - - // Install mock oracle and grant roles for test operations - mockOracle = new MockWrappedPriceOracle(); - mockOracle.setLatestAnswer(1 ether, 1 ether); - - vm.startPrank(HARBOR_MULTISIG); - IMinter(minter).updatePriceOracle(address(mockOracle)); - IBaoRoles(minter).grantRoles(address(this), IMinter(minter).ZERO_FEE_ROLE()); - IBaoRoles(stabilityPoolCollateral).grantRoles( - address(this), - IMultipleRewardDistributor(stabilityPoolCollateral).REWARD_DEPOSITOR_ROLE() - ); - vm.stopPrank(); - } - - function _mintPegged(address to, uint256 collateralAmount) internal returns (uint256 peggedMinted) { - deal(wrappedCollateral, address(this), collateralAmount); - IERC20(wrappedCollateral).approve(minter, collateralAmount); - peggedMinted = IMinter(minter).freeMintPeggedToken(collateralAmount, to); - } -} - -contract StabilityPoolAliasDeploymentTest is StabilityPoolAliasDeploymentSetUp { - // ═══════════════════════════════════════════════════════════════ - // Alias deployment verification - // ═══════════════════════════════════════════════════════════════ - - function test_aliasesDeployed() public view { - assertGt(collHarvestAlias.code.length, 0, "collHarvestAlias deployed"); - assertGt(collRebalanceAlias.code.length, 0, "collRebalanceAlias deployed"); - assertGt(levHarvestAlias.code.length, 0, "levHarvestAlias deployed"); - assertGt(levRebalanceAlias.code.length, 0, "levRebalanceAlias deployed"); - } - - function test_aliasUnderlyings() public view { - // Collateral SP aliases both point to wrappedCollateral - assertEq(IRewardAlias(collHarvestAlias).underlying(), wrappedCollateral, "coll harvest underlying"); - assertEq(IRewardAlias(collRebalanceAlias).underlying(), wrappedCollateral, "coll rebalance underlying"); - - // Leveraged SP: harvest → wrappedCollateral, rebalance → leveraged token - assertEq(IRewardAlias(levHarvestAlias).underlying(), wrappedCollateral, "lev harvest underlying"); - assertEq(IRewardAlias(levRebalanceAlias).underlying(), leveraged, "lev rebalance underlying"); - } - - // ═══════════════════════════════════════════════════════════════ - // Alias registration on SPs - // ═══════════════════════════════════════════════════════════════ - - function test_aliasesRegisteredOnCollateralSP() public view { - address[] memory tokens = IMultipleRewardDistributor(stabilityPoolCollateral).activeRewardTokens(); - bool foundHarvest; - bool foundRebalance; - for (uint256 i = 0; i < tokens.length; i++) { - if (tokens[i] == collHarvestAlias) { - foundHarvest = true; - } - if (tokens[i] == collRebalanceAlias) { - foundRebalance = true; - } - } - assertTrue(foundHarvest, "harvest alias registered on coll SP"); - assertTrue(foundRebalance, "rebalance alias registered on coll SP"); - } - - function test_aliasesRegisteredOnLeveragedSP() public view { - address[] memory tokens = IMultipleRewardDistributor(stabilityPoolLeveraged).activeRewardTokens(); - bool foundHarvest; - bool foundRebalance; - for (uint256 i = 0; i < tokens.length; i++) { - if (tokens[i] == levHarvestAlias) { - foundHarvest = true; - } - if (tokens[i] == levRebalanceAlias) { - foundRebalance = true; - } - } - assertTrue(foundHarvest, "harvest alias registered on lev SP"); - assertTrue(foundRebalance, "rebalance alias registered on lev SP"); - } - - // ═══════════════════════════════════════════════════════════════ - // Deposit via alias → claim resolves to underlying - // ═══════════════════════════════════════════════════════════════ - - function test_depositViaAlias_claimReturnsUnderlying() public { - address alice = makeAddr("alice"); - uint256 depositAmount = 100 ether; - uint256 rewardAmount = 5 ether; - - // Mint pegged and deposit into collateral SP - _mintPegged(alice, depositAmount); - vm.prank(alice); - IERC20(pegged).approve(stabilityPoolCollateral, depositAmount); - vm.prank(alice); - IStabilityPool(stabilityPoolCollateral).deposit(depositAmount, alice, 0); - - // Deposit reward via harvest alias - deal(wrappedCollateral, address(this), rewardAmount); - IERC20(wrappedCollateral).approve(stabilityPoolCollateral, rewardAmount); - IMultipleRewardDistributor(stabilityPoolCollateral).depositReward(collHarvestAlias, rewardAmount); - - // Wait for full distribution - skip(8 days); - - // Claimable should show under the alias address - uint256 claimableAlias = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(alice, collHarvestAlias); - assertApprox(claimableAlias, rewardAmount, 604800, "claimable via alias"); - - // Claim via alias — should receive wrappedCollateral (the underlying) - uint256 balBefore = IERC20(wrappedCollateral).balanceOf(alice); - vm.prank(alice); - IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim( - alice, - address(0), - collHarvestAlias, - type(uint256).max - ); - uint256 received = IERC20(wrappedCollateral).balanceOf(alice) - balBefore; - - assertApprox(received, rewardAmount, 604800, "claimed underlying amount"); - } - - function test_separateTracking_harvestVsRebalance() public { - address alice = makeAddr("alice"); - uint256 depositAmount = 100 ether; - uint256 harvestReward = 3 ether; - uint256 rebalanceReward = 7 ether; - - // Mint and deposit - _mintPegged(alice, depositAmount); - vm.prank(alice); - IERC20(pegged).approve(stabilityPoolCollateral, depositAmount); - vm.prank(alice); - IStabilityPool(stabilityPoolCollateral).deposit(depositAmount, alice, 0); - - // Deposit harvest reward via harvest alias - deal(wrappedCollateral, address(this), harvestReward); - IERC20(wrappedCollateral).approve(stabilityPoolCollateral, harvestReward); - IMultipleRewardDistributor(stabilityPoolCollateral).depositReward(collHarvestAlias, harvestReward); - - // Deposit rebalance reward via rebalance alias - deal(wrappedCollateral, address(this), rebalanceReward); - IERC20(wrappedCollateral).approve(stabilityPoolCollateral, rebalanceReward); - IMultipleRewardDistributor(stabilityPoolCollateral).depositReward(collRebalanceAlias, rebalanceReward); - - // Wait for distribution - skip(8 days); - - // Each alias tracks separately - uint256 claimableHarvest = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( - alice, - collHarvestAlias - ); - uint256 claimableRebalance = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( - alice, - collRebalanceAlias - ); - - assertApprox(claimableHarvest, harvestReward, 604800, "harvest alias tracked separately"); - assertApprox(claimableRebalance, rebalanceReward, 604800, "rebalance alias tracked separately"); - - // Aggregated claimable for underlying should be sum of aliases - uint256 claimableTotal = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable( - alice, - wrappedCollateral - ); - assertApprox(claimableTotal, harvestReward + rebalanceReward, 2 * 604800, "aggregated claimable"); - } - - function test_claimAll_collectsBothAliases() public { - address alice = makeAddr("alice"); - uint256 depositAmount = 100 ether; - uint256 harvestReward = 4 ether; - uint256 rebalanceReward = 6 ether; - - // Mint and deposit - _mintPegged(alice, depositAmount); - vm.prank(alice); - IERC20(pegged).approve(stabilityPoolCollateral, depositAmount); - vm.prank(alice); - IStabilityPool(stabilityPoolCollateral).deposit(depositAmount, alice, 0); - - // Deposit via both aliases - deal(wrappedCollateral, address(this), harvestReward + rebalanceReward); - IERC20(wrappedCollateral).approve(stabilityPoolCollateral, harvestReward + rebalanceReward); - IMultipleRewardDistributor(stabilityPoolCollateral).depositReward(collHarvestAlias, harvestReward); - IMultipleRewardDistributor(stabilityPoolCollateral).depositReward(collRebalanceAlias, rebalanceReward); - - skip(8 days); - - // Claim all — should receive total from both aliases - uint256 balBefore = IERC20(wrappedCollateral).balanceOf(alice); - vm.prank(alice); - IMultipleRewardAccumulator(stabilityPoolCollateral).claim(alice); - uint256 received = IERC20(wrappedCollateral).balanceOf(alice) - balBefore; - - assertApprox(received, harvestReward + rebalanceReward, 2 * 604800, "claim all collects both aliases"); - } -} diff --git a/test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v3.sol b/test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v3.sol new file mode 100644 index 00000000..08843f4d --- /dev/null +++ b/test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v3.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MIT + +pragma solidity >=0.8.28 <0.9.0; + +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; + +import {MultipleRewardCompoundingAccumulator_v3} from "src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol"; + +contract MockMultipleRewardCompoundingAccumulator_v3 is Initializable, MultipleRewardCompoundingAccumulator_v3 { + event AccumulateReward(address token, uint256 amount); + + uint256 public totalPoolShare; + uint128 public product; + uint256 public userPoolShare; + uint128 public userProduct; + + constructor(uint40 period) MultipleRewardCompoundingAccumulator_v3(_ROLE_0, _ROLE_1, period) {} + + function initialize(address owner_) external initializer { + _initializeOwner(owner_); + __ReentrancyGuardTransient_init(); + } + + function setTotalPoolShare(uint256 _totalPoolShare, uint128 _product) external { + totalPoolShare = _totalPoolShare; + product = _product; + } + + function setUserPoolShare(uint256 _userPoolShare, uint128 _userProduct) external { + userPoolShare = _userPoolShare; + userProduct = _userProduct; + } + + function _getTotalPoolShare() internal view virtual override returns (uint128, uint256) { + return (product, totalPoolShare); + } + + function _getUserPoolShare(address) internal view virtual override returns (uint128, uint256) { + return (userProduct, userPoolShare); + } + + function _accumulateReward(address token, uint256 amount) internal virtual override { + emit AccumulateReward(token, amount); + super._accumulateReward(token, amount); + } + + function tokenToExponentToIntegral(address token, uint8 exponent) public view returns (uint256 globalIntegral) { + globalIntegral = _tokenToExponentToIntegral(token, exponent); + } + + function userRewardSnapshot( + address account, + address token + ) public view returns (uint64 timestamp, uint256 integral, uint128 pending, uint128 claimed_) { + (timestamp, integral, pending, claimed_) = _getUserRewardSnapshot(account, token); + } +} diff --git a/test/reward/accumulator/ClaimEquivalence.t.sol b/test/reward/accumulator/ClaimEquivalence.t.sol new file mode 100644 index 00000000..48fe9b62 --- /dev/null +++ b/test/reward/accumulator/ClaimEquivalence.t.sol @@ -0,0 +1,361 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.28 <0.9.0; + +import {Test} from "forge-std/Test.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {MockERC20} from "@bao-test/mocks/MockERC20.sol"; + +import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; +import {IMultipleRewardAccumulator_v3} from "src/interfaces/IMultipleRewardAccumulator_v3.sol"; +import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; +import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; +import {MockMultipleRewardCompoundingAccumulator_v3} from "test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v3.sol"; + +/// @title ClaimEquivalenceTest +/// @notice Verifies that the v3 unified claim interface produces identical outcomes +/// to the legacy claim/claimHistorical wrappers it replaced. +/// +/// Authorization matrix tested: +/// | Scenario | Legacy path | V3 equivalent | Expected | +/// |---------------------------|--------------------------------------|--------------------------------------------|-----------------| +/// | Self claim all | claim() | claim(self, 0, 0, max) | tokens → self | +/// | 3rd party claim all | claim(other) | claim(other, 0, 0, max) | tokens → other | +/// | Self claim to receiver | claim(self, recv) | claim(self, recv, 0, max) | tokens → recv | +/// | 3rd party to receiver | claim(other, recv) | claim(other, recv, 0, max) | REVERT | +/// | Self historical | claimHistorical(tokens) | claim(self, 0, tokens, max) | tokens → self | +/// | 3rd party historical | claimHistorical(other, tokens) | claim(other, 0, tokens, max) | tokens → other | +/// | All above + stored recv | same paths | same paths | → stored recv | +/// +/// Run: forge test --mc ClaimEquivalenceTest -vv +contract ClaimEquivalenceTest is Test { + address deployer; + address alice; + address bob; + address storedReceiver; + address explicitReceiver; + + address accumulator; + address rewardToken1; + address rewardToken2; + + uint256 constant REWARD_AMOUNT = 100 ether; + uint256 constant POOL_SHARE = 10 ether; + uint128 constant PRODUCT = uint128(1e36); + + function setUp() public { + deployer = address(this); + alice = makeAddr("alice"); + bob = makeAddr("bob"); + storedReceiver = makeAddr("storedReceiver"); + explicitReceiver = makeAddr("explicitReceiver"); + + accumulator = address(new MockMultipleRewardCompoundingAccumulator_v3(1 weeks)); + MockMultipleRewardCompoundingAccumulator_v3(accumulator).initialize(deployer); + + rewardToken1 = address(new MockERC20("Token1", "T1", 18)); + rewardToken2 = address(new MockERC20("Token2", "T2", 18)); + + // Grant manager role and register tokens + uint256 managerRole = IMultipleRewardDistributor(accumulator).REWARD_MANAGER_ROLE(); + IBaoRoles(accumulator).grantRoles(deployer, managerRole); + IMultipleRewardDistributor(accumulator).registerRewardToken(rewardToken1); + IMultipleRewardDistributor(accumulator).registerRewardToken(rewardToken2); + + // Set pool shares so rewards accrue + MockMultipleRewardCompoundingAccumulator_v3(accumulator).setTotalPoolShare(POOL_SHARE, PRODUCT); + MockMultipleRewardCompoundingAccumulator_v3(accumulator).setUserPoolShare(POOL_SHARE, PRODUCT); + } + + /// @dev Deposit rewards for both tokens and advance time so they're fully claimable. + function _depositRewards() internal { + MockERC20(rewardToken1).mint(deployer, REWARD_AMOUNT); + MockERC20(rewardToken2).mint(deployer, REWARD_AMOUNT); + IERC20(rewardToken1).approve(accumulator, REWARD_AMOUNT); + IERC20(rewardToken2).approve(accumulator, REWARD_AMOUNT); + IMultipleRewardDistributor(accumulator).depositReward(rewardToken1, REWARD_AMOUNT); + IMultipleRewardDistributor(accumulator).depositReward(rewardToken2, REWARD_AMOUNT); + vm.warp(block.timestamp + 2 weeks); + } + + function _claimableTotal(address account) internal view returns (uint256) { + return + IMultipleRewardAccumulator(accumulator).claimable(account, rewardToken1) + + IMultipleRewardAccumulator(accumulator).claimable(account, rewardToken2); + } + + // ═══════════════════════════════════════════════════════════════════════ + // Without stored receiver + // ═══════════════════════════════════════════════════════════════════════ + + // ── Self claim all ────────────────────────────────────────────────── + + function test_selfClaimAll_legacy() public { + _depositRewards(); + vm.prank(alice); + IMultipleRewardAccumulator(accumulator).claim(); + assertGt(IERC20(rewardToken1).balanceOf(alice), 0, "legacy: alice got token1"); + assertGt(IERC20(rewardToken2).balanceOf(alice), 0, "legacy: alice got token2"); + } + + function test_selfClaimAll_v3() public { + _depositRewards(); + vm.prank(alice); + IMultipleRewardAccumulator_v3(accumulator).claim(alice, address(0), address(0), type(uint256).max); + assertGt(IERC20(rewardToken1).balanceOf(alice), 0, "v3: alice got token1"); + assertGt(IERC20(rewardToken2).balanceOf(alice), 0, "v3: alice got token2"); + } + + function test_selfClaimAll_equivalent() public { + // Run legacy path, snapshot balances + _depositRewards(); + vm.prank(alice); + IMultipleRewardAccumulator(accumulator).claim(); + uint256 legacyBal1 = IERC20(rewardToken1).balanceOf(alice); + uint256 legacyBal2 = IERC20(rewardToken2).balanceOf(alice); + + // Reset: redeploy + setUp(); + _depositRewards(); + vm.prank(alice); + IMultipleRewardAccumulator_v3(accumulator).claim(alice, address(0), address(0), type(uint256).max); + assertEq(IERC20(rewardToken1).balanceOf(alice), legacyBal1, "token1 equivalent"); + assertEq(IERC20(rewardToken2).balanceOf(alice), legacyBal2, "token2 equivalent"); + } + + // ── Third party claim all (no receiver) ───────────────────────────── + + function test_thirdPartyClaimAll_legacy() public { + _depositRewards(); + vm.prank(bob); + IMultipleRewardAccumulator(accumulator).claim(alice); + assertGt(IERC20(rewardToken1).balanceOf(alice), 0, "legacy: alice got token1 (claimed by bob)"); + } + + function test_thirdPartyClaimAll_v3() public { + _depositRewards(); + vm.prank(bob); + IMultipleRewardAccumulator_v3(accumulator).claim(alice, address(0), address(0), type(uint256).max); + assertGt(IERC20(rewardToken1).balanceOf(alice), 0, "v3: alice got token1 (claimed by bob)"); + } + + function test_thirdPartyClaimAll_equivalent() public { + _depositRewards(); + vm.prank(bob); + IMultipleRewardAccumulator(accumulator).claim(alice); + uint256 legacyBal1 = IERC20(rewardToken1).balanceOf(alice); + + setUp(); + _depositRewards(); + vm.prank(bob); + IMultipleRewardAccumulator_v3(accumulator).claim(alice, address(0), address(0), type(uint256).max); + assertEq(IERC20(rewardToken1).balanceOf(alice), legacyBal1, "equivalent"); + } + + // ── Self claim to explicit receiver ───────────────────────────────── + + function test_selfClaimToReceiver_legacy() public { + _depositRewards(); + vm.prank(alice); + IMultipleRewardAccumulator(accumulator).claim(alice, explicitReceiver); + assertGt(IERC20(rewardToken1).balanceOf(explicitReceiver), 0, "legacy: receiver got token1"); + assertEq(IERC20(rewardToken1).balanceOf(alice), 0, "legacy: alice got nothing"); + } + + function test_selfClaimToReceiver_v3() public { + _depositRewards(); + vm.prank(alice); + IMultipleRewardAccumulator_v3(accumulator).claim(alice, explicitReceiver, address(0), type(uint256).max); + assertGt(IERC20(rewardToken1).balanceOf(explicitReceiver), 0, "v3: receiver got token1"); + assertEq(IERC20(rewardToken1).balanceOf(alice), 0, "v3: alice got nothing"); + } + + // ── Third party claim to receiver → REVERT ────────────────────────── + + function test_thirdPartyClaimToReceiver_legacy_reverts() public { + _depositRewards(); + vm.prank(bob); + vm.expectRevert(IMultipleRewardAccumulator.ClaimOthersRewardToAnother.selector); + IMultipleRewardAccumulator(accumulator).claim(alice, explicitReceiver); + } + + function test_thirdPartyClaimToReceiver_v3_reverts() public { + _depositRewards(); + vm.prank(bob); + vm.expectRevert(IMultipleRewardAccumulator.ClaimOthersRewardToAnother.selector); + IMultipleRewardAccumulator_v3(accumulator).claim(alice, explicitReceiver, address(0), type(uint256).max); + } + + // ── Self historical ───────────────────────────────────────────────── + + function test_selfHistorical_legacy() public { + _depositRewards(); + address[] memory tokens = new address[](1); + tokens[0] = rewardToken1; + vm.prank(alice); + IMultipleRewardAccumulator(accumulator).claimHistorical(tokens); + assertGt(IERC20(rewardToken1).balanceOf(alice), 0, "legacy: alice got token1"); + assertEq(IERC20(rewardToken2).balanceOf(alice), 0, "legacy: token2 unclaimed"); + } + + function test_selfHistorical_v3() public { + _depositRewards(); + address[] memory tokens = new address[](1); + tokens[0] = rewardToken1; + vm.prank(alice); + IMultipleRewardAccumulator_v3(accumulator).claim(alice, address(0), tokens, type(uint256).max); + assertGt(IERC20(rewardToken1).balanceOf(alice), 0, "v3: alice got token1"); + assertEq(IERC20(rewardToken2).balanceOf(alice), 0, "v3: token2 unclaimed"); + } + + // ── Third party historical ────────────────────────────────────────── + + function test_thirdPartyHistorical_legacy() public { + _depositRewards(); + address[] memory tokens = new address[](1); + tokens[0] = rewardToken1; + vm.prank(bob); + IMultipleRewardAccumulator(accumulator).claimHistorical(alice, tokens); + assertGt(IERC20(rewardToken1).balanceOf(alice), 0, "legacy: alice got token1 (claimed by bob)"); + } + + function test_thirdPartyHistorical_v3() public { + _depositRewards(); + address[] memory tokens = new address[](1); + tokens[0] = rewardToken1; + vm.prank(bob); + IMultipleRewardAccumulator_v3(accumulator).claim(alice, address(0), tokens, type(uint256).max); + assertGt(IERC20(rewardToken1).balanceOf(alice), 0, "v3: alice got token1 (claimed by bob)"); + } + + // ═══════════════════════════════════════════════════════════════════════ + // With stored receiver + // ═══════════════════════════════════════════════════════════════════════ + + function _setStoredReceiver() internal { + vm.prank(alice); + IMultipleRewardAccumulator(accumulator).setRewardReceiver(storedReceiver); + } + + // ── Self claim all → stored receiver ──────────────────────────────── + + function test_selfClaimAll_storedReceiver_legacy() public { + _setStoredReceiver(); + _depositRewards(); + vm.prank(alice); + IMultipleRewardAccumulator(accumulator).claim(); + assertGt(IERC20(rewardToken1).balanceOf(storedReceiver), 0, "legacy: stored receiver got token1"); + assertEq(IERC20(rewardToken1).balanceOf(alice), 0, "legacy: alice got nothing"); + } + + function test_selfClaimAll_storedReceiver_v3() public { + _setStoredReceiver(); + _depositRewards(); + vm.prank(alice); + IMultipleRewardAccumulator_v3(accumulator).claim(alice, address(0), address(0), type(uint256).max); + assertGt(IERC20(rewardToken1).balanceOf(storedReceiver), 0, "v3: stored receiver got token1"); + assertEq(IERC20(rewardToken1).balanceOf(alice), 0, "v3: alice got nothing"); + } + + // ── Third party claim all → stored receiver ───────────────────────── + + function test_thirdPartyClaimAll_storedReceiver_legacy() public { + _setStoredReceiver(); + _depositRewards(); + vm.prank(bob); + IMultipleRewardAccumulator(accumulator).claim(alice); + assertGt(IERC20(rewardToken1).balanceOf(storedReceiver), 0, "legacy: stored receiver got token1"); + assertEq(IERC20(rewardToken1).balanceOf(alice), 0, "legacy: alice got nothing"); + } + + function test_thirdPartyClaimAll_storedReceiver_v3() public { + _setStoredReceiver(); + _depositRewards(); + vm.prank(bob); + IMultipleRewardAccumulator_v3(accumulator).claim(alice, address(0), address(0), type(uint256).max); + assertGt(IERC20(rewardToken1).balanceOf(storedReceiver), 0, "v3: stored receiver got token1"); + assertEq(IERC20(rewardToken1).balanceOf(alice), 0, "v3: alice got nothing"); + } + + // ── Self claim to explicit receiver overrides stored ───────────────── + + function test_selfClaimToExplicit_overridesStored_legacy() public { + _setStoredReceiver(); + _depositRewards(); + vm.prank(alice); + IMultipleRewardAccumulator(accumulator).claim(alice, explicitReceiver); + assertGt(IERC20(rewardToken1).balanceOf(explicitReceiver), 0, "legacy: explicit receiver got token1"); + assertEq(IERC20(rewardToken1).balanceOf(storedReceiver), 0, "legacy: stored receiver got nothing"); + } + + function test_selfClaimToExplicit_overridesStored_v3() public { + _setStoredReceiver(); + _depositRewards(); + vm.prank(alice); + IMultipleRewardAccumulator_v3(accumulator).claim(alice, explicitReceiver, address(0), type(uint256).max); + assertGt(IERC20(rewardToken1).balanceOf(explicitReceiver), 0, "v3: explicit receiver got token1"); + assertEq(IERC20(rewardToken1).balanceOf(storedReceiver), 0, "v3: stored receiver got nothing"); + } + + // ── Third party + stored receiver + explicit → REVERT ─────────────── + + function test_thirdPartyToExplicit_storedReceiver_legacy_reverts() public { + _setStoredReceiver(); + _depositRewards(); + vm.prank(bob); + vm.expectRevert(IMultipleRewardAccumulator.ClaimOthersRewardToAnother.selector); + IMultipleRewardAccumulator(accumulator).claim(alice, explicitReceiver); + } + + function test_thirdPartyToExplicit_storedReceiver_v3_reverts() public { + _setStoredReceiver(); + _depositRewards(); + vm.prank(bob); + vm.expectRevert(IMultipleRewardAccumulator.ClaimOthersRewardToAnother.selector); + IMultipleRewardAccumulator_v3(accumulator).claim(alice, explicitReceiver, address(0), type(uint256).max); + } + + // ═══════════════════════════════════════════════════════════════════════ + // V3-specific: fractional claim (no legacy equivalent) + // ═══════════════════════════════════════════════════════════════════════ + + function test_fractionalClaim_leavesRemainder() public { + _depositRewards(); + uint256 total = IMultipleRewardAccumulator(accumulator).claimable(alice, rewardToken1); + uint256 half = total / 2; + + vm.prank(alice); + IMultipleRewardAccumulator_v3(accumulator).claim(alice, address(0), rewardToken1, half); + + assertEq(IERC20(rewardToken1).balanceOf(alice), half, "got half"); + assertGt( + IMultipleRewardAccumulator(accumulator).claimable(alice, rewardToken1), + 0, + "remainder still claimable" + ); + } + + function test_fractionalClaim_thirdParty_leavesRemainder() public { + _depositRewards(); + uint256 total = IMultipleRewardAccumulator(accumulator).claimable(alice, rewardToken1); + uint256 half = total / 2; + + // Bob claims half of alice's rewards (receiver=0 → goes to alice) + vm.prank(bob); + IMultipleRewardAccumulator_v3(accumulator).claim(alice, address(0), rewardToken1, half); + + assertEq(IERC20(rewardToken1).balanceOf(alice), half, "alice got half"); + assertEq(IERC20(rewardToken1).balanceOf(bob), 0, "bob got nothing"); + assertGt( + IMultipleRewardAccumulator(accumulator).claimable(alice, rewardToken1), + 0, + "remainder still claimable" + ); + } + + function test_fractionalClaim_thirdParty_toExplicitReceiver_reverts() public { + _depositRewards(); + vm.prank(bob); + vm.expectRevert(IMultipleRewardAccumulator.ClaimOthersRewardToAnother.selector); + IMultipleRewardAccumulator_v3(accumulator).claim(alice, explicitReceiver, rewardToken1, 1 ether); + } +} From 82517300a3184a3fb6a019548316d2e8174f8c35 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Sat, 11 Apr 2026 16:31:58 +0100 Subject: [PATCH 025/232] test transfer after a rebalance fix slither issues update design docs --- doc/ideas/rebalance-fairness.md | 255 +++++++------ regression/coverage.txt | 4 +- regression/gas.txt | 34 +- regression/sizes.txt | 4 +- src/autocompounding/AutoCompounder_v1.sol | 23 +- src/interfaces/IRewardAlias.sol | 15 + src/interfaces/IStabilityPool_v3.sol | 10 + src/reward/RewardAlias_v1.sol | 58 +++ .../LinearMultipleRewardDistributor_v3.sol | 339 ++++++++++++++++++ test/StabilityPool_v3_ERC20.t.sol | 74 ++++ 10 files changed, 680 insertions(+), 136 deletions(-) create mode 100644 src/interfaces/IRewardAlias.sol create mode 100644 src/interfaces/IStabilityPool_v3.sol create mode 100644 src/reward/RewardAlias_v1.sol create mode 100644 src/reward/distributor/LinearMultipleRewardDistributor_v3.sol diff --git a/doc/ideas/rebalance-fairness.md b/doc/ideas/rebalance-fairness.md index 688f4eb2..bafe3dd0 100644 --- a/doc/ideas/rebalance-fairness.md +++ b/doc/ideas/rebalance-fairness.md @@ -33,7 +33,7 @@ All three contribute to the harvest for both pools, proportional to pool size. - Reduces the Minter's collateral holdings, reducing future harvests for everyone (component 2 is lost from the shared harvest pool) - But the transferred wCOL is itself interest-bearing (e.g., fxSAVE appreciates independently). This private yield accrues to the depositors who received it. -**Leveraged SP rebalance**: pegged tokens are redeemed and the collateral is used to mint leveraged tokens. The collateral backing those redeemed pegged tokens is consumed in the process -- it leaves the Minter to become leveraged token collateral. This **does** reduce the Minter's total collateral and therefore reduces future harvest generation, just as collateral SP rebalances do. However, the leveraged tokens received by stayers are NOT interest-bearing in the same way as wCOL -- they don't generate private yield. +**Leveraged SP rebalance**: pegged tokens are exchanged for leveraged tokens. The collateral backing those pegged tokens is **reclassified** -- it now backs leveraged tokens instead of pegged tokens, but it **stays with the Minter**. Leveraged SP rebalances do NOT reduce the Minter's total collateral and therefore do NOT directly reduce future harvest generation. The leveraged tokens received by stayers are NOT interest-bearing like wCOL -- they don't generate private yield. ### Worked Example @@ -337,7 +337,7 @@ The private yield partially compensates for the lost harvest. **A naive boost th ### Charlie's situation is worse -Charlie received 62.5 leveraged tokens (not wCOL). These are NOT interest-bearing in the same way. The collateral backing them stays with the Minter, generating harvest for everyone. Charlie cannot convert them to pegged tokens easily. His only income stream is the reduced harvest. +Charlie received 62.5 leveraged tokens (not wCOL). These are NOT interest-bearing in the same way -- they don't generate private yield. The collateral backing them stays with the Minter (reclassified from pegged-backing to leveraged-backing), continuing to generate harvest for everyone. Charlie cannot convert them to pegged tokens easily. His only income stream is the reduced harvest. --- @@ -404,8 +404,8 @@ To fix the distribution, you must change what `totalShare` means -- which is the - You hold reduced pegged + unclaimed leveraged tokens - Leveraged tokens are NOT interest-bearing like wCOL -- no private yield stream - Leveraged tokens cannot be easily converted to pegged (no direct mint path, must sell on secondary market) -- The rebalance DOES reduce Minter's collateral (and thus future harvest) -- same as collateral SP -- Your ongoing harvest is permanently reduced unless you sell leveraged tokens and re-enter +- The rebalance does NOT reduce Minter's collateral (collateral is reclassified, not removed). Future harvest is unaffected by the rebalance itself. +- Your ongoing harvest is permanently reduced (smaller pegged balance = smaller pool share) unless you sell leveraged tokens and re-enter - **The effective share boost applies here too** -- unclaimed leveraged tokens valued via `leveragedTokenPrice()` count toward your effective share. But the valuation is approximate and the tokens lack the private yield that wCOL provides. ### After a rebalance (you were NOT in the pool) @@ -418,166 +418,199 @@ To fix the distribution, you must change what `totalShare` means -- which is the ## 5. Mechanism Analysis -### A. CR-Based Dynamic Fees (Penalise the Withdrawer) +### A. CR-Based Dynamic Withdrawal Fee -Replace the withdrawal window with dynamic fees on both deposits and withdrawals that scale with collateral ratio. When CR is healthy, fees are zero. +Replace the withdrawal window with a dynamic fee on withdrawals derived from the Minter's existing fee curves. When CR is healthy, the fee is naturally zero -- no threshold parameter needed. -``` -FEE_ACTIVATION_RATIO (immutable, e.g., 1.4 if rebalance threshold is 1.3) - -if CR >= FEE_ACTIVATION_RATIO: - feeRate = 0 -elif CR >= 1.0: - feeRate = (FEE_ACTIVATION_RATIO - CR) / (FEE_ACTIVATION_RATIO - 1.0) -else: - feeRate = 1.0 (100% -- depeg, effectively blocked) -``` +#### The two Minter incentive ratios -**What it solves:** -- Deters both sides of the sandwich (withdraw + re-deposit) -- Scales with systemic risk -- zero fee under normal conditions -- Removes withdrawal window UX burden (atomic withdraw) -- Enables clean ERC4626 integration (no request/wait) -- Stateless (computed from CR on each call) +The Minter already has two CR-dependent fee curves, each capturing a different aspect of systemic stress: -**What it doesn't solve:** -- Post-rebalance gap: CR jumps back up after rebalance, fees drop -- Does not compensate stayers -- only deters leavers -- Fees go to protocol, not to remaining depositors +**`mintPeggedTokenIncentiveRatio()`** -- the cost of minting pegged tokens at the current CR. Minting pegged lowers CR (more obligations, same collateral). At low CR this is heavily penalised: +- Healthy CR (> 1.50): ~0.25% fee +- Near rebalance threshold (1.20): ~1.5% fee +- Below 1.16: disallowed (1 ether = 100%) -**Bytecode impact on SP_v3:** Net -200 to -400 bytes (removing withdrawal window saves ~500-800, adding CR fee costs ~200-300). +An SP withdrawal is economically similar to minting pegged -- it removes stability from the pool, making the system weaker. The mint-pegged fee captures how much the system is harmed by this kind of action. -### B. Effective Share (Reward the Stayer) +**`redeemPeggedTokenIncentiveRatio()`** -- the incentive for redeeming pegged tokens at the current CR. Redeeming pegged raises CR (fewer obligations, collateral returned). At low CR this is *encouraged* with a discount: +- Healthy CR (> 1.25): ~0.25% fee (slight discouragement -- system is fine) +- Near rebalance threshold (1.20): ~-0.5% (discount -- system WANTS redemptions) +- Low CR (< 1.0): ~-1% (larger discount) -For harvest distribution, a depositor's effective share includes the pegged-equivalent value of their unclaimed rebalance reward. +The negative of this ratio tells us: *how much does the system value someone taking pegged tokens OUT of circulation?* At low CR the answer is "a lot" -- meaning anyone who KEEPS pegged tokens (instead of redeeming) is sitting on value the system would like to see redeemed. A depositor who withdraws pegged from the SP (keeping them in circulation, not redeeming) is doing the opposite of what the system incentivises. -``` -effectiveShare(user) = compoundedBalance(user) + peggedValueOf(unclaimedRebalanceReward(user)) -``` +#### Combining them: `fee = mintPeggedRatio - redeemPeggedRatio` -**How it works in the accumulator:** +Subtracting the redeem ratio (which is negative at low CR) amplifies the fee: -The key change: when accumulating harvest rewards, use `totalEffectiveShare` as the denominator instead of `totalAssetSupply`: ``` -harvestIntegral += reward × P_magnitude × PRECISION / totalEffectiveShare +fee = mintPeggedTokenIncentiveRatio - redeemPeggedTokenIncentiveRatio ``` -Where `totalEffectiveShare = totalAssetSupply + peggedValueOf(totalUnclaimedRebalanceReward)`. +At **healthy CR** (e.g., 1.50): +- mint ratio ≈ +0.25%, redeem ratio ≈ +0.5% +- fee = 0.25% - 0.5% = **-0.25%** → clamped to **0** (no fee) +- The two naturally cancel: the system doesn't care about SP withdrawals when healthy. -For the user's claimable harvest, use their effective share: -``` -harvestGain = effectiveShare(user) × harvestIntegralDelta / (userProduct_magnitude × PRECISION) -``` +At **CR near rebalance threshold** (e.g., 1.20): +- mint ratio ≈ +1.5%, redeem ratio ≈ -0.5% +- fee = 1.5% - (-0.5%) = **2.0%** +- Both components reinforce: minting pegged is costly (system is stressed) AND the system is offering discounts for redemptions (it wants pegged supply reduced). An SP withdrawal goes against both signals. -**The over-compensation correction:** +At **CR below disallow** (< 1.16): +- mint ratio = 1 ether (disallowed), redeem ratio ≈ -1% +- fee would be astronomical → clamped to **MAX_WITHDRAWAL_FEE** (constructor arg, e.g., 5%) +- In practice, the pool should be empty at this CR (rebalance exhausts it). The cap is a safety bound. -The effective share must NOT be the full original deposit. It should be: -``` -effectiveShare = compoundedBalance + peggedValueOf(unclaimedRebalanceReward) -``` +#### Why this works as a punitive measure -This naturally handles the over-compensation: -- **If user has NOT claimed wCOL:** boost = peggedValueOf(wCOL). They're earning private yield + boosted harvest. But the boost is based on the wCOL value, not the full lost amount. Since wCOL value ≈ lost pegged amount (at rebalance exchange rate), the total effective share ≈ original deposit. Slight over-compensation due to private yield, but it decays as users claim. -- **If user HAS claimed wCOL:** boost = 0. No double-dipping. They extracted the wCOL and lose the boost. -- **If user compounds (claims + mints + redeposits):** their compounded balance grows, boost drops to 0, net effect ≈ original deposit restored as pegged. Fair. +The combined fee punishes the withdrawer *proportionally to how much their action harms the system*, measured by the system's own existing, audited fee curves: -**Multiple rebalances:** Each rebalance adds more unclaimed wCOL. The boost is cumulative -- `unclaimedRebalanceReward` is the total across all rebalances. Claiming any portion reduces the boost proportionally. +1. **Withdrawing pegged weakens the SP** (fewer depositors to absorb rebalance losses). The mint-pegged ratio captures this: the system charges more for actions that weaken it. +2. **Withdrawing pegged instead of redeeming keeps obligations outstanding** when the system wants them reduced. The redeem ratio's negative value (discount) measures how badly the system wants pegged supply to shrink. By NOT redeeming, the withdrawer is denying the system what it needs. +3. **At healthy CR, both components cancel** -- the system doesn't need SP stability OR pegged supply reduction, so no fee. +4. **No new parameters** except `MAX_WITHDRAWAL_FEE` (constructor immutable, e.g., 5%) for the disallow edge case. -**The AC interaction:** The AC claims wCOL (boost drops to 0) and mints pegged (balance grows). The AC's effective share is always close to its actual total value. No special handling needed. +#### Implementation + +``` +int256 mintRatio = IMinter(minter).mintPeggedTokenIncentiveRatio(); +int256 redeemRatio = IMinter(minter).redeemPeggedTokenIncentiveRatio(); +int256 combined = mintRatio - redeemRatio; + +if (combined >= int256(MAX_WITHDRAWAL_FEE)) { + feeRate = MAX_WITHDRAWAL_FEE; // cap, never block withdrawals +} else if (combined <= 0) { + feeRate = 0; // healthy CR, no fee +} else { + feeRate = uint256(combined); +} +``` + +- `MAX_WITHDRAWAL_FEE`: constructor immutable (e.g., `0.05 ether` = 5%) +- Two view calls per withdrawal: `mintPeggedTokenIncentiveRatio()` + `redeemPeggedTokenIncentiveRatio()` +- Fees go to the protocol fee address (same as the current early withdrawal fee) **What it solves:** -- Stayers earn harvest proportional to their full position value (pegged + compensation) -- Natural decay via claiming -- no governance parameter -- Compounding is incentivised when healthy (low mint fee), holding when stressed (high mint fee) -- No penalty on new depositors -- they have no unclaimed reward -- AC works correctly -- claim removes boost, redeposit restores balance +- Deters withdrawals during stress (fee scales with CR deterioration) +- Naturally zero at healthy CR (no threshold parameter) +- Removes withdrawal window UX burden (atomic withdraw) +- Enables clean ERC4626 integration (no request/wait) +- Stateless, derives from audited Minter fee curves +- Adapts automatically if Minter fee config is updated **What it doesn't solve:** -- Oracle dependency: converting wCOL to pegged-equivalent requires price/rate -- Does not prevent the withdraw/re-deposit attack itself -- Leveraged SP: leveraged token pricing is approximate +- Post-rebalance gap: CR jumps back up after rebalance, fee drops immediately +- Does not compensate stayers -- only deters leavers (auto-compounding handles restoration -- see Section 5B) +- Fees go to protocol, not to remaining depositors -**Implementation: virtual effective share functions + separate accumulation paths.** +**Note on deposit fees:** Only withdrawals are penalised. Deposit fees would penalise the AC's redeposit step and legitimate new entrants. The AC restores the stayer's position via compound (deposit pegged), which should be fee-free. -The accumulator already has two virtual functions that the SP overrides: -- `_getTotalPoolShare()` → returns `(product, totalAssetSupply)` -- `_getUserPoolShare(account)` → returns `(product, storedBalance)` +**Bytecode impact on SP_v4:** Net -200 to -400 bytes (removing withdrawal window saves ~500-800, adding the two view calls + fee logic costs ~200-300). -Add parallel virtuals in the accumulator: -- `_getEffectiveTotalPoolShare()` → default: delegates to `_getTotalPoolShare()`. SP overrides to return `(product, totalAssetSupply + peggedValueOf(totalUnclaimedRebalanceReward))`. -- `_getEffectiveUserPoolShare(account)` → default: delegates to `_getUserPoolShare()`. SP overrides to return `(product, storedBalance + peggedValueOf(unclaimedRebalanceReward(account)))`. +### B. Auto-Compounding + Withdrawal Fees (Practical Fairness) -Two accumulation paths (no flags, no hidden state): +The effective share mechanism (see earlier analysis) provides mathematically precise harvest fairness. However, **auto-compounding largely supersedes it for collateral SPs**. -**Harvest path** (called from linear distributor drip via `depositReward`): -`_accumulateReward(token, amount)` uses `_getEffectiveTotalPoolShare()` for denominator. Harvest is distributed proportional to effective shares. +**How auto-compounding closes the gap:** -**Rebalance path** (called only from `notifyLiquidation`): -New `_accumulateRewardAndNotifyLoss(token, reward, loss)` in the accumulator. Uses `_getTotalPoolShare()` (actual shares, no boost) for reward accumulation, then applies loss via product. These two operations must happen atomically at the same product value. +The AC compounds by: claim wCOL → mint pegged → redeposit. After compounding, the stayer's pegged balance is restored (minus mint fee). Their harvest share immediately returns to its pre-rebalance proportion. -`notifyLiquidation` becomes: -``` -_checkpoint(address(0)) // drips pending harvest (effective shares) -_accumulateRewardAndNotifyLoss(rewardToken, returned, liquidated) // rebalance reward (actual shares) + loss -``` +For the worked example: Alice has 62.5 pegged + 166,667 fxSAVE unclaimed. The AC claims the fxSAVE, mints ~37.5 pegged (at the rebalance exchange rate, minus mint fee), and redeposits. Alice's balance grows to ~100 pegged. The 37.5% harvest gap closes in one `compound()` call. + +**The timing constraint:** The AC can only compound when the mint fee is acceptable. Right after rebalance, CR is at the threshold (1.30) and the mint fee may be high (minting lowers CR). The AC's `maxFeeRatio` caps this. If fees are too high, `compound()` skips and the gap persists until CR recovers. + +In practice: +- If CR recovers quickly (days): the gap is short-lived. Auto-compounding resolves it. +- If CR stays near threshold (weeks): the gap persists. During this stress period, the withdrawal fee (Section 5A) deters the withdraw-and-redeposit attack anyway. +- If multiple rapid rebalances occur (observed in production: 5 in succession): the AC compounds after the series ends. The gap exists during the series but is bounded. + +**Combined with withdrawal fees:** + +The withdrawal fee deters the attack. The AC closes the gap for stayers. Together: +1. Bob can't cheaply withdraw before rebalance (fee) +2. If Bob does pay the fee and withdraws, he has less capital to re-enter with +3. Alice's unclaimed wCOL is auto-compounded, restoring her harvest share +4. The window of unfairness is limited to the time between rebalance and compound + +**What this doesn't solve -- the leveraged SP:** -User-side claimable: `_claimableFrom` uses `_getEffectiveUserPoolShare` for harvest tokens and `_getUserPoolShare` for rebalance tokens. The distinction between harvest and rebalance tokens is provided by the existing alias/token registration system. +The AC can only compound wCOL (harvest rewards), not leveraged tokens (rebalance rewards). For the leveraged SP, Charlie's 37.5% harvest gap persists permanently because: +- Leveraged tokens can't be minted back to pegged +- They aren't interest-bearing (no private yield) +- The AC doesn't help with leveraged token rewards -This approach: -- No `_accumulateReward` override in SP -- only the virtual share functions are overridden -- No flags or hidden state -- denomination choice is explicit in which virtual function each path calls -- The accumulator base owns both paths -- SP only provides the effective share calculation -- ~100 bytes in accumulator (new virtual functions + `_accumulateRewardAndNotifyLoss`), ~100 bytes in SP (two overrides returning boosted values) +For leveraged SP fairness, the effective share mechanism or a separate solution would still be needed. However, this is a known risk trade-off of choosing the leveraged pool (higher risk, different reward profile), and could be addressed in a future upgrade. -### C. Combined: CR Fees + Effective Share +**Effective share as a future enhancement:** -Fees deter the movement; effective share corrects the distribution. +If needed, the effective share mechanism can be added later without breaking the AC or fee mechanism. The implementation architecture (virtual `_getEffectiveTotalPoolShare` / `_getEffectiveUserPoolShare` functions in the accumulator, overridden by SP) is compatible with both. It would provide precise fairness during the window between rebalance and compound for collateral SPs, and address the leveraged SP gap permanently. -**Bob's attack (combined):** -1. Withdrawal fee at CR=1.20: `(1.40 - 1.20) / (1.40 - 1.00) = 50%`. Bob withdraws 100 haETH, pays 50, receives 50. -2. Re-deposit fee at CR=1.30 (post-rebalance): `(1.40 - 1.30) / (1.40 - 1.00) = 25%`. Bob deposits 50, pays 12.5, credited 37.5. -3. Alice's effective share: 62.5 + peggedValueOf(166,666.67 fxSAVE) ≈ 100 haETH (the wCOL is valued at exactly the lost 37.5 haETH at the rebalance exchange rate). Bob: 37.5, no boost. -4. Alice dominates. Attack clearly unprofitable. +### C. Combined: Withdrawal Fees + Auto-Compounding + +Fees deter the withdrawal; auto-compounding restores the stayer. + +**Bob's attack:** +1. Withdrawal fee at CR=1.20: Minter's `mintPeggedTokenIncentiveRatio` is ~1.5% (below disallow threshold). Bob pays ~1.5% of 100 haETH = 1.5 haETH. Receives 98.5. +2. After rebalance (CR back to 1.30): Bob deposits 98.5 haETH. No deposit fee (withdrawal-only). +3. Alice: 62.5 pegged + 166,667 fxSAVE claimable. AC compounds: claims fxSAVE, mints ~37.5 pegged (minus mint fee), redeposits. Alice ≈ 100 pegged. +4. Result: Alice and Bob are roughly equal in pegged balance. Bob lost 1.5 haETH to the withdrawal fee. Attack is mildly unprofitable. + +**Note:** The deterrent effect depends on the Minter's fee curve magnitude. The current config has modest fees (1-1.5% near the rebalance threshold). If stronger deterrence is needed, the SP could multiply the Minter fee by a configurable factor, or use a steeper curve. **Fred (legitimate new entrant):** 1. No withdrawal fee (wasn't in pool). -2. Deposit fee at CR=1.30: 25% of 100 = 25 fee. Credited 75. -3. No effective share boost (no unclaimed). +2. Deposits 100 haETH freely. +3. No penalty. The AC auto-compounds Alice's position, so Fred doesn't dilute her. -Fred pays a fee for entering during stress. This is arguable -- a genuine supporter is penalised. The effective share alone (without deposit fee) handles Fred more fairly: no fee, but stayers have boosted shares so Fred doesn't dilute them. +Fred is treated fairly -- no fee, and auto-compounding ensures Alice's position is restored without Fred being penalised. --- ## 6. Mechanism Comparison Under Auto-Compounding -(haETH balances; the dollar values follow the price.) +The AC changes the dynamics fundamentally. Without the AC, stayers must manually claim-mint-redeposit (expensive, timing-dependent). With the AC, compounding is automatic and happens as soon as fees allow. + +**Collateral SP (Alice vs Bob):** -| Mechanism | Alice (1yr compound) | Bob (1yr compound) | Notes | -|-----------|---------------------|-------------------|-------| -| **No protection** | 62.5 × (1+r)^52 | 100 × (1+r)^52 | Bob compounds from 1.6× base | -| **CR fees only** | 62.5 × (1+r)^52 | 37.5 × (1+r)^52 | Gap reversed by fees | -| **Effective share only** | ~100 effective, compounds when claimed | 100 × (1+r)^52 | Alice's effective share matches her pre-rebalance deposit; once she compounds, both grow | -| **Combined** | ~100 effective, compounds | 37.5 × (1+r)^52 | Strongest protection | +| Mechanism | Alice after compound | Bob | Unfairness window | Notes | +|-----------|---------------------|-----|-------------------|-------| +| **No protection** | AC compounds immediately. Alice ≈ 100 pegged. | Bob 100 pegged (re-entered free). | Minimal — compound closes gap in one tx. | AC removes the ongoing harvest unfairness. But Bob had zero cost to dodge. | +| **Withdrawal fee only** | AC compounds immediately. Alice ≈ 100 pegged. | Bob ≈ 98.5 pegged (paid 1.5% fee). | Minimal. | Bob pays a small fee. Alice is made whole by AC. | +| **Withdrawal fee + AC** | Alice ≈ 100 pegged (restored). | Bob ≈ 98.5 pegged. | Near zero. | **Recommended.** Bob's attack is mildly unprofitable. Alice is restored. Simple to implement. | + +**Leveraged SP (Charlie vs Dave):** + +| Mechanism | Charlie after compound | Dave | Unfairness window | Notes | +|-----------|----------------------|------|-------------------|-------| +| **No protection** | AC compounds harvest only. Charlie 62.5 pegged (lev tokens can't be compounded). | Dave 100 pegged. | **Permanent.** | The leveraged SP gap is not closed by the AC. | +| **Withdrawal fee** | Same as above. Fee deters Dave but doesn't help Charlie. | Dave ≈ 98.5 pegged. | Permanent (but smaller). | Deterrence helps; Charlie still earns 37.5% less. | +| **Effective share (future)** | Charlie's effective share includes lev token value. Harvest boosted. | Dave 100 pegged, no boost. | Closed. | Requires accumulator changes — deferred. | + +**Key insight:** For collateral SPs, withdrawal fees + auto-compounding provide practical fairness with minimal implementation complexity. For leveraged SPs, the gap persists and the effective share mechanism (or a separate solution) is needed long-term. --- ## 7. Open Questions -1. **Deposit fees for new entrants:** are they justified, or should only withdrawals during stress be penalised? -2. **FEE_ACTIVATION_RATIO calibration:** how far above the rebalance threshold? Too close = post-rebalance gap; too far = fees on healthy activity. -3. **Effective share oracle risk:** can oracle manipulation inflate the boost? The oracle is already trusted for CR, so this is not a new attack surface, but the magnitude of impact may differ. -4. **Charlie's leveraged token boost:** `leveragedTokenPrice()` from the Minter is an approximation. Is it accurate enough for the effective share calculation? -5. **Multiple rapid rebalances:** the mechanism handles them (cumulative boost), but the oracle price may differ at each rebalance. The boost is based on current claimable value (re-priced each time), not the historical exchange rate. Is this correct? +1. **Fee curve magnitude:** The Minter's `mintPeggedTokenIncentiveRatio` reaches ~1.5% near the rebalance threshold. Is this sufficient deterrent? If not, the SP could apply a multiplier (e.g., 10× the Minter fee), but this introduces a parameter. +2. **Post-rebalance gap:** After rebalance, CR jumps back to threshold and the fee drops immediately. An attacker who can re-enter in the same block faces a low fee. Mitigation: private mempool for rebalance tx, or a brief cooldown (simpler than the full withdrawal window). +3. **Leveraged SP fairness:** Auto-compounding doesn't help Charlie. The effective share mechanism would, but adds implementation complexity. Is the leveraged SP gap acceptable as a known risk trade-off, or must it be addressed before deployment? +4. **Multiple rapid rebalances:** Production has seen 5 rebalances in succession. The AC compounds after the series ends. The unfairness window spans the full series. Is this acceptable? +5. **BOLD B-sum as future enhancement:** Proven not to help with the same denominator (Section 3), but a `totalOriginalDeposits` denominator variant (discussed in earlier analysis) could provide precise fairness. Worth revisiting if the practical approach proves insufficient? --- ## 8. Summary: Defence Layers -| Layer | Mechanism | Addresses | -|-------|-----------|-----------| -| **CR-based withdrawal fee** | Dynamic fee scaling with CR | Deters frontrun withdrawal | -| **CR-based deposit fee** | Same formula on deposits | Deters address-switching, post-rebalance re-entry | -| **Effective share boost** | Unclaimed rebalance reward counts toward harvest share | Corrects harvest distribution for stayers | -| **Private mempool** (off-chain) | Submit rebalance via Flashbots Protect | Mempool frontrunning specifically | +| Layer | Mechanism | Addresses | Status | +|-------|-----------|-----------|--------| +| **Withdrawal fee** | Derived from Minter's `mintPeggedTokenIncentiveRatio()` | Deters frontrun withdrawal | Implement in SP_v4 | +| **Auto-compounding** | AC claims wCOL, mints pegged, redeposits | Restores stayer's harvest share (collateral SP only) | Implemented (AutoCompounder_v1) | +| **Private mempool** (off-chain) | Submit rebalance via Flashbots Protect | Mempool frontrunning specifically | Operational | +| **Effective share boost** (future) | Unclaimed rebalance reward counts toward harvest share | Corrects harvest distribution (needed for leveraged SP) | Deferred | + +For collateral SPs, withdrawal fees + auto-compounding provide practical fairness: fees deter the attack, the AC restores the stayer's position. The unfairness window is bounded by the time between rebalance and compound. -The effective share mechanism corrects the harvest distribution without governance parameters, decays naturally via claiming, and interacts correctly with auto-compounding (claim removes boost, redeposit restores balance). CR-based fees complement it by deterring the attack itself. Together they address both deterrence and compensation. +For leveraged SPs, the 37.5% harvest gap persists permanently. This is a known risk trade-off of the leveraged pool. The effective share mechanism can address it in a future upgrade without breaking the existing fee or AC mechanisms. diff --git a/regression/coverage.txt b/regression/coverage.txt index 1ebaa579..ebf4b225 100644 --- a/regression/coverage.txt +++ b/regression/coverage.txt @@ -52,7 +52,7 @@ | script/src/v3/contracts/PeggedToken.sol | X 83% (20/24) | X 94% (31/33) | X 50% (3/6) | ✓ 100% (1/1) | | script/src/v3/contracts/StabilityPool.sol | ✓ 100% (26/26) | ✓ 100% (41/41) | ✓ 100% (0/0) | ✓ 100% (3/3) | | script/src/v3/contracts/StabilityPoolManager.sol | X 54% (14/26) | X 50% (15/30) | ✓ 100% (0/0) | X 50% (2/4) | -| src/autocompounding/AutoCompounder_v1.sol | X 96% (73/76) | X 97% (72/74) | X 60% (3/5) | X 94% (16/17) | +| src/autocompounding/AutoCompounder_v1.sol | X 96% (76/79) | X 97% (75/77) | X 60% (3/5) | X 94% (16/17) | | src/math/DecrementalFloatingPoint.sol | ✓ 100% (31/31) | ✓ 100% (33/33) | ✓ 100% (9/9) | ✓ 100% (6/6) | | src/minter/Genesis_v1.sol | X 99% (88/89) | ✓ 100% (88/88) | ✓ 100% (14/14) | X 92% (11/12) | | src/minter/Minter_v1.sol | X 0% (0/601) | X 0% (0/649) | X 0% (0/102) | X 0% (0/68) | @@ -77,4 +77,4 @@ | src/reward/distributor/LinearReward.sol | ✓ 100% (25/25) | ✓ 100% (27/27) | ✓ 100% (6/6) | ✓ 100% (2/2) | | src/util/FmtLib.sol | X 79% (15/19) | X 73% (19/26) | X 50% (2/4) | ✓ 100% (1/1) | | src/util/WordCodec.sol | ✓ 100% (15/15) | ✓ 100% (14/14) | ✓ 100% (0/0) | ✓ 100% (4/4) | -| Total | X 59% (4833/8160) | X 58% (5123/8831) | X 48% (429/891) | X 61% (722/1190) | +| Total | X 59% (4836/8163) | X 58% (5126/8834) | X 48% (429/891) | X 61% (722/1190) | diff --git a/regression/gas.txt b/regression/gas.txt index 2b29f418..90454a2b 100644 --- a/regression/gas.txt +++ b/regression/gas.txt @@ -5,23 +5,23 @@ src/autocompounding/AutoCompounder_v1.sol:AutoCompounder_v1 | PEGGED_TOKEN | 3.050e+02 | | STABILITY_POOL | 2.840e+02 | | WRAPPED_COLLATERAL | 3.050e+02 | -| approveCompoundTokens | 6.985e+04 | +| approveCompoundTokens | 7.004e+04 | | asset | 2.421e+03 | | balanceOf | 2.592e+03 | -| compound | 3.943e+05 | +| compound | 3.948e+05 | | decimals | 2.880e+02 | | deposit | 2.114e+05 | -| depositPeggedToken | 3.209e+05 | -| initialize | 1.015e+05 | +| depositPeggedToken | 3.213e+05 | +| initialize | 1.017e+05 | | maxFeeRatio | 2.391e+03 | | name | 1.751e+04 | | owner | 2.440e+03 | -| previewRedeem | 3.632e+04 | -| redeem | 9.753e+04 | +| previewRedeem | 3.630e+04 | +| redeem | 9.756e+04 | | setMaxFeeRatio | 2.562e+04 | -| sweep | 4.524e+04 | +| sweep | 4.525e+04 | | symbol | 1.874e+04 | -| totalAssets | 7.820e+04 | +| totalAssets | 7.818e+04 | | transferOwnership | 1.202e+04 | src/minter/Genesis_v1.sol:Genesis_v1 @@ -164,12 +164,12 @@ src/minter/StabilityPool_v3.sol:StabilityPool_v3 | EXEMPT_WITHDRAWAL_FEE_ROLE | 2.860e+02 | | LIQUIDATION_TOKEN | 3.500e+02 | | REBALANCER_ROLE | 2.840e+02 | -| REWARD_DEPOSITOR_ROLE | 3.060e+02 | -| REWARD_MANAGER_ROLE | 2.630e+02 | +| REWARD_DEPOSITOR_ROLE | 2.840e+02 | +| REWARD_MANAGER_ROLE | 3.270e+02 | | allowance | 2.789e+03 | | approve | 2.458e+04 | -| assetBalanceOf | 5.834e+03 | -| balanceOf | 5.811e+03 | +| assetBalanceOf | 8.053e+03 | +| balanceOf | 5.789e+03 | | checkpoint | 1.465e+05 | | claim(address) | 2.246e+05 | | claim(address,address) | 1.536e+05 | @@ -178,20 +178,20 @@ src/minter/StabilityPool_v3.sol:StabilityPool_v3 | claimed | 7.472e+03 | | decimals | 2.950e+02 | | deposit | 2.848e+05 | -| depositReward | 6.726e+04 | -| getWithdrawalRequest | 2.767e+03 | +| depositReward | 6.723e+04 | +| getWithdrawalRequest | 2.745e+03 | | grantRoles | 2.638e+04 | | historicalRewardTokens | 5.180e+03 | | initialize | 2.041e+05 | | name | 1.926e+04 | | notifyLiquidation | 1.235e+05 | -| owner | 2.446e+03 | +| owner | 2.424e+03 | | proxiableUUID | 3.640e+02 | | registerRewardToken | 8.852e+04 | | requestWithdrawal | 2.501e+04 | | sweep | 4.020e+04 | -| symbol | 1.950e+04 | -| totalAssetSupply | 2.423e+03 | +| symbol | 1.948e+04 | +| totalAssetSupply | 2.489e+03 | | totalSupply | 2.424e+03 | | transfer | 1.880e+05 | | transferFrom | 1.316e+05 | diff --git a/regression/sizes.txt b/regression/sizes.txt index 455a2b47..e793e6dd 100644 --- a/regression/sizes.txt +++ b/regression/sizes.txt @@ -1,6 +1,6 @@ | Contract | Runtime Size (B) | Runtime Margin (B) | Initcode Size (B) | Deploy Gas | Deploy Cost ($) | |-----------------------------------|--------------------|----------------------|---------------------|--------------|-------------------| -| AutoCompounder_v1 | 12,167 | 12,409 | 13,776 | 2,571,160 | 257.12 | +| AutoCompounder_v1 | 12,163 | 12,413 | 13,829 | 2,570,890 | 257.09 | | ConfigIncentiveLib | 85 | 24,491 | 135 | 18,350 | 1.84 | | ConfigMarket_BTC_fxUSD_mainnet | 6,677 | 17,899 | 6,705 | 1,402,450 | 140.25 | | ConfigMarket_BTC_stETH_mainnet | 6,703 | 17,873 | 6,731 | 1,407,910 | 140.79 | @@ -46,7 +46,7 @@ | StabilityPoolManager_v1 | 11,209 | 13,367 | 13,057 | 2,372,370 | 237.24 | | StabilityPool_v1 | 21,092 | 3,484 | 23,085 | 4,449,250 | 444.93 | | StabilityPool_v2 | 20,711 | 3,865 | 22,579 | 4,367,990 | 436.80 | -| StabilityPool_v3 | 23,064 | 1,512 | 25,683 | 4,869,630 | 486.96 | +| StabilityPool_v3 | 23,225 | 1,351 | 25,844 | 4,903,440 | 490.34 | | StakedETHWrappedPriceOracle_v1 | 3,695 | 20,881 | 4,653 | 785,530 | 78.55 | | StringPacking_v1 | 1,218 | 23,358 | 1,270 | 256,300 | 25.63 | | TokenDistributor_v1 | 10,116 | 14,460 | 10,365 | 2,126,850 | 212.69 | diff --git a/src/autocompounding/AutoCompounder_v1.sol b/src/autocompounding/AutoCompounder_v1.sol index 0cf76a77..d9d3a79d 100644 --- a/src/autocompounding/AutoCompounder_v1.sol +++ b/src/autocompounding/AutoCompounder_v1.sol @@ -5,6 +5,7 @@ import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Ini import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import {ERC4626Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC4626Upgradeable.sol"; import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; +import {ReentrancyGuardTransientUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardTransientUpgradeable.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import {IERC5313} from "@openzeppelin/contracts/interfaces/IERC5313.sol"; @@ -36,6 +37,7 @@ contract AutoCompounder_v1 is Initializable, UUPSUpgradeable, ERC4626Upgradeable, + ReentrancyGuardTransientUpgradeable, HarborOwnable, TokenHolder, IERC5313, @@ -129,6 +131,7 @@ contract AutoCompounder_v1 is //////////////////////////////////////////////////////////////////////////*/ /// @custom:oz-upgrades-unsafe-allow constructor + // slither-disable-next-line void-cst constructor( address stabilityPool_, address minter_, @@ -136,7 +139,11 @@ contract AutoCompounder_v1 is string memory symbol_ ) ERC20Upgradeable() ERC4626Upgradeable() { _disableInitializers(); + Token.ensureNonZeroAddress(stabilityPool_); + Token.ensureNonZeroAddress(minter_); + // slither-disable-next-line missing-zero-check STABILITY_POOL = stabilityPool_; + // slither-disable-next-line missing-zero-check MINTER = minter_; WRAPPED_COLLATERAL = IMinter(minter_).WRAPPED_COLLATERAL_TOKEN(); PEGGED_TOKEN = IMinter(minter_).PEGGED_TOKEN(); @@ -152,6 +159,7 @@ contract AutoCompounder_v1 is function initialize(address deployerOwner_, address pendingOwner_) external initializer { _initializeOwner(deployerOwner_, pendingOwner_); __UUPSUpgradeable_init(); + __ReentrancyGuardTransient_init(); __ERC4626_init(IERC20(STABILITY_POOL)); } @@ -190,8 +198,8 @@ contract AutoCompounder_v1 is /// @dev Called by the deployer after proxy creation. Approves the SP to spend pegged tokens /// and the Minter to spend wrapped collateral. function approveCompoundTokens() external onlyOwner { - IERC20(PEGGED_TOKEN).approve(STABILITY_POOL, type(uint256).max); - IERC20(WRAPPED_COLLATERAL).approve(MINTER, type(uint256).max); + IERC20(PEGGED_TOKEN).forceApprove(STABILITY_POOL, type(uint256).max); + IERC20(WRAPPED_COLLATERAL).forceApprove(MINTER, type(uint256).max); } /*////////////////////////////////////////////////////////////////////////// @@ -232,6 +240,7 @@ contract AutoCompounder_v1 is // price = underlying collateral price in peg terms (18 dec) // rate = wrapped-to-underlying rate (18 dec) // claimableValue = claimableCollateral * rate * price / 1e36 + // slither-disable-next-line unused-return (, , , , uint256 price, uint256 rate) = IMinter_v3(MINTER).mintPeggedTokenDryRun( claimableCollateral, type(uint256).max @@ -244,7 +253,7 @@ contract AutoCompounder_v1 is //////////////////////////////////////////////////////////////////////////*/ /// @inheritdoc IAutoCompounder - function compound() external { + function compound() external nonReentrant { uint256 claimable = IMultipleRewardAccumulator(STABILITY_POOL).claimable(address(this), WRAPPED_COLLATERAL); if (claimable == 0) { revert NothingToCompound(); @@ -253,6 +262,7 @@ contract AutoCompounder_v1 is uint256 maxFee = _getAutoCompounderStorage().maxFeeRatio; // Dry run to see how much can be profitably minted within the fee cap + // slither-disable-next-line unused-return (, , uint256 collateralTaken, , , ) = IMinter_v3(MINTER).mintPeggedTokenDryRun(claimable, maxFee); if (collateralTaken == 0) { @@ -271,9 +281,11 @@ contract AutoCompounder_v1 is ); // Mint pegged tokens from the claimed collateral + // slither-disable-next-line unused-return (uint256 minted, ) = IMinter_v3(MINTER).mintPeggedToken(collateralTaken, address(this), 0, maxFee); // Deposit minted pegged tokens back into the SP + // slither-disable-next-line unused-return IStabilityPool(STABILITY_POOL).deposit(minted, address(this), 0); emit Compounded(msg.sender, claimable, collateralTaken, minted); @@ -284,7 +296,8 @@ contract AutoCompounder_v1 is //////////////////////////////////////////////////////////////////////////*/ /// @inheritdoc IAutoCompounder - function depositPeggedToken(uint256 peggedAmount, address receiver) external returns (uint256 shares) { + // slither-disable-next-line reentrancy-no-eth + function depositPeggedToken(uint256 peggedAmount, address receiver) external nonReentrant returns (uint256 shares) { peggedAmount = Token.allOf(msg.sender, PEGGED_TOKEN, peggedAmount); // Snapshot exchange rate BEFORE the SP deposit changes totalAssets @@ -294,11 +307,13 @@ contract AutoCompounder_v1 is // Transfer pegged tokens from caller, deposit to SP IERC20(PEGGED_TOKEN).safeTransferFrom(msg.sender, address(this), peggedAmount); uint256 spBalanceBefore = IERC20(STABILITY_POOL).balanceOf(address(this)); + // slither-disable-next-line unused-return IStabilityPool(STABILITY_POOL).deposit(peggedAmount, address(this), 0); uint256 spReceived = IERC20(STABILITY_POOL).balanceOf(address(this)) - spBalanceBefore; // Compute shares at the pre-deposit exchange rate (matches ERC4626._convertToShares) shares = Math.mulDiv(spReceived, supplyBefore + 1, assetsBefore + 1); + // slither-disable-next-line incorrect-equality if (shares == 0) { revert DepositPeggedTokenZeroShares(); } diff --git a/src/interfaces/IRewardAlias.sol b/src/interfaces/IRewardAlias.sol new file mode 100644 index 00000000..7d92bb7e --- /dev/null +++ b/src/interfaces/IRewardAlias.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT + +pragma solidity >=0.8.28 <0.9.0; + +/// @notice Interface for a reward token alias. +/// @dev If a reward token address implements this interface and returns a non-zero underlying, +/// the reward system treats it as an alias: integrals track under the alias address, +/// but token transfers use the underlying address. +interface IRewardAlias { + error ZeroAddress(); + + /// @notice Returns the underlying token this alias represents. + /// @return The underlying token address. address(0) means not an alias. + function underlying() external view returns (address); +} diff --git a/src/interfaces/IStabilityPool_v3.sol b/src/interfaces/IStabilityPool_v3.sol new file mode 100644 index 00000000..fe338c4e --- /dev/null +++ b/src/interfaces/IStabilityPool_v3.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT + +pragma solidity >=0.8.28 <0.9.0; + +import {IMultipleRewardAccumulator_v3} from "src/interfaces/IMultipleRewardAccumulator_v3.sol"; + +/// @notice StabilityPool v3 additions: unified claim interface. +/// @dev Does NOT inherit IStabilityPool — the SP_v3 contract inherits both separately. +// solhint-disable-next-line contract-name-capwords,no-empty-blocks +interface IStabilityPool_v3 is IMultipleRewardAccumulator_v3 {} diff --git a/src/reward/RewardAlias_v1.sol b/src/reward/RewardAlias_v1.sol new file mode 100644 index 00000000..c328f993 --- /dev/null +++ b/src/reward/RewardAlias_v1.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.30; + +import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; +import {IERC5313} from "@openzeppelin/contracts/interfaces/IERC5313.sol"; +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; + +import {HarborOwnable} from "@bao/HarborOwnable.sol"; +import {IRewardAlias} from "src/interfaces/IRewardAlias.sol"; + +/// @title RewardAlias_v1 +/// @notice A minimal UUPS-upgradeable contract that identifies itself as an alias for an underlying reward token. +/// @dev Deploy via BaoFactory (CREATE3) at a predictable address. +/// The reward system detects aliases via IRewardAlias.underlying() during registration. +/// The alias address is used for integral tracking; the underlying is used for token transfers. +// solhint-disable-next-line contract-name-capwords +contract RewardAlias_v1 is Initializable, UUPSUpgradeable, HarborOwnable, IERC5313, IRewardAlias { + /// @notice The underlying reward token this alias represents. + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + address internal immutable UNDERLYING; // solhint-disable-line immutable-vars-naming + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor(address underlying_) { + _disableInitializers(); + if (underlying_ == address(0)) { + revert ZeroAddress(); + } + UNDERLYING = underlying_; + } + + /// @notice Initialize ownership. + /// @param deployerOwner_ The initial (temporary) owner — typically the FactoryDeployer contract. + /// @param pendingOwner_ The final owner — typically the Harbor multisig. + function initialize(address deployerOwner_, address pendingOwner_) external initializer { + _initializeOwner(deployerOwner_, pendingOwner_); + __UUPSUpgradeable_init(); + } + + /// @inheritdoc IERC5313 + function owner() public view override(HarborOwnable, IERC5313) returns (address owner_) { + owner_ = HarborOwnable.owner(); + } + + /// @inheritdoc IRewardAlias + function underlying() external view returns (address) { + return UNDERLYING; + } + + /// @inheritdoc IERC165 + function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + return interfaceId == type(IERC5313).interfaceId || super.supportsInterface(interfaceId); + } + + /// @notice Authorize upgrades — only owner can upgrade. + function _authorizeUpgrade(address) internal override onlyOwner {} // solhint-disable-line no-empty-blocks +} diff --git a/src/reward/distributor/LinearMultipleRewardDistributor_v3.sol b/src/reward/distributor/LinearMultipleRewardDistributor_v3.sol new file mode 100644 index 00000000..5a1444a5 --- /dev/null +++ b/src/reward/distributor/LinearMultipleRewardDistributor_v3.sol @@ -0,0 +1,339 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.30; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {ContextUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ContextUpgradeable.sol"; +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; + +import {BaoOwnableRoles} from "@bao/BaoOwnableRoles.sol"; + +import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; +import {IRewardAlias} from "src/interfaces/IRewardAlias.sol"; +import {LinearReward} from "./LinearReward.sol"; + +// solhint-disable no-empty-blocks +// solhint-disable not-rely-on-time + +/// @title Linear Multiple Reward Distributor +/// @dev A base contract for distributing multiple reward tokens linearly over time. +/// +/// This contract manages the registration, tracking, and linear distribution of +/// multiple reward tokens. It maintains a list of active and historical reward tokens, +/// associates distributors using roles based access, and calculates distribution rates +/// over defined time periods. +/// +/// Key features: +/// - Register and unregister reward tokens +/// - Configure linear reward distribution with customizable period lengths +/// - Track pending and distributed rewards +/// - Manage active and historical reward tokens +/// +/// The contract uses a role-based access control system to manage distributors +/// and supports immediate or time-based reward distribution depending on the +/// configured period length. +// solhint-disable-next-line contract-name-capwords +abstract contract LinearMultipleRewardDistributor_v3 is + Initializable, + ContextUpgradeable, + BaoOwnableRoles, + IMultipleRewardDistributor +{ + using EnumerableSet for EnumerableSet.AddressSet; + using SafeERC20 for IERC20; + + using LinearReward for LinearReward.RewardData; + + /************* + * Constants * + *************/ + + /// @notice The role used to manage rewards. + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + uint256 public immutable REWARD_MANAGER_ROLE; + + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + uint256 public immutable REWARD_DEPOSITOR_ROLE; + + /// @notice The length of reward period in seconds. + /// @dev If the value is zero, the reward will be distributed immediately. + /// @dev It is either zero or at least 1 day (which is 86400). + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + uint40 public immutable REWARD_PERIOD_LENGTH; + + /************* + * Variables * + *************/ + + struct LinearMultipleRewardDistributorStorage { + /// @notice Mapping from reward token address to linear distribution reward data. + mapping(address => LinearReward.RewardData) rewardData; + /// @dev The list of active reward tokens. + EnumerableSet.AddressSet activeRewardTokens; + /// @dev The list of historical reward tokens. + EnumerableSet.AddressSet historicalRewardTokens; + /// @dev Alias address => underlying token address. Set at registration, used for token transfers. + mapping(address => address) aliasToUnderlying; + /// @dev Underlying token => ordered list of aliases (drain order for claimSingle(underlying)). + mapping(address => address[]) aliases; + } + + // chisel eval 'keccak256(abi.encode(uint256(keccak256("bao.storage.LinearMultipleRewardDistributor")) - 1)) & ~bytes32(uint256(0xff))' + bytes32 private constant _LINEARMULTIPLEREWARDDISTRIBUTOR_STORAGE = + 0xe9dd8489e2940f6fb582767a094c112cfce2739b7a5f3357b085cab0a6a7d300; + + function _getLinearMultipleRewardDistributorStorage() + private + pure + returns (LinearMultipleRewardDistributorStorage storage $) + { + // solhint-disable-next-line no-inline-assembly + assembly { + $.slot := _LINEARMULTIPLEREWARDDISTRIBUTOR_STORAGE + } + } + + /*************** + * Constructor * + ***************/ + /// @dev there is no need for an initializer + /// @dev abstract classes should not define role numbers, so pass them in + /// @custom:oz-upgrades-unsafe-allow constructor + constructor(uint256 rewardManagerRole, uint256 rewardDepositorRole, uint40 periodLength_) { + REWARD_MANAGER_ROLE = rewardManagerRole; + + if (periodLength_ != 0 && (periodLength_ < 1 days || periodLength_ > 28 days)) { + revert InvalidPeriodLength(periodLength_); + } + REWARD_PERIOD_LENGTH = periodLength_; + REWARD_DEPOSITOR_ROLE = rewardDepositorRole; + } + + /************************* + * Public View Functions * + *************************/ + + /// @inheritdoc IMultipleRewardDistributor + function rewardData( + address token + ) external view returns (uint256 lastUpdate, uint256 finishAt, uint256 rate, uint256 queued) { + LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); + LinearReward.RewardData memory data = $.rewardData[token]; + return (data.lastUpdate, data.finishAt, data.rate, data.queued); + } + + /// @inheritdoc IMultipleRewardDistributor + // slither-disable-next-line shadowing-local // this isn't shadowing, it's implementing an interface + function activeRewardTokens() public view override returns (address[] memory rewardTokens) { + LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); + rewardTokens = $.activeRewardTokens.values(); + } + + /// @inheritdoc IMultipleRewardDistributor + function isActiveRewardToken(address token) public view returns (bool isActive) { + LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); + isActive = $.activeRewardTokens.contains(token); + } + + /// @inheritdoc IMultipleRewardDistributor + function historicalRewardTokens() public view override returns (address[] memory rewardTokens) { + LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); + rewardTokens = $.historicalRewardTokens.values(); + } + + /// @inheritdoc IMultipleRewardDistributor + function pendingRewards( + address token + ) external view override returns (uint256 distributable, uint256 undistributed) { + (distributable, undistributed) = _pendingRewards(token); + } + + /**************************** + * Public Mutator Functions * + ****************************/ + + /// @inheritdoc IMultipleRewardDistributor + function depositReward(address token, uint256 amount) external override onlyOwnerOrRoles(REWARD_DEPOSITOR_ROLE) { + address _distributor = _msgSender(); + LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); + + if (!$.activeRewardTokens.contains(token)) { + revert NotActiveRewardToken(); + } + if (amount > 0) { + IERC20(_resolveUnderlying(token)).safeTransferFrom(_distributor, address(this), amount); + } + + _distributePendingReward(); + + _notifyReward(token, amount); + + emit DepositReward(token, amount); + } + + /************************ + * Restricted Functions * + ************************/ + + /// @inheritdoc IMultipleRewardDistributor + function registerRewardToken(address token) external onlyOwnerOrRoles(REWARD_MANAGER_ROLE) { + _registerRewardToken(token); + } + + /// @notice Register a reward token with an ordered list of aliases. + /// @dev Each alias must implement IRewardAlias.underlying() returning `token`. + /// Aliases are registered as active tokens with their own integrals. + /// claimSingle(underlying) drains aliases in this order, then underlying's own. + /// @param token The underlying reward token. + /// @param tokenAliases Ordered list of alias addresses (drain order). + function registerRewardToken( + address token, + address[] calldata tokenAliases + ) external onlyOwnerOrRoles(REWARD_MANAGER_ROLE) { + _registerRewardToken(token); + + LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); + for (uint256 i = 0; i < tokenAliases.length; i++) { + address alias_ = tokenAliases[i]; + // Reverts if alias doesn't implement underlying() or returns wrong address + // slither-disable-next-line calls-loop + if (IRewardAlias(alias_).underlying() != token) { + revert AliasUnderlyingMismatch(); + } + _registerRewardToken(alias_); + $.aliasToUnderlying[alias_] = token; + $.aliases[token].push(alias_); + } + } + + function _registerRewardToken(address token) internal { + if (token == address(0)) { + revert RewardTokenIsZero(); + } + LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); + + if (!$.activeRewardTokens.add(token)) { + revert DuplicatedRewardToken(); + } + // slither-disable-next-line unused-return we don't care if the the token was already in the set + $.historicalRewardTokens.remove(token); // wake-disable-line unchecked-return-value + + emit RegisterRewardToken(token); + } + + /// @inheritdoc IMultipleRewardDistributor + function unregisterRewardToken(address token) external onlyOwnerOrRoles(REWARD_MANAGER_ROLE) { + _unregisterRewardToken(token); + + // If token has aliases, unregister them too (they're a unit) + LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); + address[] storage tokenAliases = $.aliases[token]; + for (uint256 i = 0; i < tokenAliases.length; i++) { + address alias_ = tokenAliases[i]; + _unregisterRewardToken(alias_); + delete $.aliasToUnderlying[alias_]; + } + delete $.aliases[token]; + } + + function _unregisterRewardToken(address token) internal { + LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); + + if (!$.activeRewardTokens.remove(token)) { + revert NotActiveRewardToken(); + } + LinearReward.RewardData memory _data = $.rewardData[token]; + unchecked { + (uint256 _distributable, uint256 _undistributed) = _data.pending(); + if (_data.queued < REWARD_PERIOD_LENGTH) { + _data.queued = 0; // ignore round error + } + if (_data.queued + _distributable + _undistributed > 0) { + revert RewardDistributionNotFinished(); + } + } + + // slither-disable-next-line unused-return + $.historicalRewardTokens.add(token); // wake-disable-line unchecked-return-value + emit UnregisterRewardToken(token); + } + + /********************** + * Internal Functions * + **********************/ + + /// @dev Internal function to notify new rewards. + /// + /// @param token The address of token. + /// @param amount The amount of new rewards. + function _notifyReward(address token, uint256 amount) internal { + LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); + + if (REWARD_PERIOD_LENGTH == 0) { + _accumulateReward(token, amount); + } else { + LinearReward.RewardData memory data = $.rewardData[token]; + data.increase(REWARD_PERIOD_LENGTH, amount); + $.rewardData[token] = data; + } + } + + /// @dev Internal function to distribute all pending reward tokens. + function _distributePendingReward() internal { + LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); + + // If the reward period length is zero, we distribute rewards immediately. + // If there are no active reward tokens, we do nothing. + if (REWARD_PERIOD_LENGTH == 0 || $.activeRewardTokens.length() == 0) { + return; + } + address[] memory activeRewardTokens_ = $.activeRewardTokens.values(); + for (uint256 i = 0; i < activeRewardTokens_.length; i++) { + address token = activeRewardTokens_[i]; + + // slither-disable-next-line unused-return + (uint256 pending, ) = $.rewardData[token].pending(); + + $.rewardData[token].lastUpdate = uint40(block.timestamp); + + if (pending > 0) { + _accumulateReward(token, pending); + } + } + } + + function _getRewardData(address token) internal view returns (LinearReward.RewardData storage) { + LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); + return $.rewardData[token]; + } + + /// @dev Internal function to accumulate distributed rewards. + /// @dev derived contracts should implement this + /// @param token The address of token. + /// @param amount The amount of rewards to accumulate. + function _accumulateReward(address token, uint256 amount) internal virtual; + + function _pendingRewards(address token) internal view returns (uint256 distributable, uint256 undistributed) { + LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); + (distributable, undistributed) = $.rewardData[token].pending(); + } + + // ═══════════════════════════════════════════════════════════════════════ + // Alias support + // ═══════════════════════════════════════════════════════════════════════ + + /// @dev Returns the underlying token for transfers. If not an alias, returns the token itself. + function _resolveUnderlying(address token) internal view returns (address) { + LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); + address underlying = $.aliasToUnderlying[token]; + return underlying != address(0) ? underlying : token; + } + + /// @dev Returns the ordered alias list for an underlying token. + function _getAliases(address token) internal view returns (address[] memory) { + LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); + return $.aliases[token]; + } +} diff --git a/test/StabilityPool_v3_ERC20.t.sol b/test/StabilityPool_v3_ERC20.t.sol index df91cd31..d1a501be 100644 --- a/test/StabilityPool_v3_ERC20.t.sol +++ b/test/StabilityPool_v3_ERC20.t.sol @@ -497,4 +497,78 @@ contract TestStabilityPool_v3_ERC20 is DeployEURSetUp { assertApproxEqAbs(IERC20(sp).balanceOf(user1), 50 ether, 1, "user1 50 after loss"); assertApproxEqAbs(IERC20(sp).balanceOf(user2), 50 ether, 1, "user2 50 after loss"); } + + // ═══════════════════════════════════════════════════════════════════════ + // Transfer after loss: _transferBalance bug + // ═══════════════════════════════════════════════════════════════════════ + + /// Intent: after a loss, transferring the FULL compounded balance should leave the sender with 0. + /// Bug: _transferBalance subtracts the compounded amount from the stored amount (which is larger), + /// leaving a phantom balance that compounds to a non-zero value. + function test_transferFullBalanceAfterLoss_senderHasZero() public { + _deposit(user1, 100 ether); + + // Apply a 37.5% loss (same as the worked example: 100 -> 62.5) + _applyLoss(37.5 ether, 37.5 ether); + + uint256 balanceAfterLoss = IERC20(sp).balanceOf(user1); + assertApproxEqAbs(balanceAfterLoss, 62.5 ether, 1e15, "user1 has 62.5 after loss"); + + // Transfer the full compounded balance to user2 + vm.prank(user1); + IERC20(sp).transfer(user2, balanceAfterLoss); + + // Sender should have 0 + assertEq(IERC20(sp).balanceOf(user1), 0, "sender should have 0 after full transfer"); + // Receiver should have the full amount + assertApproxEqAbs(IERC20(sp).balanceOf(user2), balanceAfterLoss, 1, "receiver gets the full amount"); + } + + /// Intent: after a loss, transferring a partial compounded amount should leave sender with the remainder. + function test_transferPartialBalanceAfterLoss_correctRemainder() public { + _deposit(user1, 100 ether); + + // Apply a 37.5% loss: 100 -> 62.5 + _applyLoss(37.5 ether, 37.5 ether); + + uint256 balanceAfterLoss = IERC20(sp).balanceOf(user1); + uint256 halfBalance = balanceAfterLoss / 2; // ~31.25 + + // Transfer half the compounded balance + vm.prank(user1); + IERC20(sp).transfer(user2, halfBalance); + + // Sender should have the other half + uint256 senderRemaining = IERC20(sp).balanceOf(user1); + assertApproxEqAbs(senderRemaining, balanceAfterLoss - halfBalance, 1, "sender has correct remainder"); + // Receiver should have what was sent + assertApproxEqAbs(IERC20(sp).balanceOf(user2), halfBalance, 1, "receiver has correct amount"); + // Total should be conserved + assertApproxEqAbs(senderRemaining + IERC20(sp).balanceOf(user2), balanceAfterLoss, 1, "total conserved"); + } + + /// Intent: two sequential transfers after a loss should both work correctly. + function test_twoTransfersAfterLoss_totalConserved() public { + _deposit(user1, 100 ether); + + // Apply a 50% loss: 100 -> 50 + _applyLoss(50 ether, 50 ether); + + uint256 balanceAfterLoss = IERC20(sp).balanceOf(user1); + uint256 firstTransfer = 20 ether; + uint256 secondTransfer = 20 ether; + + // First transfer + vm.prank(user1); + IERC20(sp).transfer(user2, firstTransfer); + + // Second transfer + vm.prank(user1); + IERC20(sp).transfer(user3, secondTransfer); + + uint256 remaining = IERC20(sp).balanceOf(user1); + uint256 total = remaining + IERC20(sp).balanceOf(user2) + IERC20(sp).balanceOf(user3); + assertApproxEqAbs(total, balanceAfterLoss, 2, "total conserved across 3 addresses"); + assertApproxEqAbs(remaining, balanceAfterLoss - firstTransfer - secondTransfer, 1, "sender remainder correct"); + } } From 4b14d76da7bc6e9b4e7e590fd388a211a7f658bb Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Sun, 12 Apr 2026 10:40:36 +0100 Subject: [PATCH 026/232] first cut harbor yield contract. comparison with existing hyToken in another repo --- doc/ideas/harbor-yield-comparison.md | 176 ++++++++++++++ foundry.toml | 1 + script/src/v3/contracts/HarborYield.sol | 70 ++++++ src/autocompounding/HarborYield_v1.sol | 302 ++++++++++++++++++++++++ src/interfaces/IHarborYield.sol | 57 +++++ 5 files changed, 606 insertions(+) create mode 100644 doc/ideas/harbor-yield-comparison.md create mode 100644 script/src/v3/contracts/HarborYield.sol create mode 100644 src/autocompounding/HarborYield_v1.sol create mode 100644 src/interfaces/IHarborYield.sol diff --git a/doc/ideas/harbor-yield-comparison.md b/doc/ideas/harbor-yield-comparison.md new file mode 100644 index 00000000..7a04e419 --- /dev/null +++ b/doc/ideas/harbor-yield-comparison.md @@ -0,0 +1,176 @@ +# Harbor Yield: Implementation Comparison + +**harbor** (AutoCompounder_v1 + HarborYield_v1) vs **harbor-yield.wip-hytoken** (hyToken_v1 + HarborAnchoredVault_v1) + +## 1. Architecture + +### harbor: Two-Layer Separation + +``` +User → HarborYield_v1 (holds ERC4626 vault shares) + ├→ AutoCompounder_v1 (ERC4626, wraps one SP) + │ └→ StabilityPool_v3 + ├→ AutoCompounder_v1 (ERC4626, wraps another SP) + │ └→ StabilityPool_v3 + └→ wstETH/fxSAVE (ERC4626, external) +``` + +- **AutoCompounder_v1**: One per SP. Non-rebasing ERC4626. Wraps a rebasing SP token. Compounds harvest rewards (claim wCOL → mint pegged → redeposit). 12KB. +- **HarborYield_v1**: One per peg. Manages multiple ERC4626 vaults (ACs + equivalents). 10KB. +- Total: ~22KB across two contracts, plus the SP. + +### harbor-yield.wip-hytoken: Monolith + Distributor + +``` +User → hyToken_v1 (talks directly to one SP + one secondary asset) + └→ StabilityPool (v1 or v2) + +User → HarborAnchoredVault_v1 (distributes across multiple SPs by weight) + ├→ StabilityPool (collateral 1) + └→ StabilityPool (collateral 2) +``` + +- **hyToken_v1**: One per SP×peg combination. Monolith: handles deposits, withdrawals, claiming, compounding, 1inch swapping, rebalancing, withdrawal requests, oracle pricing, multi-asset accounting. 1175 lines. All-in-one. Swap logic is at this lower level. +- **HarborAnchoredVault_v1**: Weighted distributor across multiple SPs for one pegged asset. Separate concern from compounding/swapping. 358 lines. Clean ERC4626. +- Both are two-level architectures, but they don't compose: HarborAnchoredVault distributes deposits across SPs but has no compounding, while hyToken compounds but only handles one SP. A user wanting multi-SP + compounding cannot get both from either system alone. + +--- + +## 2. Feature Comparison + +| Feature | harbor AC + HY | hyToken_v1 | Winner | +|---------|---------------|------------|--------| +| **ERC4626 compliance** | AC is standard ERC4626. HY is custom (multi-asset deposit/redeem). | ERC4626 with overrides. `asset()` = primary asset. | **harbor** (AC is cleaner ERC4626) | +| **Multi-collateral** | HY manages N vaults (ACs + equivalents). Dynamic add/remove. | hyToken is 1:1 with SP. HarborAnchoredVault distributes across N SPs by weight. | **harbor** (single contract manages all) | +| **Compounding** | AC claims wCOL from SP, mints pegged, redeposits. Uses Minter_v3 fee-capped mint. Permissionless. | `claim()` claims from SP. If CR favorable: mint + redeposit. If not: emit SwapRequested, keeper executes 1inch swap. | **hyToken** (handles unfavorable CR via swap to secondary asset) | +| **Secondary asset management** | HY holds ERC4626 vault shares (wstETH wrapper, fxSAVE wrapper). Values via `convertToAssets`. No swap logic yet. | hyToken holds wstETH directly. Uses 1inch for swaps. Keeper-operated. Price oracles for valuation. | **hyToken** (swap logic implemented, but tightly coupled) | +| **Withdrawal** | AC: standard ERC4626 redeem (returns SP tokens). HY: proportional redeem from all vaults. | Withdrawal request + time window + early fee. Multi-asset withdrawal (secondary first, then primary). | **Tie** — both work but differently. hyToken's withdrawal window is being deprecated. | +| **Oracle dependency** | AC uses `mintPeggedTokenDryRun` for valuation (Minter is the oracle). No external oracle. HY uses `convertToAssets` from each vault. | Uses `IHarborSingleFeedAndRateAggregator` for both primary and secondary assets. Required for non-ETH pegs. | **harbor** (less oracle surface) | +| **1inch integration** | None. | Built into hyToken. `executeSwapWith1inch()` with keeper role. | **hyToken** (has it, harbor doesn't) | +| **Access control** | AC: owner only (setMaxFeeRatio, approveCompoundTokens). HY: owner (addVault, activate/deactivate). Compound is permissionless. | KEEPER_ROLE, EMERGENCY_ROLE, MAINTENANCE_ROLE + owner. More granular. | **hyToken** (more roles, but more complexity) | +| **Emergency functions** | AC: sweep (owner). HY: sweep (owner). | `emergencyWithdrawFromStabilityPool()` + maintenance mode toggle. | **hyToken** (dedicated emergency) | +| **Balance tracking** | AC trusts SP.balanceOf + claimable. HY trusts vault.balanceOf + convertToAssets. No internal balance tracking. | Internal `primaryAssetBalance` + `secondaryAssetBalance` tracking alongside actual balances. | **harbor** (simpler, no divergence risk) | +| **Rebalance handling** | AC: absorbs loss passively (SP product mechanism). `totalAssets` reflects it. No active rebalancing. | hyToken: active rebalancing via MAINTENANCE_ROLE. Can withdraw from SP, redeem pegged, swap to secondary. | **hyToken** (active rebalance) | +| **Upgrade path** | Both UUPS. AC+HY are separate — can upgrade independently. | UUPS. Single contract upgrade. | **harbor** (independent upgrades) | + +--- + +## 3. Critical Analysis + +### harbor (AC + HY) — Honest Assessment + +**Strengths:** +- Clean separation of concerns. AC does one thing (compound one SP). HY does one thing (manage multiple ERC4626 vaults). +- Each contract is independently testable, deployable, upgradeable. +- ERC4626 all the way down — composable with any ERC4626 tooling. +- No internal balance tracking — trusts the underlying vaults. Less state, less divergence risk. +- No oracle dependency for the AC (uses Minter dry run). +- Permissionless compounding — anyone can trigger. +- Small contracts (10-12KB each) with room to grow. + +**Weaknesses:** +- **No swap logic.** When CR is unfavorable and the AC can't profitably mint, it just... waits. The rewards sit as unclaimed wCOL in the SP, valued in `totalAssets` via dry run. There's no mechanism to convert them to a secondary asset. +- **No active rebalance.** If the SP rebalances, the AC passively absorbs the loss. No ability to proactively move assets to a safer position. +- **HarborYield is thin.** It adds/removes ERC4626 vaults and does proportional redeem. No pricing logic beyond `convertToAssets`. No compound logic (removed). The "Level 2" in the design doc promised wXXXn → wCOLn → haXXX conversion, which requires swap infrastructure that doesn't exist yet. +- **No keeper/bounty system.** Compounding is permissionless but there's no incentive to trigger it. In hyToken, the 0.25% bounty incentivizes bots. +- **No emergency functions** beyond sweep. If the SP is in distress, there's no emergency withdraw. +- **Withdrawal window inheritance.** The AC uses `EXEMPT_WITHDRAWAL_FEE_ROLE` to bypass the SP's withdrawal window. This couples the AC to the SP's fee mechanism. When the SP moves to CR-based fees, this needs updating. + +### harbor-yield.wip-hytoken (hyToken_v1) — Honest Assessment + +**Strengths:** +- **End-to-end.** Handles the full lifecycle: deposit, compound, swap, rebalance, emergency, withdrawal. Nothing is deferred. +- **1inch integration.** Can swap wCOL to secondary assets when CR is unfavorable. This is a real operational need. +- **Bounty system.** 0.25% bounty on `claim()` incentivizes keepers. +- **Emergency functions.** Dedicated emergency withdrawal and maintenance mode. +- **Granular roles.** KEEPER, EMERGENCY, MAINTENANCE — clear separation of operational concerns. +- **Multi-asset withdrawal.** Can return secondary asset first (wstETH), then primary. + +**Weaknesses:** +- **Monolith.** 1175 lines in one contract. Compounding, swapping, oracle pricing, withdrawal requests, balance tracking, rebalancing — all in one place. Hard to test individual pieces. Hard to upgrade one concern without touching everything. +- **Internal balance tracking.** `primaryAssetBalance` and `secondaryAssetBalance` are maintained alongside actual token balances. If they diverge (bug, unexpected transfer, token rebasing), the vault misbehaves. This is a significant risk vector. +- **Not ERC4626-composable.** The `asset()` is PRIMARY_ASSET but the vault actually holds two assets. `totalAssets()` sums both in "ETH terms" via oracles. Standard ERC4626 tooling expects `totalAssets()` to be in units of `asset()`. This breaks composability. +- **Oracle-heavy.** Needs `primaryAssetPriceOracle` + `secondaryAssetPriceOracle`. More oracle surface = more manipulation risk + more operational burden. +- **1:1 with SP.** One hyToken per SP. For N collateral types, you need N hyTokens + a separate HarborAnchoredVault to combine them. harbor's architecture handles this with N ACs + 1 HY. +- **1inch dependency.** Off-chain route calculation required. Keeper must call `executeSwapWith1inch()` with pre-computed route data. Two-step async flow for what could be a simple swap. +- **HarborAnchoredVault is disconnected.** It distributes deposits across SPs by weight but has no compounding, no swap logic, no secondary asset handling. It's a dumb distributor. The interesting logic is all in hyToken, which only handles one SP. There's a gap: no single contract combines multi-SP management with compounding/swapping. +- **Withdrawal window duplicated.** hyToken re-implements the SP's withdrawal window logic internally, including request/cancel/timing. This duplicates what the SP already does and will be further duplicated when the SP moves to CR-based fees. +- **StabilityPool_v2 dependency.** Uses an older SP that doesn't have ERC20 transfers, unified claim, or aliases. The `claim()` uses a raw `call` with `encodeWithSignature("claim(address)")` — fragile. +- **`_HYTOKEN_STORAGE` hash is incorrect.** The storage slot `0x8a4c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c00` is a placeholder, not a computed ERC7201 hash. This would cause storage collisions in a real deployment. +- **Constants like `COLLATERAL_RATIO_BUFFER = 0.05 ether`** are hardcoded. Should be configurable or at least constructor args. +- **`_shouldMintPrimaryAsset()` uses `rebalanceThreshold + 5% buffer`** — but this buffer is arbitrary and doesn't account for the fee-capped minting that the harbor AC uses. The harbor AC uses `maxFeeRatio` to let the Minter decide, which is more precise. + +### HarborAnchoredVault_v1 — Honest Assessment + +**Strengths:** +- Simple, clean ERC4626. +- Weighted distribution is clear and correct. +- Uses Solady's ERC4626 (gas-efficient). + +**Weaknesses:** +- No compounding, no reward claiming, no swap logic. It's a deposit router, not a yield vault. +- Fixed weights at initialization, no ability to rebalance or update. +- Calls `IStabilityPool.assetBalanceOf` which doesn't exist on SP_v3 (renamed to `balanceOf`). Would need updating. +- Deposits haToken directly to SPs — no fee-capped minting, no wCOL handling. Assumes user already has the pegged token. +- No relationship with hyToken — they don't compose. A complete solution would need both, but they don't share any infrastructure. + +--- + +## 4. Should They Merge? + +**Yes.** Neither codebase is complete on its own: +- harbor has clean architecture but no swap logic and a thin HarborYield. +- hyToken has swap logic and operational features but is a monolith that doesn't scale to multi-collateral. + +### Recommended Merge Approach + +**Keep harbor's two-layer architecture (AC + HY) but bring hyToken's operational features into it:** + +1. **AutoCompounder_v1** — keep as-is. Clean ERC4626 wrapper for one SP. Add: + - Bounty system (0.25% to caller on compound — from hyToken) + - Nothing else. The AC stays simple. + +2. **HarborYield_v1** — this is where hyToken's features belong. Extend with: + - **`compound()`**: For each managed vault that's an AC, call `ac.compound()`. For equivalent token vaults, convert holdings to AC shares when fees are acceptable (this needs the swapper). + - **Swapper integration**: `ISwapper` interface for converting between tokens (wXXXn → wCOLn, or wCOLn → haXXX). Initially a simple wrapper around a DEX aggregator; later can be 1inch, Paraswap, or any router. Keep the swap execution in the HY (not the AC) because equivalent token management is the HY's concern. + - **Keeper role + bounty**: KEEPER_ROLE can trigger compound + swaps. Bounty incentivizes keepers. + - **Emergency withdraw**: Pull all AC shares back to HY, optionally redeem to underlying SP tokens. + - **No withdrawal window**: Rely on the SP's upcoming CR-based fees. The HY just calls AC.redeem(), which calls SP.withdraw(). Fees are handled at the SP level. + - **No internal balance tracking**: Trust ERC4626 `balanceOf` and `convertToAssets` for all valuations. No `primaryAssetBalance` / `secondaryAssetBalance` shadowing. + - **No oracle**: Value everything via ERC4626 `convertToAssets`. For non-ERC4626 tokens, wrap them in an ERC4626 adapter first (as already decided). + +3. **Drop HarborAnchoredVault_v1.** Its weighted distribution is subsumed by HarborYield's managed vault list. The HY can route a haXXX deposit to any registered AC (user specifies which, or HY picks the largest). + +4. **Drop the 1inch-specific integration.** Replace with a generic `ISwapper` interface that can be backed by 1inch, a simple DEX swap, or any other router. The swap execution should be a separate contract (the Swapper), not embedded in the yield vault. This makes the HY repo-portable. + +### What Moves to the New Repo + +If HarborYield moves to another repo: +- `HarborYield_v1.sol` + `IHarborYield.sol` +- `ISwapper.sol` (interface only — implementation is separate) +- Deployment script (`script/src/v3/contracts/HarborYield.sol`) +- Tests + +What stays in harbor: +- `AutoCompounder_v1.sol` + `IAutoCompounder.sol` (depends on SP and Minter) +- All SP and Minter contracts +- Deployment infrastructure + +The AC is a dependency of the HY (the HY holds AC shares), but the AC doesn't know about the HY. Clean dependency direction. + +--- + +## 5. Summary + +| Aspect | harbor (AC+HY) | hyToken | Recommendation | +|--------|----------------|---------|----------------| +| Architecture | Clean layers, composable | Monolith, complete | Keep harbor's layers | +| Swap/rebalance | Missing | Implemented (1inch) | Add to HY via ISwapper | +| ERC4626 | Clean compliance | Broken (multi-asset totalAssets) | Keep harbor's approach | +| Operational features | Basic | Bounty, emergency, keeper roles | Add to HY from hyToken | +| Oracle dependency | Minimal | Heavy | Keep harbor's approach | +| Multi-collateral | Native (N vaults per HY) | One hyToken per SP | Keep harbor's approach | +| Balance tracking | Trust underlying vaults | Internal + actual (divergence risk) | Keep harbor's approach | +| Contract size | 10-12KB each | Large monolith | Keep harbor's approach | +| Testability | Each layer independent | Must test everything together | Keep harbor's approach | +| Completeness | Incomplete (no swap, thin HY) | More complete | Add missing pieces to HY | diff --git a/foundry.toml b/foundry.toml index 7d42b23e..f26bc398 100644 --- a/foundry.toml +++ b/foundry.toml @@ -46,6 +46,7 @@ remappings = [ fuzz.gas_report_samples = 64 # gas report doesn't need so many test cases # gas_reports_ignore takes contract names, not paths — use gas_reports whitelist instead gas_reports = [ + "HarborYield_v1", "AutoCompounder_v1", "Genesis_v1", "Minter_v3", diff --git a/script/src/v3/contracts/HarborYield.sol b/script/src/v3/contracts/HarborYield.sol new file mode 100644 index 00000000..5adb9b01 --- /dev/null +++ b/script/src/v3/contracts/HarborYield.sol @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.28 <0.9.0; + +import {console2 as console} from "forge-std/console2.sol"; +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import {HarborFactoryDeployer} from "script/src/HarborFactoryDeployer.sol"; +import {DeploymentState} from "@bao-script/deployment/DeploymentState.sol"; +import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; + +import {HarborYield_v1} from "@harbor/autocompounding/HarborYield_v1.sol"; + +/// @notice Harbor HarborYield deployment logic. +/// @dev One HarborYield per peg. Manages multiple ERC4626 vaults (AutoCompounders, wrapped +/// collateral, equivalents) that share the same peg. Standalone -- minimal dependencies +/// on the minter deployment infrastructure. +abstract contract HarborYield is HarborFactoryDeployer { + // ========== HARBOR YIELD DEPLOYMENT ========== + + /// @notice Deploy HarborYield_v1 impl only, record in state. + function deployHarborYieldImplementation( + DeploymentTypes.State memory stateData, + string memory yieldKey, + string memory tokenName, + string memory tokenSymbol + ) internal virtual returns (address impl) { + console.log(" > %s", yieldKey); + + impl = address(new HarborYield_v1(tokenName, tokenSymbol)); + console.log(" Impl: %s", impl); + console.log(" Name: %s", tokenName); + console.log(" Symbol: %s", tokenSymbol); + + DeploymentState.recordImplementation( + stateData, + DeploymentTypes.ImplementationRecord({ + proxy: yieldKey, + contractSource: "@harbor/autocompounding/HarborYield_v1.sol", + contractType: "HarborYield_v1", + implementation: impl, + deploymentTime: uint64(block.timestamp) + }) + ); + } + + /// @notice Deploy HarborYield_v1 impl+proxy, record in state. + function deployHarborYield( + DeploymentTypes.State memory stateData, + string memory yieldKey, + string memory tokenName, + string memory tokenSymbol + ) internal returns (address proxy) { + address impl = deployHarborYieldImplementation(stateData, yieldKey, tokenName, tokenSymbol); + + bytes memory initData = abi.encodeCall(HarborYield_v1.initialize, (address(this), owner())); + + proxy = _deployProxyAndRecord(stateData, yieldKey, impl, initData); + } + + /// @notice Register ERC4626 vaults with a deployed HarborYield. + /// @param hyProxy The HarborYield proxy address. + /// @param vaults Array of ERC4626 vault addresses to register. + function configureHarborYield(address hyProxy, address[] memory vaults) internal { + for (uint256 i = 0; i < vaults.length; i++) { + address vault = vaults[i]; + address asset = IERC4626(vault).asset(); + console.log(" addVault: %s (asset: %s)", vault, asset); + HarborYield_v1(hyProxy).addVault(vault); + } + } +} diff --git a/src/autocompounding/HarborYield_v1.sol b/src/autocompounding/HarborYield_v1.sol new file mode 100644 index 00000000..28354880 --- /dev/null +++ b/src/autocompounding/HarborYield_v1.sol @@ -0,0 +1,302 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.30; + +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import {IERC5313} from "@openzeppelin/contracts/interfaces/IERC5313.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; + +import {HarborOwnable} from "@bao/HarborOwnable.sol"; +import {Token} from "@bao/Token.sol"; +import {TokenHolder, ITokenHolder} from "@bao/TokenHolder.sol"; + +import {IHarborYield} from "src/interfaces/IHarborYield.sol"; +import {StringPacking_v1} from "src/minter/library/StringPacking_v1.sol"; + +/// @title HarborYield_v1 +/// @notice Level 2 yield vault: one per peg. Manages multiple ERC4626 vaults (AutoCompounders, +/// wrapped collateral, equivalents) that share the same peg. +/// @dev Users deposit peg-denominated assets (stETH, fxUSD, SP tokens). The vault deposits them +/// into the corresponding ERC4626 vault and holds the interest-bearing shares. The hyXXX share +/// represents a proportional claim on all held vault shares. +/// +/// All assets are assumed pegged 1:1 to the same unit. totalAssets() sums +/// IERC4626(vault).convertToAssets(balance) across all managed vaults. +/// +/// Withdrawal returns a proportional mix of all held vault assets. +// solhint-disable-next-line contract-name-capwords +contract HarborYield_v1 is + Initializable, + UUPSUpgradeable, + ERC20Upgradeable, + HarborOwnable, + TokenHolder, + IERC5313, + IHarborYield +{ + using SafeERC20 for IERC20; + + /*////////////////////////////////////////////////////////////////////////// + ERRORS + //////////////////////////////////////////////////////////////////////////*/ + + error VaultNotRegistered(address asset); + error VaultNotActive(address vault); + error VaultAlreadyRegistered(address vault); + error ZeroShares(); + + /*////////////////////////////////////////////////////////////////////////// + IMMUTABLES + //////////////////////////////////////////////////////////////////////////*/ + + /// @dev ERC20 name stored as two bytes32 (up to 64 characters) + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + bytes32 private immutable _ERC20_NAME_0; + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + bytes32 private immutable _ERC20_NAME_1; + + /// @dev ERC20 symbol stored as bytes32 (up to 32 characters) + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + bytes32 private immutable _ERC20_SYMBOL; + + /*////////////////////////////////////////////////////////////////////////// + STORAGE (ERC7201) + //////////////////////////////////////////////////////////////////////////*/ + + /// @custom:storage-location erc7201:harbor.storage.HarborYield_v1 + // chisel eval 'keccak256(abi.encode(uint256(keccak256("harbor.storage.HarborYield_v1")) - 1)) & ~bytes32(uint256(0xff))' + bytes32 private constant _HARBOR_YIELD_STORAGE = + 0xb05ebd6dfc4d62a678d881c33089de39bf9f2de81bf0c8c698a99ab10ff31300; + + struct ManagedVault { + address vault; // ERC4626 vault (wstETH, fxSAVE, AutoCompounder, or adapter) + address asset; // the vault's underlying asset (stETH, fxUSD, hpETH.stETH) + bool active; // accepts new deposits + } + + struct HarborYieldStorage { + ManagedVault[] vaults; + mapping(address => uint256) assetToVaultIndex; // asset address => index+1 (0 = not registered) + } + + function _getHarborYieldStorage() private pure returns (HarborYieldStorage storage $) { + // solhint-disable-next-line no-inline-assembly + assembly { + $.slot := _HARBOR_YIELD_STORAGE + } + } + + /*////////////////////////////////////////////////////////////////////////// + CONSTRUCTOR / INITIALIZER + //////////////////////////////////////////////////////////////////////////*/ + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor( + string memory name_, + string memory symbol_ + ) ERC20Upgradeable() { + _disableInitializers(); + (_ERC20_NAME_0, _ERC20_NAME_1) = StringPacking_v1.pack64(name_); + // slither-disable-next-line unused-return + (_ERC20_SYMBOL,) = StringPacking_v1.pack64(symbol_); + } + + /// @notice Initialize the HarborYield vault. + /// @param deployerOwner_ The initial owner (typically the FactoryDeployer). + /// @param pendingOwner_ The final owner (typically the Harbor multisig). + function initialize(address deployerOwner_, address pendingOwner_) external initializer { + _initializeOwner(deployerOwner_, pendingOwner_); + __UUPSUpgradeable_init(); + } + + /*////////////////////////////////////////////////////////////////////////// + UUPS + //////////////////////////////////////////////////////////////////////////*/ + + function _authorizeUpgrade(address) internal override onlyOwner {} // solhint-disable-line no-empty-blocks + + /*////////////////////////////////////////////////////////////////////////// + OWNERSHIP + //////////////////////////////////////////////////////////////////////////*/ + + /// @inheritdoc IERC5313 + function owner() public view override(HarborOwnable, IERC5313) returns (address owner_) { + owner_ = HarborOwnable.owner(); + } + + /*////////////////////////////////////////////////////////////////////////// + ADMIN + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice Register a new ERC4626 vault to manage. + /// @param vault The ERC4626 vault address. + function addVault(address vault) external onlyOwner { + Token.ensureContract(vault); + address asset = IERC4626(vault).asset(); + + HarborYieldStorage storage $ = _getHarborYieldStorage(); + if ($.assetToVaultIndex[asset] != 0) { + revert VaultAlreadyRegistered(vault); + } + + $.vaults.push(ManagedVault({vault: vault, asset: asset, active: true})); + $.assetToVaultIndex[asset] = $.vaults.length; // 1-indexed + + // Permanent approval for deposits into this vault + IERC20(asset).approve(vault, type(uint256).max); + + emit VaultAdded(vault, asset); + } + + /// @notice Deactivate a vault (stop accepting deposits, keep existing holdings). + /// @param vault The vault to deactivate. + function deactivateVault(address vault) external onlyOwner { + HarborYieldStorage storage $ = _getHarborYieldStorage(); + for (uint256 i = 0; i < $.vaults.length; i++) { + if ($.vaults[i].vault == vault) { + $.vaults[i].active = false; + emit VaultDeactivated(vault); + return; + } + } + revert VaultNotRegistered(vault); + } + + /// @notice Reactivate a previously deactivated vault. + /// @param vault The vault to reactivate. + function activateVault(address vault) external onlyOwner { + HarborYieldStorage storage $ = _getHarborYieldStorage(); + for (uint256 i = 0; i < $.vaults.length; i++) { + if ($.vaults[i].vault == vault) { + $.vaults[i].active = true; + emit VaultActivated(vault); + return; + } + } + revert VaultNotRegistered(vault); + } + + /*////////////////////////////////////////////////////////////////////////// + ERC20 METADATA (IMMUTABLE) + //////////////////////////////////////////////////////////////////////////*/ + + function name() public view override returns (string memory) { + return StringPacking_v1.unpack64(_ERC20_NAME_0, _ERC20_NAME_1); + } + + function symbol() public view override returns (string memory) { + return StringPacking_v1.unpack64(_ERC20_SYMBOL, bytes32(0)); + } + + function decimals() public pure override returns (uint8) { + return 18; + } + + /*////////////////////////////////////////////////////////////////////////// + CORE: TOTAL ASSETS + //////////////////////////////////////////////////////////////////////////*/ + + /// @inheritdoc IHarborYield + function totalAssets() public view returns (uint256 total) { + HarborYieldStorage storage $ = _getHarborYieldStorage(); + for (uint256 i = 0; i < $.vaults.length; i++) { + address vault = $.vaults[i].vault; + uint256 vaultShares = IERC20(vault).balanceOf(address(this)); + if (vaultShares > 0) { + total += IERC4626(vault).convertToAssets(vaultShares); + } + } + } + + /*////////////////////////////////////////////////////////////////////////// + CORE: DEPOSIT + //////////////////////////////////////////////////////////////////////////*/ + + /// @inheritdoc IHarborYield + function deposit(address asset, uint256 amount, address receiver) external returns (uint256 shares) { + amount = Token.allOf(msg.sender, asset, amount); + + HarborYieldStorage storage $ = _getHarborYieldStorage(); + uint256 idx = $.assetToVaultIndex[asset]; + if (idx == 0) { + revert VaultNotRegistered(asset); + } + ManagedVault storage mv = $.vaults[idx - 1]; + if (!mv.active) { + revert VaultNotActive(mv.vault); + } + + // Snapshot totalAssets before deposit changes it + uint256 assetsBefore = totalAssets(); + uint256 supplyBefore = totalSupply(); + + // Transfer asset from caller and deposit into the ERC4626 vault + IERC20(asset).safeTransferFrom(msg.sender, address(this), amount); + IERC4626(mv.vault).deposit(amount, address(this)); + + // Compute hyXXX shares at the pre-deposit exchange rate + shares = Math.mulDiv(amount, supplyBefore + 1, assetsBefore + 1); + if (shares == 0) { + revert ZeroShares(); + } + _mint(receiver, shares); + } + + /*////////////////////////////////////////////////////////////////////////// + CORE: REDEEM + //////////////////////////////////////////////////////////////////////////*/ + + /// @inheritdoc IHarborYield + function redeem(uint256 shares, address receiver, address tokenOwner) external { + if (msg.sender != tokenOwner) { + _spendAllowance(tokenOwner, msg.sender, shares); + } + + uint256 supply = totalSupply(); + _burn(tokenOwner, shares); + + // Redeem proportional vault shares from each managed vault + HarborYieldStorage storage $ = _getHarborYieldStorage(); + for (uint256 i = 0; i < $.vaults.length; i++) { + address vault = $.vaults[i].vault; + uint256 vaultShares = IERC20(vault).balanceOf(address(this)); + if (vaultShares > 0) { + uint256 redeemAmount = Math.mulDiv(vaultShares, shares, supply); + if (redeemAmount > 0) { + IERC4626(vault).redeem(redeemAmount, receiver, address(this)); + } + } + } + } + + /*////////////////////////////////////////////////////////////////////////// + VIEW: VAULT INFO + //////////////////////////////////////////////////////////////////////////*/ + + /// @inheritdoc IHarborYield + function vaultCount() external view returns (uint256) { + return _getHarborYieldStorage().vaults.length; + } + + /// @inheritdoc IHarborYield + function vaultAt(uint256 index) external view returns (address vault, address asset, bool active) { + ManagedVault storage mv = _getHarborYieldStorage().vaults[index]; + vault = mv.vault; + asset = mv.asset; + active = mv.active; + } + + /*////////////////////////////////////////////////////////////////////////// + SWEEP + //////////////////////////////////////////////////////////////////////////*/ + + /// @inheritdoc TokenHolder + function _checkSweeper() internal view override(TokenHolder) { + _checkOwner(); + } +} diff --git a/src/interfaces/IHarborYield.sol b/src/interfaces/IHarborYield.sol new file mode 100644 index 00000000..ef9d61a7 --- /dev/null +++ b/src/interfaces/IHarborYield.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.28 <0.9.0; + +/// @title IHarborYield +/// @notice Interface for the HarborYield vault (Level 2, one per peg). +/// @dev Manages multiple ERC4626 vaults that share the same peg. +/// Each managed vault has an asset (peg-denominated) that users deposit, +/// and vault shares (interest-bearing) that the HarborYield holds. +/// All assets are assumed to be worth 1 peg unit (e.g., 1 stETH = 1 ETH peg). +/// +/// Examples for hyETH: +/// Asset (users deposit) Vault (HY holds shares) +/// stETH wstETH +/// fxUSD fxSAVE +/// hpETH.stETH hcETH.stETH (AutoCompounder) +/// hpETH.fxUSD hcETH.fxUSD (AutoCompounder) +/// +/// Non-ERC4626 tokens (e.g., USDC) are supported by wrapping them +/// in an ERC4626 adapter before registration. +interface IHarborYield { + /// @notice Emitted when a new managed vault is added. + event VaultAdded(address indexed vault, address indexed asset); + + /// @notice Emitted when a managed vault is deactivated (no longer accepts deposits). + event VaultDeactivated(address indexed vault); + + /// @notice Emitted when a managed vault is reactivated. + event VaultActivated(address indexed vault); + + /// @notice Deposit an asset into the HarborYield. The asset must belong to a registered, active vault. + /// The HarborYield deposits into the corresponding ERC4626 vault and mints hyXXX shares. + /// @param asset The asset token to deposit (e.g., stETH, fxUSD, hpETH.stETH). + /// @param amount The amount to deposit. Use type(uint256).max for full balance. + /// @param receiver The address to receive hyXXX shares. + /// @return shares The amount of hyXXX shares minted. + function deposit(address asset, uint256 amount, address receiver) external returns (uint256 shares); + + /// @notice Redeem hyXXX shares for a proportional mix of all held vault assets. + /// Each managed vault's shares are redeemed proportionally. + /// @param shares The amount of hyXXX shares to burn. + /// @param receiver The address to receive the redeemed assets. + /// @param owner The address whose shares are burned. + function redeem(uint256 shares, address receiver, address owner) external; + + /// @notice Total value of all managed holdings, in peg units. + /// @dev SUM(IERC4626(vault).convertToAssets(vault.balanceOf(this))) for all managed vaults. + function totalAssets() external view returns (uint256); + + /// @notice The number of managed vaults. + function vaultCount() external view returns (uint256); + + /// @notice Get the managed vault info at a given index. + /// @return vault The ERC4626 vault address. + /// @return asset The vault's asset token. + /// @return active Whether the vault accepts new deposits. + function vaultAt(uint256 index) external view returns (address vault, address asset, bool active); +} From 563469ebe56ae54f8971f13cf229391fc69cb624 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Sun, 12 Apr 2026 10:47:00 +0100 Subject: [PATCH 027/232] added mock swapper --- src/interfaces/ISwapper.sol | 36 +++++++++++++++++++++++++ test/mocks/MockSwapper.sol | 53 +++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 src/interfaces/ISwapper.sol create mode 100644 test/mocks/MockSwapper.sol diff --git a/src/interfaces/ISwapper.sol b/src/interfaces/ISwapper.sol new file mode 100644 index 00000000..fa2d653e --- /dev/null +++ b/src/interfaces/ISwapper.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.28 <0.9.0; + +/// @title ISwapper +/// @notice Generic interface for token-to-token swaps. +/// @dev Implementations may wrap 1inch, Uniswap, Paraswap, or any DEX aggregator. +/// The caller provides adapter-specific route data via the `data` parameter. +interface ISwapper { + /// @notice Swap one token for another. + /// @param fromToken The token to swap from. + /// @param toToken The token to swap to. + /// @param amountIn Amount of fromToken to swap. + /// @param minAmountOut Minimum acceptable output (slippage protection). + /// @param data Adapter-specific route data (e.g., 1inch encoded route). + /// @return amountOut Actual amount of toToken received. + function swap( + address fromToken, + address toToken, + uint256 amountIn, + uint256 minAmountOut, + bytes calldata data + ) external returns (uint256 amountOut); + + /// @notice Preview the expected output of a swap without executing it. + /// @dev Analogous to ERC4626's previewDeposit/previewRedeem. + /// May revert if the adapter does not support quoting. + /// @param fromToken The token to swap from. + /// @param toToken The token to swap to. + /// @param amountIn Amount of fromToken. + /// @return amountOut Expected amount of toToken. + function previewSwap( + address fromToken, + address toToken, + uint256 amountIn + ) external view returns (uint256 amountOut); +} diff --git a/test/mocks/MockSwapper.sol b/test/mocks/MockSwapper.sol new file mode 100644 index 00000000..ae7ba0a1 --- /dev/null +++ b/test/mocks/MockSwapper.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.28 <0.9.0; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {ISwapper} from "src/interfaces/ISwapper.sol"; + +/// @title MockSwapper +/// @notice Fixed-rate swapper for testing. Swaps at a configurable rate with no DEX dependency. +/// @dev Requires pre-funding output tokens via `deal()`. For forge tests only. +contract MockSwapper is ISwapper { + using SafeERC20 for IERC20; + + /// @notice Fixed rate: amountOut = amountIn * rate / 1e18 + uint256 public rate; + + /// @notice If true, the next swap will revert (for testing error handling). + bool public shouldRevert; + + constructor(uint256 rate_) { + rate = rate_; + } + + function setRate(uint256 rate_) external { + rate = rate_; + } + + function setShouldRevert(bool shouldRevert_) external { + shouldRevert = shouldRevert_; + } + + function previewSwap(address, address, uint256 amountIn) public view override returns (uint256 amountOut) { + amountOut = (amountIn * rate) / 1e18; + } + + function swap( + address fromToken, + address toToken, + uint256 amountIn, + uint256 minAmountOut, + bytes calldata + ) external override returns (uint256 amountOut) { + if (shouldRevert) { + revert("MockSwapper: forced revert"); + } + + amountOut = previewSwap(fromToken, toToken, amountIn); + require(amountOut >= minAmountOut, "MockSwapper: slippage"); + + IERC20(fromToken).safeTransferFrom(msg.sender, address(this), amountIn); + IERC20(toToken).safeTransfer(msg.sender, amountOut); + } +} From 6deba810f72055b1cf5b25aae9b34452854f060e Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Sun, 12 Apr 2026 12:54:09 +0100 Subject: [PATCH 028/232] removed residual reward alias code make swapper immutable move stability pool to harborownable removed IERC5315 from individual contracts because harborownable has it --- CLAUDE.md | 2 + lib/bao-base | 2 +- regression/sizes.txt | 5 +- script/src/v3/contracts/HarborYield.sol | 29 +++++--- script/src/v3/contracts/StabilityPool.sol | 11 ++- src/autocompounding/AutoCompounder_v1.sol | 11 --- src/interfaces/IHarborYield.sol | 71 ++++++++++++------- src/interfaces/IRewardAlias.sol | 15 ---- src/minter/StabilityPool_v3.sol | 4 +- src/reward/RewardAlias_v1.sol | 58 --------------- ...ultipleRewardCompoundingAccumulator_v3.sol | 8 +-- .../LinearMultipleRewardDistributor_v3.sol | 62 +--------------- test/Rebalance.t.sol | 4 +- test/StabilityPool.t.sol | 10 +-- test/StabilityPoolExtras2.t.sol | 2 +- test/deployment/DeployETHfxUSD.t.sol | 10 --- ...ultipleRewardCompoundingAccumulator_v3.sol | 2 +- 17 files changed, 97 insertions(+), 209 deletions(-) delete mode 100644 src/interfaces/IRewardAlias.sol delete mode 100644 src/reward/RewardAlias_v1.sol diff --git a/CLAUDE.md b/CLAUDE.md index 042d7a2c..f29e1ca0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,5 +25,7 @@ - In tests, never create and then remove files or directories — forge runs tests in parallel so you can create a race condition. Write test output to `./results` and leave it there. - Tests verify *what code is supposed to do*, not merely that lines execute. When asked to improve testing, think: "what is the intended behaviour?" — then construct scenarios that demonstrate the code fulfils that intent. If unsure what a function is supposed to do, ask — the specification is not in the code. Avoid writing tests that only exercise code paths to increase coverage metrics; such tests reinforce any misunderstanding in the implementation and give false confidence. - In tests, prefer `console2.log` over `emit` for debug logging — it shows in `forge test -vvv` output without cluttering the event log. Use the `Fmt` library with `string.concat` for readable formatted messages. +- Prefer immutable constructor arguments over configurable storage for addresses of related contracts deployed at predictable proxy addresses. The related contract can be upgraded via its own proxy without the consuming contract needing a setter. This saves bytecode (no setter function, no zero-address checks, no storage reads) and gas. Only use storage for addresses that genuinely need to change independently of contract upgrades. +- Always use HarborOwnable/HarborOwnableRoles over BaoOwnable/BaoOwnableRoles. They are near-drop-in replacements that take explicit `(deployerOwner, pendingOwner)` instead of relying on `msg.sender`. They don't need the UUPSProxyDeployStub — deploy via `_deployProxyAndRecord` (direct), not `_deployProxyViaStubAndRecord`. When upgrading a contract from BaoOwnable to a new version, switch to HarborOwnable. - Never use module-level or contract-level flags/booleans to communicate state between functions within a single call. If a function needs to behave differently based on context, pass the context explicitly via parameters or use separate functions. Hidden state makes code harder to reason about and introduces coupling that isn't visible in function signatures. Use explicit parameters or dedicated function variants instead. - Never modify a deployed contract's source file. Check `deployments/*.state.json` for deployed contracts. To add functionality: inherit from the deployed version (e.g. `Minter_v3 is Minter_v2`) if the changes are additive, or clone and modify if the inheritance chain doesn't work. The proxy upgrade mechanism allows swapping implementations, but the old source must remain unchanged for audit traceability. \ No newline at end of file diff --git a/lib/bao-base b/lib/bao-base index cc355a55..e15eb5d9 160000 --- a/lib/bao-base +++ b/lib/bao-base @@ -1 +1 @@ -Subproject commit cc355a55d4734c11142d0a506993a6e09614c1e4 +Subproject commit e15eb5d98cf748fb9abffbc9a30b40bb3f8a6df4 diff --git a/regression/sizes.txt b/regression/sizes.txt index e793e6dd..0cbb5f4d 100644 --- a/regression/sizes.txt +++ b/regression/sizes.txt @@ -1,6 +1,6 @@ | Contract | Runtime Size (B) | Runtime Margin (B) | Initcode Size (B) | Deploy Gas | Deploy Cost ($) | |-----------------------------------|--------------------|----------------------|---------------------|--------------|-------------------| -| AutoCompounder_v1 | 12,163 | 12,413 | 13,829 | 2,570,890 | 257.09 | +| AutoCompounder_v1 | 12,153 | 12,423 | 13,819 | 2,568,790 | 256.88 | | ConfigIncentiveLib | 85 | 24,491 | 135 | 18,350 | 1.84 | | ConfigMarket_BTC_fxUSD_mainnet | 6,677 | 17,899 | 6,705 | 1,402,450 | 140.25 | | ConfigMarket_BTC_stETH_mainnet | 6,703 | 17,873 | 6,731 | 1,407,910 | 140.79 | @@ -36,6 +36,7 @@ | FakeUUPSUpgradeable | 3,236 | 21,340 | 3,293 | 680,130 | 68.01 | | FmtLib | 85 | 24,491 | 135 | 18,350 | 1.84 | | Genesis_v1 | 7,486 | 17,090 | 8,423 | 1,581,430 | 158.14 | +| HarborYield_v1 | 13,841 | 10,735 | 14,823 | 2,916,430 | 291.64 | | LinearReward | 85 | 24,491 | 135 | 18,350 | 1.84 | | MinterMarketConfigLib | 85 | 24,491 | 135 | 18,350 | 1.84 | | Minter_v1 | 23,681 | 895 | 25,515 | 4,991,350 | 499.14 | @@ -46,7 +47,7 @@ | StabilityPoolManager_v1 | 11,209 | 13,367 | 13,057 | 2,372,370 | 237.24 | | StabilityPool_v1 | 21,092 | 3,484 | 23,085 | 4,449,250 | 444.93 | | StabilityPool_v2 | 20,711 | 3,865 | 22,579 | 4,367,990 | 436.80 | -| StabilityPool_v3 | 23,225 | 1,351 | 25,844 | 4,903,440 | 490.34 | +| StabilityPool_v3 | 23,304 | 1,272 | 25,923 | 4,920,030 | 492.00 | | StakedETHWrappedPriceOracle_v1 | 3,695 | 20,881 | 4,653 | 785,530 | 78.55 | | StringPacking_v1 | 1,218 | 23,358 | 1,270 | 256,300 | 25.63 | | TokenDistributor_v1 | 10,116 | 14,460 | 10,365 | 2,126,850 | 212.69 | diff --git a/script/src/v3/contracts/HarborYield.sol b/script/src/v3/contracts/HarborYield.sol index 5adb9b01..4f33e1e2 100644 --- a/script/src/v3/contracts/HarborYield.sol +++ b/script/src/v3/contracts/HarborYield.sol @@ -21,11 +21,12 @@ abstract contract HarborYield is HarborFactoryDeployer { DeploymentTypes.State memory stateData, string memory yieldKey, string memory tokenName, - string memory tokenSymbol + string memory tokenSymbol, + address swapper ) internal virtual returns (address impl) { console.log(" > %s", yieldKey); - impl = address(new HarborYield_v1(tokenName, tokenSymbol)); + impl = address(new HarborYield_v1(tokenName, tokenSymbol, swapper)); console.log(" Impl: %s", impl); console.log(" Name: %s", tokenName); console.log(" Symbol: %s", tokenSymbol); @@ -47,24 +48,32 @@ abstract contract HarborYield is HarborFactoryDeployer { DeploymentTypes.State memory stateData, string memory yieldKey, string memory tokenName, - string memory tokenSymbol + string memory tokenSymbol, + address swapper ) internal returns (address proxy) { - address impl = deployHarborYieldImplementation(stateData, yieldKey, tokenName, tokenSymbol); + address impl = deployHarborYieldImplementation(stateData, yieldKey, tokenName, tokenSymbol, swapper); bytes memory initData = abi.encodeCall(HarborYield_v1.initialize, (address(this), owner())); proxy = _deployProxyAndRecord(stateData, yieldKey, impl, initData); } + /// @notice Vault registration config. + struct VaultConfig { + address vault; // ERC4626 vault address + uint96 weight; // target distribution weight + bool isAutoCompounder; // true if vault implements IAutoCompounder + } + /// @notice Register ERC4626 vaults with a deployed HarborYield. /// @param hyProxy The HarborYield proxy address. - /// @param vaults Array of ERC4626 vault addresses to register. - function configureHarborYield(address hyProxy, address[] memory vaults) internal { - for (uint256 i = 0; i < vaults.length; i++) { - address vault = vaults[i]; + /// @param configs Array of vault configurations to register. + function configureHarborYield(address hyProxy, VaultConfig[] memory configs) internal { + for (uint256 i = 0; i < configs.length; i++) { + address vault = configs[i].vault; address asset = IERC4626(vault).asset(); - console.log(" addVault: %s (asset: %s)", vault, asset); - HarborYield_v1(hyProxy).addVault(vault); + console.log(" addVault: %s (asset: %s, weight: %s)", vault, asset, configs[i].weight); + HarborYield_v1(hyProxy).addVault(vault, configs[i].weight, configs[i].isAutoCompounder); } } } diff --git a/script/src/v3/contracts/StabilityPool.sol b/script/src/v3/contracts/StabilityPool.sol index cc27931c..9957fbb3 100644 --- a/script/src/v3/contracts/StabilityPool.sol +++ b/script/src/v3/contracts/StabilityPool.sol @@ -92,10 +92,17 @@ abstract contract StabilityPool is HarborFactoryDeployer { IStabilityPoolMarketConfig cfg = IStabilityPoolMarketConfig(address(marketConfig)); bytes memory initData = abi.encodeCall( StabilityPool_v3.initialize, - (owner(), cfg.stabilityPoolEarlyWithdrawalFeeRatio(), treasury()) + (address(this), owner(), cfg.stabilityPoolEarlyWithdrawalFeeRatio(), treasury()) ); - proxy = _deployProxyViaStubAndRecord(stateData, spKey, impl, initData); + proxy = _deployProxyAndRecord( + stateData, + spKey, + impl, + "@harbor/minter/StabilityPool_v3.sol", + "StabilityPool_v3", + initData + ); } /// @notice Grant StabilityPool roles to StabilityPoolManager. diff --git a/src/autocompounding/AutoCompounder_v1.sol b/src/autocompounding/AutoCompounder_v1.sol index d9d3a79d..e0fb4652 100644 --- a/src/autocompounding/AutoCompounder_v1.sol +++ b/src/autocompounding/AutoCompounder_v1.sol @@ -8,7 +8,6 @@ import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ import {ReentrancyGuardTransientUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardTransientUpgradeable.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; -import {IERC5313} from "@openzeppelin/contracts/interfaces/IERC5313.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; @@ -40,7 +39,6 @@ contract AutoCompounder_v1 is ReentrancyGuardTransientUpgradeable, HarborOwnable, TokenHolder, - IERC5313, IAutoCompounder { using SafeERC20 for IERC20; @@ -169,15 +167,6 @@ contract AutoCompounder_v1 is function _authorizeUpgrade(address) internal override onlyOwner {} // solhint-disable-line no-empty-blocks - /*////////////////////////////////////////////////////////////////////////// - OWNERSHIP - //////////////////////////////////////////////////////////////////////////*/ - - /// @inheritdoc IERC5313 - function owner() public view override(HarborOwnable, IERC5313) returns (address owner_) { - owner_ = HarborOwnable.owner(); - } - /*////////////////////////////////////////////////////////////////////////// ADMIN //////////////////////////////////////////////////////////////////////////*/ diff --git a/src/interfaces/IHarborYield.sol b/src/interfaces/IHarborYield.sol index ef9d61a7..39a66d64 100644 --- a/src/interfaces/IHarborYield.sol +++ b/src/interfaces/IHarborYield.sol @@ -3,55 +3,74 @@ pragma solidity >=0.8.28 <0.9.0; /// @title IHarborYield /// @notice Interface for the HarborYield vault (Level 2, one per peg). -/// @dev Manages multiple ERC4626 vaults that share the same peg. -/// Each managed vault has an asset (peg-denominated) that users deposit, -/// and vault shares (interest-bearing) that the HarborYield holds. -/// All assets are assumed to be worth 1 peg unit (e.g., 1 stETH = 1 ETH peg). -/// -/// Examples for hyETH: -/// Asset (users deposit) Vault (HY holds shares) -/// stETH wstETH -/// fxUSD fxSAVE -/// hpETH.stETH hcETH.stETH (AutoCompounder) -/// hpETH.fxUSD hcETH.fxUSD (AutoCompounder) -/// -/// Non-ERC4626 tokens (e.g., USDC) are supported by wrapping them -/// in an ERC4626 adapter before registration. +/// @dev Manages multiple ERC4626 vaults that share the same peg, with target weight +/// distribution and compound/swap capabilities. interface IHarborYield { - /// @notice Emitted when a new managed vault is added. - event VaultAdded(address indexed vault, address indexed asset); + // ── Events ────────────────────────────────────────────────────────── - /// @notice Emitted when a managed vault is deactivated (no longer accepts deposits). + event VaultAdded(address indexed vault, address indexed asset, uint96 weight); event VaultDeactivated(address indexed vault); - - /// @notice Emitted when a managed vault is reactivated. event VaultActivated(address indexed vault); + event VaultWeightUpdated(address indexed vault, uint96 weight); + + event Compounded(address indexed caller, address fromVault, address toVault, uint256 amountIn, uint256 amountOut); + event Redistributed(address indexed caller, address fromVault, address toVault, uint256 amountIn, uint256 amountOut); + + // ── Deposit ───────────────────────────────────────────────────────── /// @notice Deposit an asset into the HarborYield. The asset must belong to a registered, active vault. - /// The HarborYield deposits into the corresponding ERC4626 vault and mints hyXXX shares. /// @param asset The asset token to deposit (e.g., stETH, fxUSD, hpETH.stETH). /// @param amount The amount to deposit. Use type(uint256).max for full balance. /// @param receiver The address to receive hyXXX shares. /// @return shares The amount of hyXXX shares minted. function deposit(address asset, uint256 amount, address receiver) external returns (uint256 shares); + // ── Redeem ────────────────────────────────────────────────────────── + /// @notice Redeem hyXXX shares for a proportional mix of all held vault assets. - /// Each managed vault's shares are redeemed proportionally. /// @param shares The amount of hyXXX shares to burn. /// @param receiver The address to receive the redeemed assets. /// @param owner The address whose shares are burned. function redeem(uint256 shares, address receiver, address owner) external; + // ── Compound ──────────────────────────────────────────────────────── + + /// @notice Convert holdings in one vault to another via the swapper. + /// Used to compound equivalent tokens into AC holdings when profitable. + /// @param fromVault The source ERC4626 vault to redeem from. + /// @param toVault The target ERC4626 vault to deposit into. + /// @param vaultShareAmount Amount of source vault shares to redeem. + /// @param minAmountOut Minimum output from the swap (slippage protection). + /// @param swapData Adapter-specific route data for the swapper. + function compound( + address fromVault, + address toVault, + uint256 vaultShareAmount, + uint256 minAmountOut, + bytes calldata swapData + ) external; + + // ── Redistribute ──────────────────────────────────────────────────── + + /// @notice Move holdings toward the target weight distribution. + /// Finds the most over-weight vault and the most under-weight vault, + /// then transfers value from source to target. + /// @param maxVaultSharesPerVault Cap on vault shares redeemed (prevents overshooting). + /// @param minAmountOut Minimum output from any swap (slippage protection). + /// @param swapData Adapter-specific route data for the swapper (used if assets differ). + function redistribute(uint256 maxVaultSharesPerVault, uint256 minAmountOut, bytes calldata swapData) external; + + // ── Views ─────────────────────────────────────────────────────────── + /// @notice Total value of all managed holdings, in peg units. - /// @dev SUM(IERC4626(vault).convertToAssets(vault.balanceOf(this))) for all managed vaults. function totalAssets() external view returns (uint256); /// @notice The number of managed vaults. function vaultCount() external view returns (uint256); /// @notice Get the managed vault info at a given index. - /// @return vault The ERC4626 vault address. - /// @return asset The vault's asset token. - /// @return active Whether the vault accepts new deposits. - function vaultAt(uint256 index) external view returns (address vault, address asset, bool active); + function vaultAt(uint256 index) external view returns (address vault, address asset, bool active, uint96 weight); + + /// @notice The cached total of all vault weights. + function totalWeight() external view returns (uint256); } diff --git a/src/interfaces/IRewardAlias.sol b/src/interfaces/IRewardAlias.sol deleted file mode 100644 index 7d92bb7e..00000000 --- a/src/interfaces/IRewardAlias.sol +++ /dev/null @@ -1,15 +0,0 @@ -// SPDX-License-Identifier: MIT - -pragma solidity >=0.8.28 <0.9.0; - -/// @notice Interface for a reward token alias. -/// @dev If a reward token address implements this interface and returns a non-zero underlying, -/// the reward system treats it as an alias: integrals track under the alias address, -/// but token transfers use the underlying address. -interface IRewardAlias { - error ZeroAddress(); - - /// @notice Returns the underlying token this alias represents. - /// @return The underlying token address. address(0) means not an alias. - function underlying() external view returns (address); -} diff --git a/src/minter/StabilityPool_v3.sol b/src/minter/StabilityPool_v3.sol index aed06f29..73e4336c 100644 --- a/src/minter/StabilityPool_v3.sol +++ b/src/minter/StabilityPool_v3.sol @@ -185,8 +185,8 @@ contract StabilityPool_v3 is * Constructor * ***************/ - function initialize(address owner_, uint256 earlyWithdrawalFee_, address feeAddress_) external initializer { - _initializeOwner(owner_); + function initialize(address deployerOwner_, address pendingOwner_, uint256 earlyWithdrawalFee_, address feeAddress_) external initializer { + _initializeOwner(deployerOwner_, pendingOwner_); __UUPSUpgradeable_init(); __ReentrancyGuardTransient_init(); diff --git a/src/reward/RewardAlias_v1.sol b/src/reward/RewardAlias_v1.sol deleted file mode 100644 index c328f993..00000000 --- a/src/reward/RewardAlias_v1.sol +++ /dev/null @@ -1,58 +0,0 @@ -// SPDX-License-Identifier: MIT - -pragma solidity 0.8.30; - -import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; -import {IERC5313} from "@openzeppelin/contracts/interfaces/IERC5313.sol"; -import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; -import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; - -import {HarborOwnable} from "@bao/HarborOwnable.sol"; -import {IRewardAlias} from "src/interfaces/IRewardAlias.sol"; - -/// @title RewardAlias_v1 -/// @notice A minimal UUPS-upgradeable contract that identifies itself as an alias for an underlying reward token. -/// @dev Deploy via BaoFactory (CREATE3) at a predictable address. -/// The reward system detects aliases via IRewardAlias.underlying() during registration. -/// The alias address is used for integral tracking; the underlying is used for token transfers. -// solhint-disable-next-line contract-name-capwords -contract RewardAlias_v1 is Initializable, UUPSUpgradeable, HarborOwnable, IERC5313, IRewardAlias { - /// @notice The underlying reward token this alias represents. - /// @custom:oz-upgrades-unsafe-allow state-variable-immutable - address internal immutable UNDERLYING; // solhint-disable-line immutable-vars-naming - - /// @custom:oz-upgrades-unsafe-allow constructor - constructor(address underlying_) { - _disableInitializers(); - if (underlying_ == address(0)) { - revert ZeroAddress(); - } - UNDERLYING = underlying_; - } - - /// @notice Initialize ownership. - /// @param deployerOwner_ The initial (temporary) owner — typically the FactoryDeployer contract. - /// @param pendingOwner_ The final owner — typically the Harbor multisig. - function initialize(address deployerOwner_, address pendingOwner_) external initializer { - _initializeOwner(deployerOwner_, pendingOwner_); - __UUPSUpgradeable_init(); - } - - /// @inheritdoc IERC5313 - function owner() public view override(HarborOwnable, IERC5313) returns (address owner_) { - owner_ = HarborOwnable.owner(); - } - - /// @inheritdoc IRewardAlias - function underlying() external view returns (address) { - return UNDERLYING; - } - - /// @inheritdoc IERC165 - function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { - return interfaceId == type(IERC5313).interfaceId || super.supportsInterface(interfaceId); - } - - /// @notice Authorize upgrades — only owner can upgrade. - function _authorizeUpgrade(address) internal override onlyOwner {} // solhint-disable-line no-empty-blocks -} diff --git a/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol b/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol index 92c5684b..163bc547 100644 --- a/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol +++ b/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol @@ -11,7 +11,7 @@ import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumula import {IMultipleRewardAccumulator_v3} from "src/interfaces/IMultipleRewardAccumulator_v3.sol"; import {DecrementalFloatingPoint} from "src/math/DecrementalFloatingPoint.sol"; -import {LinearMultipleRewardDistributor} from "src/reward/distributor/LinearMultipleRewardDistributor_v2.sol"; +import {LinearMultipleRewardDistributor_v3} from "src/reward/distributor/LinearMultipleRewardDistributor_v3.sol"; // solhint-disable not-rely-on-time @@ -115,7 +115,7 @@ import {LinearMultipleRewardDistributor} from "src/reward/distributor/LinearMult // solhint-disable-next-line contract-name-capwords abstract contract MultipleRewardCompoundingAccumulator_v3 is ReentrancyGuardTransientUpgradeable, - LinearMultipleRewardDistributor, + LinearMultipleRewardDistributor_v3, IMultipleRewardAccumulator, IMultipleRewardAccumulator_v3 { @@ -273,7 +273,7 @@ abstract contract MultipleRewardCompoundingAccumulator_v3 is uint256 rewardManagerRole, uint256 rewardDepositorRole, uint40 periodLength - ) LinearMultipleRewardDistributor(rewardManagerRole, rewardDepositorRole, periodLength) {} + ) LinearMultipleRewardDistributor_v3(rewardManagerRole, rewardDepositorRole, periodLength) {} /************************* * Public View Functions * @@ -564,7 +564,7 @@ abstract contract MultipleRewardCompoundingAccumulator_v3 is return amount; } - /// @inheritdoc LinearMultipleRewardDistributor + /// @inheritdoc LinearMultipleRewardDistributor_v3 function _accumulateReward(address token, uint256 amount) internal virtual override { // slither-disable-next-line incorrect-equality if (amount == 0) { diff --git a/src/reward/distributor/LinearMultipleRewardDistributor_v3.sol b/src/reward/distributor/LinearMultipleRewardDistributor_v3.sol index 5a1444a5..f5724d91 100644 --- a/src/reward/distributor/LinearMultipleRewardDistributor_v3.sol +++ b/src/reward/distributor/LinearMultipleRewardDistributor_v3.sol @@ -8,10 +8,9 @@ import {ContextUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/Cont import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; -import {BaoOwnableRoles} from "@bao/BaoOwnableRoles.sol"; +import {HarborOwnableRoles} from "@bao/HarborOwnableRoles.sol"; import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; -import {IRewardAlias} from "src/interfaces/IRewardAlias.sol"; import {LinearReward} from "./LinearReward.sol"; // solhint-disable no-empty-blocks @@ -38,7 +37,7 @@ import {LinearReward} from "./LinearReward.sol"; abstract contract LinearMultipleRewardDistributor_v3 is Initializable, ContextUpgradeable, - BaoOwnableRoles, + HarborOwnableRoles, IMultipleRewardDistributor { using EnumerableSet for EnumerableSet.AddressSet; @@ -74,10 +73,6 @@ abstract contract LinearMultipleRewardDistributor_v3 is EnumerableSet.AddressSet activeRewardTokens; /// @dev The list of historical reward tokens. EnumerableSet.AddressSet historicalRewardTokens; - /// @dev Alias address => underlying token address. Set at registration, used for token transfers. - mapping(address => address) aliasToUnderlying; - /// @dev Underlying token => ordered list of aliases (drain order for claimSingle(underlying)). - mapping(address => address[]) aliases; } // chisel eval 'keccak256(abi.encode(uint256(keccak256("bao.storage.LinearMultipleRewardDistributor")) - 1)) & ~bytes32(uint256(0xff))' @@ -163,7 +158,7 @@ abstract contract LinearMultipleRewardDistributor_v3 is revert NotActiveRewardToken(); } if (amount > 0) { - IERC20(_resolveUnderlying(token)).safeTransferFrom(_distributor, address(this), amount); + IERC20(token).safeTransferFrom(_distributor, address(this), amount); } _distributePendingReward(); @@ -182,31 +177,6 @@ abstract contract LinearMultipleRewardDistributor_v3 is _registerRewardToken(token); } - /// @notice Register a reward token with an ordered list of aliases. - /// @dev Each alias must implement IRewardAlias.underlying() returning `token`. - /// Aliases are registered as active tokens with their own integrals. - /// claimSingle(underlying) drains aliases in this order, then underlying's own. - /// @param token The underlying reward token. - /// @param tokenAliases Ordered list of alias addresses (drain order). - function registerRewardToken( - address token, - address[] calldata tokenAliases - ) external onlyOwnerOrRoles(REWARD_MANAGER_ROLE) { - _registerRewardToken(token); - - LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); - for (uint256 i = 0; i < tokenAliases.length; i++) { - address alias_ = tokenAliases[i]; - // Reverts if alias doesn't implement underlying() or returns wrong address - // slither-disable-next-line calls-loop - if (IRewardAlias(alias_).underlying() != token) { - revert AliasUnderlyingMismatch(); - } - _registerRewardToken(alias_); - $.aliasToUnderlying[alias_] = token; - $.aliases[token].push(alias_); - } - } function _registerRewardToken(address token) internal { if (token == address(0)) { @@ -226,16 +196,6 @@ abstract contract LinearMultipleRewardDistributor_v3 is /// @inheritdoc IMultipleRewardDistributor function unregisterRewardToken(address token) external onlyOwnerOrRoles(REWARD_MANAGER_ROLE) { _unregisterRewardToken(token); - - // If token has aliases, unregister them too (they're a unit) - LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); - address[] storage tokenAliases = $.aliases[token]; - for (uint256 i = 0; i < tokenAliases.length; i++) { - address alias_ = tokenAliases[i]; - _unregisterRewardToken(alias_); - delete $.aliasToUnderlying[alias_]; - } - delete $.aliases[token]; } function _unregisterRewardToken(address token) internal { @@ -320,20 +280,4 @@ abstract contract LinearMultipleRewardDistributor_v3 is (distributable, undistributed) = $.rewardData[token].pending(); } - // ═══════════════════════════════════════════════════════════════════════ - // Alias support - // ═══════════════════════════════════════════════════════════════════════ - - /// @dev Returns the underlying token for transfers. If not an alias, returns the token itself. - function _resolveUnderlying(address token) internal view returns (address) { - LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); - address underlying = $.aliasToUnderlying[token]; - return underlying != address(0) ? underlying : token; - } - - /// @dev Returns the ordered alias list for an underlying token. - function _getAliases(address token) internal view returns (address[] memory) { - LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); - return $.aliases[token]; - } } diff --git a/test/Rebalance.t.sol b/test/Rebalance.t.sol index bf401f90..8bc16b9a 100644 --- a/test/Rebalance.t.sol +++ b/test/Rebalance.t.sol @@ -44,7 +44,7 @@ contract TestLiquidate is TestStabilityPool2SetUp { address(new StabilityPool_v3(minter, wrappedCollateralToken, 3600, 90000, 1 ether, "SP Col", "spC")), abi.encodeCall( StabilityPool_v3.initialize, - (owner, 0.025 ether, 0x3dFc49e5112005179Da613BdE5973229082dAc35) + (address(this), owner, 0.025 ether, 0x3dFc49e5112005179Da613BdE5973229082dAc35) ) ); IBaoOwnable(stabilityPoolCollateralEmpty).transferOwnership(owner); @@ -53,7 +53,7 @@ contract TestLiquidate is TestStabilityPool2SetUp { address(new StabilityPool_v3(minter, leveragedToken, 3600, 90000, 1 ether, "SP Lev", "spL")), abi.encodeCall( StabilityPool_v3.initialize, - (owner, 0.025 ether, 0x3dFc49e5112005179Da613BdE5973229082dAc35) + (address(this), owner, 0.025 ether, 0x3dFc49e5112005179Da613BdE5973229082dAc35) ) ); IBaoOwnable(stabilityPoolLeveragedEmpty).transferOwnership(owner); diff --git a/test/StabilityPool.t.sol b/test/StabilityPool.t.sol index 38b4804a..416e054a 100644 --- a/test/StabilityPool.t.sol +++ b/test/StabilityPool.t.sol @@ -113,7 +113,7 @@ contract TestStabilityPoolSetUp is TestMinterFeeSetUp { // use mock stability pool to expose internals for testing, otherwise it's identical to StabilityPool_v3 stabilityPool = UnsafeUpgrades.deployUUPSProxy( address(new MockStabilityPool(minter, liquidationToken)), // "StabilityPool_v3.sol", - abi.encodeCall(StabilityPool_v3.initialize, (owner, EARLY_WITHDRAWAL_FEE, FEE_ADDRESS)) + abi.encodeCall(StabilityPool_v3.initialize, (address(this), owner, EARLY_WITHDRAWAL_FEE, FEE_ADDRESS)) ); vm.label(stabilityPool, SPName); @@ -256,7 +256,7 @@ contract TestStabilityPoolInitEvents is TestStabilityPoolSetUp { address spProxy = UnsafeUpgrades.deployUUPSProxy( sp, // "StabilityPool_v3.sol", - abi.encodeCall(StabilityPool_v3.initialize, (owner, EARLY_WITHDRAWAL_FEE, FEE_ADDRESS)) + abi.encodeCall(StabilityPool_v3.initialize, (address(this), owner, EARLY_WITHDRAWAL_FEE, FEE_ADDRESS)) ); IBaoOwnable(spProxy).transferOwnership(owner); @@ -286,7 +286,7 @@ contract TestStabilityPoolInitEvents is TestStabilityPoolSetUp { vm.expectRevert(abi.encodeWithSelector(IStabilityPool.InvalidFee.selector, 1 ether + 1)); UnsafeUpgrades.deployUUPSProxy( spImpl, - abi.encodeCall(StabilityPool_v3.initialize, (owner, 1 ether + 1, FEE_ADDRESS)) + abi.encodeCall(StabilityPool_v3.initialize, (address(this), owner, 1 ether + 1, FEE_ADDRESS)) ); } @@ -305,7 +305,7 @@ contract TestStabilityPoolInitEvents is TestStabilityPoolSetUp { vm.expectRevert(abi.encodeWithSelector(IStabilityPool.InvalidFeeAddress.selector, address(0))); UnsafeUpgrades.deployUUPSProxy( spImpl, - abi.encodeCall(StabilityPool_v3.initialize, (owner, EARLY_WITHDRAWAL_FEE, address(0))) + abi.encodeCall(StabilityPool_v3.initialize, (address(this), owner, EARLY_WITHDRAWAL_FEE, address(0))) ); } } @@ -427,7 +427,7 @@ contract TestStabilityPoolDepositWithdraw is TestStabilityPoolSetUp { // Deploy a fresh pool proxy but skip configuring window/fee address unconfigured = UnsafeUpgrades.deployUUPSProxy( address(new MockStabilityPool(minter, wrappedCollateralToken)), - abi.encodeCall(StabilityPool_v3.initialize, (owner, EARLY_WITHDRAWAL_FEE, FEE_ADDRESS)) + abi.encodeCall(StabilityPool_v3.initialize, (address(this), owner, EARLY_WITHDRAWAL_FEE, FEE_ADDRESS)) ); IBaoOwnable(unconfigured).transferOwnership(owner); diff --git a/test/StabilityPoolExtras2.t.sol b/test/StabilityPoolExtras2.t.sol index 046958ff..82402896 100644 --- a/test/StabilityPoolExtras2.t.sol +++ b/test/StabilityPoolExtras2.t.sol @@ -213,6 +213,6 @@ contract TestStabilityPoolExtra2 is TestStabilityPoolSetUp { function testReinitializeContract() public { // Try to initialize again (contract is already initialized) vm.expectRevert(Initializable.InvalidInitialization.selector); - StabilityPool_v3(stabilityPoolCollateral).initialize(owner, EARLY_WITHDRAWAL_FEE, FEE_ADDRESS); + StabilityPool_v3(stabilityPoolCollateral).initialize(address(this), owner, EARLY_WITHDRAWAL_FEE, FEE_ADDRESS); } } diff --git a/test/deployment/DeployETHfxUSD.t.sol b/test/deployment/DeployETHfxUSD.t.sol index 719b40ed..51045828 100644 --- a/test/deployment/DeployETHfxUSD.t.sol +++ b/test/deployment/DeployETHfxUSD.t.sol @@ -26,11 +26,6 @@ abstract contract DeployETHfxUSDSetUp is BaoTest, Deploy_ETH_Minter { address leveraged; address wrappedCollateral; - address collHarvestAlias; - address collRebalanceAlias; - address levHarvestAlias; - address levRebalanceAlias; - MockWrappedPriceOracle mockOracle; function _shouldPersistState() internal pure override returns (bool) { @@ -60,11 +55,6 @@ abstract contract DeployETHfxUSDSetUp is BaoTest, Deploy_ETH_Minter { leveraged = _predictAddress(_key(mk, "leveraged")); wrappedCollateral = IMinter(minter).WRAPPED_COLLATERAL_TOKEN(); - collHarvestAlias = _predictAddress(_key(mk, "stabilityPoolCollateral", "harvest")); - collRebalanceAlias = _predictAddress(_key(mk, "stabilityPoolCollateral", "rebalance")); - levHarvestAlias = _predictAddress(_key(mk, "stabilityPoolLeveraged", "harvest")); - levRebalanceAlias = _predictAddress(_key(mk, "stabilityPoolLeveraged", "rebalance")); - mockOracle = new MockWrappedPriceOracle(); mockOracle.setLatestAnswer(1 ether, 1 ether); diff --git a/test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v3.sol b/test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v3.sol index 08843f4d..f164fe13 100644 --- a/test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v3.sol +++ b/test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v3.sol @@ -17,7 +17,7 @@ contract MockMultipleRewardCompoundingAccumulator_v3 is Initializable, MultipleR constructor(uint40 period) MultipleRewardCompoundingAccumulator_v3(_ROLE_0, _ROLE_1, period) {} function initialize(address owner_) external initializer { - _initializeOwner(owner_); + _initializeOwner(address(this), owner_); __ReentrancyGuardTransient_init(); } From 8fb49f5b896df9167bc561f1826fde7ebf438990 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Sun, 12 Apr 2026 19:06:57 +0100 Subject: [PATCH 029/232] complete HarborYield_v1 implementation - add compound() and redistribute() to absorb the roles of hyToken_v1 and HarborAnchoredVault_v1. Vaults now carry a target weight and isAutoCompounder flag, with cached totalWeight. Introduce COMPOUNDER_ROLE and REDISTRIBUTOR_ROLE via HarborOwnableRoles (replacing HarborOwnable), and an immutable SWAPPER address used by the new internal _swapIfNeeded() helper. tidy deploy scripts --- regression/sizes.txt | 24 +- script/Deploy_BTC_mainnet.s.sol | 2 +- script/Deploy_ETH_mainnet.s.sol | 2 +- script/Deploy_EUR_mainnet.s.sol | 2 +- script/Deploy_GOLD_mainnet.s.sol | 2 +- script/Deploy_MCAP_mainnet.s.sol | 2 +- script/Deploy_Minter_v2_mainnet.s.sol | 12 +- script/Deploy_SILVER_mainnet.s.sol | 2 +- script/Deploy_StabilityPool_v3_mainnet.s.sol | 16 +- .../Grant_Minter_ZeroFeeRoles_mainnet.s.sol | 12 +- script/Remediate_Accumulators.s.sol | 12 +- script/config/ConfigTokenNames.sol | 12 + script/src/{v3 => }/DeployMintersShared.sol | 39 +-- script/src/{v2 => }/Deploy_BTC_Minter.sol | 0 script/src/{v2 => }/Deploy_ETH_Minter.sol | 0 script/src/{v2 => }/Deploy_EUR_Minter.sol | 0 script/src/{v2 => }/Deploy_GOLD_Minter.sol | 0 script/src/{v2 => }/Deploy_MCAP_Minter.sol | 0 script/src/{v2 => }/Deploy_SILVER_Minter.sol | 0 .../src/{v3 => }/contracts/AutoCompounder.sol | 26 +- script/src/{v3 => }/contracts/Genesis.sol | 2 +- script/src/{v3 => }/contracts/HarborYield.sol | 21 +- .../src/{v3 => }/contracts/LeveragedToken.sol | 2 +- script/src/{v3 => }/contracts/Minter.sol | 29 +- script/src/{v3 => }/contracts/PeggedToken.sol | 0 .../src/{v3 => }/contracts/StabilityPool.sol | 35 +-- .../contracts/StabilityPoolManager.sol | 10 +- script/src/v2/DeployMintersShared.sol | 281 ------------------ script/src/v2/contracts/Genesis.sol | 58 ---- script/src/v2/contracts/LeveragedToken.sol | 56 ---- script/src/v2/contracts/Minter.sol | 180 ----------- script/src/v2/contracts/PeggedToken.sol | 82 ----- script/src/v2/contracts/StabilityPool.sol | 112 ------- .../src/v2/contracts/StabilityPoolManager.sol | 108 ------- script/src/v3/Deploy_BTC_Minter.sol | 22 -- script/src/v3/Deploy_ETH_Minter.sol | 20 -- script/src/v3/Deploy_EUR_Minter.sol | 22 -- script/src/v3/Deploy_GOLD_Minter.sol | 22 -- script/src/v3/Deploy_MCAP_Minter.sol | 22 -- script/src/v3/Deploy_SILVER_Minter.sol | 22 -- .../minter-v2-upgrade/DeployMinters.t.sol | 10 +- src/autocompounding/HarborYield_v1.sol | 259 ++++++++++++---- src/interfaces/IHarborYield.sol | 8 +- src/minter/StabilityPool_v3.sol | 7 +- .../LinearMultipleRewardDistributor_v3.sol | 2 - test/deployment/DeployETHfxUSD.t.sol | 2 +- test/deployment/DeployEURSetUp.t.sol | 2 +- test/deployment/MinterCappedMint.t.sol | 2 +- test/deployment/RebalanceFairness.t.sol | 2 +- test/deployment/RewardSystem.t.sol | 2 +- 50 files changed, 352 insertions(+), 1215 deletions(-) rename script/src/{v3 => }/DeployMintersShared.sol (85%) rename script/src/{v2 => }/Deploy_BTC_Minter.sol (100%) rename script/src/{v2 => }/Deploy_ETH_Minter.sol (100%) rename script/src/{v2 => }/Deploy_EUR_Minter.sol (100%) rename script/src/{v2 => }/Deploy_GOLD_Minter.sol (100%) rename script/src/{v2 => }/Deploy_MCAP_Minter.sol (100%) rename script/src/{v2 => }/Deploy_SILVER_Minter.sol (100%) rename script/src/{v3 => }/contracts/AutoCompounder.sol (79%) rename script/src/{v3 => }/contracts/Genesis.sol (96%) rename script/src/{v3 => }/contracts/HarborYield.sol (84%) rename script/src/{v3 => }/contracts/LeveragedToken.sol (96%) rename script/src/{v3 => }/contracts/Minter.sol (85%) rename script/src/{v3 => }/contracts/PeggedToken.sol (100%) rename script/src/{v3 => }/contracts/StabilityPool.sol (81%) rename script/src/{v3 => }/contracts/StabilityPoolManager.sol (88%) delete mode 100644 script/src/v2/DeployMintersShared.sol delete mode 100644 script/src/v2/contracts/Genesis.sol delete mode 100644 script/src/v2/contracts/LeveragedToken.sol delete mode 100644 script/src/v2/contracts/Minter.sol delete mode 100644 script/src/v2/contracts/PeggedToken.sol delete mode 100644 script/src/v2/contracts/StabilityPool.sol delete mode 100644 script/src/v2/contracts/StabilityPoolManager.sol delete mode 100644 script/src/v3/Deploy_BTC_Minter.sol delete mode 100644 script/src/v3/Deploy_ETH_Minter.sol delete mode 100644 script/src/v3/Deploy_EUR_Minter.sol delete mode 100644 script/src/v3/Deploy_GOLD_Minter.sol delete mode 100644 script/src/v3/Deploy_MCAP_Minter.sol delete mode 100644 script/src/v3/Deploy_SILVER_Minter.sol diff --git a/regression/sizes.txt b/regression/sizes.txt index 0cbb5f4d..f43ab0db 100644 --- a/regression/sizes.txt +++ b/regression/sizes.txt @@ -2,17 +2,17 @@ |-----------------------------------|--------------------|----------------------|---------------------|--------------|-------------------| | AutoCompounder_v1 | 12,153 | 12,423 | 13,819 | 2,568,790 | 256.88 | | ConfigIncentiveLib | 85 | 24,491 | 135 | 18,350 | 1.84 | -| ConfigMarket_BTC_fxUSD_mainnet | 6,677 | 17,899 | 6,705 | 1,402,450 | 140.25 | -| ConfigMarket_BTC_stETH_mainnet | 6,703 | 17,873 | 6,731 | 1,407,910 | 140.79 | -| ConfigMarket_ETH_fxUSD_mainnet | 6,677 | 17,899 | 6,705 | 1,402,450 | 140.25 | -| ConfigMarket_EUR_fxUSD_mainnet | 6,665 | 17,911 | 6,693 | 1,399,930 | 139.99 | -| ConfigMarket_EUR_stETH_mainnet | 6,691 | 17,885 | 6,719 | 1,405,390 | 140.54 | -| ConfigMarket_GOLD_fxUSD_mainnet | 6,681 | 17,895 | 6,709 | 1,403,290 | 140.33 | -| ConfigMarket_GOLD_stETH_mainnet | 6,707 | 17,869 | 6,735 | 1,408,750 | 140.88 | -| ConfigMarket_MCAP_fxUSD_mainnet | 6,683 | 17,893 | 6,711 | 1,403,710 | 140.37 | -| ConfigMarket_MCAP_stETH_mainnet | 6,709 | 17,867 | 6,737 | 1,409,170 | 140.92 | -| ConfigMarket_SILVER_fxUSD_mainnet | 6,677 | 17,899 | 6,705 | 1,402,450 | 140.25 | -| ConfigMarket_SILVER_stETH_mainnet | 6,703 | 17,873 | 6,731 | 1,407,910 | 140.79 | +| ConfigMarket_BTC_fxUSD_mainnet | 6,856 | 17,720 | 6,884 | 1,440,040 | 144.00 | +| ConfigMarket_BTC_stETH_mainnet | 6,882 | 17,694 | 6,910 | 1,445,500 | 144.55 | +| ConfigMarket_ETH_fxUSD_mainnet | 6,856 | 17,720 | 6,884 | 1,440,040 | 144.00 | +| ConfigMarket_EUR_fxUSD_mainnet | 6,844 | 17,732 | 6,872 | 1,437,520 | 143.75 | +| ConfigMarket_EUR_stETH_mainnet | 6,870 | 17,706 | 6,898 | 1,442,980 | 144.30 | +| ConfigMarket_GOLD_fxUSD_mainnet | 6,860 | 17,716 | 6,888 | 1,440,880 | 144.09 | +| ConfigMarket_GOLD_stETH_mainnet | 6,886 | 17,690 | 6,914 | 1,446,340 | 144.63 | +| ConfigMarket_MCAP_fxUSD_mainnet | 6,862 | 17,714 | 6,890 | 1,441,300 | 144.13 | +| ConfigMarket_MCAP_stETH_mainnet | 6,888 | 17,688 | 6,916 | 1,446,760 | 144.68 | +| ConfigMarket_SILVER_fxUSD_mainnet | 6,856 | 17,720 | 6,884 | 1,440,040 | 144.00 | +| ConfigMarket_SILVER_stETH_mainnet | 6,882 | 17,694 | 6,910 | 1,445,500 | 144.55 | | ConfigPeg_BTC | 766 | 23,810 | 794 | 161,140 | 16.11 | | ConfigPeg_ETH | 766 | 23,810 | 794 | 161,140 | 16.11 | | ConfigPeg_EUR | 768 | 23,808 | 796 | 161,560 | 16.16 | @@ -36,7 +36,7 @@ | FakeUUPSUpgradeable | 3,236 | 21,340 | 3,293 | 680,130 | 68.01 | | FmtLib | 85 | 24,491 | 135 | 18,350 | 1.84 | | Genesis_v1 | 7,486 | 17,090 | 8,423 | 1,581,430 | 158.14 | -| HarborYield_v1 | 13,841 | 10,735 | 14,823 | 2,916,430 | 291.64 | +| HarborYield_v1 | 13,551 | 11,025 | 14,519 | 2,855,390 | 285.54 | | LinearReward | 85 | 24,491 | 135 | 18,350 | 1.84 | | MinterMarketConfigLib | 85 | 24,491 | 135 | 18,350 | 1.84 | | Minter_v1 | 23,681 | 895 | 25,515 | 4,991,350 | 499.14 | diff --git a/script/Deploy_BTC_mainnet.s.sol b/script/Deploy_BTC_mainnet.s.sol index c6f24a37..cf4f5108 100644 --- a/script/Deploy_BTC_mainnet.s.sol +++ b/script/Deploy_BTC_mainnet.s.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {Script} from "forge-std/Script.sol"; -import {Deploy_BTC_Minter} from "script/src/v3/Deploy_BTC_Minter.sol"; +import {Deploy_BTC_Minter} from "script/src/Deploy_BTC_Minter.sol"; import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; import {Config_MinterMarket} from "script/config/ConfigBase.sol"; diff --git a/script/Deploy_ETH_mainnet.s.sol b/script/Deploy_ETH_mainnet.s.sol index a46b8505..06c5678d 100644 --- a/script/Deploy_ETH_mainnet.s.sol +++ b/script/Deploy_ETH_mainnet.s.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {Script} from "forge-std/Script.sol"; -import {Deploy_ETH_Minter} from "script/src/v3/Deploy_ETH_Minter.sol"; +import {Deploy_ETH_Minter} from "script/src/Deploy_ETH_Minter.sol"; import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; import {Config_MinterMarket} from "script/config/ConfigBase.sol"; diff --git a/script/Deploy_EUR_mainnet.s.sol b/script/Deploy_EUR_mainnet.s.sol index 7358c612..a6d708ec 100644 --- a/script/Deploy_EUR_mainnet.s.sol +++ b/script/Deploy_EUR_mainnet.s.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {Script} from "forge-std/Script.sol"; -import {Deploy_EUR_Minter} from "script/src/v3/Deploy_EUR_Minter.sol"; +import {Deploy_EUR_Minter} from "script/src/Deploy_EUR_Minter.sol"; import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; import {Config_MinterMarket} from "script/config/ConfigBase.sol"; diff --git a/script/Deploy_GOLD_mainnet.s.sol b/script/Deploy_GOLD_mainnet.s.sol index 7a8d0580..2c86b64c 100644 --- a/script/Deploy_GOLD_mainnet.s.sol +++ b/script/Deploy_GOLD_mainnet.s.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {Script} from "forge-std/Script.sol"; -import {Deploy_GOLD_Minter} from "script/src/v3/Deploy_GOLD_Minter.sol"; +import {Deploy_GOLD_Minter} from "script/src/Deploy_GOLD_Minter.sol"; import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; import {Config_MinterMarket} from "script/config/ConfigBase.sol"; diff --git a/script/Deploy_MCAP_mainnet.s.sol b/script/Deploy_MCAP_mainnet.s.sol index 0dba9ad3..661431cd 100644 --- a/script/Deploy_MCAP_mainnet.s.sol +++ b/script/Deploy_MCAP_mainnet.s.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {Script} from "forge-std/Script.sol"; -import {Deploy_MCAP_Minter} from "script/src/v3/Deploy_MCAP_Minter.sol"; +import {Deploy_MCAP_Minter} from "script/src/Deploy_MCAP_Minter.sol"; import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; import {Config_MinterMarket} from "script/config/ConfigBase.sol"; diff --git a/script/Deploy_Minter_v2_mainnet.s.sol b/script/Deploy_Minter_v2_mainnet.s.sol index 19a49bda..ce963535 100644 --- a/script/Deploy_Minter_v2_mainnet.s.sol +++ b/script/Deploy_Minter_v2_mainnet.s.sol @@ -9,12 +9,12 @@ import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; import {Config_MinterMarket, IMarketConfig, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; -import {Deploy_BTC_Minter} from "script/src/v3/Deploy_BTC_Minter.sol"; -import {Deploy_ETH_Minter} from "script/src/v3/Deploy_ETH_Minter.sol"; -import {Deploy_EUR_Minter} from "script/src/v3/Deploy_EUR_Minter.sol"; -import {Deploy_GOLD_Minter} from "script/src/v3/Deploy_GOLD_Minter.sol"; -import {Deploy_MCAP_Minter} from "script/src/v3/Deploy_MCAP_Minter.sol"; -import {Deploy_SILVER_Minter} from "script/src/v3/Deploy_SILVER_Minter.sol"; +import {Deploy_BTC_Minter} from "script/src/Deploy_BTC_Minter.sol"; +import {Deploy_ETH_Minter} from "script/src/Deploy_ETH_Minter.sol"; +import {Deploy_EUR_Minter} from "script/src/Deploy_EUR_Minter.sol"; +import {Deploy_GOLD_Minter} from "script/src/Deploy_GOLD_Minter.sol"; +import {Deploy_MCAP_Minter} from "script/src/Deploy_MCAP_Minter.sol"; +import {Deploy_SILVER_Minter} from "script/src/Deploy_SILVER_Minter.sol"; import {SafeBatch} from "script/safe/SafeBatch.s.sol"; diff --git a/script/Deploy_SILVER_mainnet.s.sol b/script/Deploy_SILVER_mainnet.s.sol index d5761b85..f92291ad 100644 --- a/script/Deploy_SILVER_mainnet.s.sol +++ b/script/Deploy_SILVER_mainnet.s.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {Script} from "forge-std/Script.sol"; -import {Deploy_SILVER_Minter} from "script/src/v3/Deploy_SILVER_Minter.sol"; +import {Deploy_SILVER_Minter} from "script/src/Deploy_SILVER_Minter.sol"; import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; import {Config_MinterMarket} from "script/config/ConfigBase.sol"; diff --git a/script/Deploy_StabilityPool_v3_mainnet.s.sol b/script/Deploy_StabilityPool_v3_mainnet.s.sol index ad9cb606..8f1d7cf9 100644 --- a/script/Deploy_StabilityPool_v3_mainnet.s.sol +++ b/script/Deploy_StabilityPool_v3_mainnet.s.sol @@ -8,14 +8,14 @@ import {DeploymentState} from "@bao-script/deployment/DeploymentState.sol"; import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; import {Config_MinterMarket, IMarketConfig, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; -import {StabilityPool} from "script/src/v3/contracts/StabilityPool.sol"; - -import {Deploy_BTC_Minter} from "script/src/v3/Deploy_BTC_Minter.sol"; -import {Deploy_ETH_Minter} from "script/src/v3/Deploy_ETH_Minter.sol"; -import {Deploy_EUR_Minter} from "script/src/v3/Deploy_EUR_Minter.sol"; -import {Deploy_GOLD_Minter} from "script/src/v3/Deploy_GOLD_Minter.sol"; -import {Deploy_MCAP_Minter} from "script/src/v3/Deploy_MCAP_Minter.sol"; -import {Deploy_SILVER_Minter} from "script/src/v3/Deploy_SILVER_Minter.sol"; +import {StabilityPool} from "script/src/contracts/StabilityPool.sol"; + +import {Deploy_BTC_Minter} from "script/src/Deploy_BTC_Minter.sol"; +import {Deploy_ETH_Minter} from "script/src/Deploy_ETH_Minter.sol"; +import {Deploy_EUR_Minter} from "script/src/Deploy_EUR_Minter.sol"; +import {Deploy_GOLD_Minter} from "script/src/Deploy_GOLD_Minter.sol"; +import {Deploy_MCAP_Minter} from "script/src/Deploy_MCAP_Minter.sol"; +import {Deploy_SILVER_Minter} from "script/src/Deploy_SILVER_Minter.sol"; import {SafeBatch} from "script/safe/SafeBatch.s.sol"; diff --git a/script/Grant_Minter_ZeroFeeRoles_mainnet.s.sol b/script/Grant_Minter_ZeroFeeRoles_mainnet.s.sol index 13cfb201..288a8780 100644 --- a/script/Grant_Minter_ZeroFeeRoles_mainnet.s.sol +++ b/script/Grant_Minter_ZeroFeeRoles_mainnet.s.sol @@ -8,12 +8,12 @@ import {Config_MinterMarket, MinterMarketConfigLib} from "script/config/ConfigBa import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; import {IMinter} from "src/interfaces/IMinter.sol"; -import {Deploy_BTC_Minter} from "script/src/v3/Deploy_BTC_Minter.sol"; -import {Deploy_ETH_Minter} from "script/src/v3/Deploy_ETH_Minter.sol"; -import {Deploy_EUR_Minter} from "script/src/v3/Deploy_EUR_Minter.sol"; -import {Deploy_GOLD_Minter} from "script/src/v3/Deploy_GOLD_Minter.sol"; -import {Deploy_MCAP_Minter} from "script/src/v3/Deploy_MCAP_Minter.sol"; -import {Deploy_SILVER_Minter} from "script/src/v3/Deploy_SILVER_Minter.sol"; +import {Deploy_BTC_Minter} from "script/src/Deploy_BTC_Minter.sol"; +import {Deploy_ETH_Minter} from "script/src/Deploy_ETH_Minter.sol"; +import {Deploy_EUR_Minter} from "script/src/Deploy_EUR_Minter.sol"; +import {Deploy_GOLD_Minter} from "script/src/Deploy_GOLD_Minter.sol"; +import {Deploy_MCAP_Minter} from "script/src/Deploy_MCAP_Minter.sol"; +import {Deploy_SILVER_Minter} from "script/src/Deploy_SILVER_Minter.sol"; import {SafeBatch} from "script/safe/SafeBatch.s.sol"; diff --git a/script/Remediate_Accumulators.s.sol b/script/Remediate_Accumulators.s.sol index 4bdbd264..0f659702 100644 --- a/script/Remediate_Accumulators.s.sol +++ b/script/Remediate_Accumulators.s.sol @@ -9,12 +9,12 @@ import {Config_MinterMarket, MinterMarketConfigLib} from "script/config/ConfigBa import {ForceMigrateAccumulator_v1} from "script/verify/sp-v3-migration/ForceMigrateAccumulator_v1.sol"; import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; -import {Deploy_BTC_Minter} from "script/src/v3/Deploy_BTC_Minter.sol"; -import {Deploy_ETH_Minter} from "script/src/v3/Deploy_ETH_Minter.sol"; -import {Deploy_EUR_Minter} from "script/src/v3/Deploy_EUR_Minter.sol"; -import {Deploy_GOLD_Minter} from "script/src/v3/Deploy_GOLD_Minter.sol"; -import {Deploy_MCAP_Minter} from "script/src/v3/Deploy_MCAP_Minter.sol"; -import {Deploy_SILVER_Minter} from "script/src/v3/Deploy_SILVER_Minter.sol"; +import {Deploy_BTC_Minter} from "script/src/Deploy_BTC_Minter.sol"; +import {Deploy_ETH_Minter} from "script/src/Deploy_ETH_Minter.sol"; +import {Deploy_EUR_Minter} from "script/src/Deploy_EUR_Minter.sol"; +import {Deploy_GOLD_Minter} from "script/src/Deploy_GOLD_Minter.sol"; +import {Deploy_MCAP_Minter} from "script/src/Deploy_MCAP_Minter.sol"; +import {Deploy_SILVER_Minter} from "script/src/Deploy_SILVER_Minter.sol"; import {SafeBatch} from "script/safe/SafeBatch.s.sol"; diff --git a/script/config/ConfigTokenNames.sol b/script/config/ConfigTokenNames.sol index 0de42c3a..7008c8d1 100644 --- a/script/config/ConfigTokenNames.sol +++ b/script/config/ConfigTokenNames.sol @@ -108,4 +108,16 @@ abstract contract ConfigTokenNames { function acLeveragedSymbol() public view returns (string memory symbol) { (, symbol) = _acStrings(Liquidation.Leveraged); } + + // ── HarborYield token (one per peg) ──────────────────────────────── + + /// @notice HarborYield name (e.g., "Harbor yield: ETH"). + function harborYieldName() public view returns (string memory) { + return string.concat("Harbor yield: ", _peg()); + } + + /// @notice HarborYield symbol (e.g., "hyETH"). + function harborYieldSymbol() public view returns (string memory) { + return string.concat("hy", _peg().upper()); + } } diff --git a/script/src/v3/DeployMintersShared.sol b/script/src/DeployMintersShared.sol similarity index 85% rename from script/src/v3/DeployMintersShared.sol rename to script/src/DeployMintersShared.sol index 5ceed9f7..8f072236 100644 --- a/script/src/v3/DeployMintersShared.sol +++ b/script/src/DeployMintersShared.sol @@ -286,47 +286,28 @@ abstract contract DeployMintersShared is function _configureMinter(Config_MinterMarket market, string memory marketKey) internal { IFullMinterConfig cfg = IFullMinterConfig(address(market)); address minter = _predictAddress(_key(marketKey, "minter")); - address reservePool = _predictAddress(_key(marketKey, "reservePool")); - address spCollateral = _predictAddress(_key(marketKey, "stabilityPoolCollateral")); - address spLeveraged = _predictAddress(_key(marketKey, "stabilityPoolLeveraged")); - address spm = _predictAddress(_key(marketKey, "stabilityPoolManager")); - address genesis = _predictAddress(_key(marketKey, "genesis")); address priceOracle = _predictAddress(MinterMarketConfigLib.priceOracleKey(market)); // Update minter configuration (incentive ratios) IMinter(minter).updateConfig(cfg.minterConfig()); - IMinter(minter).updateReservePool(reservePool); + IMinter(minter).updateReservePool(_predictAddress(_key(marketKey, "reservePool"))); IMinter(minter).updateFeeReceiver(treasury()); IMinter(minter).updatePriceOracle(priceOracle); - // Grant roles - grantReservePoolRoles(string.concat(marketKey, "::reservePool"), reservePool, minter); - grantMinterRoles(string.concat(marketKey, "::minter"), minter, spm, genesis); - grantStabilityPoolRoles(string.concat(marketKey, "::stabilityPoolCollateral"), spCollateral, spm); - grantStabilityPoolRoles(string.concat(marketKey, "::stabilityPoolLeveraged"), spLeveraged, spm); - - // Grant SP fee exemption to auto-compounders (using predicted addresses — AC need not be deployed) - { - address acCollateral = _predictAddress(_key(marketKey, AutoCompounderCollateral)); - address acLeveraged = _predictAddress(_key(marketKey, AutoCompounderLeveraged)); - string memory spCollKey = string.concat(marketKey, "::stabilityPoolCollateral"); - string memory spLevKey = string.concat(marketKey, "::stabilityPoolLeveraged"); - grantStabilityPoolAutoCompounderRole(spCollKey, spCollateral, acCollateral, "autoCompounderCollateral"); - grantStabilityPoolAutoCompounderRole(spLevKey, spLeveraged, acLeveraged, "autoCompounderLeveraged"); - } + // Grant roles — each helper predicts its own addresses from marketKey + grantReservePoolRoles(marketKey); + grantMinterRoles(marketKey); + grantStabilityPoolRoles(marketKey, StabilityPoolCollateral, AutoCompounderCollateral); + grantStabilityPoolRoles(marketKey, StabilityPoolLeveraged, AutoCompounderLeveraged); // Configure Auto-Compounders (maxFeeRatio, approvals) - { - uint256 maxFeeRatio = IAutoCompounderMarketConfig(address(market)).autoCompounderMaxFeeRatio(); - address acCollateral = _predictAddress(_key(marketKey, AutoCompounderCollateral)); - address acLeveraged = _predictAddress(_key(marketKey, AutoCompounderLeveraged)); - configureAutoCompounder(acCollateral, maxFeeRatio); - configureAutoCompounder(acLeveraged, maxFeeRatio); - } + uint256 maxFeeRatio = IAutoCompounderMarketConfig(address(market)).autoCompounderMaxFeeRatio(); + configureAutoCompounder(marketKey, AutoCompounderCollateral, maxFeeRatio); + configureAutoCompounder(marketKey, AutoCompounderLeveraged, maxFeeRatio); // Configure StabilityPoolManager configureStabilityPoolManager( - spm, + marketKey, SPMConfig({ rebalanceThreshold: cfg.rebalanceThreshold(), rebalanceBountyRatio: cfg.rebalanceBountyRatio(), diff --git a/script/src/v2/Deploy_BTC_Minter.sol b/script/src/Deploy_BTC_Minter.sol similarity index 100% rename from script/src/v2/Deploy_BTC_Minter.sol rename to script/src/Deploy_BTC_Minter.sol diff --git a/script/src/v2/Deploy_ETH_Minter.sol b/script/src/Deploy_ETH_Minter.sol similarity index 100% rename from script/src/v2/Deploy_ETH_Minter.sol rename to script/src/Deploy_ETH_Minter.sol diff --git a/script/src/v2/Deploy_EUR_Minter.sol b/script/src/Deploy_EUR_Minter.sol similarity index 100% rename from script/src/v2/Deploy_EUR_Minter.sol rename to script/src/Deploy_EUR_Minter.sol diff --git a/script/src/v2/Deploy_GOLD_Minter.sol b/script/src/Deploy_GOLD_Minter.sol similarity index 100% rename from script/src/v2/Deploy_GOLD_Minter.sol rename to script/src/Deploy_GOLD_Minter.sol diff --git a/script/src/v2/Deploy_MCAP_Minter.sol b/script/src/Deploy_MCAP_Minter.sol similarity index 100% rename from script/src/v2/Deploy_MCAP_Minter.sol rename to script/src/Deploy_MCAP_Minter.sol diff --git a/script/src/v2/Deploy_SILVER_Minter.sol b/script/src/Deploy_SILVER_Minter.sol similarity index 100% rename from script/src/v2/Deploy_SILVER_Minter.sol rename to script/src/Deploy_SILVER_Minter.sol diff --git a/script/src/v3/contracts/AutoCompounder.sol b/script/src/contracts/AutoCompounder.sol similarity index 79% rename from script/src/v3/contracts/AutoCompounder.sol rename to script/src/contracts/AutoCompounder.sol index 2b826196..ea9ee2e5 100644 --- a/script/src/v3/contracts/AutoCompounder.sol +++ b/script/src/contracts/AutoCompounder.sol @@ -15,10 +15,6 @@ interface IAutoCompounderMarketConfig { function autoCompounderMaxFeeRatio() external pure returns (uint256); } -interface IStabilityPoolRole { - function EXEMPT_WITHDRAWAL_FEE_ROLE() external view returns (uint256); // solhint-disable-line func-name-mixedcase -} - /// @notice Harbor AutoCompounder deployment logic. /// @dev Each market has TWO auto-compounders: Collateral and Leveraged (one per stability pool). /// Post-deployment: setMaxFeeRatio, approveCompoundTokens. @@ -38,7 +34,7 @@ abstract contract AutoCompounder is HarborFactoryDeployer { address minter ) internal virtual returns (address impl) { string memory marketKey = MinterMarketConfigLib.salt(marketConfig); - string memory acKey = string.concat(marketKey, "::", acType); + string memory acKey = _key(marketKey, acType); console.log(" > %s", acKey); ConfigTokenNames names = ConfigTokenNames(address(marketConfig)); @@ -72,7 +68,7 @@ abstract contract AutoCompounder is HarborFactoryDeployer { address minter ) internal returns (address proxy) { string memory marketKey = MinterMarketConfigLib.salt(marketConfig); - string memory acKey = string.concat(marketKey, "::", acType); + string memory acKey = _key(marketKey, acType); address impl = deployAutoCompounderImplementation(acType, stateData, marketConfig, stabilityPool, minter); @@ -82,20 +78,12 @@ abstract contract AutoCompounder is HarborFactoryDeployer { } /// @notice Post-deployment configuration: set maxFeeRatio and approve tokens. - function configureAutoCompounder(address acProxy, uint256 maxFeeRatio) internal { + /// @param marketKey The market salt key (e.g., "ETH::fxUSD"). + /// @param acType "autoCompounderCollateral" or "autoCompounderLeveraged". + /// @param maxFeeRatio The max fee ratio for compound minting (18 decimals). + function configureAutoCompounder(string memory marketKey, string memory acType, uint256 maxFeeRatio) internal { + address acProxy = _predictAddress(_key(marketKey, acType)); AutoCompounder_v1(acProxy).setMaxFeeRatio(maxFeeRatio); AutoCompounder_v1(acProxy).approveCompoundTokens(); } - - /// @notice Grant EXEMPT_WITHDRAWAL_FEE_ROLE on a stability pool to an auto-compounder. - /// @dev Can be called with a predicted (not-yet-deployed) acProxy address. - function grantStabilityPoolAutoCompounderRole( - string memory stabilityPoolKey, - address stabilityPool, - address acProxy, - string memory acLabel - ) internal { - uint256 exemptRole = IStabilityPoolRole(stabilityPool).EXEMPT_WITHDRAWAL_FEE_ROLE(); - _grantRoles(stabilityPoolKey, stabilityPool, acProxy, acLabel, exemptRole, "EXEMPT_WITHDRAWAL_FEE"); - } } diff --git a/script/src/v3/contracts/Genesis.sol b/script/src/contracts/Genesis.sol similarity index 96% rename from script/src/v3/contracts/Genesis.sol rename to script/src/contracts/Genesis.sol index e35d76bd..43ea43bb 100644 --- a/script/src/v3/contracts/Genesis.sol +++ b/script/src/contracts/Genesis.sol @@ -26,7 +26,7 @@ abstract contract Genesis is HarborFactoryDeployer { string memory marketKey, address minter ) internal returns (address proxy) { - string memory genesisKey = string.concat(marketKey, "::genesis"); + string memory genesisKey = _key(marketKey, "genesis"); console.log(" > %s", genesisKey); address impl = address(new Genesis_v1(minter)); diff --git a/script/src/v3/contracts/HarborYield.sol b/script/src/contracts/HarborYield.sol similarity index 84% rename from script/src/v3/contracts/HarborYield.sol rename to script/src/contracts/HarborYield.sol index 4f33e1e2..57abf85f 100644 --- a/script/src/v3/contracts/HarborYield.sol +++ b/script/src/contracts/HarborYield.sol @@ -8,6 +8,8 @@ import {DeploymentState} from "@bao-script/deployment/DeploymentState.sol"; import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; import {HarborYield_v1} from "@harbor/autocompounding/HarborYield_v1.sol"; +import {ConfigTokenNames} from "script/config/ConfigTokenNames.sol"; +import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; /// @notice Harbor HarborYield deployment logic. /// @dev One HarborYield per peg. Manages multiple ERC4626 vaults (AutoCompounders, wrapped @@ -20,12 +22,15 @@ abstract contract HarborYield is HarborFactoryDeployer { function deployHarborYieldImplementation( DeploymentTypes.State memory stateData, string memory yieldKey, - string memory tokenName, - string memory tokenSymbol, - address swapper + ConfigPeg pegConfig ) internal virtual returns (address impl) { console.log(" > %s", yieldKey); + ConfigTokenNames names = ConfigTokenNames(address(pegConfig)); + string memory tokenName = names.harborYieldName(); + string memory tokenSymbol = names.harborYieldSymbol(); + address swapper = _predictAddressFromFullSalt("harbor_v1::swapper"); + impl = address(new HarborYield_v1(tokenName, tokenSymbol, swapper)); console.log(" Impl: %s", impl); console.log(" Name: %s", tokenName); @@ -47,11 +52,9 @@ abstract contract HarborYield is HarborFactoryDeployer { function deployHarborYield( DeploymentTypes.State memory stateData, string memory yieldKey, - string memory tokenName, - string memory tokenSymbol, - address swapper + ConfigPeg pegConfig ) internal returns (address proxy) { - address impl = deployHarborYieldImplementation(stateData, yieldKey, tokenName, tokenSymbol, swapper); + address impl = deployHarborYieldImplementation(stateData, yieldKey, pegConfig); bytes memory initData = abi.encodeCall(HarborYield_v1.initialize, (address(this), owner())); @@ -60,8 +63,8 @@ abstract contract HarborYield is HarborFactoryDeployer { /// @notice Vault registration config. struct VaultConfig { - address vault; // ERC4626 vault address - uint96 weight; // target distribution weight + address vault; // ERC4626 vault address + uint96 weight; // target distribution weight bool isAutoCompounder; // true if vault implements IAutoCompounder } diff --git a/script/src/v3/contracts/LeveragedToken.sol b/script/src/contracts/LeveragedToken.sol similarity index 96% rename from script/src/v3/contracts/LeveragedToken.sol rename to script/src/contracts/LeveragedToken.sol index c0f27b73..00773596 100644 --- a/script/src/v3/contracts/LeveragedToken.sol +++ b/script/src/contracts/LeveragedToken.sol @@ -21,7 +21,7 @@ abstract contract LeveragedToken is HarborFactoryDeployer { ) internal returns (address leveragedToken) { string memory marketKey = MinterMarketConfigLib.salt(marketConfig); - string memory leveragedKey = string.concat(marketKey, "::leveraged"); + string memory leveragedKey = _key(marketKey, "leveraged"); string memory tokenName = ConfigTokenNames(address(marketConfig)).leveragedName(); string memory tokenSymbol = ConfigTokenNames(address(marketConfig)).leveragedSymbol(); diff --git a/script/src/v3/contracts/Minter.sol b/script/src/contracts/Minter.sol similarity index 85% rename from script/src/v3/contracts/Minter.sol rename to script/src/contracts/Minter.sol index cdf4c0db..62e0a184 100644 --- a/script/src/v3/contracts/Minter.sol +++ b/script/src/contracts/Minter.sol @@ -32,7 +32,7 @@ abstract contract Minter is HarborFactoryDeployer { address peggedToken, address leveragedToken ) internal virtual returns (address impl, string memory minterKey) { - minterKey = string.concat(marketKey, "::minter"); + minterKey = _key(marketKey, "minter"); console.log(" > %s", minterKey); impl = address(new Minter_v3(wrappedCollateral, peggedToken, leveragedToken, "burn(uint256)")); @@ -86,16 +86,16 @@ abstract contract Minter is HarborFactoryDeployer { } /// @notice Grant Minter roles to downstream contracts. - function grantMinterRoles( - string memory minterKey, - address minterProxy, - address stabilityPoolManager, - address genesis - ) internal { + /// @param marketKey The market salt key (e.g., "ETH::fxUSD"). + function grantMinterRoles(string memory marketKey) internal { + string memory minterKey = _key(marketKey, "minter"); + address minterProxy = _predictAddress(minterKey); + address spm = _predictAddress(_key(marketKey, "stabilityPoolManager")); + address genesis = _predictAddress(_key(marketKey, "genesis")); _grantRoles( minterKey, minterProxy, - stabilityPoolManager, + spm, "stabilityPoolManager", IMinter(minterProxy).HARVESTER_ROLE() | IMinter(minterProxy).ZERO_FEE_ROLE(), "HARVESTER | ZERO_FEE" @@ -110,7 +110,7 @@ abstract contract Minter is HarborFactoryDeployer { DeploymentTypes.State memory stateData, string memory marketKey ) internal returns (address proxy) { - string memory reservePoolKey = string.concat(marketKey, "::reservePool"); + string memory reservePoolKey = _key(marketKey, "reservePool"); console.log(" > %s", reservePoolKey); address impl = address(new ReservePool_v1()); @@ -129,9 +129,12 @@ abstract contract Minter is HarborFactoryDeployer { } /// @notice Grant ReservePool REQUESTER_ROLE to Minter. - function grantReservePoolRoles(string memory reservePoolKey, address reservePoolProxy, address minter) internal { - ReservePool_v1 reservePool = ReservePool_v1(reservePoolProxy); - _grantRoles(reservePoolKey, reservePoolProxy, minter, "minter", reservePool.REQUESTER_ROLE(), "REQUESTER"); + /// @param marketKey The market salt key (e.g., "ETH::fxUSD"). + function grantReservePoolRoles(string memory marketKey) internal { + string memory rpKey = _key(marketKey, "reservePool"); + address rp = _predictAddress(rpKey); + address minter = _predictAddress(_key(marketKey, "minter")); + _grantRoles(rpKey, rp, minter, "minter", ReservePool_v1(rp).REQUESTER_ROLE(), "REQUESTER"); } // ========== FEE RECEIVER (TOKEN DISTRIBUTOR) DEPLOYMENT ========== @@ -142,7 +145,7 @@ abstract contract Minter is HarborFactoryDeployer { string memory marketKey, string memory name ) internal returns (address proxy) { - string memory feeReceiverKey = string.concat(marketKey, "::minterFeeReceiver"); + string memory feeReceiverKey = _key(marketKey, "minterFeeReceiver"); console.log(" > %s", feeReceiverKey); address impl = address(new TokenDistributor_v1()); diff --git a/script/src/v3/contracts/PeggedToken.sol b/script/src/contracts/PeggedToken.sol similarity index 100% rename from script/src/v3/contracts/PeggedToken.sol rename to script/src/contracts/PeggedToken.sol diff --git a/script/src/v3/contracts/StabilityPool.sol b/script/src/contracts/StabilityPool.sol similarity index 81% rename from script/src/v3/contracts/StabilityPool.sol rename to script/src/contracts/StabilityPool.sol index 9957fbb3..4b4955c6 100644 --- a/script/src/v3/contracts/StabilityPool.sol +++ b/script/src/contracts/StabilityPool.sol @@ -38,7 +38,7 @@ abstract contract StabilityPool is HarborFactoryDeployer { address liquidationToken ) internal virtual returns (address impl) { string memory marketKey = MinterMarketConfigLib.salt(marketConfig); - string memory spKey = string.concat(marketKey, "::", spType); + string memory spKey = _key(marketKey, spType); console.log(" > %s", spKey); ConfigTokenNames names = ConfigTokenNames(address(marketConfig)); @@ -84,7 +84,7 @@ abstract contract StabilityPool is HarborFactoryDeployer { address liquidationToken ) internal returns (address proxy) { string memory marketKey = MinterMarketConfigLib.salt(marketConfig); - string memory spKey = string.concat(marketKey, "::", spType); + string memory spKey = _key(marketKey, spType); console.log(" > %s", spKey); address impl = deployStabilityPoolImplementation(spType, stateData, marketConfig, minter, liquidationToken); @@ -105,21 +105,22 @@ abstract contract StabilityPool is HarborFactoryDeployer { ); } - /// @notice Grant StabilityPool roles to StabilityPoolManager. - function grantStabilityPoolRoles( - string memory stabilityPoolKey, - address stabilityPoolProxy, - address stabilityPoolManager - ) internal { - StabilityPool_v3 pool = StabilityPool_v3(stabilityPoolProxy); + /// @notice Grant StabilityPool roles to StabilityPoolManager and AutoCompounder. + /// @param marketKey The market salt key (e.g., "ETH::fxUSD"). + /// @param spType "stabilityPoolCollateral" or "stabilityPoolLeveraged". + /// @param acType The matching AC type ("autoCompounderCollateral" or "autoCompounderLeveraged"). + function grantStabilityPoolRoles(string memory marketKey, string memory spType, string memory acType) internal { + string memory spKey = _key(marketKey, spType); + address sp = _predictAddress(spKey); + + // SPM gets REBALANCER + REWARD_DEPOSITOR + address spm = _predictAddress(_key(marketKey, "stabilityPoolManager")); + StabilityPool_v3 pool = StabilityPool_v3(sp); uint256 roles = pool.REBALANCER_ROLE() | pool.REWARD_DEPOSITOR_ROLE(); - _grantRoles( - stabilityPoolKey, - stabilityPoolProxy, - stabilityPoolManager, - "stabilityPoolManager", - roles, - "REBALANCER | REWARD_DEPOSITOR" - ); + _grantRoles(spKey, sp, spm, "stabilityPoolManager", roles, "REBALANCER | REWARD_DEPOSITOR"); + + // AC gets EXEMPT_WITHDRAWAL_FEE + address ac = _predictAddress(_key(marketKey, acType)); + _grantRoles(spKey, sp, ac, acType, pool.EXEMPT_WITHDRAWAL_FEE_ROLE(), "EXEMPT_WITHDRAWAL_FEE"); } } diff --git a/script/src/v3/contracts/StabilityPoolManager.sol b/script/src/contracts/StabilityPoolManager.sol similarity index 88% rename from script/src/v3/contracts/StabilityPoolManager.sol rename to script/src/contracts/StabilityPoolManager.sol index 7c967b26..37b09941 100644 --- a/script/src/v3/contracts/StabilityPoolManager.sol +++ b/script/src/contracts/StabilityPoolManager.sol @@ -34,7 +34,7 @@ abstract contract StabilityPoolManager is HarborFactoryDeployer { address stabilityPoolCollateral, address stabilityPoolLeveraged ) internal virtual returns (address proxy) { - string memory spmKey = string.concat(marketKey, "::stabilityPoolManager"); + string memory spmKey = _key(marketKey, "stabilityPoolManager"); console.log(" > %s", spmKey); address impl = address( @@ -55,8 +55,10 @@ abstract contract StabilityPoolManager is HarborFactoryDeployer { } /// @notice Configure a deployed StabilityPoolManager with its operational parameters. - function configureStabilityPoolManager(address spmProxy, SPMConfig memory config) internal { - StabilityPoolManager_v1 spm = StabilityPoolManager_v1(spmProxy); + /// @param marketKey The market salt key (e.g., "ETH::fxUSD"). + /// @param config The SPM configuration parameters. + function configureStabilityPoolManager(string memory marketKey, SPMConfig memory config) internal { + StabilityPoolManager_v1 spm = StabilityPoolManager_v1(_predictAddress(_key(marketKey, "stabilityPoolManager"))); spm.updateRebalanceThreshold(config.rebalanceThreshold); spm.updateRebalanceBountyRatio(config.rebalanceBountyRatio); spm.updateHarvestBountyRatio(config.harvestBountyRatio); @@ -72,7 +74,7 @@ abstract contract StabilityPoolManager is HarborFactoryDeployer { string memory marketKey, string memory name ) internal returns (address proxy) { - string memory feeReceiverKey = string.concat(marketKey, "::spmFeeReceiver"); + string memory feeReceiverKey = _key(marketKey, "spmFeeReceiver"); console.log(" > %s", feeReceiverKey); address impl = address(new TokenDistributor_v1()); diff --git a/script/src/v2/DeployMintersShared.sol b/script/src/v2/DeployMintersShared.sol deleted file mode 100644 index 738a8600..00000000 --- a/script/src/v2/DeployMintersShared.sol +++ /dev/null @@ -1,281 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.28 <0.9.0; - -import {console2 as console} from "forge-std/console2.sol"; -import {LibString} from "@solady/utils/LibString.sol"; -import {PeggedToken} from "./contracts/PeggedToken.sol"; -import {LeveragedToken} from "./contracts/LeveragedToken.sol"; -import {Minter} from "./contracts/Minter.sol"; -import {StabilityPool} from "./contracts/StabilityPool.sol"; -import {StabilityPoolManager} from "./contracts/StabilityPoolManager.sol"; -import {Genesis} from "./contracts/Genesis.sol"; -import {HarborFactoryDeployer} from "script/src/HarborFactoryDeployer.sol"; -import {DeploymentState} from "@bao-script/deployment/DeploymentState.sol"; -import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; -import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; -import {Config_MinterMarket, IMarketConfig, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; -import {Minter_v2} from "@harbor/minter/Minter_v2.sol"; -import {StabilityPool_v2} from "@harbor/minter/StabilityPool_v2.sol"; -import {IMinter} from "src/interfaces/IMinter.sol"; - -/// @notice Extended market config interface with methods from collateral and chain configs. -interface IFullMinterConfig { - function peg() external view returns (string memory); - function collateral() external view returns (string memory); - function wrappedCollateralToken() external view returns (address); - function minterConfig() external pure returns (IMinter.Config memory); - // Peg config - function minTotalSupply() external view returns (uint256); - // Stability pool config - function stabilityPoolWithdrawalDelay() external pure returns (uint256); - function stabilityPoolWithdrawalPeriod() external pure returns (uint256); - function stabilityPoolEarlyWithdrawalFeeRatio() external pure returns (uint256); - // StabilityPoolManager config (rebalanceThreshold comes from volatility config) - function rebalanceThreshold() external pure returns (uint256); - function rebalanceBountyRatio() external pure returns (uint256); - function harvestBountyRatio() external pure returns (uint256); - function harvestCutRatio() external pure returns (uint256); -} - -/// @notice Shared functionality for all minter deployment contracts. -/// @dev Provides common infrastructure and deployment primitives. -abstract contract DeployMintersShared is - PeggedToken, - LeveragedToken, - Minter, - StabilityPool, - StabilityPoolManager, - Genesis -{ - using LibString for string; - - // ========== MARKET LOOKUP ========== - - /// @notice Find a market config by collateral name. - /// @param markets Array of market configurations. - /// @param collateral Collateral name to find. - /// @return The matching market config. - /// @dev Reverts if not found. - function findMarket( - Config_MinterMarket[] memory markets, - string memory collateral - ) internal view returns (Config_MinterMarket) { - bytes32 target = keccak256(bytes(collateral)); - for (uint256 i = 0; i < markets.length; i++) { - if (keccak256(bytes(MinterMarketConfigLib.collateral(markets[i]))) == target) { - return markets[i]; - } - } - revert(string.concat("Market not found for collateral: ", collateral)); - } - - /// @notice Parse collateral filter string into array of markets. - /// @param allMarkets All configured markets. - /// @param collateralFilter "*" for all, or single collateral name. - /// @return Filtered array of markets. - function parseCollateralFilter( - Config_MinterMarket[] memory allMarkets, - string memory collateralFilter - ) internal view returns (Config_MinterMarket[] memory) { - if (collateralFilter.eq("*")) { - return allMarkets; - } - if (bytes(collateralFilter).length == 0) { - return new Config_MinterMarket[](0); - } - Config_MinterMarket[] memory result = new Config_MinterMarket[](1); - result[0] = findMarket(allMarkets, collateralFilter); - return result; - } - - // ========== MAIN ENTRY POINT ========== - - /// @notice Deploy pegged token and/or markets for a peg. - /// @param saltPrefix Salt prefix for CREATE3 deployment namespacing. - /// @param peg Peg configuration. - /// @param allMarkets All configured markets for this peg (used for role grants). - /// @param network Network name (e.g., "mainnet"). - /// @param deployPeg Whether to deploy the pegged token. - /// @param marketsToDeploy Markets to deploy (empty array = none). - function deployForPeg( - string memory saltPrefix, - ConfigPeg peg, - Config_MinterMarket[] memory allMarkets, - string memory network, - bool deployPeg, - Config_MinterMarket[] memory marketsToDeploy - ) internal { - _setSaltPrefix(saltPrefix); - - // Load or seed state - DeploymentTypes.State memory state = _shouldPersistState() - ? DeploymentState.load(_stateFileRead()) - : DeploymentTypes.State({ - network: network, - saltPrefix: saltPrefix, - directoryPrefix: "", - implementations: new DeploymentTypes.ImplementationRecord[](0), - proxies: new DeploymentTypes.ProxyRecord[](0), - baoFactory: address(0) - }); - state.baoFactory = baoFactory(); - - console.log("=== Deploying Minter Contracts ==="); - console.log(" Salt: %s", saltPrefix); - console.log(" Network: %s", network); - - if (deployPeg) { - console.log(""); - console.log("--- Deploying %s Pegged Token ---", peg.key()); - deployPeggedTokenWithRoles(state, peg, allMarkets); - } - - for (uint256 i = 0; i < marketsToDeploy.length; i++) { - _deployMinterInfrastructure(state, marketsToDeploy[i]); - } - - // Finalize: transfer ownerships and save state - console.log(""); - console.log("--- Transferring Ownerships ---"); - _transferAllOwnerships(); - _saveState(state); - console.log("=== Minter Deployment Done ==="); - } - - // ========== MINTER INFRASTRUCTURE DEPLOYMENT ========== - - /// @notice Deploy infrastructure for a single market. - /// @param state Deployment state (modified in place). - /// @param market Market configuration. - function _deployMinterInfrastructure(DeploymentTypes.State memory state, Config_MinterMarket market) private { - IFullMinterConfig cfg = IFullMinterConfig(address(market)); - string memory marketKey = MinterMarketConfigLib.salt(market); - - console.log(""); - console.log(" > Market: %s", marketKey); - - // Deploy LeveragedToken - _deployLeveragedTokenWithRoles(state, market); - - // Deploy ReservePool - deployReservePool(state, marketKey); - - // Deploy Minter - _deployMinter(state, cfg, marketKey); - - // Deploy Stability Pools - _deployStabilityPools(state, cfg, marketKey); - - // Deploy StabilityPoolManager - _deployStabilityPoolManager(state, cfg, marketKey); - - // Deploy Genesis - _deployGenesis(state, cfg, marketKey); - - // Configure Minter and grant roles - _configureMinter(market, marketKey); - - console.log(" [complete]"); - } - - function _deployMinter( - DeploymentTypes.State memory stateData, - IFullMinterConfig cfg, - string memory marketKey - ) internal { - address wrappedCollateral = cfg.wrappedCollateralToken(); - address peggedToken = _predictAddress(_key(cfg.peg(), "pegged")); - address leveragedToken = _predictAddress(_key(marketKey, "leveraged")); - - deployMinter(stateData, marketKey, wrappedCollateral, peggedToken, leveragedToken); - } - - function _deployStabilityPools( - DeploymentTypes.State memory stateData, - IFullMinterConfig cfg, - string memory marketKey - ) internal { - address minter = _predictAddress(_key(marketKey, "minter")); - - deployStabilityPool( - StabilityPoolCollateral, - stateData, - marketKey, - minter, - cfg.wrappedCollateralToken(), - address(cfg) - ); - - deployStabilityPool( - StabilityPoolLeveraged, - stateData, - marketKey, - minter, - _predictAddress(_key(marketKey, "leveraged")), - address(cfg) - ); - } - - function _deployStabilityPoolManager( - DeploymentTypes.State memory stateData, - IFullMinterConfig, - string memory marketKey - ) internal { - address minter = _predictAddress(_key(marketKey, "minter")); - address spCollateral = _predictAddress(_key(marketKey, "stabilityPoolCollateral")); - address spLeveraged = _predictAddress(_key(marketKey, "stabilityPoolLeveraged")); - - deployStabilityPoolManager(stateData, marketKey, minter, treasury(), spCollateral, spLeveraged); - } - - function _deployGenesis( - DeploymentTypes.State memory stateData, - IFullMinterConfig cfg, - string memory marketKey - ) internal { - cfg; - address minter = _predictAddress(_key(marketKey, "minter")); - deployGenesis(stateData, marketKey, minter); - } - - function _configureMinter(Config_MinterMarket market, string memory marketKey) internal { - IFullMinterConfig cfg = IFullMinterConfig(address(market)); - address minter = _predictAddress(_key(marketKey, "minter")); - address reservePool = _predictAddress(_key(marketKey, "reservePool")); - address spCollateral = _predictAddress(_key(marketKey, "stabilityPoolCollateral")); - address spLeveraged = _predictAddress(_key(marketKey, "stabilityPoolLeveraged")); - address spm = _predictAddress(_key(marketKey, "stabilityPoolManager")); - address genesis = _predictAddress(_key(marketKey, "genesis")); - address leveragedToken = _predictAddress(_key(marketKey, "leveraged")); - address priceOracle = _predictAddress(MinterMarketConfigLib.priceOracleKey(market)); - - // Update minter configuration (incentive ratios) - Minter_v2(minter).updateConfig(cfg.minterConfig()); - Minter_v2(minter).updateReservePool(reservePool); - Minter_v2(minter).updateFeeReceiver(treasury()); - Minter_v2(minter).updatePriceOracle(priceOracle); - - // Grant roles - grantReservePoolRoles(string.concat(marketKey, "::reservePool"), reservePool, minter); - grantMinterRoles(string.concat(marketKey, "::minter"), minter, spm, genesis); - grantStabilityPoolRoles(string.concat(marketKey, "::stabilityPoolCollateral"), spCollateral, spm); - grantStabilityPoolRoles(string.concat(marketKey, "::stabilityPoolLeveraged"), spLeveraged, spm); - - // Register reward tokens - StabilityPool_v2(spCollateral).registerRewardToken(cfg.wrappedCollateralToken()); - StabilityPool_v2(spLeveraged).registerRewardToken(cfg.wrappedCollateralToken()); - StabilityPool_v2(spLeveraged).registerRewardToken(leveragedToken); - - // Configure StabilityPoolManager - configureStabilityPoolManager( - spm, - SPMConfig({ - rebalanceThreshold: cfg.rebalanceThreshold(), - rebalanceBountyRatio: cfg.rebalanceBountyRatio(), - harvestBountyRatio: cfg.harvestBountyRatio(), - harvestCutRatio: cfg.harvestCutRatio(), - feeReceiver: treasury() - }) - ); - } -} diff --git a/script/src/v2/contracts/Genesis.sol b/script/src/v2/contracts/Genesis.sol deleted file mode 100644 index 9e24363a..00000000 --- a/script/src/v2/contracts/Genesis.sol +++ /dev/null @@ -1,58 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.28 <0.9.0; - -import {console2 as console} from "forge-std/console2.sol"; -import {HarborFactoryDeployer} from "script/src/HarborFactoryDeployer.sol"; -import {DeploymentState} from "@bao-script/deployment/DeploymentState.sol"; -import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; -import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; - -import {Genesis_v1} from "@harbor/minter/Genesis_v1.sol"; - -/// @notice Harbor Genesis_v1 deployment logic. -/// @dev File Organization Pattern (see deployment2-design.md Section 3.3.2): -/// @dev - This file: contract-specific deployment for Genesis -/// @dev - Uses DeploymentOwnership pattern: register deployed contracts, transfer at end -/// -/// @dev Genesis Ecosystem: -/// @dev - Genesis is a special contract for initial token minting during launch -/// @dev - Genesis needs: ZERO_FEE_ROLE on Minter (obtained via Minter deployment) -abstract contract Genesis is HarborFactoryDeployer { - // ========== GENESIS DEPLOYMENT ========== - - /// @notice Deploy Genesis impl+proxy, record both in state, register for ownership transfer. - function deployGenesis( - DeploymentTypes.State memory stateData, - string memory marketKey, - address minter - ) internal returns (address proxy) { - string memory genesisKey = string.concat(marketKey, "::genesis"); - console.log(" > %s", genesisKey); - - address impl = address(new Genesis_v1(minter)); - console.log(" Impl: %s", impl); - - bytes memory initData = abi.encodeCall(Genesis_v1.initialize, (owner())); - - proxy = _deployProxyAndRecord( - stateData, - genesisKey, - impl, - "@harbor/minter/Genesis_v1.sol", - "Genesis_v1", - initData - ); - } - - // ========== ADDRESS PREDICTION ========== - - /// @notice Predict genesis contract address from salt. - function predictGenesisAddress( - address baoFactoryAddr, - string memory saltPrefix, - string memory marketKey - ) internal view returns (address) { - bytes32 salt = keccak256(abi.encodePacked(saltPrefix, "::", marketKey, "::genesis")); - return IBaoFactory(baoFactoryAddr).predictAddress(salt); - } -} diff --git a/script/src/v2/contracts/LeveragedToken.sol b/script/src/v2/contracts/LeveragedToken.sol deleted file mode 100644 index 12713de6..00000000 --- a/script/src/v2/contracts/LeveragedToken.sol +++ /dev/null @@ -1,56 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.28 <0.9.0; - -import {console2 as console} from "forge-std/console2.sol"; -import {HarborFactoryDeployer} from "script/src/HarborFactoryDeployer.sol"; -import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; -import {MintableBurnableERC20_v1} from "@bao/MintableBurnableERC20_v1.sol"; -import {IMintableRole} from "@bao/interfaces/IMintableRole.sol"; -import {IBurnableRole} from "@bao/interfaces/IBurnableRole.sol"; -import {Config_MinterMarket, IMarketConfig, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; -import {LibString} from "@solady/utils/LibString.sol"; - -/// @notice Harbor leveraged token deployment logic. -/// @dev Leveraged tokens are unique per market (e.g., hsFXUSD-BTC for BTC::fxUSD market). -abstract contract LeveragedToken is HarborFactoryDeployer { - using LibString for string; - - // ========== LEVERAGED TOKEN DEPLOYMENT ========== - - /// @notice Deploy a leveraged token and grant minter roles. - function _deployLeveragedTokenWithRoles( - DeploymentTypes.State memory stateData, - Config_MinterMarket marketConfig - ) internal returns (address leveragedToken) { - string memory marketKey = MinterMarketConfigLib.salt(marketConfig); - string memory peg = MinterMarketConfigLib.peg(marketConfig); - string memory collateral = MinterMarketConfigLib.collateral(marketConfig); - - string memory leveragedKey = string.concat(marketKey, "::leveraged"); - string memory tokenName = string.concat("Harbor sail: variable leveraged long ", collateral, " against ", peg); - string memory tokenSymbol = string.concat("hs", collateral.upper(), "-", peg.upper()); - - console.log(" > %s", leveragedKey); - console.log(" Name: %s", tokenName); - console.log(" Symbol: %s", tokenSymbol); - - address impl = address(new MintableBurnableERC20_v1()); - console.log(" Impl: %s", impl); - - bytes memory initData = abi.encodeCall(MintableBurnableERC20_v1.initialize, (owner(), tokenName, tokenSymbol)); - - leveragedToken = _deployProxyAndRecord( - stateData, - leveragedKey, - impl, - "@bao/MintableBurnableERC20_v1.sol", - "MintableBurnableERC20_v1", - initData - ); - - // Grant minter roles - address minter = _predictAddress(_key(marketKey, "minter")); - uint256 roles = IMintableRole(leveragedToken).MINTER_ROLE() | IBurnableRole(leveragedToken).BURNER_ROLE(); - _grantRoles(leveragedKey, leveragedToken, minter, marketKey, roles, "MINTER | BURNER"); - } -} diff --git a/script/src/v2/contracts/Minter.sol b/script/src/v2/contracts/Minter.sol deleted file mode 100644 index f4042ed5..00000000 --- a/script/src/v2/contracts/Minter.sol +++ /dev/null @@ -1,180 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.28 <0.9.0; - -import {console2 as console} from "forge-std/console2.sol"; -import {HarborFactoryDeployer} from "script/src/HarborFactoryDeployer.sol"; -import {DeploymentState} from "@bao-script/deployment/DeploymentState.sol"; -import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; -import {Config_MinterMarket, IMarketConfig, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; -import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; - -import {Minter_v2} from "@harbor/minter/Minter_v2.sol"; -import {ReservePool_v1} from "@harbor/minter/ReservePool_v1.sol"; -import {TokenDistributor_v1} from "@harbor/minter/TokenDistributor_v1.sol"; -import {IMinter} from "@harbor/interfaces/IMinter.sol"; - -/// @notice Harbor Minter_v2 deployment logic (including ReservePool and FeeReceiver). -/// @dev File Organization Pattern (see deployment2-design.md Section 3.3.2): -/// @dev - This file: contract-specific deployment for Minter, ReservePool, MinterFeeReceiver -/// @dev - Uses DeploymentOwnership pattern: register deployed contracts, transfer at end -/// -/// @dev Minter Ecosystem Dependencies: -/// @dev - Minter needs: wrappedCollateral, peggedToken, leveragedToken, priceOracle, reservePool, feeReceiver -/// @dev - Minter grants: HARVESTER_ROLE to StabilityPoolManager, ZERO_FEE_ROLE to Genesis -/// @dev - ReservePool grants: REQUESTER_ROLE to Minter -abstract contract Minter is HarborFactoryDeployer { - // ========== MINTER DEPLOYMENT ========== - - function deployMinterImplementation( - DeploymentTypes.State memory stateData, - string memory marketKey, - address wrappedCollateral, - address peggedToken, - address leveragedToken - ) internal virtual returns (address impl, string memory minterKey) { - minterKey = string.concat(marketKey, "::minter"); - console.log(" > %s", minterKey); - - impl = address(new Minter_v2(wrappedCollateral, peggedToken, leveragedToken, "burn(uint256)")); - console.log(" Impl: %s", impl); - - DeploymentState.recordImplementation( - stateData, - DeploymentTypes.ImplementationRecord({ - proxy: minterKey, - contractSource: "@harbor/minter/Minter_v2.sol", - contractType: "Minter_v2", - implementation: impl, - deploymentTime: uint64(block.timestamp) - }) - ); - } - - /// @notice Deploy Minter impl+proxy, record both in state, register for ownership transfer. - function deployMinter( - DeploymentTypes.State memory stateData, - string memory marketKey, - address wrappedCollateral, - address peggedToken, - address leveragedToken - ) internal virtual returns (address proxy) { - (address impl, string memory minterKey) = deployMinterImplementation( - stateData, - marketKey, - wrappedCollateral, - peggedToken, - leveragedToken - ); - - bytes memory initData = abi.encodeCall(Minter_v2.initialize, (owner())); - - proxy = _deployProxyAndRecord(stateData, minterKey, impl, initData); - } - - /// @notice Configure a deployed Minter with its operational parameters. - function configureMinter( - address minterProxy, - IMinter.Config memory config, - address feeReceiver, - address priceOracle, - address reservePool - ) internal { - Minter_v2 minter = Minter_v2(minterProxy); - minter.updateConfig(config); - minter.updateFeeReceiver(feeReceiver); - minter.updatePriceOracle(priceOracle); - minter.updateReservePool(reservePool); - } - - /// @notice Grant Minter roles to downstream contracts. - function grantMinterRoles( - string memory minterKey, - address minterProxy, - address stabilityPoolManager, - address genesis - ) internal { - Minter_v2 minter = Minter_v2(minterProxy); - _grantRoles( - minterKey, - minterProxy, - stabilityPoolManager, - "stabilityPoolManager", - minter.HARVESTER_ROLE() | minter.ZERO_FEE_ROLE(), - "HARVESTER | ZERO_FEE" - ); - _grantRoles(minterKey, minterProxy, genesis, "genesis", minter.ZERO_FEE_ROLE(), "ZERO_FEE"); - } - - // ========== RESERVE POOL DEPLOYMENT ========== - - /// @notice Deploy ReservePool impl+proxy, record both in state, register for ownership transfer. - function deployReservePool( - DeploymentTypes.State memory stateData, - string memory marketKey - ) internal returns (address proxy) { - string memory reservePoolKey = string.concat(marketKey, "::reservePool"); - console.log(" > %s", reservePoolKey); - - address impl = address(new ReservePool_v1()); - console.log(" Impl: %s", impl); - - bytes memory initData = abi.encodeCall(ReservePool_v1.initialize, (owner())); - - proxy = _deployProxyAndRecord( - stateData, - reservePoolKey, - impl, - "@harbor/minter/ReservePool_v1.sol", - "ReservePool_v1", - initData - ); - } - - /// @notice Grant ReservePool REQUESTER_ROLE to Minter. - function grantReservePoolRoles(string memory reservePoolKey, address reservePoolProxy, address minter) internal { - ReservePool_v1 reservePool = ReservePool_v1(reservePoolProxy); - _grantRoles(reservePoolKey, reservePoolProxy, minter, "minter", reservePool.REQUESTER_ROLE(), "REQUESTER"); - } - - // ========== FEE RECEIVER (TOKEN DISTRIBUTOR) DEPLOYMENT ========== - - /// @notice Deploy TokenDistributor_v1 as Minter fee receiver. - function deployMinterFeeReceiver( - DeploymentTypes.State memory stateData, - string memory marketKey, - string memory name - ) internal returns (address proxy) { - string memory feeReceiverKey = string.concat(marketKey, "::minterFeeReceiver"); - console.log(" > %s", feeReceiverKey); - - address impl = address(new TokenDistributor_v1()); - console.log(" Impl: %s", impl); - - bytes memory initData = abi.encodeCall(TokenDistributor_v1.initialize, (owner(), name)); - - proxy = _deployProxyAndRecord( - stateData, - feeReceiverKey, - impl, - "@harbor/minter/TokenDistributor_v1.sol", - "TokenDistributor_v1", - initData - ); - } - - /// @notice Configure TokenDistributor with tokens and distribution. - function configureFeeReceiver( - address feeReceiverProxy, - address[] memory tokens, - address[] memory recipients, - uint256[] memory shares - ) internal { - TokenDistributor_v1 distributor = TokenDistributor_v1(feeReceiverProxy); - - for (uint256 i = 0; i < tokens.length; i++) { - distributor.addToken(tokens[i]); - } - - distributor.setDistribution(recipients, shares); - } -} diff --git a/script/src/v2/contracts/PeggedToken.sol b/script/src/v2/contracts/PeggedToken.sol deleted file mode 100644 index 70e27b7b..00000000 --- a/script/src/v2/contracts/PeggedToken.sol +++ /dev/null @@ -1,82 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.28 <0.9.0; - -import {console2 as console} from "forge-std/console2.sol"; -import {HarborFactoryDeployer} from "script/src/HarborFactoryDeployer.sol"; -import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; -import {MintableBurnableERC20_v1} from "@bao/MintableBurnableERC20_v1.sol"; -import {IMintableRole} from "@bao/interfaces/IMintableRole.sol"; -import {IBurnableRole} from "@bao/interfaces/IBurnableRole.sol"; -import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; -import {Config_MinterMarket, IMarketConfig, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; -import {LibString} from "@solady/utils/LibString.sol"; - -/// @notice Harbor pegged token deployment logic. -/// @dev Pegged tokens are one per peg (ETH, BTC, GOLD, EUR), shared by all markets with that peg. -/// @dev If a pegged token already exists, logs the manual grantRoles transactions required. -abstract contract PeggedToken is HarborFactoryDeployer { - using LibString for string; - - // ========== PEGGED TOKEN DEPLOYMENT ========== - - /// @notice Deploy a pegged token and grant minter roles to all markets using this peg. - /// @dev If the pegged token already exists at the predicted address, logs manual TX requirements. - function deployPeggedTokenWithRoles( - DeploymentTypes.State memory stateData, - ConfigPeg pegConfig, - Config_MinterMarket[] memory marketConfigs - ) internal returns (address peggedToken) { - string memory pegKey = pegConfig.key(); - string memory tokenKey = string.concat(pegKey, "::pegged"); - - console.log(" > %s", tokenKey); - - // Check if pegged token already exists at predicted address - peggedToken = _predictAddress(tokenKey); - bool alreadyDeployed = peggedToken.code.length > 0; - - if (alreadyDeployed) { - console.log(" Already deployed at: %s", peggedToken); - } else { - console.log(" Name: %s", pegConfig.name()); - console.log(" Symbol: %s", pegConfig.symbol()); - - address impl = address(new MintableBurnableERC20_v1()); - console.log(" Impl: %s", impl); - - bytes memory initData = abi.encodeCall( - MintableBurnableERC20_v1.initialize, - (owner(), pegConfig.name(), pegConfig.symbol()) - ); - - peggedToken = _deployProxyAndRecord( - stateData, - tokenKey, - impl, - "@bao/MintableBurnableERC20_v1.sol", - "MintableBurnableERC20_v1", - initData - ); - } - - // Grant minter roles for each market - for (uint256 i = 0; i < marketConfigs.length; i++) { - Config_MinterMarket market = marketConfigs[i]; - string memory configPeg = MinterMarketConfigLib.peg(market); - require( - configPeg.eq(pegKey), - string.concat("Market config peg '", configPeg, "' does not match pegged token '", pegKey, "'") - ); - - string memory marketKey = MinterMarketConfigLib.salt(marketConfigs[i]); - address minter = _predictAddress(_key(marketKey, "minter")); - uint256 roles = IMintableRole(peggedToken).MINTER_ROLE() | IBurnableRole(peggedToken).BURNER_ROLE(); - - if (alreadyDeployed) { - _logManualRoleGrant(tokenKey, peggedToken, minter, marketKey, roles, "MINTER | BURNER"); - } else { - _grantRoles(tokenKey, peggedToken, minter, marketKey, roles, "MINTER | BURNER"); - } - } - } -} diff --git a/script/src/v2/contracts/StabilityPool.sol b/script/src/v2/contracts/StabilityPool.sol deleted file mode 100644 index f52bbfaf..00000000 --- a/script/src/v2/contracts/StabilityPool.sol +++ /dev/null @@ -1,112 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.28 <0.9.0; - -import {console2 as console} from "forge-std/console2.sol"; -import {HarborFactoryDeployer} from "script/src/HarborFactoryDeployer.sol"; -import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; -import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; -import {DeploymentState} from "@bao-script/deployment/DeploymentState.sol"; - -import {StabilityPool_v2} from "@harbor/minter/StabilityPool_v2.sol"; - -/// @notice Config interface for stability pool deployment parameters. -interface IStabilityPoolMarketConfig { - function stabilityPoolWithdrawalDelay() external pure returns (uint256); - function stabilityPoolWithdrawalPeriod() external pure returns (uint256); - function stabilityPoolEarlyWithdrawalFeeRatio() external pure returns (uint256); - function minTotalSupply() external view returns (uint256); -} - -/// @notice Harbor StabilityPool_v2 deployment logic. -/// @dev Each market has TWO stability pools: Collateral (wrapped collateral) and Leveraged (leveraged token). -/// @dev Both pools grant: REBALANCER_ROLE, REWARD_DEPOSITOR_ROLE to StabilityPoolManager. -abstract contract StabilityPool is HarborFactoryDeployer { - string StabilityPoolCollateral = "stabilityPoolCollateral"; - string StabilityPoolLeveraged = "stabilityPoolLeveraged"; - - // ========== STABILITY POOL DEPLOYMENT ========== - - /// @notice Deploy StabilityPool impl only, record in state. - function deployStabilityPoolImplementation( - string memory spType, - DeploymentTypes.State memory stateData, - string memory marketKey, - address minter, - address liquidationToken, - address configContract - ) internal virtual returns (address impl) { - string memory spKey = string.concat(marketKey, "::", spType); - console.log(" > %s", spKey); - - IStabilityPoolMarketConfig cfg = IStabilityPoolMarketConfig(configContract); - impl = address( - new StabilityPool_v2( - minter, - liquidationToken, - cfg.stabilityPoolWithdrawalDelay(), - cfg.stabilityPoolWithdrawalPeriod(), - cfg.minTotalSupply() - ) - ); - console.log(" Impl: %s", impl); - - DeploymentState.recordImplementation( - stateData, - DeploymentTypes.ImplementationRecord({ - proxy: spKey, - contractSource: "@harbor/minter/StabilityPool_v2.sol", - contractType: "StabilityPool_v2", - implementation: impl, - deploymentTime: uint64(block.timestamp) - }) - ); - } - - /// @notice Deploy StabilityPool impl+proxy, record in state. - function deployStabilityPool( - string memory spType, - DeploymentTypes.State memory stateData, - string memory marketKey, - address minter, - address liquidationToken, - address configContract - ) internal returns (address proxy) { - string memory spKey = string.concat(marketKey, "::", spType); - console.log(" > %s", spKey); - - address impl = deployStabilityPoolImplementation( - spType, - stateData, - marketKey, - minter, - liquidationToken, - configContract - ); - - IStabilityPoolMarketConfig cfg = IStabilityPoolMarketConfig(configContract); - bytes memory initData = abi.encodeCall( - StabilityPool_v2.initialize, - (owner(), cfg.stabilityPoolEarlyWithdrawalFeeRatio(), treasury()) - ); - - proxy = _deployProxyAndRecord(stateData, spKey, impl, initData); - } - - /// @notice Grant StabilityPool roles to StabilityPoolManager. - function grantStabilityPoolRoles( - string memory stabilityPoolKey, - address stabilityPoolProxy, - address stabilityPoolManager - ) internal { - StabilityPool_v2 pool = StabilityPool_v2(stabilityPoolProxy); - uint256 roles = pool.REBALANCER_ROLE() | pool.REWARD_DEPOSITOR_ROLE(); - _grantRoles( - stabilityPoolKey, - stabilityPoolProxy, - stabilityPoolManager, - "stabilityPoolManager", - roles, - "REBALANCER | REWARD_DEPOSITOR" - ); - } -} diff --git a/script/src/v2/contracts/StabilityPoolManager.sol b/script/src/v2/contracts/StabilityPoolManager.sol deleted file mode 100644 index 07be9425..00000000 --- a/script/src/v2/contracts/StabilityPoolManager.sol +++ /dev/null @@ -1,108 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.28 <0.9.0; - -import {console2 as console} from "forge-std/console2.sol"; -import {HarborFactoryDeployer} from "script/src/HarborFactoryDeployer.sol"; -import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; -import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; - -import {StabilityPoolManager_v1} from "@harbor/minter/StabilityPoolManager_v1.sol"; -import {TokenDistributor_v1} from "@harbor/minter/TokenDistributor_v1.sol"; - -/// @notice Harbor StabilityPoolManager_v1 deployment logic (including SPMFeeReceiver). -/// @dev SPM coordinates the two stability pools per market. -/// @dev SPM grants: HARVESTER_ROLE on Minter (obtained via Minter deployment). -/// @dev SPM needs: REBALANCER_ROLE, REWARD_DEPOSITOR_ROLE on both stability pools. -abstract contract StabilityPoolManager is HarborFactoryDeployer { - /// @notice StabilityPoolManager configuration. - struct SPMConfig { - uint256 rebalanceThreshold; - uint256 rebalanceBountyRatio; - uint256 harvestBountyRatio; - uint256 harvestCutRatio; - address feeReceiver; - } - - // ========== STABILITY POOL MANAGER DEPLOYMENT ========== - - /// @notice Deploy StabilityPoolManager impl+proxy, record in state. - function deployStabilityPoolManager( - DeploymentTypes.State memory stateData, - string memory marketKey, - address minter, - address treasury, - address stabilityPoolCollateral, - address stabilityPoolLeveraged - ) internal virtual returns (address proxy) { - string memory spmKey = string.concat(marketKey, "::stabilityPoolManager"); - console.log(" > %s", spmKey); - - address impl = address( - new StabilityPoolManager_v1(minter, treasury, stabilityPoolCollateral, stabilityPoolLeveraged) - ); - console.log(" Impl: %s", impl); - - bytes memory initData = abi.encodeCall(StabilityPoolManager_v1.initialize, (owner())); - - proxy = _deployProxyAndRecord( - stateData, - spmKey, - impl, - "@harbor/minter/StabilityPoolManager_v1.sol", - "StabilityPoolManager_v1", - initData - ); - } - - /// @notice Configure a deployed StabilityPoolManager with its operational parameters. - function configureStabilityPoolManager(address spmProxy, SPMConfig memory config) internal { - StabilityPoolManager_v1 spm = StabilityPoolManager_v1(spmProxy); - spm.updateRebalanceThreshold(config.rebalanceThreshold); - spm.updateRebalanceBountyRatio(config.rebalanceBountyRatio); - spm.updateHarvestBountyRatio(config.harvestBountyRatio); - spm.updateHarvestCutRatio(config.harvestCutRatio); - spm.updateFeeReceiver(config.feeReceiver); - } - - // ========== SPM FEE RECEIVER (TOKEN DISTRIBUTOR) DEPLOYMENT ========== - - /// @notice Deploy TokenDistributor_v1 as SPMFeeReceiver impl+proxy, record in state. - function deploySPMFeeReceiver( - DeploymentTypes.State memory stateData, - string memory marketKey, - string memory name - ) internal returns (address proxy) { - string memory feeReceiverKey = string.concat(marketKey, "::spmFeeReceiver"); - console.log(" > %s", feeReceiverKey); - - address impl = address(new TokenDistributor_v1()); - console.log(" Impl: %s", impl); - - bytes memory initData = abi.encodeCall(TokenDistributor_v1.initialize, (owner(), name)); - - proxy = _deployProxyAndRecord( - stateData, - feeReceiverKey, - impl, - "@harbor/minter/TokenDistributor_v1.sol", - "TokenDistributor_v1", - initData - ); - } - - /// @notice Configure TokenDistributor with tokens and distribution. - function configureSPMFeeReceiver( - address feeReceiverProxy, - address[] memory tokens, - address[] memory recipients, - uint256[] memory shares - ) internal { - TokenDistributor_v1 distributor = TokenDistributor_v1(feeReceiverProxy); - - for (uint256 i = 0; i < tokens.length; i++) { - distributor.addToken(tokens[i]); - } - - distributor.setDistribution(recipients, shares); - } -} diff --git a/script/src/v3/Deploy_BTC_Minter.sol b/script/src/v3/Deploy_BTC_Minter.sol deleted file mode 100644 index 9e0d2a5d..00000000 --- a/script/src/v3/Deploy_BTC_Minter.sol +++ /dev/null @@ -1,22 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.28 <0.9.0; - -import {console2 as console} from "forge-std/console2.sol"; -import {DeployMintersShared} from "./DeployMintersShared.sol"; -import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; -import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; -import {ConfigPeg_BTC} from "script/config/pegs/ConfigPeg_BTC.sol"; -import {ConfigMarket_BTC_fxUSD_mainnet} from "script/config/markets/ConfigMarket_BTC_fxUSD_mainnet.sol"; -import {ConfigMarket_BTC_stETH_mainnet} from "script/config/markets/ConfigMarket_BTC_stETH_mainnet.sol"; -import {Config_MinterMarket} from "script/config/ConfigBase.sol"; - -/// @notice BTC-specific minter deployment functionality. -abstract contract Deploy_BTC_Minter is DeployMintersShared { - /// @notice Create BTC-specific config objects. - function createBTCMintersConfig() internal returns (ConfigPeg peg, Config_MinterMarket[] memory markets) { - peg = new ConfigPeg_BTC(); - markets = new Config_MinterMarket[](2); - markets[0] = new ConfigMarket_BTC_fxUSD_mainnet(); - markets[1] = new ConfigMarket_BTC_stETH_mainnet(); - } -} diff --git a/script/src/v3/Deploy_ETH_Minter.sol b/script/src/v3/Deploy_ETH_Minter.sol deleted file mode 100644 index aa92c58a..00000000 --- a/script/src/v3/Deploy_ETH_Minter.sol +++ /dev/null @@ -1,20 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.28 <0.9.0; - -import {console2 as console} from "forge-std/console2.sol"; -import {DeployMintersShared} from "./DeployMintersShared.sol"; -import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; -import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; -import {ConfigPeg_ETH} from "script/config/pegs/ConfigPeg_ETH.sol"; -import {ConfigMarket_ETH_fxUSD_mainnet} from "script/config/markets/ConfigMarket_ETH_fxUSD_mainnet.sol"; -import {Config_MinterMarket} from "script/config/ConfigBase.sol"; - -/// @notice ETH-specific minter deployment functionality. -abstract contract Deploy_ETH_Minter is DeployMintersShared { - /// @notice Create ETH-specific config objects. - function createETHMintersConfig() internal returns (ConfigPeg peg, Config_MinterMarket[] memory markets) { - peg = new ConfigPeg_ETH(); - markets = new Config_MinterMarket[](1); - markets[0] = new ConfigMarket_ETH_fxUSD_mainnet(); - } -} diff --git a/script/src/v3/Deploy_EUR_Minter.sol b/script/src/v3/Deploy_EUR_Minter.sol deleted file mode 100644 index 38aacfc9..00000000 --- a/script/src/v3/Deploy_EUR_Minter.sol +++ /dev/null @@ -1,22 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.28 <0.9.0; - -import {console2 as console} from "forge-std/console2.sol"; -import {DeployMintersShared} from "./DeployMintersShared.sol"; -import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; -import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; -import {ConfigPeg_EUR} from "script/config/pegs/ConfigPeg_EUR.sol"; -import {ConfigMarket_EUR_fxUSD_mainnet} from "script/config/markets/ConfigMarket_EUR_fxUSD_mainnet.sol"; -import {ConfigMarket_EUR_stETH_mainnet} from "script/config/markets/ConfigMarket_EUR_stETH_mainnet.sol"; -import {Config_MinterMarket} from "script/config/ConfigBase.sol"; - -/// @notice EUR-specific minter deployment functionality. -abstract contract Deploy_EUR_Minter is DeployMintersShared { - /// @notice Create EUR-specific config objects. - function createEURMintersConfig() internal returns (ConfigPeg peg, Config_MinterMarket[] memory markets) { - peg = new ConfigPeg_EUR(); - markets = new Config_MinterMarket[](2); - markets[0] = new ConfigMarket_EUR_fxUSD_mainnet(); - markets[1] = new ConfigMarket_EUR_stETH_mainnet(); - } -} diff --git a/script/src/v3/Deploy_GOLD_Minter.sol b/script/src/v3/Deploy_GOLD_Minter.sol deleted file mode 100644 index 73365b94..00000000 --- a/script/src/v3/Deploy_GOLD_Minter.sol +++ /dev/null @@ -1,22 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.28 <0.9.0; - -import {console2 as console} from "forge-std/console2.sol"; -import {DeployMintersShared} from "./DeployMintersShared.sol"; -import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; -import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; -import {ConfigPeg_GOLD} from "script/config/pegs/ConfigPeg_GOLD.sol"; -import {ConfigMarket_GOLD_fxUSD_mainnet} from "script/config/markets/ConfigMarket_GOLD_fxUSD_mainnet.sol"; -import {ConfigMarket_GOLD_stETH_mainnet} from "script/config/markets/ConfigMarket_GOLD_stETH_mainnet.sol"; -import {Config_MinterMarket} from "script/config/ConfigBase.sol"; - -/// @notice GOLD-specific minter deployment functionality. -abstract contract Deploy_GOLD_Minter is DeployMintersShared { - /// @notice Create GOLD-specific config objects. - function createGOLDMintersConfig() internal returns (ConfigPeg peg, Config_MinterMarket[] memory markets) { - peg = new ConfigPeg_GOLD(); - markets = new Config_MinterMarket[](2); - markets[0] = new ConfigMarket_GOLD_fxUSD_mainnet(); - markets[1] = new ConfigMarket_GOLD_stETH_mainnet(); - } -} diff --git a/script/src/v3/Deploy_MCAP_Minter.sol b/script/src/v3/Deploy_MCAP_Minter.sol deleted file mode 100644 index 436bc463..00000000 --- a/script/src/v3/Deploy_MCAP_Minter.sol +++ /dev/null @@ -1,22 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.28 <0.9.0; - -import {console2 as console} from "forge-std/console2.sol"; -import {DeployMintersShared} from "./DeployMintersShared.sol"; -import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; -import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; -import {ConfigPeg_MCAP} from "script/config/pegs/ConfigPeg_MCAP.sol"; -import {ConfigMarket_MCAP_fxUSD_mainnet} from "script/config/markets/ConfigMarket_MCAP_fxUSD_mainnet.sol"; -import {ConfigMarket_MCAP_stETH_mainnet} from "script/config/markets/ConfigMarket_MCAP_stETH_mainnet.sol"; -import {Config_MinterMarket} from "script/config/ConfigBase.sol"; - -/// @notice MCAP-specific minter deployment functionality. -abstract contract Deploy_MCAP_Minter is DeployMintersShared { - /// @notice Create MCAP-specific config objects. - function createMCAPMintersConfig() internal returns (ConfigPeg peg, Config_MinterMarket[] memory markets) { - peg = new ConfigPeg_MCAP(); - markets = new Config_MinterMarket[](2); - markets[0] = new ConfigMarket_MCAP_fxUSD_mainnet(); - markets[1] = new ConfigMarket_MCAP_stETH_mainnet(); - } -} diff --git a/script/src/v3/Deploy_SILVER_Minter.sol b/script/src/v3/Deploy_SILVER_Minter.sol deleted file mode 100644 index d6cb1a7f..00000000 --- a/script/src/v3/Deploy_SILVER_Minter.sol +++ /dev/null @@ -1,22 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.28 <0.9.0; - -import {console2 as console} from "forge-std/console2.sol"; -import {DeployMintersShared} from "./DeployMintersShared.sol"; -import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; -import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; -import {ConfigPeg_SILVER} from "script/config/pegs/ConfigPeg_SILVER.sol"; -import {ConfigMarket_SILVER_fxUSD_mainnet} from "script/config/markets/ConfigMarket_SILVER_fxUSD_mainnet.sol"; -import {ConfigMarket_SILVER_stETH_mainnet} from "script/config/markets/ConfigMarket_SILVER_stETH_mainnet.sol"; -import {Config_MinterMarket} from "script/config/ConfigBase.sol"; - -/// @notice SILVER-specific minter deployment functionality. -abstract contract Deploy_SILVER_Minter is DeployMintersShared { - /// @notice Create SILVER-specific config objects. - function createSILVERMintersConfig() internal returns (ConfigPeg peg, Config_MinterMarket[] memory markets) { - peg = new ConfigPeg_SILVER(); - markets = new Config_MinterMarket[](2); - markets[0] = new ConfigMarket_SILVER_fxUSD_mainnet(); - markets[1] = new ConfigMarket_SILVER_stETH_mainnet(); - } -} diff --git a/script/verify/minter-v2-upgrade/DeployMinters.t.sol b/script/verify/minter-v2-upgrade/DeployMinters.t.sol index 70bf052c..8cbac123 100644 --- a/script/verify/minter-v2-upgrade/DeployMinters.t.sol +++ b/script/verify/minter-v2-upgrade/DeployMinters.t.sol @@ -3,11 +3,11 @@ pragma solidity >=0.8.28 <0.9.0; import {BaoTest} from "@bao-test/BaoTest.sol"; import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; -import {Deploy_BTC_Minter} from "script/src/v3/Deploy_BTC_Minter.sol"; -import {Deploy_ETH_Minter} from "script/src/v3/Deploy_ETH_Minter.sol"; -import {Deploy_EUR_Minter} from "script/src/v3/Deploy_EUR_Minter.sol"; -import {Deploy_GOLD_Minter} from "script/src/v3/Deploy_GOLD_Minter.sol"; -import {Deploy_SILVER_Minter} from "script/src/v3/Deploy_SILVER_Minter.sol"; +import {Deploy_BTC_Minter} from "script/src/Deploy_BTC_Minter.sol"; +import {Deploy_ETH_Minter} from "script/src/Deploy_ETH_Minter.sol"; +import {Deploy_EUR_Minter} from "script/src/Deploy_EUR_Minter.sol"; +import {Deploy_GOLD_Minter} from "script/src/Deploy_GOLD_Minter.sol"; +import {Deploy_SILVER_Minter} from "script/src/Deploy_SILVER_Minter.sol"; import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; import {Config_MinterMarket, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; import {MintableBurnableERC20_v1} from "@bao/MintableBurnableERC20_v1.sol"; diff --git a/src/autocompounding/HarborYield_v1.sol b/src/autocompounding/HarborYield_v1.sol index 28354880..e28e1fa6 100644 --- a/src/autocompounding/HarborYield_v1.sol +++ b/src/autocompounding/HarborYield_v1.sol @@ -5,38 +5,36 @@ import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Ini import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; -import {IERC5313} from "@openzeppelin/contracts/interfaces/IERC5313.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; -import {HarborOwnable} from "@bao/HarborOwnable.sol"; +import {HarborOwnableRoles} from "@bao/HarborOwnableRoles.sol"; import {Token} from "@bao/Token.sol"; import {TokenHolder, ITokenHolder} from "@bao/TokenHolder.sol"; import {IHarborYield} from "src/interfaces/IHarborYield.sol"; +import {IAutoCompounder} from "src/interfaces/IAutoCompounder.sol"; +import {ISwapper} from "src/interfaces/ISwapper.sol"; import {StringPacking_v1} from "src/minter/library/StringPacking_v1.sol"; /// @title HarborYield_v1 /// @notice Level 2 yield vault: one per peg. Manages multiple ERC4626 vaults (AutoCompounders, /// wrapped collateral, equivalents) that share the same peg. -/// @dev Users deposit peg-denominated assets (stETH, fxUSD, SP tokens). The vault deposits them -/// into the corresponding ERC4626 vault and holds the interest-bearing shares. The hyXXX share -/// represents a proportional claim on all held vault shares. +/// @dev Replaces both hyToken_v1 (compound/swap) and HarborAnchoredVault_v1 (weighted distribution). /// -/// All assets are assumed pegged 1:1 to the same unit. totalAssets() sums -/// IERC4626(vault).convertToAssets(balance) across all managed vaults. +/// Each managed vault has a weight. Deposits are routed to the vault the user specifies. +/// `redistribute()` moves holdings toward the target weight distribution. Permissionless. +/// `compound()` converts equivalent vault holdings into AC vault holdings via the swapper. /// -/// Withdrawal returns a proportional mix of all held vault assets. +/// All assets are assumed pegged 1:1. totalAssets() = SUM(IERC4626(v).convertToAssets(balance)). // solhint-disable-next-line contract-name-capwords contract HarborYield_v1 is Initializable, UUPSUpgradeable, ERC20Upgradeable, - HarborOwnable, + HarborOwnableRoles, TokenHolder, - IERC5313, IHarborYield { using SafeERC20 for IERC20; @@ -45,43 +43,58 @@ contract HarborYield_v1 is ERRORS //////////////////////////////////////////////////////////////////////////*/ - error VaultNotRegistered(address asset); + error VaultNotRegistered(address token); error VaultNotActive(address vault); error VaultAlreadyRegistered(address vault); error ZeroShares(); + error ZeroWeight(); + error NothingToRedistribute(); + + /*////////////////////////////////////////////////////////////////////////// + CONSTANTS + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice Role for triggering compound (equivalent → AC conversion via swapper). + uint256 public constant COMPOUNDER_ROLE = _ROLE_0; + + /// @notice Role for triggering redistribution toward target weights. + uint256 public constant REDISTRIBUTOR_ROLE = _ROLE_1; /*////////////////////////////////////////////////////////////////////////// IMMUTABLES //////////////////////////////////////////////////////////////////////////*/ - /// @dev ERC20 name stored as two bytes32 (up to 64 characters) /// @custom:oz-upgrades-unsafe-allow state-variable-immutable bytes32 private immutable _ERC20_NAME_0; /// @custom:oz-upgrades-unsafe-allow state-variable-immutable bytes32 private immutable _ERC20_NAME_1; - - /// @dev ERC20 symbol stored as bytes32 (up to 32 characters) /// @custom:oz-upgrades-unsafe-allow state-variable-immutable bytes32 private immutable _ERC20_SYMBOL; + /// @notice The swapper contract for token conversions (at a predictable proxy address). + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + address public immutable SWAPPER; // solhint-disable-line immutable-vars-naming + /*////////////////////////////////////////////////////////////////////////// STORAGE (ERC7201) //////////////////////////////////////////////////////////////////////////*/ /// @custom:storage-location erc7201:harbor.storage.HarborYield_v1 // chisel eval 'keccak256(abi.encode(uint256(keccak256("harbor.storage.HarborYield_v1")) - 1)) & ~bytes32(uint256(0xff))' - bytes32 private constant _HARBOR_YIELD_STORAGE = - 0xb05ebd6dfc4d62a678d881c33089de39bf9f2de81bf0c8c698a99ab10ff31300; + bytes32 private constant _HARBOR_YIELD_STORAGE = 0xb05ebd6dfc4d62a678d881c33089de39bf9f2de81bf0c8c698a99ab10ff31300; struct ManagedVault { - address vault; // ERC4626 vault (wstETH, fxSAVE, AutoCompounder, or adapter) - address asset; // the vault's underlying asset (stETH, fxUSD, hpETH.stETH) - bool active; // accepts new deposits + address vault; // ERC4626 vault (AutoCompounder, wstETH wrapper, fxSAVE wrapper, etc.) + address asset; // the vault's underlying asset + uint96 weight; // target distribution weight (arbitrary units, not BPS) + bool active; // accepts new deposits + bool isAutoCompounder; // true if vault implements IAutoCompounder } struct HarborYieldStorage { ManagedVault[] vaults; - mapping(address => uint256) assetToVaultIndex; // asset address => index+1 (0 = not registered) + mapping(address => uint256) assetToVaultIndex; // asset => index+1 (0 = not registered) + uint256 totalWeight; // sum of all vault weights (cached for gas) } function _getHarborYieldStorage() private pure returns (HarborYieldStorage storage $) { @@ -96,19 +109,14 @@ contract HarborYield_v1 is //////////////////////////////////////////////////////////////////////////*/ /// @custom:oz-upgrades-unsafe-allow constructor - constructor( - string memory name_, - string memory symbol_ - ) ERC20Upgradeable() { + constructor(string memory name_, string memory symbol_, address swapper_) ERC20Upgradeable() { _disableInitializers(); (_ERC20_NAME_0, _ERC20_NAME_1) = StringPacking_v1.pack64(name_); // slither-disable-next-line unused-return - (_ERC20_SYMBOL,) = StringPacking_v1.pack64(symbol_); + (_ERC20_SYMBOL, ) = StringPacking_v1.pack64(symbol_); + SWAPPER = swapper_; } - /// @notice Initialize the HarborYield vault. - /// @param deployerOwner_ The initial owner (typically the FactoryDeployer). - /// @param pendingOwner_ The final owner (typically the Harbor multisig). function initialize(address deployerOwner_, address pendingOwner_) external initializer { _initializeOwner(deployerOwner_, pendingOwner_); __UUPSUpgradeable_init(); @@ -121,21 +129,17 @@ contract HarborYield_v1 is function _authorizeUpgrade(address) internal override onlyOwner {} // solhint-disable-line no-empty-blocks /*////////////////////////////////////////////////////////////////////////// - OWNERSHIP + ADMIN: VAULT MANAGEMENT //////////////////////////////////////////////////////////////////////////*/ - /// @inheritdoc IERC5313 - function owner() public view override(HarborOwnable, IERC5313) returns (address owner_) { - owner_ = HarborOwnable.owner(); - } - - /*////////////////////////////////////////////////////////////////////////// - ADMIN - //////////////////////////////////////////////////////////////////////////*/ - - /// @notice Register a new ERC4626 vault to manage. + /// @notice Register a new ERC4626 vault with a target weight. /// @param vault The ERC4626 vault address. - function addVault(address vault) external onlyOwner { + /// @param weight Target distribution weight (arbitrary units, must be > 0). + /// @param isAutoCompounder Whether the vault implements IAutoCompounder. + function addVault(address vault, uint96 weight, bool isAutoCompounder) external onlyOwner { + if (weight == 0) { + revert ZeroWeight(); + } Token.ensureContract(vault); address asset = IERC4626(vault).asset(); @@ -144,17 +148,32 @@ contract HarborYield_v1 is revert VaultAlreadyRegistered(vault); } - $.vaults.push(ManagedVault({vault: vault, asset: asset, active: true})); + $.vaults.push( + ManagedVault({vault: vault, asset: asset, weight: weight, active: true, isAutoCompounder: isAutoCompounder}) + ); $.assetToVaultIndex[asset] = $.vaults.length; // 1-indexed + $.totalWeight += weight; - // Permanent approval for deposits into this vault IERC20(asset).approve(vault, type(uint256).max); - emit VaultAdded(vault, asset); + emit VaultAdded(vault, asset, weight); + } + + /// @notice Update a vault's target weight. Set to 0 to drain via redistribution. + function setVaultWeight(address vault, uint96 weight) external onlyOwner { + HarborYieldStorage storage $ = _getHarborYieldStorage(); + for (uint256 i = 0; i < $.vaults.length; i++) { + if ($.vaults[i].vault == vault) { + $.totalWeight = $.totalWeight - $.vaults[i].weight + weight; + $.vaults[i].weight = weight; + emit VaultWeightUpdated(vault, weight); + return; + } + } + revert VaultNotRegistered(vault); } /// @notice Deactivate a vault (stop accepting deposits, keep existing holdings). - /// @param vault The vault to deactivate. function deactivateVault(address vault) external onlyOwner { HarborYieldStorage storage $ = _getHarborYieldStorage(); for (uint256 i = 0; i < $.vaults.length; i++) { @@ -168,7 +187,6 @@ contract HarborYield_v1 is } /// @notice Reactivate a previously deactivated vault. - /// @param vault The vault to reactivate. function activateVault(address vault) external onlyOwner { HarborYieldStorage storage $ = _getHarborYieldStorage(); for (uint256 i = 0; i < $.vaults.length; i++) { @@ -205,10 +223,9 @@ contract HarborYield_v1 is function totalAssets() public view returns (uint256 total) { HarborYieldStorage storage $ = _getHarborYieldStorage(); for (uint256 i = 0; i < $.vaults.length; i++) { - address vault = $.vaults[i].vault; - uint256 vaultShares = IERC20(vault).balanceOf(address(this)); + uint256 vaultShares = IERC20($.vaults[i].vault).balanceOf(address(this)); if (vaultShares > 0) { - total += IERC4626(vault).convertToAssets(vaultShares); + total += IERC4626($.vaults[i].vault).convertToAssets(vaultShares); } } } @@ -231,15 +248,12 @@ contract HarborYield_v1 is revert VaultNotActive(mv.vault); } - // Snapshot totalAssets before deposit changes it uint256 assetsBefore = totalAssets(); uint256 supplyBefore = totalSupply(); - // Transfer asset from caller and deposit into the ERC4626 vault IERC20(asset).safeTransferFrom(msg.sender, address(this), amount); IERC4626(mv.vault).deposit(amount, address(this)); - // Compute hyXXX shares at the pre-deposit exchange rate shares = Math.mulDiv(amount, supplyBefore + 1, assetsBefore + 1); if (shares == 0) { revert ZeroShares(); @@ -260,18 +274,143 @@ contract HarborYield_v1 is uint256 supply = totalSupply(); _burn(tokenOwner, shares); - // Redeem proportional vault shares from each managed vault HarborYieldStorage storage $ = _getHarborYieldStorage(); for (uint256 i = 0; i < $.vaults.length; i++) { - address vault = $.vaults[i].vault; - uint256 vaultShares = IERC20(vault).balanceOf(address(this)); + uint256 vaultShares = IERC20($.vaults[i].vault).balanceOf(address(this)); if (vaultShares > 0) { uint256 redeemAmount = Math.mulDiv(vaultShares, shares, supply); if (redeemAmount > 0) { - IERC4626(vault).redeem(redeemAmount, receiver, address(this)); + IERC4626($.vaults[i].vault).redeem(redeemAmount, receiver, address(this)); + } + } + } + } + + /*////////////////////////////////////////////////////////////////////////// + CORE: COMPOUND + //////////////////////////////////////////////////////////////////////////*/ + + /// @inheritdoc IHarborYield + function compound( + address fromVault, + address toVault, + uint256 vaultShareAmount, + uint256 minAmountOut, + bytes calldata swapData + ) external onlyOwnerOrRoles(COMPOUNDER_ROLE) { + // Redeem from the source equivalent vault to get its underlying asset + uint256 assetAmount = IERC4626(fromVault).redeem(vaultShareAmount, address(this), address(this)); + + // Swap the asset to the target vault's asset + uint256 swappedAmount = _swapIfNeeded( + IERC4626(fromVault).asset(), + IERC4626(toVault).asset(), + assetAmount, + minAmountOut, + swapData + ); + + // Deposit into the target vault (typically an AC) + IERC4626(toVault).deposit(swappedAmount, address(this)); + + emit Compounded(msg.sender, fromVault, toVault, assetAmount, swappedAmount); + } + + /*////////////////////////////////////////////////////////////////////////// + CORE: REDISTRIBUTE + //////////////////////////////////////////////////////////////////////////*/ + + struct RedistributeWork { + uint256 sourceIdx; + uint256 targetIdx; + uint256 moveValue; + } + + /// @inheritdoc IHarborYield + function redistribute( + uint256 maxVaultSharesPerVault, + uint256 minAmountOut, + bytes calldata swapData + ) external onlyOwnerOrRoles(REDISTRIBUTOR_ROLE) { + HarborYieldStorage storage $ = _getHarborYieldStorage(); + uint256 tw = $.totalWeight; + if (tw == 0) { + revert NothingToRedistribute(); + } + uint256 total = totalAssets(); + if (total == 0) { + revert NothingToRedistribute(); + } + + // Find the most over/under-weight vaults + RedistributeWork memory w; + { + uint256 maxExcess; + uint256 maxDeficit; + for (uint256 i = 0; i < $.vaults.length; i++) { + uint256 bal = IERC20($.vaults[i].vault).balanceOf(address(this)); + uint256 cur = bal > 0 ? IERC4626($.vaults[i].vault).convertToAssets(bal) : 0; + uint256 tgt = Math.mulDiv(total, $.vaults[i].weight, tw); + if (cur > tgt) { + uint256 excess = cur - tgt; + if (excess > maxExcess) { + maxExcess = excess; + w.sourceIdx = i; + } + } else { + uint256 deficit = tgt - cur; + if (deficit > maxDeficit) { + maxDeficit = deficit; + w.targetIdx = i; + } } } + if (maxExcess == 0 || maxDeficit == 0) { + revert NothingToRedistribute(); + } + w.moveValue = maxExcess < maxDeficit ? maxExcess : maxDeficit; + } + + // Redeem from source, cap shares + address srcVault = $.vaults[w.sourceIdx].vault; + address dstVault = $.vaults[w.targetIdx].vault; + { + uint256 srcShares = IERC4626(srcVault).convertToShares(w.moveValue); + if (srcShares > maxVaultSharesPerVault) { + srcShares = maxVaultSharesPerVault; + } + w.moveValue = IERC4626(srcVault).redeem(srcShares, address(this), address(this)); + } + // Swap if needed, deposit to target + uint256 deposited = _swapIfNeeded( + $.vaults[w.sourceIdx].asset, + $.vaults[w.targetIdx].asset, + w.moveValue, + minAmountOut, + swapData + ); + IERC4626(dstVault).deposit(deposited, address(this)); + emit Redistributed(msg.sender, srcVault, dstVault, w.moveValue, deposited); + } + + /*////////////////////////////////////////////////////////////////////////// + INTERNAL + //////////////////////////////////////////////////////////////////////////*/ + + /// @dev Swap fromAsset -> toAsset via SWAPPER, or pass through if same asset. + /// Called from both compound() and redistribute(). + function _swapIfNeeded( + address fromAsset, + address toAsset, + uint256 amountIn, + uint256 minAmountOut, + bytes calldata swapData + ) private returns (uint256 amountOut) { + if (fromAsset == toAsset) { + return amountIn; } + IERC20(fromAsset).approve(SWAPPER, amountIn); + amountOut = ISwapper(SWAPPER).swap(fromAsset, toAsset, amountIn, minAmountOut, swapData); } /*////////////////////////////////////////////////////////////////////////// @@ -284,11 +423,17 @@ contract HarborYield_v1 is } /// @inheritdoc IHarborYield - function vaultAt(uint256 index) external view returns (address vault, address asset, bool active) { + function vaultAt(uint256 index) external view returns (address vault, address asset, bool active, uint96 weight) { ManagedVault storage mv = _getHarborYieldStorage().vaults[index]; vault = mv.vault; asset = mv.asset; active = mv.active; + weight = mv.weight; + } + + /// @notice The cached total of all vault weights. + function totalWeight() external view returns (uint256) { + return _getHarborYieldStorage().totalWeight; } /*////////////////////////////////////////////////////////////////////////// diff --git a/src/interfaces/IHarborYield.sol b/src/interfaces/IHarborYield.sol index 39a66d64..ff3af786 100644 --- a/src/interfaces/IHarborYield.sol +++ b/src/interfaces/IHarborYield.sol @@ -14,7 +14,13 @@ interface IHarborYield { event VaultWeightUpdated(address indexed vault, uint96 weight); event Compounded(address indexed caller, address fromVault, address toVault, uint256 amountIn, uint256 amountOut); - event Redistributed(address indexed caller, address fromVault, address toVault, uint256 amountIn, uint256 amountOut); + event Redistributed( + address indexed caller, + address fromVault, + address toVault, + uint256 amountIn, + uint256 amountOut + ); // ── Deposit ───────────────────────────────────────────────────────── diff --git a/src/minter/StabilityPool_v3.sol b/src/minter/StabilityPool_v3.sol index 73e4336c..bc3664da 100644 --- a/src/minter/StabilityPool_v3.sol +++ b/src/minter/StabilityPool_v3.sol @@ -185,7 +185,12 @@ contract StabilityPool_v3 is * Constructor * ***************/ - function initialize(address deployerOwner_, address pendingOwner_, uint256 earlyWithdrawalFee_, address feeAddress_) external initializer { + function initialize( + address deployerOwner_, + address pendingOwner_, + uint256 earlyWithdrawalFee_, + address feeAddress_ + ) external initializer { _initializeOwner(deployerOwner_, pendingOwner_); __UUPSUpgradeable_init(); __ReentrancyGuardTransient_init(); diff --git a/src/reward/distributor/LinearMultipleRewardDistributor_v3.sol b/src/reward/distributor/LinearMultipleRewardDistributor_v3.sol index f5724d91..39f7c88f 100644 --- a/src/reward/distributor/LinearMultipleRewardDistributor_v3.sol +++ b/src/reward/distributor/LinearMultipleRewardDistributor_v3.sol @@ -177,7 +177,6 @@ abstract contract LinearMultipleRewardDistributor_v3 is _registerRewardToken(token); } - function _registerRewardToken(address token) internal { if (token == address(0)) { revert RewardTokenIsZero(); @@ -279,5 +278,4 @@ abstract contract LinearMultipleRewardDistributor_v3 is LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); (distributable, undistributed) = $.rewardData[token].pending(); } - } diff --git a/test/deployment/DeployETHfxUSD.t.sol b/test/deployment/DeployETHfxUSD.t.sol index 51045828..afed7700 100644 --- a/test/deployment/DeployETHfxUSD.t.sol +++ b/test/deployment/DeployETHfxUSD.t.sol @@ -4,7 +4,7 @@ pragma solidity >=0.8.28 <0.9.0; import {BaoTest} from "@bao-test/BaoTest.sol"; import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; -import {Deploy_ETH_Minter} from "script/src/v3/Deploy_ETH_Minter.sol"; +import {Deploy_ETH_Minter} from "script/src/Deploy_ETH_Minter.sol"; import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; import {Config_MinterMarket} from "script/config/ConfigBase.sol"; diff --git a/test/deployment/DeployEURSetUp.t.sol b/test/deployment/DeployEURSetUp.t.sol index a26dabe1..a541db08 100644 --- a/test/deployment/DeployEURSetUp.t.sol +++ b/test/deployment/DeployEURSetUp.t.sol @@ -4,7 +4,7 @@ pragma solidity >=0.8.28 <0.9.0; import {BaoTest} from "@bao-test/BaoTest.sol"; import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; -import {Deploy_EUR_Minter} from "script/src/v3/Deploy_EUR_Minter.sol"; +import {Deploy_EUR_Minter} from "script/src/Deploy_EUR_Minter.sol"; import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; import {Config_MinterMarket} from "script/config/ConfigBase.sol"; diff --git a/test/deployment/MinterCappedMint.t.sol b/test/deployment/MinterCappedMint.t.sol index a7a54ca7..9deb23f4 100644 --- a/test/deployment/MinterCappedMint.t.sol +++ b/test/deployment/MinterCappedMint.t.sol @@ -3,7 +3,7 @@ pragma solidity >=0.8.28 <0.9.0; import {BaoTest} from "@bao-test/BaoTest.sol"; import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; -import {Deploy_ETH_Minter} from "script/src/v3/Deploy_ETH_Minter.sol"; +import {Deploy_ETH_Minter} from "script/src/Deploy_ETH_Minter.sol"; import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; import {Config_MinterMarket} from "script/config/ConfigBase.sol"; diff --git a/test/deployment/RebalanceFairness.t.sol b/test/deployment/RebalanceFairness.t.sol index 51e997bb..2feeb416 100644 --- a/test/deployment/RebalanceFairness.t.sol +++ b/test/deployment/RebalanceFairness.t.sol @@ -3,7 +3,7 @@ pragma solidity >=0.8.28 <0.9.0; import {BaoTest} from "@bao-test/BaoTest.sol"; import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; -import {Deploy_ETH_Minter} from "script/src/v3/Deploy_ETH_Minter.sol"; +import {Deploy_ETH_Minter} from "script/src/Deploy_ETH_Minter.sol"; import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; import {Config_MinterMarket, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; diff --git a/test/deployment/RewardSystem.t.sol b/test/deployment/RewardSystem.t.sol index e26692f3..b1c00b4a 100644 --- a/test/deployment/RewardSystem.t.sol +++ b/test/deployment/RewardSystem.t.sol @@ -4,7 +4,7 @@ pragma solidity >=0.8.28 <0.9.0; import {BaoTest} from "@bao-test/BaoTest.sol"; import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; -import {Deploy_ETH_Minter} from "script/src/v3/Deploy_ETH_Minter.sol"; +import {Deploy_ETH_Minter} from "script/src/Deploy_ETH_Minter.sol"; import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; import {Config_MinterMarket} from "script/config/ConfigBase.sol"; From 8c38ff44196a8db8c07683b4e043dccd96d2258a Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Sun, 12 Apr 2026 21:10:21 +0100 Subject: [PATCH 030/232] fixed slither issues first steps to get wake running fix rationalised deploy --- .gitignore | 7 +- CLAUDE.md | 5 +- lib/bao-base | 2 +- package.json | 2 +- regression/coverage.txt | 52 ++++++-------- regression/gas.txt | 29 ++++---- regression/sizes.txt | 8 +-- script/src/contracts/AutoCompounder.sol | 12 +--- script/src/contracts/Genesis.sol | 24 ++++--- script/src/contracts/HarborYield.sol | 12 +--- script/src/contracts/LeveragedToken.sol | 29 +++++--- script/src/contracts/Minter.sol | 64 +++++++++-------- script/src/contracts/PeggedToken.sol | 29 +++++--- script/src/contracts/StabilityPool.sol | 21 +----- script/src/contracts/StabilityPoolManager.sol | 71 +++++++++++++------ scripts/__init__.py | 0 scripts/deploy.py | 8 +++ src/autocompounding/AutoCompounder_v1.sol | 11 +-- src/autocompounding/HarborYield_v1.sol | 44 ++++++++---- src/minter/StabilityPool_v3.sol | 3 +- src/minter/library/StringPacking_v1.sol | 15 ++++ test/Minter_feeRange.t.sol | 2 +- test/StabilityPoolUpgradeMigration.t.sol | 8 +-- ...ultipleRewardCompoundingAccumulator_v3.sol | 4 +- .../reward/accumulator/ClaimEquivalence.t.sol | 2 +- tests/__init__.py | 0 tests/test_default.py | 12 ++++ 27 files changed, 268 insertions(+), 208 deletions(-) create mode 100644 scripts/__init__.py create mode 100644 scripts/deploy.py create mode 100644 tests/__init__.py create mode 100644 tests/test_default.py diff --git a/.gitignore b/.gitignore index e1b50e49..5ece3885 100644 --- a/.gitignore +++ b/.gitignore @@ -54,4 +54,9 @@ docs/ *.log # temp or prliminary files -*.*- \ No newline at end of file +*.*- +.wake +pytypes +*.py[cod] +.hypothesis/ +wake-coverage.cov \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index f29e1ca0..307a9336 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,9 +23,10 @@ - Each UUPS contract composes Initializable + UUPSUpgradeable + ownership mixin directly — don't create "Upgradeable" base contracts that bundle these, as each contract has different init needs (roles, reentrancy, custom state). The "Upgradeable" suffix means something different in OZ (storage-safe proxy variant) and combining meanings causes confusion. - When adding functions to interfaces in an inheritance hierarchy, avoid creating diamond inheritance. If a function is defined on both an interface and a concrete base, the derived contract must override to resolve the ambiguity. Instead, put the function on only one path — either a new versioned interface (e.g. `IMultipleRewardDistributor_v3`) or directly on the implementation. Prefer eliminating the diamond over resolving it with overrides. - In tests, never create and then remove files or directories — forge runs tests in parallel so you can create a race condition. Write test output to `./results` and leave it there. -- Tests verify *what code is supposed to do*, not merely that lines execute. When asked to improve testing, think: "what is the intended behaviour?" — then construct scenarios that demonstrate the code fulfils that intent. If unsure what a function is supposed to do, ask — the specification is not in the code. Avoid writing tests that only exercise code paths to increase coverage metrics; such tests reinforce any misunderstanding in the implementation and give false confidence. +- Tests verify *what code is supposed to do*, not merely that lines execute. When asked to improve testing, think: "what is the intended behaviour?" — then construct scenarios that demonstrate the code fulfils that intent. If unsure what a function is supposed to do, ask — the specification is not in the code. Avoid writing tests that only exercise code paths to increase coverage metrics; such tests reinforce any misunderstanding in the implementation and give false confidence. Always add a comment at the top of a test to say what functionality it is testing: keep it concise. - In tests, prefer `console2.log` over `emit` for debug logging — it shows in `forge test -vvv` output without cluttering the event log. Use the `Fmt` library with `string.concat` for readable formatted messages. - Prefer immutable constructor arguments over configurable storage for addresses of related contracts deployed at predictable proxy addresses. The related contract can be upgraded via its own proxy without the consuming contract needing a setter. This saves bytecode (no setter function, no zero-address checks, no storage reads) and gas. Only use storage for addresses that genuinely need to change independently of contract upgrades. - Always use HarborOwnable/HarborOwnableRoles over BaoOwnable/BaoOwnableRoles. They are near-drop-in replacements that take explicit `(deployerOwner, pendingOwner)` instead of relying on `msg.sender`. They don't need the UUPSProxyDeployStub — deploy via `_deployProxyAndRecord` (direct), not `_deployProxyViaStubAndRecord`. When upgrading a contract from BaoOwnable to a new version, switch to HarborOwnable. - Never use module-level or contract-level flags/booleans to communicate state between functions within a single call. If a function needs to behave differently based on context, pass the context explicitly via parameters or use separate functions. Hidden state makes code harder to reason about and introduces coupling that isn't visible in function signatures. Use explicit parameters or dedicated function variants instead. -- Never modify a deployed contract's source file. Check `deployments/*.state.json` for deployed contracts. To add functionality: inherit from the deployed version (e.g. `Minter_v3 is Minter_v2`) if the changes are additive, or clone and modify if the inheritance chain doesn't work. The proxy upgrade mechanism allows swapping implementations, but the old source must remain unchanged for audit traceability. \ No newline at end of file +- Never modify a deployed contract's source file. Check `deployments/*.state.json` for deployed contracts. To add functionality: inherit from the deployed version (e.g. `Minter_v3 is Minter_v2`) if the changes are additive, or clone and modify if the inheritance chain doesn't work. The proxy upgrade mechanism allows swapping implementations, but the old source must remain unchanged for audit traceability. +- Never read files that are likely to contain secrets — `.env`, `.env.*`, `*.pem`, `*.key`, `credentials*`, `*.secret`, `id_rsa*`, etc. — unless the user explicitly asks for it. This applies even when investigating something unrelated (e.g. resolving a symlink, looking for shell hooks): skip the file. Once a secret is read by a tool call, the contents are in the conversation transcript and must be treated as compromised. If you need information *about* such a file (existence, size, ownership), use `ls -la`, not `cat`. Match the scope of investigation to the actual question being asked. \ No newline at end of file diff --git a/lib/bao-base b/lib/bao-base index e15eb5d9..a8af5726 160000 --- a/lib/bao-base +++ b/lib/bao-base @@ -1 +1 @@ -Subproject commit e15eb5d98cf748fb9abffbc9a30b40bb3f8a6df4 +Subproject commit a8af5726ba76caeba85bf57b9f1cb722fc923ea8 diff --git a/package.json b/package.json index 2ebf8f2e..a58d858a 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "test": "mkdir -p results; ./lib/bao-base/run test", "gas": "./lib/bao-base/run regression-of gas", "coverage": "./lib/bao-base/run regression-of coverage", - "wake": "wake detect all", + "wake": "uv run wake detect all src", "slither": "./lib/bao-base/run slither --filter-paths 'script/verify'", "verify-audit": "lib/bao-base/run verify-audit", "validate": "./lib/bao-base/run validate", diff --git a/regression/coverage.txt b/regression/coverage.txt index ebf4b225..9ccace8d 100644 --- a/regression/coverage.txt +++ b/regression/coverage.txt @@ -1,7 +1,7 @@ | File | % Lines | % Statements | % Branches | % Funcs | |--------------------------------------------------------------------|--------------------|--------------------|------------------|-------------------| | script/config/ConfigBase.sol | ✓ 100% (8/8) | ✓ 100% (8/8) | ✓ 100% (0/0) | ✓ 100% (4/4) | -| script/config/ConfigTokenNames.sol | X 92% (33/36) | X 93% (26/28) | ✓ 100% (0/0) | X 88% (14/16) | +| script/config/ConfigTokenNames.sol | X 82% (33/40) | X 81% (26/32) | ✓ 100% (0/0) | X 78% (14/18) | | script/config/autocompounder/ConfigAutoCompounder.sol | ✓ 100% (2/2) | ✓ 100% (1/1) | ✓ 100% (0/0) | ✓ 100% (1/1) | | script/config/chains/ConfigChain_mainnet.sol | X 10% (2/21) | X 15% (2/13) | ✓ 100% (0/0) | X 0% (0/8) | | script/config/collaterals/ConfigCollateral_fxUSD_mainnet.sol | X 67% (4/6) | X 60% (3/5) | ✓ 100% (0/0) | X 67% (2/3) | @@ -24,35 +24,24 @@ | script/config/volatility/ConfigPriceVolatility_125_stable.sol | X 0% (0/65) | X 0% (0/71) | ✓ 100% (0/0) | X 0% (0/2) | | script/config/volatility/ConfigPriceVolatility_130.sol | ✓ 100% (65/65) | ✓ 100% (71/71) | ✓ 100% (0/0) | ✓ 100% (2/2) | | script/config/volatility/ConfigPriceVolatility_130_stable.sol | ✓ 100% (65/65) | ✓ 100% (71/71) | ✓ 100% (0/0) | ✓ 100% (2/2) | +| script/src/DeployMintersShared.sol | X 85% (80/94) | X 84% (94/112) | X 25% (1/4) | X 82% (9/11) | +| script/src/Deploy_BTC_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | +| script/src/Deploy_ETH_Minter.sol | ✓ 100% (4/4) | ✓ 100% (3/3) | ✓ 100% (0/0) | ✓ 100% (1/1) | +| script/src/Deploy_EUR_Minter.sol | ✓ 100% (5/5) | ✓ 100% (4/4) | ✓ 100% (0/0) | ✓ 100% (1/1) | +| script/src/Deploy_GOLD_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | +| script/src/Deploy_MCAP_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | +| script/src/Deploy_SILVER_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | | script/src/HarborFactoryDeployer.sol | X 56% (9/16) | X 73% (8/11) | ✓ 100% (0/0) | X 20% (1/5) | -| script/src/v2/DeployMintersShared.sol | X 0% (0/84) | X 0% (0/102) | X 0% (0/4) | X 0% (0/9) | -| script/src/v2/Deploy_BTC_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | -| script/src/v2/Deploy_ETH_Minter.sol | X 0% (0/4) | X 0% (0/3) | ✓ 100% (0/0) | X 0% (0/1) | -| script/src/v2/Deploy_EUR_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | -| script/src/v2/Deploy_GOLD_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | -| script/src/v2/Deploy_MCAP_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | -| script/src/v2/Deploy_SILVER_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | -| script/src/v2/contracts/Genesis.sol | X 0% (0/10) | X 0% (0/12) | ✓ 100% (0/0) | X 0% (0/2) | -| script/src/v2/contracts/LeveragedToken.sol | X 0% (0/17) | X 0% (0/27) | ✓ 100% (0/0) | X 0% (0/1) | -| script/src/v2/contracts/Minter.sol | X 0% (0/42) | X 0% (0/46) | ✓ 100% (0/0) | X 0% (0/8) | -| script/src/v2/contracts/PeggedToken.sol | X 0% (0/24) | X 0% (0/33) | X 0% (0/6) | X 0% (0/1) | -| script/src/v2/contracts/StabilityPool.sol | X 0% (0/18) | X 0% (0/25) | ✓ 100% (0/0) | X 0% (0/3) | -| script/src/v2/contracts/StabilityPoolManager.sol | X 0% (0/26) | X 0% (0/30) | ✓ 100% (0/0) | X 0% (0/4) | -| script/src/v3/DeployMintersShared.sol | X 87% (93/107) | X 87% (118/136) | X 25% (1/4) | X 82% (9/11) | -| script/src/v3/Deploy_BTC_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | -| script/src/v3/Deploy_ETH_Minter.sol | ✓ 100% (4/4) | ✓ 100% (3/3) | ✓ 100% (0/0) | ✓ 100% (1/1) | -| script/src/v3/Deploy_EUR_Minter.sol | ✓ 100% (5/5) | ✓ 100% (4/4) | ✓ 100% (0/0) | ✓ 100% (1/1) | -| script/src/v3/Deploy_GOLD_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | -| script/src/v3/Deploy_MCAP_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | -| script/src/v3/Deploy_SILVER_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | -| script/src/v3/contracts/AutoCompounder.sol | ✓ 100% (25/25) | ✓ 100% (34/34) | ✓ 100% (0/0) | ✓ 100% (4/4) | -| script/src/v3/contracts/Genesis.sol | X 70% (7/10) | X 67% (8/12) | ✓ 100% (0/0) | X 50% (1/2) | -| script/src/v3/contracts/LeveragedToken.sol | ✓ 100% (15/15) | ✓ 100% (23/23) | ✓ 100% (0/0) | ✓ 100% (1/1) | -| script/src/v3/contracts/Minter.sol | X 58% (23/40) | X 55% (23/42) | ✓ 100% (0/0) | X 62% (5/8) | -| script/src/v3/contracts/PeggedToken.sol | X 83% (20/24) | X 94% (31/33) | X 50% (3/6) | ✓ 100% (1/1) | -| script/src/v3/contracts/StabilityPool.sol | ✓ 100% (26/26) | ✓ 100% (41/41) | ✓ 100% (0/0) | ✓ 100% (3/3) | -| script/src/v3/contracts/StabilityPoolManager.sol | X 54% (14/26) | X 50% (15/30) | ✓ 100% (0/0) | X 50% (2/4) | -| src/autocompounding/AutoCompounder_v1.sol | X 96% (76/79) | X 97% (75/77) | X 60% (3/5) | X 94% (16/17) | +| script/src/contracts/AutoCompounder.sol | ✓ 100% (23/23) | ✓ 100% (33/33) | ✓ 100% (0/0) | ✓ 100% (3/3) | +| script/src/contracts/Genesis.sol | X 77% (10/13) | X 73% (11/15) | ✓ 100% (0/0) | X 67% (2/3) | +| script/src/contracts/HarborYield.sol | X 0% (0/21) | X 0% (0/27) | ✓ 100% (0/0) | X 0% (0/3) | +| script/src/contracts/LeveragedToken.sol | ✓ 100% (18/18) | ✓ 100% (26/26) | ✓ 100% (0/0) | ✓ 100% (2/2) | +| script/src/contracts/Minter.sol | X 62% (32/52) | X 63% (38/60) | ✓ 100% (0/0) | X 60% (6/10) | +| script/src/contracts/PeggedToken.sol | X 85% (23/27) | X 94% (34/36) | X 50% (3/6) | ✓ 100% (2/2) | +| script/src/contracts/StabilityPool.sol | ✓ 100% (31/31) | ✓ 100% (50/50) | ✓ 100% (0/0) | ✓ 100% (3/3) | +| script/src/contracts/StabilityPoolManager.sol | X 53% (17/32) | X 50% (18/36) | ✓ 100% (0/0) | X 50% (3/6) | +| src/autocompounding/AutoCompounder_v1.sol | X 96% (74/77) | X 97% (74/76) | X 60% (3/5) | X 94% (15/16) | +| src/autocompounding/HarborYield_v1.sol | X 0% (0/153) | X 0% (0/173) | X 0% (0/21) | X 0% (0/21) | | src/math/DecrementalFloatingPoint.sol | ✓ 100% (31/31) | ✓ 100% (33/33) | ✓ 100% (9/9) | ✓ 100% (6/6) | | src/minter/Genesis_v1.sol | X 99% (88/89) | ✓ 100% (88/88) | ✓ 100% (14/14) | X 92% (11/12) | | src/minter/Minter_v1.sol | X 0% (0/601) | X 0% (0/649) | X 0% (0/102) | X 0% (0/68) | @@ -66,7 +55,7 @@ | src/minter/TokenDistributor_v1.sol | X 96% (94/98) | X 97% (112/116) | X 77% (10/13) | X 93% (14/15) | | src/minter/library/ConfigIncentiveLib.sol | ✓ 100% (24/24) | ✓ 100% (17/17) | ✓ 100% (2/2) | ✓ 100% (9/9) | | src/minter/library/Config_v1.sol | X 96% (76/79) | X 97% (92/95) | X 90% (18/20) | ✓ 100% (6/6) | -| src/minter/library/StringPacking_v1.sol | ✓ 100% (26/26) | ✓ 100% (33/33) | ✓ 100% (6/6) | ✓ 100% (2/2) | +| src/minter/library/StringPacking_v1.sol | ✓ 100% (33/33) | ✓ 100% (39/39) | ✓ 100% (8/8) | ✓ 100% (3/3) | | src/price/PriceOracle_v1.sol | X 97% (31/32) | X 97% (36/37) | X 85% (11/13) | ✓ 100% (3/3) | | src/price/StakedETHWrappedPriceOracle_v1.sol | X 0% (0/29) | X 0% (0/27) | X 0% (0/5) | X 0% (0/4) | | src/reward/accumulator/MultipleRewardCompoundingAccumulator.sol | X 91% (124/136) | X 90% (154/171) | X 75% (12/16) | X 90% (19/21) | @@ -74,7 +63,8 @@ | src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol | X 96% (145/151) | X 97% (177/183) | X 85% (17/20) | X 96% (24/25) | | src/reward/distributor/LinearMultipleRewardDistributor.sol | ✓ 100% (77/77) | ✓ 100% (86/86) | ✓ 100% (12/12) | ✓ 100% (14/14) | | src/reward/distributor/LinearMultipleRewardDistributor_v2.sol | ✓ 100% (77/77) | ✓ 100% (86/86) | ✓ 100% (12/12) | ✓ 100% (14/14) | +| src/reward/distributor/LinearMultipleRewardDistributor_v3.sol | X 96% (78/81) | X 97% (85/88) | X 75% (9/12) | ✓ 100% (16/16) | | src/reward/distributor/LinearReward.sol | ✓ 100% (25/25) | ✓ 100% (27/27) | ✓ 100% (6/6) | ✓ 100% (2/2) | | src/util/FmtLib.sol | X 79% (15/19) | X 73% (19/26) | X 50% (2/4) | ✓ 100% (1/1) | | src/util/WordCodec.sol | ✓ 100% (15/15) | ✓ 100% (14/14) | ✓ 100% (0/0) | ✓ 100% (4/4) | -| Total | X 59% (4836/8163) | X 58% (5126/8834) | X 48% (429/891) | X 61% (722/1190) | +| Total | X 60% (4930/8205) | X 59% (5227/8855) | X 48% (440/919) | X 61% (742/1209) | diff --git a/regression/gas.txt b/regression/gas.txt index 90454a2b..03bea810 100644 --- a/regression/gas.txt +++ b/regression/gas.txt @@ -14,13 +14,13 @@ src/autocompounding/AutoCompounder_v1.sol:AutoCompounder_v1 | depositPeggedToken | 3.213e+05 | | initialize | 1.017e+05 | | maxFeeRatio | 2.391e+03 | -| name | 1.751e+04 | -| owner | 2.440e+03 | +| name | 1.753e+04 | +| owner | 2.403e+03 | | previewRedeem | 3.630e+04 | | redeem | 9.756e+04 | | setMaxFeeRatio | 2.562e+04 | | sweep | 4.525e+04 | -| symbol | 1.874e+04 | +| symbol | 1.876e+04 | | totalAssets | 7.818e+04 | | transferOwnership | 1.202e+04 | @@ -71,7 +71,7 @@ src/minter/Minter_v3.sol:Minter_v3 | mintLeveragedTokenIncentiveRatio | 3.108e+04 | | mintPeggedToken(uint256,address,uint256) | 1.911e+05 | | mintPeggedToken(uint256,address,uint256,uint256) | 1.251e+05 | -| mintPeggedTokenDryRun(uint256) | 6.399e+04 | +| mintPeggedTokenDryRun(uint256) | 6.401e+04 | | mintPeggedTokenDryRun(uint256,uint256) | 4.556e+04 | | mintPeggedTokenIncentiveRatio | 3.012e+04 | | owner | 2.402e+03 | @@ -112,7 +112,7 @@ src/minter/StabilityPoolManager_v1.sol:StabilityPoolManager_v1 | function name | max | |----------------------------|-----------| | feeReceiver | 2.441e+03 | -| harvest | 4.442e+05 | +| harvest | 4.443e+05 | | harvestBountyRatio | 2.369e+03 | | harvestCutRatio | 2.371e+03 | | harvestable | 3.052e+04 | @@ -178,25 +178,25 @@ src/minter/StabilityPool_v3.sol:StabilityPool_v3 | claimed | 7.472e+03 | | decimals | 2.950e+02 | | deposit | 2.848e+05 | -| depositReward | 6.723e+04 | +| depositReward | 6.726e+04 | | getWithdrawalRequest | 2.745e+03 | | grantRoles | 2.638e+04 | | historicalRewardTokens | 5.180e+03 | -| initialize | 2.041e+05 | -| name | 1.926e+04 | +| initialize | 2.042e+05 | +| name | 1.928e+04 | | notifyLiquidation | 1.235e+05 | | owner | 2.424e+03 | | proxiableUUID | 3.640e+02 | -| registerRewardToken | 8.852e+04 | +| registerRewardToken | 8.857e+04 | | requestWithdrawal | 2.501e+04 | -| sweep | 4.020e+04 | -| symbol | 1.948e+04 | +| sweep | 4.024e+04 | +| symbol | 1.950e+04 | | totalAssetSupply | 2.489e+03 | | totalSupply | 2.424e+03 | | transfer | 1.880e+05 | | transferFrom | 1.316e+05 | | transferOwnership | 1.204e+04 | -| unregisterRewardToken | 9.139e+04 | +| unregisterRewardToken | 9.144e+04 | | withdraw | 2.585e+05 | src/minter/TokenDistributor_v1.sol:TokenDistributor_v1 @@ -223,5 +223,6 @@ src/minter/TokenDistributor_v1.sol:TokenDistributor_v1 src/minter/library/StringPacking_v1.sol:StringPacking_v1 | function name | max | |-----------------|-----------| -| pack64 | 9.370e+02 | -| unpack64 | 1.580e+04 | +| pack32 | 7.480e+02 | +| pack64 | 9.590e+02 | +| unpack64 | 1.582e+04 | diff --git a/regression/sizes.txt b/regression/sizes.txt index f43ab0db..748c409e 100644 --- a/regression/sizes.txt +++ b/regression/sizes.txt @@ -1,6 +1,6 @@ | Contract | Runtime Size (B) | Runtime Margin (B) | Initcode Size (B) | Deploy Gas | Deploy Cost ($) | |-----------------------------------|--------------------|----------------------|---------------------|--------------|-------------------| -| AutoCompounder_v1 | 12,153 | 12,423 | 13,819 | 2,568,790 | 256.88 | +| AutoCompounder_v1 | 12,153 | 12,423 | 13,842 | 2,569,020 | 256.90 | | ConfigIncentiveLib | 85 | 24,491 | 135 | 18,350 | 1.84 | | ConfigMarket_BTC_fxUSD_mainnet | 6,856 | 17,720 | 6,884 | 1,440,040 | 144.00 | | ConfigMarket_BTC_stETH_mainnet | 6,882 | 17,694 | 6,910 | 1,445,500 | 144.55 | @@ -36,7 +36,7 @@ | FakeUUPSUpgradeable | 3,236 | 21,340 | 3,293 | 680,130 | 68.01 | | FmtLib | 85 | 24,491 | 135 | 18,350 | 1.84 | | Genesis_v1 | 7,486 | 17,090 | 8,423 | 1,581,430 | 158.14 | -| HarborYield_v1 | 13,551 | 11,025 | 14,519 | 2,855,390 | 285.54 | +| HarborYield_v1 | 13,629 | 10,947 | 14,668 | 2,872,480 | 287.25 | | LinearReward | 85 | 24,491 | 135 | 18,350 | 1.84 | | MinterMarketConfigLib | 85 | 24,491 | 135 | 18,350 | 1.84 | | Minter_v1 | 23,681 | 895 | 25,515 | 4,991,350 | 499.14 | @@ -47,8 +47,8 @@ | StabilityPoolManager_v1 | 11,209 | 13,367 | 13,057 | 2,372,370 | 237.24 | | StabilityPool_v1 | 21,092 | 3,484 | 23,085 | 4,449,250 | 444.93 | | StabilityPool_v2 | 20,711 | 3,865 | 22,579 | 4,367,990 | 436.80 | -| StabilityPool_v3 | 23,304 | 1,272 | 25,923 | 4,920,030 | 492.00 | +| StabilityPool_v3 | 23,304 | 1,272 | 25,945 | 4,920,250 | 492.03 | | StakedETHWrappedPriceOracle_v1 | 3,695 | 20,881 | 4,653 | 785,530 | 78.55 | -| StringPacking_v1 | 1,218 | 23,358 | 1,270 | 256,300 | 25.63 | +| StringPacking_v1 | 1,345 | 23,231 | 1,397 | 282,970 | 28.30 | | TokenDistributor_v1 | 10,116 | 14,460 | 10,365 | 2,126,850 | 212.69 | | WordCodec | 85 | 24,491 | 135 | 18,350 | 1.84 | diff --git a/script/src/contracts/AutoCompounder.sol b/script/src/contracts/AutoCompounder.sol index ea9ee2e5..908492e1 100644 --- a/script/src/contracts/AutoCompounder.sol +++ b/script/src/contracts/AutoCompounder.sol @@ -3,7 +3,6 @@ pragma solidity >=0.8.28 <0.9.0; import {console2 as console} from "forge-std/console2.sol"; import {HarborFactoryDeployer} from "script/src/HarborFactoryDeployer.sol"; -import {DeploymentState} from "@bao-script/deployment/DeploymentState.sol"; import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; import {AutoCompounder_v1} from "@harbor/autocompounding/AutoCompounder_v1.sol"; @@ -47,16 +46,7 @@ abstract contract AutoCompounder is HarborFactoryDeployer { console.log(" Name: %s", tokenName); console.log(" Symbol: %s", tokenSymbol); - DeploymentState.recordImplementation( - stateData, - DeploymentTypes.ImplementationRecord({ - proxy: acKey, - contractSource: "@harbor/autocompounding/AutoCompounder_v1.sol", - contractType: "AutoCompounder_v1", - implementation: impl, - deploymentTime: uint64(block.timestamp) - }) - ); + _recordImplementation(stateData, acKey, "@harbor/autocompounding/AutoCompounder_v1.sol", "AutoCompounder_v1", impl); } /// @notice Deploy AutoCompounder impl+proxy, record in state. diff --git a/script/src/contracts/Genesis.sol b/script/src/contracts/Genesis.sol index 43ea43bb..901049b9 100644 --- a/script/src/contracts/Genesis.sol +++ b/script/src/contracts/Genesis.sol @@ -20,6 +20,18 @@ import {Genesis_v1} from "@harbor/minter/Genesis_v1.sol"; abstract contract Genesis is HarborFactoryDeployer { // ========== GENESIS DEPLOYMENT ========== + /// @notice Deploy Genesis_v1 impl only, record in state. + function deployGenesisImplementation( + DeploymentTypes.State memory stateData, + string memory genesisKey, + address minter + ) internal virtual returns (address impl) { + impl = address(new Genesis_v1(minter)); + console.log(" Impl: %s", impl); + + _recordImplementation(stateData, genesisKey, "@harbor/minter/Genesis_v1.sol", "Genesis_v1", impl); + } + /// @notice Deploy Genesis impl+proxy, record both in state, register for ownership transfer. function deployGenesis( DeploymentTypes.State memory stateData, @@ -29,19 +41,11 @@ abstract contract Genesis is HarborFactoryDeployer { string memory genesisKey = _key(marketKey, "genesis"); console.log(" > %s", genesisKey); - address impl = address(new Genesis_v1(minter)); - console.log(" Impl: %s", impl); + address impl = deployGenesisImplementation(stateData, genesisKey, minter); bytes memory initData = abi.encodeCall(Genesis_v1.initialize, (owner())); - proxy = _deployProxyViaStubAndRecord( - stateData, - genesisKey, - impl, - "@harbor/minter/Genesis_v1.sol", - "Genesis_v1", - initData - ); + proxy = _deployProxyViaStubAndRecord(stateData, genesisKey, impl, initData); } // ========== ADDRESS PREDICTION ========== diff --git a/script/src/contracts/HarborYield.sol b/script/src/contracts/HarborYield.sol index 57abf85f..5b85fbb8 100644 --- a/script/src/contracts/HarborYield.sol +++ b/script/src/contracts/HarborYield.sol @@ -4,7 +4,6 @@ pragma solidity >=0.8.28 <0.9.0; import {console2 as console} from "forge-std/console2.sol"; import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; import {HarborFactoryDeployer} from "script/src/HarborFactoryDeployer.sol"; -import {DeploymentState} from "@bao-script/deployment/DeploymentState.sol"; import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; import {HarborYield_v1} from "@harbor/autocompounding/HarborYield_v1.sol"; @@ -36,16 +35,7 @@ abstract contract HarborYield is HarborFactoryDeployer { console.log(" Name: %s", tokenName); console.log(" Symbol: %s", tokenSymbol); - DeploymentState.recordImplementation( - stateData, - DeploymentTypes.ImplementationRecord({ - proxy: yieldKey, - contractSource: "@harbor/autocompounding/HarborYield_v1.sol", - contractType: "HarborYield_v1", - implementation: impl, - deploymentTime: uint64(block.timestamp) - }) - ); + _recordImplementation(stateData, yieldKey, "@harbor/autocompounding/HarborYield_v1.sol", "HarborYield_v1", impl); } /// @notice Deploy HarborYield_v1 impl+proxy, record in state. diff --git a/script/src/contracts/LeveragedToken.sol b/script/src/contracts/LeveragedToken.sol index 00773596..53a70655 100644 --- a/script/src/contracts/LeveragedToken.sol +++ b/script/src/contracts/LeveragedToken.sol @@ -14,6 +14,23 @@ import {ConfigTokenNames} from "script/config/ConfigTokenNames.sol"; abstract contract LeveragedToken is HarborFactoryDeployer { // ========== LEVERAGED TOKEN DEPLOYMENT ========== + /// @notice Deploy MintableBurnableERC20_v1 impl only (for leveraged token), record in state. + function deployLeveragedTokenImplementation( + DeploymentTypes.State memory stateData, + string memory leveragedKey + ) internal virtual returns (address impl) { + impl = address(new MintableBurnableERC20_v1()); + console.log(" Impl: %s", impl); + + _recordImplementation( + stateData, + leveragedKey, + "@bao/MintableBurnableERC20_v1.sol", + "MintableBurnableERC20_v1", + impl + ); + } + /// @notice Deploy a leveraged token and grant minter roles. function _deployLeveragedTokenWithRoles( DeploymentTypes.State memory stateData, @@ -29,19 +46,11 @@ abstract contract LeveragedToken is HarborFactoryDeployer { console.log(" Name: %s", tokenName); console.log(" Symbol: %s", tokenSymbol); - address impl = address(new MintableBurnableERC20_v1()); - console.log(" Impl: %s", impl); + address impl = deployLeveragedTokenImplementation(stateData, leveragedKey); bytes memory initData = abi.encodeCall(MintableBurnableERC20_v1.initialize, (owner(), tokenName, tokenSymbol)); - leveragedToken = _deployProxyViaStubAndRecord( - stateData, - leveragedKey, - impl, - "@bao/MintableBurnableERC20_v1.sol", - "MintableBurnableERC20_v1", - initData - ); + leveragedToken = _deployProxyViaStubAndRecord(stateData, leveragedKey, impl, initData); // Grant minter roles address minter = _predictAddress(_key(marketKey, "minter")); diff --git a/script/src/contracts/Minter.sol b/script/src/contracts/Minter.sol index 62e0a184..0d706d6b 100644 --- a/script/src/contracts/Minter.sol +++ b/script/src/contracts/Minter.sol @@ -3,7 +3,6 @@ pragma solidity >=0.8.28 <0.9.0; import {console2 as console} from "forge-std/console2.sol"; import {HarborFactoryDeployer} from "script/src/HarborFactoryDeployer.sol"; -import {DeploymentState} from "@bao-script/deployment/DeploymentState.sol"; import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; import {Config_MinterMarket, IMarketConfig, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; @@ -38,16 +37,7 @@ abstract contract Minter is HarborFactoryDeployer { impl = address(new Minter_v3(wrappedCollateral, peggedToken, leveragedToken, "burn(uint256)")); console.log(" Impl: %s", impl); - DeploymentState.recordImplementation( - stateData, - DeploymentTypes.ImplementationRecord({ - proxy: minterKey, - contractSource: "@harbor/minter/Minter_v3.sol", - contractType: "Minter_v3", - implementation: impl, - deploymentTime: uint64(block.timestamp) - }) - ); + _recordImplementation(stateData, minterKey, "@harbor/minter/Minter_v3.sol", "Minter_v3", impl); } /// @notice Deploy Minter impl+proxy, record both in state, register for ownership transfer. @@ -105,6 +95,17 @@ abstract contract Minter is HarborFactoryDeployer { // ========== RESERVE POOL DEPLOYMENT ========== + /// @notice Deploy ReservePool_v1 impl only, record in state. + function deployReservePoolImplementation( + DeploymentTypes.State memory stateData, + string memory reservePoolKey + ) internal virtual returns (address impl) { + impl = address(new ReservePool_v1()); + console.log(" Impl: %s", impl); + + _recordImplementation(stateData, reservePoolKey, "@harbor/minter/ReservePool_v1.sol", "ReservePool_v1", impl); + } + /// @notice Deploy ReservePool impl+proxy, record both in state, register for ownership transfer. function deployReservePool( DeploymentTypes.State memory stateData, @@ -113,19 +114,11 @@ abstract contract Minter is HarborFactoryDeployer { string memory reservePoolKey = _key(marketKey, "reservePool"); console.log(" > %s", reservePoolKey); - address impl = address(new ReservePool_v1()); - console.log(" Impl: %s", impl); + address impl = deployReservePoolImplementation(stateData, reservePoolKey); bytes memory initData = abi.encodeCall(ReservePool_v1.initialize, (owner())); - proxy = _deployProxyViaStubAndRecord( - stateData, - reservePoolKey, - impl, - "@harbor/minter/ReservePool_v1.sol", - "ReservePool_v1", - initData - ); + proxy = _deployProxyViaStubAndRecord(stateData, reservePoolKey, impl, initData); } /// @notice Grant ReservePool REQUESTER_ROLE to Minter. @@ -139,6 +132,23 @@ abstract contract Minter is HarborFactoryDeployer { // ========== FEE RECEIVER (TOKEN DISTRIBUTOR) DEPLOYMENT ========== + /// @notice Deploy TokenDistributor_v1 impl only, record in state. + function deployMinterFeeReceiverImplementation( + DeploymentTypes.State memory stateData, + string memory feeReceiverKey + ) internal virtual returns (address impl) { + impl = address(new TokenDistributor_v1()); + console.log(" Impl: %s", impl); + + _recordImplementation( + stateData, + feeReceiverKey, + "@harbor/minter/TokenDistributor_v1.sol", + "TokenDistributor_v1", + impl + ); + } + /// @notice Deploy TokenDistributor_v1 as Minter fee receiver. function deployMinterFeeReceiver( DeploymentTypes.State memory stateData, @@ -148,19 +158,11 @@ abstract contract Minter is HarborFactoryDeployer { string memory feeReceiverKey = _key(marketKey, "minterFeeReceiver"); console.log(" > %s", feeReceiverKey); - address impl = address(new TokenDistributor_v1()); - console.log(" Impl: %s", impl); + address impl = deployMinterFeeReceiverImplementation(stateData, feeReceiverKey); bytes memory initData = abi.encodeCall(TokenDistributor_v1.initialize, (owner(), name)); - proxy = _deployProxyViaStubAndRecord( - stateData, - feeReceiverKey, - impl, - "@harbor/minter/TokenDistributor_v1.sol", - "TokenDistributor_v1", - initData - ); + proxy = _deployProxyViaStubAndRecord(stateData, feeReceiverKey, impl, initData); } /// @notice Configure TokenDistributor with tokens and distribution. diff --git a/script/src/contracts/PeggedToken.sol b/script/src/contracts/PeggedToken.sol index ff5749c2..0ec39a5e 100644 --- a/script/src/contracts/PeggedToken.sol +++ b/script/src/contracts/PeggedToken.sol @@ -19,6 +19,23 @@ abstract contract PeggedToken is HarborFactoryDeployer { // ========== PEGGED TOKEN DEPLOYMENT ========== + /// @notice Deploy MintableBurnableERC20_v1 impl only, record in state. + function deployPeggedTokenImplementation( + DeploymentTypes.State memory stateData, + string memory tokenKey + ) internal virtual returns (address impl) { + impl = address(new MintableBurnableERC20_v1()); + console.log(" Impl: %s", impl); + + _recordImplementation( + stateData, + tokenKey, + "@bao/MintableBurnableERC20_v1.sol", + "MintableBurnableERC20_v1", + impl + ); + } + /// @notice Deploy a pegged token and grant minter roles to all markets using this peg. /// @dev If the pegged token already exists at the predicted address, logs manual TX requirements. function deployPeggedTokenWithRoles( @@ -41,22 +58,14 @@ abstract contract PeggedToken is HarborFactoryDeployer { console.log(" Name: %s", pegConfig.name()); console.log(" Symbol: %s", pegConfig.symbol()); - address impl = address(new MintableBurnableERC20_v1()); - console.log(" Impl: %s", impl); + address impl = deployPeggedTokenImplementation(stateData, tokenKey); bytes memory initData = abi.encodeCall( MintableBurnableERC20_v1.initialize, (owner(), pegConfig.name(), pegConfig.symbol()) ); - peggedToken = _deployProxyViaStubAndRecord( - stateData, - tokenKey, - impl, - "@bao/MintableBurnableERC20_v1.sol", - "MintableBurnableERC20_v1", - initData - ); + peggedToken = _deployProxyViaStubAndRecord(stateData, tokenKey, impl, initData); } // Grant minter roles for each market diff --git a/script/src/contracts/StabilityPool.sol b/script/src/contracts/StabilityPool.sol index 4b4955c6..0272014e 100644 --- a/script/src/contracts/StabilityPool.sol +++ b/script/src/contracts/StabilityPool.sol @@ -5,7 +5,6 @@ import {console2 as console} from "forge-std/console2.sol"; import {HarborFactoryDeployer} from "script/src/HarborFactoryDeployer.sol"; import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; -import {DeploymentState} from "@bao-script/deployment/DeploymentState.sol"; import {StabilityPool_v3} from "@harbor/minter/StabilityPool_v3.sol"; import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; @@ -63,16 +62,7 @@ abstract contract StabilityPool is HarborFactoryDeployer { console.log(" Name: %s", tokenName); console.log(" Symbol: %s", tokenSymbol); - DeploymentState.recordImplementation( - stateData, - DeploymentTypes.ImplementationRecord({ - proxy: spKey, - contractSource: "@harbor/minter/StabilityPool_v3.sol", - contractType: "StabilityPool_v3", - implementation: impl, - deploymentTime: uint64(block.timestamp) - }) - ); + _recordImplementation(stateData, spKey, "@harbor/minter/StabilityPool_v3.sol", "StabilityPool_v3", impl); } /// @notice Deploy StabilityPool impl+proxy, record in state. @@ -95,14 +85,7 @@ abstract contract StabilityPool is HarborFactoryDeployer { (address(this), owner(), cfg.stabilityPoolEarlyWithdrawalFeeRatio(), treasury()) ); - proxy = _deployProxyAndRecord( - stateData, - spKey, - impl, - "@harbor/minter/StabilityPool_v3.sol", - "StabilityPool_v3", - initData - ); + proxy = _deployProxyAndRecord(stateData, spKey, impl, initData); } /// @notice Grant StabilityPool roles to StabilityPoolManager and AutoCompounder. diff --git a/script/src/contracts/StabilityPoolManager.sol b/script/src/contracts/StabilityPoolManager.sol index 37b09941..e6267afa 100644 --- a/script/src/contracts/StabilityPoolManager.sol +++ b/script/src/contracts/StabilityPoolManager.sol @@ -25,6 +25,29 @@ abstract contract StabilityPoolManager is HarborFactoryDeployer { // ========== STABILITY POOL MANAGER DEPLOYMENT ========== + /// @notice Deploy StabilityPoolManager_v1 impl only, record in state. + function deployStabilityPoolManagerImplementation( + DeploymentTypes.State memory stateData, + string memory spmKey, + address minter, + address treasury, + address stabilityPoolCollateral, + address stabilityPoolLeveraged + ) internal virtual returns (address impl) { + impl = address( + new StabilityPoolManager_v1(minter, treasury, stabilityPoolCollateral, stabilityPoolLeveraged) + ); + console.log(" Impl: %s", impl); + + _recordImplementation( + stateData, + spmKey, + "@harbor/minter/StabilityPoolManager_v1.sol", + "StabilityPoolManager_v1", + impl + ); + } + /// @notice Deploy StabilityPoolManager_v1 impl+proxy, record in state. function deployStabilityPoolManager( DeploymentTypes.State memory stateData, @@ -37,21 +60,18 @@ abstract contract StabilityPoolManager is HarborFactoryDeployer { string memory spmKey = _key(marketKey, "stabilityPoolManager"); console.log(" > %s", spmKey); - address impl = address( - new StabilityPoolManager_v1(minter, treasury, stabilityPoolCollateral, stabilityPoolLeveraged) + address impl = deployStabilityPoolManagerImplementation( + stateData, + spmKey, + minter, + treasury, + stabilityPoolCollateral, + stabilityPoolLeveraged ); - console.log(" Impl: %s", impl); bytes memory initData = abi.encodeCall(StabilityPoolManager_v1.initialize, (owner())); - proxy = _deployProxyViaStubAndRecord( - stateData, - spmKey, - impl, - "@harbor/minter/StabilityPoolManager_v1.sol", - "StabilityPoolManager_v1", - initData - ); + proxy = _deployProxyViaStubAndRecord(stateData, spmKey, impl, initData); } /// @notice Configure a deployed StabilityPoolManager with its operational parameters. @@ -68,6 +88,23 @@ abstract contract StabilityPoolManager is HarborFactoryDeployer { // ========== SPM FEE RECEIVER (TOKEN DISTRIBUTOR) DEPLOYMENT ========== + /// @notice Deploy TokenDistributor_v1 (SPMFeeReceiver) impl only, record in state. + function deploySPMFeeReceiverImplementation( + DeploymentTypes.State memory stateData, + string memory feeReceiverKey + ) internal virtual returns (address impl) { + impl = address(new TokenDistributor_v1()); + console.log(" Impl: %s", impl); + + _recordImplementation( + stateData, + feeReceiverKey, + "@harbor/minter/TokenDistributor_v1.sol", + "TokenDistributor_v1", + impl + ); + } + /// @notice Deploy TokenDistributor_v1 as SPMFeeReceiver impl+proxy, record in state. function deploySPMFeeReceiver( DeploymentTypes.State memory stateData, @@ -77,19 +114,11 @@ abstract contract StabilityPoolManager is HarborFactoryDeployer { string memory feeReceiverKey = _key(marketKey, "spmFeeReceiver"); console.log(" > %s", feeReceiverKey); - address impl = address(new TokenDistributor_v1()); - console.log(" Impl: %s", impl); + address impl = deploySPMFeeReceiverImplementation(stateData, feeReceiverKey); bytes memory initData = abi.encodeCall(TokenDistributor_v1.initialize, (owner(), name)); - proxy = _deployProxyViaStubAndRecord( - stateData, - feeReceiverKey, - impl, - "@harbor/minter/TokenDistributor_v1.sol", - "TokenDistributor_v1", - initData - ); + proxy = _deployProxyViaStubAndRecord(stateData, feeReceiverKey, impl, initData); } /// @notice Configure TokenDistributor with tokens and distribution. diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/scripts/deploy.py b/scripts/deploy.py new file mode 100644 index 00000000..6ead707e --- /dev/null +++ b/scripts/deploy.py @@ -0,0 +1,8 @@ +from wake.deployment import * + +NODE_URL = "ENTER_NODE_URL_HERE" + + +@chain.connect(NODE_URL) +def main(): + chain.set_default_accounts(Account.from_alias("deployment")) diff --git a/src/autocompounding/AutoCompounder_v1.sol b/src/autocompounding/AutoCompounder_v1.sol index e0fb4652..51e47df2 100644 --- a/src/autocompounding/AutoCompounder_v1.sol +++ b/src/autocompounding/AutoCompounder_v1.sol @@ -129,13 +129,7 @@ contract AutoCompounder_v1 is //////////////////////////////////////////////////////////////////////////*/ /// @custom:oz-upgrades-unsafe-allow constructor - // slither-disable-next-line void-cst - constructor( - address stabilityPool_, - address minter_, - string memory name_, - string memory symbol_ - ) ERC20Upgradeable() ERC4626Upgradeable() { + constructor(address stabilityPool_, address minter_, string memory name_, string memory symbol_) { _disableInitializers(); Token.ensureNonZeroAddress(stabilityPool_); Token.ensureNonZeroAddress(minter_); @@ -147,8 +141,7 @@ contract AutoCompounder_v1 is PEGGED_TOKEN = IMinter(minter_).PEGGED_TOKEN(); assert(IStabilityPool(stabilityPool_).ASSET_TOKEN() == PEGGED_TOKEN); (_ERC20_NAME_0, _ERC20_NAME_1) = StringPacking_v1.pack64(name_); - // slither-disable-next-line unused-return - (_ERC20_SYMBOL, ) = StringPacking_v1.pack64(symbol_); + _ERC20_SYMBOL = StringPacking_v1.pack32(symbol_); } /// @notice Initialize the auto-compounder. diff --git a/src/autocompounding/HarborYield_v1.sol b/src/autocompounding/HarborYield_v1.sol index e28e1fa6..69fe0f5a 100644 --- a/src/autocompounding/HarborYield_v1.sol +++ b/src/autocompounding/HarborYield_v1.sol @@ -4,6 +4,7 @@ pragma solidity 0.8.30; import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; +import {ReentrancyGuardTransientUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardTransientUpgradeable.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; @@ -33,6 +34,7 @@ contract HarborYield_v1 is Initializable, UUPSUpgradeable, ERC20Upgradeable, + ReentrancyGuardTransientUpgradeable, HarborOwnableRoles, TokenHolder, IHarborYield @@ -109,17 +111,19 @@ contract HarborYield_v1 is //////////////////////////////////////////////////////////////////////////*/ /// @custom:oz-upgrades-unsafe-allow constructor - constructor(string memory name_, string memory symbol_, address swapper_) ERC20Upgradeable() { + constructor(string memory name_, string memory symbol_, address swapper_) { _disableInitializers(); (_ERC20_NAME_0, _ERC20_NAME_1) = StringPacking_v1.pack64(name_); - // slither-disable-next-line unused-return - (_ERC20_SYMBOL, ) = StringPacking_v1.pack64(symbol_); + _ERC20_SYMBOL = StringPacking_v1.pack32(symbol_); + Token.ensureNonZeroAddress(swapper_); + // slither-disable-next-line missing-zero-check SWAPPER = swapper_; } function initialize(address deployerOwner_, address pendingOwner_) external initializer { _initializeOwner(deployerOwner_, pendingOwner_); __UUPSUpgradeable_init(); + __ReentrancyGuardTransient_init(); } /*////////////////////////////////////////////////////////////////////////// @@ -136,6 +140,7 @@ contract HarborYield_v1 is /// @param vault The ERC4626 vault address. /// @param weight Target distribution weight (arbitrary units, must be > 0). /// @param isAutoCompounder Whether the vault implements IAutoCompounder. + // slither-disable-next-line reentrancy-no-eth,reentrancy-events function addVault(address vault, uint96 weight, bool isAutoCompounder) external onlyOwner { if (weight == 0) { revert ZeroWeight(); @@ -154,7 +159,7 @@ contract HarborYield_v1 is $.assetToVaultIndex[asset] = $.vaults.length; // 1-indexed $.totalWeight += weight; - IERC20(asset).approve(vault, type(uint256).max); + IERC20(asset).forceApprove(vault, type(uint256).max); emit VaultAdded(vault, asset, weight); } @@ -222,9 +227,12 @@ contract HarborYield_v1 is /// @inheritdoc IHarborYield function totalAssets() public view returns (uint256 total) { HarborYieldStorage storage $ = _getHarborYieldStorage(); - for (uint256 i = 0; i < $.vaults.length; i++) { + uint256 length = $.vaults.length; + for (uint256 i = 0; i < length; i++) { + // slither-disable-next-line calls-loop uint256 vaultShares = IERC20($.vaults[i].vault).balanceOf(address(this)); if (vaultShares > 0) { + // slither-disable-next-line calls-loop total += IERC4626($.vaults[i].vault).convertToAssets(vaultShares); } } @@ -235,7 +243,8 @@ contract HarborYield_v1 is //////////////////////////////////////////////////////////////////////////*/ /// @inheritdoc IHarborYield - function deposit(address asset, uint256 amount, address receiver) external returns (uint256 shares) { + // slither-disable-next-line reentrancy-no-eth + function deposit(address asset, uint256 amount, address receiver) external nonReentrant returns (uint256 shares) { amount = Token.allOf(msg.sender, asset, amount); HarborYieldStorage storage $ = _getHarborYieldStorage(); @@ -252,6 +261,7 @@ contract HarborYield_v1 is uint256 supplyBefore = totalSupply(); IERC20(asset).safeTransferFrom(msg.sender, address(this), amount); + // slither-disable-next-line unused-return IERC4626(mv.vault).deposit(amount, address(this)); shares = Math.mulDiv(amount, supplyBefore + 1, assetsBefore + 1); @@ -266,7 +276,7 @@ contract HarborYield_v1 is //////////////////////////////////////////////////////////////////////////*/ /// @inheritdoc IHarborYield - function redeem(uint256 shares, address receiver, address tokenOwner) external { + function redeem(uint256 shares, address receiver, address tokenOwner) external nonReentrant { if (msg.sender != tokenOwner) { _spendAllowance(tokenOwner, msg.sender, shares); } @@ -275,11 +285,14 @@ contract HarborYield_v1 is _burn(tokenOwner, shares); HarborYieldStorage storage $ = _getHarborYieldStorage(); - for (uint256 i = 0; i < $.vaults.length; i++) { + uint256 length = $.vaults.length; + for (uint256 i = 0; i < length; i++) { + // slither-disable-next-line calls-loop uint256 vaultShares = IERC20($.vaults[i].vault).balanceOf(address(this)); if (vaultShares > 0) { uint256 redeemAmount = Math.mulDiv(vaultShares, shares, supply); if (redeemAmount > 0) { + // slither-disable-next-line calls-loop,unused-return IERC4626($.vaults[i].vault).redeem(redeemAmount, receiver, address(this)); } } @@ -291,13 +304,14 @@ contract HarborYield_v1 is //////////////////////////////////////////////////////////////////////////*/ /// @inheritdoc IHarborYield + // slither-disable-next-line reentrancy-events function compound( address fromVault, address toVault, uint256 vaultShareAmount, uint256 minAmountOut, bytes calldata swapData - ) external onlyOwnerOrRoles(COMPOUNDER_ROLE) { + ) external nonReentrant onlyOwnerOrRoles(COMPOUNDER_ROLE) { // Redeem from the source equivalent vault to get its underlying asset uint256 assetAmount = IERC4626(fromVault).redeem(vaultShareAmount, address(this), address(this)); @@ -311,6 +325,7 @@ contract HarborYield_v1 is ); // Deposit into the target vault (typically an AC) + // slither-disable-next-line unused-return IERC4626(toVault).deposit(swappedAmount, address(this)); emit Compounded(msg.sender, fromVault, toVault, assetAmount, swappedAmount); @@ -327,11 +342,12 @@ contract HarborYield_v1 is } /// @inheritdoc IHarborYield + // slither-disable-next-line reentrancy-events function redistribute( uint256 maxVaultSharesPerVault, uint256 minAmountOut, bytes calldata swapData - ) external onlyOwnerOrRoles(REDISTRIBUTOR_ROLE) { + ) external nonReentrant onlyOwnerOrRoles(REDISTRIBUTOR_ROLE) { HarborYieldStorage storage $ = _getHarborYieldStorage(); uint256 tw = $.totalWeight; if (tw == 0) { @@ -347,8 +363,11 @@ contract HarborYield_v1 is { uint256 maxExcess; uint256 maxDeficit; - for (uint256 i = 0; i < $.vaults.length; i++) { + uint256 length = $.vaults.length; + for (uint256 i = 0; i < length; i++) { + // slither-disable-next-line calls-loop uint256 bal = IERC20($.vaults[i].vault).balanceOf(address(this)); + // slither-disable-next-line calls-loop uint256 cur = bal > 0 ? IERC4626($.vaults[i].vault).convertToAssets(bal) : 0; uint256 tgt = Math.mulDiv(total, $.vaults[i].weight, tw); if (cur > tgt) { @@ -389,6 +408,7 @@ contract HarborYield_v1 is minAmountOut, swapData ); + // slither-disable-next-line unused-return IERC4626(dstVault).deposit(deposited, address(this)); emit Redistributed(msg.sender, srcVault, dstVault, w.moveValue, deposited); } @@ -409,7 +429,7 @@ contract HarborYield_v1 is if (fromAsset == toAsset) { return amountIn; } - IERC20(fromAsset).approve(SWAPPER, amountIn); + IERC20(fromAsset).forceApprove(SWAPPER, amountIn); amountOut = ISwapper(SWAPPER).swap(fromAsset, toAsset, amountIn, minAmountOut, swapData); } diff --git a/src/minter/StabilityPool_v3.sol b/src/minter/StabilityPool_v3.sol index bc3664da..70cfce5e 100644 --- a/src/minter/StabilityPool_v3.sol +++ b/src/minter/StabilityPool_v3.sol @@ -230,8 +230,7 @@ contract StabilityPool_v3 is ) MultipleRewardCompoundingAccumulator_v3(_REWARD_MANAGER_ROLE, _REWARD_DEPOSITOR_ROLE, 1 weeks) { _disableInitializers(); (_ERC20_NAME_0, _ERC20_NAME_1) = StringPacking_v1.pack64(name_); - // slither-disable-next-line unused-return - (_ERC20_SYMBOL, ) = StringPacking_v1.pack64(symbol_); + _ERC20_SYMBOL = StringPacking_v1.pack32(symbol_); address asset = IMinter(minter_).PEGGED_TOKEN(); _ERC20_DECIMALS = IERC20Metadata(asset).decimals(); Token.sanityCheckERC20Token(asset); diff --git a/src/minter/library/StringPacking_v1.sol b/src/minter/library/StringPacking_v1.sol index 9ff7368d..8dbdbd7c 100644 --- a/src/minter/library/StringPacking_v1.sol +++ b/src/minter/library/StringPacking_v1.sol @@ -28,6 +28,21 @@ library StringPacking_v1 { } } + /// @notice Pack a string (up to 32 chars) into a single bytes32 value. + function pack32(string memory s) public pure returns (bytes32 b0) { + bytes memory b = bytes(s); + if (b.length > 32) { + revert StringTooLong(); + } + // solhint-disable-next-line no-inline-assembly + assembly { + b0 := mload(add(b, 32)) + } + if (b.length < 32) { + b0 = bytes32(uint256(b0) & ~(type(uint256).max >> (b.length * 8))); + } + } + /// @notice Unpack two bytes32 values back into a string. function unpack64(bytes32 b0, bytes32 b1) public pure returns (string memory) { uint256 len0; diff --git a/test/Minter_feeRange.t.sol b/test/Minter_feeRange.t.sol index 623a0d5e..6a5d5738 100644 --- a/test/Minter_feeRange.t.sol +++ b/test/Minter_feeRange.t.sol @@ -581,7 +581,7 @@ contract TestMinterFixedFeeRange_ is TestMinterFeeRange { // console2.log("fee=%s", fee); // console2.log("discount=%s", discount); - assertNear(post.feeWrapped, pre.feeWrapped + fee, 2, 4, "rp fee wrapped"); + assertNear(post.feeWrapped, pre.feeWrapped + fee, 3, 4, "rp fee wrapped"); assertNear(post.reservePoolWrapped, pre.reservePoolWrapped - discount, 0, 0, "rp discount wrapped"); assertNear( diff --git a/test/StabilityPoolUpgradeMigration.t.sol b/test/StabilityPoolUpgradeMigration.t.sol index e8037b3b..b7cf81fc 100644 --- a/test/StabilityPoolUpgradeMigration.t.sol +++ b/test/StabilityPoolUpgradeMigration.t.sol @@ -645,7 +645,7 @@ contract TestStabilityPoolUpgradeMigration is TestStabilityPoolSetUp { assertEq(v2End, v1End, "Withdrawal end preserved"); // Verify balances and claimable preserved - assertEq(IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1), v1_bal, "Balance preserved"); + assertEq(IERC20(stabilityPoolCollateral).balanceOf(user1), v1_bal, "Balance preserved"); assertEq( IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, steam), v1_claimable, @@ -665,7 +665,7 @@ contract TestStabilityPoolUpgradeMigration is TestStabilityPoolSetUp { assertEq(withdrawn, 50 ether, "Withdraw correct amount on v2"); assertEq(IERC20(peggedToken).balanceOf(user1) - peggedBefore, 50 ether, "Pegged tokens received"); assertEq( - IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1), + IERC20(stabilityPoolCollateral).balanceOf(user1), v1_bal - 50 ether, "Balance reduced after withdrawal" ); @@ -738,7 +738,7 @@ contract TestStabilityPoolUpgradeMigration is TestStabilityPoolSetUp { "Collateral claimable preserved across 2 exponent shifts" ); assertEq( - IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1), + IERC20(stabilityPoolCollateral).balanceOf(user1), v1_bal, "Balance preserved across 2 exponent shifts" ); @@ -814,7 +814,7 @@ contract TestStabilityPoolUpgradeMigration is TestStabilityPoolSetUp { "Collateral claimable preserved after re-deposit + partial liq" ); assertEq( - IStabilityPool(stabilityPoolCollateral).assetBalanceOf(user1), + IERC20(stabilityPoolCollateral).balanceOf(user1), v1_bal, "Balance preserved after re-deposit + partial liq" ); diff --git a/test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v3.sol b/test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v3.sol index f164fe13..e16f4349 100644 --- a/test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v3.sol +++ b/test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v3.sol @@ -16,8 +16,8 @@ contract MockMultipleRewardCompoundingAccumulator_v3 is Initializable, MultipleR constructor(uint40 period) MultipleRewardCompoundingAccumulator_v3(_ROLE_0, _ROLE_1, period) {} - function initialize(address owner_) external initializer { - _initializeOwner(address(this), owner_); + function initialize(address deployerOwner_, address pendingOwner_) external initializer { + _initializeOwner(deployerOwner_, pendingOwner_); __ReentrancyGuardTransient_init(); } diff --git a/test/reward/accumulator/ClaimEquivalence.t.sol b/test/reward/accumulator/ClaimEquivalence.t.sol index 48fe9b62..9a5deed4 100644 --- a/test/reward/accumulator/ClaimEquivalence.t.sol +++ b/test/reward/accumulator/ClaimEquivalence.t.sol @@ -50,7 +50,7 @@ contract ClaimEquivalenceTest is Test { explicitReceiver = makeAddr("explicitReceiver"); accumulator = address(new MockMultipleRewardCompoundingAccumulator_v3(1 weeks)); - MockMultipleRewardCompoundingAccumulator_v3(accumulator).initialize(deployer); + MockMultipleRewardCompoundingAccumulator_v3(accumulator).initialize(deployer, deployer); rewardToken1 = address(new MockERC20("Token1", "T1", 18)); rewardToken2 = address(new MockERC20("Token2", "T2", 18)); diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_default.py b/tests/test_default.py new file mode 100644 index 00000000..b145a25c --- /dev/null +++ b/tests/test_default.py @@ -0,0 +1,12 @@ +from wake.testing import * + +# Print failing tx call trace +# def revert_handler(e: TransactionRevertedError): +# if e.tx is not None: +# print(e.tx.call_trace) + + +@chain.connect() +# @on_revert(revert_handler) +def test_default(): + pass From 87876ff723964c1ba4f98e0477a727038889ef62 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Mon, 13 Apr 2026 10:25:07 +0100 Subject: [PATCH 031/232] added HarborYield tests --- lib/bao-base | 2 +- regression/coverage.txt | 4 +- regression/gas.txt | 26 +- script/src/contracts/AutoCompounder.sol | 8 +- script/src/contracts/HarborYield.sol | 8 +- script/src/contracts/StabilityPoolManager.sol | 4 +- test/autocompounding/HarborYield.t.sol | 438 ++++++++++++++++++ 7 files changed, 481 insertions(+), 9 deletions(-) create mode 100644 test/autocompounding/HarborYield.t.sol diff --git a/lib/bao-base b/lib/bao-base index a8af5726..e1813a83 160000 --- a/lib/bao-base +++ b/lib/bao-base @@ -1 +1 @@ -Subproject commit a8af5726ba76caeba85bf57b9f1cb722fc923ea8 +Subproject commit e1813a8362a19ef0e605f3a9fd0471ab3474e871 diff --git a/regression/coverage.txt b/regression/coverage.txt index 9ccace8d..970c3b03 100644 --- a/regression/coverage.txt +++ b/regression/coverage.txt @@ -41,7 +41,7 @@ | script/src/contracts/StabilityPool.sol | ✓ 100% (31/31) | ✓ 100% (50/50) | ✓ 100% (0/0) | ✓ 100% (3/3) | | script/src/contracts/StabilityPoolManager.sol | X 53% (17/32) | X 50% (18/36) | ✓ 100% (0/0) | X 50% (3/6) | | src/autocompounding/AutoCompounder_v1.sol | X 96% (74/77) | X 97% (74/76) | X 60% (3/5) | X 94% (15/16) | -| src/autocompounding/HarborYield_v1.sol | X 0% (0/153) | X 0% (0/173) | X 0% (0/21) | X 0% (0/21) | +| src/autocompounding/HarborYield_v1.sol | X 91% (139/153) | X 92% (160/173) | X 86% (18/21) | X 76% (16/21) | | src/math/DecrementalFloatingPoint.sol | ✓ 100% (31/31) | ✓ 100% (33/33) | ✓ 100% (9/9) | ✓ 100% (6/6) | | src/minter/Genesis_v1.sol | X 99% (88/89) | ✓ 100% (88/88) | ✓ 100% (14/14) | X 92% (11/12) | | src/minter/Minter_v1.sol | X 0% (0/601) | X 0% (0/649) | X 0% (0/102) | X 0% (0/68) | @@ -67,4 +67,4 @@ | src/reward/distributor/LinearReward.sol | ✓ 100% (25/25) | ✓ 100% (27/27) | ✓ 100% (6/6) | ✓ 100% (2/2) | | src/util/FmtLib.sol | X 79% (15/19) | X 73% (19/26) | X 50% (2/4) | ✓ 100% (1/1) | | src/util/WordCodec.sol | ✓ 100% (15/15) | ✓ 100% (14/14) | ✓ 100% (0/0) | ✓ 100% (4/4) | -| Total | X 60% (4930/8205) | X 59% (5227/8855) | X 48% (440/919) | X 61% (742/1209) | +| Total | X 62% (5079/8207) | X 61% (5394/8856) | X 50% (460/919) | X 63% (761/1210) | diff --git a/regression/gas.txt b/regression/gas.txt index 03bea810..65a022b2 100644 --- a/regression/gas.txt +++ b/regression/gas.txt @@ -24,6 +24,30 @@ src/autocompounding/AutoCompounder_v1.sol:AutoCompounder_v1 | totalAssets | 7.818e+04 | | transferOwnership | 1.202e+04 | +src/autocompounding/HarborYield_v1.sol:HarborYield_v1 +| function name | max | +|--------------------|-----------| +| COMPOUNDER_ROLE | 2.290e+02 | +| REDISTRIBUTOR_ROLE | 2.720e+02 | +| activateVault | 1.345e+04 | +| addVault | 1.694e+05 | +| allowance | 2.793e+03 | +| approve | 2.482e+04 | +| balanceOf | 2.607e+03 | +| compound | 1.700e+05 | +| deactivateVault | 1.347e+04 | +| deposit | 1.872e+05 | +| grantRoles | 2.637e+04 | +| initialize | 7.066e+04 | +| redeem | 1.307e+05 | +| redistribute | 2.496e+05 | +| setVaultWeight | 1.946e+04 | +| totalAssets | 3.736e+04 | +| totalSupply | 2.371e+03 | +| totalWeight | 2.347e+03 | +| vaultAt | 9.181e+03 | +| vaultCount | 2.420e+03 | + src/minter/Genesis_v1.sol:Genesis_v1 | function name | max | |--------------------------|-----------| @@ -71,7 +95,7 @@ src/minter/Minter_v3.sol:Minter_v3 | mintLeveragedTokenIncentiveRatio | 3.108e+04 | | mintPeggedToken(uint256,address,uint256) | 1.911e+05 | | mintPeggedToken(uint256,address,uint256,uint256) | 1.251e+05 | -| mintPeggedTokenDryRun(uint256) | 6.401e+04 | +| mintPeggedTokenDryRun(uint256) | 6.402e+04 | | mintPeggedTokenDryRun(uint256,uint256) | 4.556e+04 | | mintPeggedTokenIncentiveRatio | 3.012e+04 | | owner | 2.402e+03 | diff --git a/script/src/contracts/AutoCompounder.sol b/script/src/contracts/AutoCompounder.sol index 908492e1..c26ce1b4 100644 --- a/script/src/contracts/AutoCompounder.sol +++ b/script/src/contracts/AutoCompounder.sol @@ -46,7 +46,13 @@ abstract contract AutoCompounder is HarborFactoryDeployer { console.log(" Name: %s", tokenName); console.log(" Symbol: %s", tokenSymbol); - _recordImplementation(stateData, acKey, "@harbor/autocompounding/AutoCompounder_v1.sol", "AutoCompounder_v1", impl); + _recordImplementation( + stateData, + acKey, + "@harbor/autocompounding/AutoCompounder_v1.sol", + "AutoCompounder_v1", + impl + ); } /// @notice Deploy AutoCompounder impl+proxy, record in state. diff --git a/script/src/contracts/HarborYield.sol b/script/src/contracts/HarborYield.sol index 5b85fbb8..1ad6810b 100644 --- a/script/src/contracts/HarborYield.sol +++ b/script/src/contracts/HarborYield.sol @@ -35,7 +35,13 @@ abstract contract HarborYield is HarborFactoryDeployer { console.log(" Name: %s", tokenName); console.log(" Symbol: %s", tokenSymbol); - _recordImplementation(stateData, yieldKey, "@harbor/autocompounding/HarborYield_v1.sol", "HarborYield_v1", impl); + _recordImplementation( + stateData, + yieldKey, + "@harbor/autocompounding/HarborYield_v1.sol", + "HarborYield_v1", + impl + ); } /// @notice Deploy HarborYield_v1 impl+proxy, record in state. diff --git a/script/src/contracts/StabilityPoolManager.sol b/script/src/contracts/StabilityPoolManager.sol index e6267afa..ec233bd2 100644 --- a/script/src/contracts/StabilityPoolManager.sol +++ b/script/src/contracts/StabilityPoolManager.sol @@ -34,9 +34,7 @@ abstract contract StabilityPoolManager is HarborFactoryDeployer { address stabilityPoolCollateral, address stabilityPoolLeveraged ) internal virtual returns (address impl) { - impl = address( - new StabilityPoolManager_v1(minter, treasury, stabilityPoolCollateral, stabilityPoolLeveraged) - ); + impl = address(new StabilityPoolManager_v1(minter, treasury, stabilityPoolCollateral, stabilityPoolLeveraged)); console.log(" Impl: %s", impl); _recordImplementation( diff --git a/test/autocompounding/HarborYield.t.sol b/test/autocompounding/HarborYield.t.sol new file mode 100644 index 00000000..6a6a418b --- /dev/null +++ b/test/autocompounding/HarborYield.t.sol @@ -0,0 +1,438 @@ +// SPDX-License-Identifier: MIT +// solhint-disable one-contract-per-file +pragma solidity >=0.8.28 <0.9.0; + +import "forge-std/Test.sol"; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {ERC4626} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +import {MockERC20} from "@bao-test/mocks/MockERC20.sol"; + +import {HarborYield_v1} from "src/autocompounding/HarborYield_v1.sol"; +import {MockSwapper} from "test/mocks/MockSwapper.sol"; + +/// @notice Minimal ERC4626 vault used as a managed vault inside HarborYield tests. +/// Yield is simulated by calling addYield(), which drops extra assets into the vault +/// and thereby increases convertToAssets() for existing shares. +contract MockERC4626Vault is ERC4626 { + constructor(IERC20 asset_, string memory name_, string memory symbol_) ERC4626(asset_) ERC20(name_, symbol_) {} + + /// @dev Drop extra underlying into the vault, simulating yield accrual. + function addYield(uint256 amount) external { + MockERC20(asset()).mint(address(this), amount); + } +} + +/// @title HarborYield_v1 unit tests +/// @notice Tests HarborYield in isolation using MockERC20 assets, MockERC4626Vault, and MockSwapper. +/// Avoids the full Minter+SP+AC deployment to keep tests fast and focused on HY behaviour. +/// +/// Run: forge test --mc HarborYieldTest -vv +contract HarborYieldTest is Test { + // ── Actors ───────────────────────────────────────────────────────── + address alice = makeAddr("alice"); + address bob = makeAddr("bob"); + address keeper = makeAddr("keeper"); + + // ── Tokens ───────────────────────────────────────────────────────── + MockERC20 asset0; // e.g. stETH + MockERC20 asset1; // e.g. fxSAVE + + // ── Managed ERC4626 vaults ───────────────────────────────────────── + MockERC4626Vault vault0; + MockERC4626Vault vault1; + + // ── Infrastructure ───────────────────────────────────────────────── + MockSwapper swapper; + HarborYield_v1 hy; + + // ── Constants ────────────────────────────────────────────────────── + uint96 constant WEIGHT_0 = 60; // 60% of target + uint96 constant WEIGHT_1 = 40; // 40% of target + + function setUp() public virtual { + asset0 = new MockERC20("Asset 0", "A0", 18); + asset1 = new MockERC20("Asset 1", "A1", 18); + + vault0 = new MockERC4626Vault(IERC20(address(asset0)), "Vault 0", "V0"); + vault1 = new MockERC4626Vault(IERC20(address(asset1)), "Vault 1", "V1"); + + // Swapper at 1:1 rate — pre-fund with enough of each token for tests. + swapper = new MockSwapper(1 ether); + asset0.mint(address(swapper), 1_000_000 ether); + asset1.mint(address(swapper), 1_000_000 ether); + + // Deploy HarborYield_v1 impl + proxy. + // address(this) is both deployer-owner and pending-owner: owner is address(this). + HarborYield_v1 impl = new HarborYield_v1("Harbor Yield Test", "hyTEST", address(swapper)); + bytes memory initData = abi.encodeCall(HarborYield_v1.initialize, (address(this), address(this))); + hy = HarborYield_v1(address(new ERC1967Proxy(address(impl), initData))); + + hy.addVault(address(vault0), WEIGHT_0, true); + hy.addVault(address(vault1), WEIGHT_1, false); + } + + // ── Helpers ──────────────────────────────────────────────────────── + + /// @dev Deposit `amount` of `asset` into HY on behalf of `user`. + function _deposit(address user, MockERC20 asset_, uint256 amount) internal returns (uint256 shares) { + asset_.mint(user, amount); + vm.startPrank(user); + IERC20(address(asset_)).approve(address(hy), amount); + shares = hy.deposit(address(asset_), amount, user); + vm.stopPrank(); + } + + /*////////////////////////////////////////////////////////////////////////// + VAULT MANAGEMENT + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice addVault registers a vault, updates totalWeight, and sets the asset index. + function test_addVault_registersAndTracksWeight() public view { + assertEq(hy.vaultCount(), 2); + assertEq(hy.totalWeight(), uint256(WEIGHT_0) + WEIGHT_1); + + (address v0, address a0, bool active0, uint96 w0) = hy.vaultAt(0); + assertEq(v0, address(vault0)); + assertEq(a0, address(asset0)); + assertTrue(active0); + assertEq(w0, WEIGHT_0); + + (address v1, , bool active1, uint96 w1) = hy.vaultAt(1); + assertEq(v1, address(vault1)); + assertTrue(active1); + assertEq(w1, WEIGHT_1); + } + + /// @notice addVault reverts when the weight is zero. + function test_addVault_zeroWeight_reverts() public { + MockERC20 asset2 = new MockERC20("Asset 2", "A2", 18); + MockERC4626Vault vault2 = new MockERC4626Vault(IERC20(address(asset2)), "Vault 2", "V2"); + + vm.expectRevert(HarborYield_v1.ZeroWeight.selector); + hy.addVault(address(vault2), 0, false); + } + + /// @notice addVault reverts when the asset is already registered by another vault. + function test_addVault_duplicateAsset_reverts() public { + MockERC4626Vault dup = new MockERC4626Vault(IERC20(address(asset0)), "Dup", "DUP"); + vm.expectRevert(abi.encodeWithSelector(HarborYield_v1.VaultAlreadyRegistered.selector, address(dup))); + hy.addVault(address(dup), 10, false); + } + + /// @notice addVault is owner-only; non-owners revert with Unauthorized. + function test_addVault_nonOwner_reverts() public { + MockERC20 asset2 = new MockERC20("Asset 2", "A2", 18); + MockERC4626Vault vault2 = new MockERC4626Vault(IERC20(address(asset2)), "Vault 2", "V2"); + vm.prank(alice); + vm.expectRevert(); // HarborOwnable Unauthorized + hy.addVault(address(vault2), 10, false); + } + + /// @notice setVaultWeight adjusts the cached totalWeight correctly. + function test_setVaultWeight_updatesTotalWeight() public { + hy.setVaultWeight(address(vault0), 80); + assertEq(hy.totalWeight(), 80 + WEIGHT_1); + + (, , , uint96 w0) = hy.vaultAt(0); + assertEq(w0, 80); + } + + /// @notice setVaultWeight on an unregistered vault reverts. + function test_setVaultWeight_unknownVault_reverts() public { + address ghost = makeAddr("ghost"); + vm.expectRevert(abi.encodeWithSelector(HarborYield_v1.VaultNotRegistered.selector, ghost)); + hy.setVaultWeight(ghost, 100); + } + + /// @notice deactivateVault and activateVault toggle the active flag. + function test_deactivateAndActivateVault() public { + hy.deactivateVault(address(vault0)); + (, , bool active, ) = hy.vaultAt(0); + assertFalse(active); + + hy.activateVault(address(vault0)); + (, , active, ) = hy.vaultAt(0); + assertTrue(active); + } + + /*////////////////////////////////////////////////////////////////////////// + DEPOSIT / REDEEM + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice First deposit mints shares 1:1 with assets (at the empty-vault rate). + function test_deposit_firstDepositOneToOne() public { + uint256 shares = _deposit(alice, asset0, 100 ether); + assertEq(shares, 100 ether, "first deposit 1:1"); + assertEq(hy.balanceOf(alice), shares); + assertEq(hy.totalAssets(), 100 ether); + // Vault holds the deposited assets, HY holds the vault shares. + assertEq(asset0.balanceOf(address(vault0)), 100 ether); + assertEq(IERC20(address(vault0)).balanceOf(address(hy)), 100 ether); + } + + /// @notice Deposit to an unregistered asset reverts. + function test_deposit_unregisteredAsset_reverts() public { + MockERC20 other = new MockERC20("Other", "OTH", 18); + other.mint(alice, 1 ether); + vm.startPrank(alice); + IERC20(address(other)).approve(address(hy), 1 ether); + vm.expectRevert(abi.encodeWithSelector(HarborYield_v1.VaultNotRegistered.selector, address(other))); + hy.deposit(address(other), 1 ether, alice); + vm.stopPrank(); + } + + /// @notice Deposit to a deactivated vault reverts with VaultNotActive. + function test_deposit_deactivatedVault_reverts() public { + hy.deactivateVault(address(vault0)); + asset0.mint(alice, 1 ether); + vm.startPrank(alice); + IERC20(address(asset0)).approve(address(hy), 1 ether); + vm.expectRevert(abi.encodeWithSelector(HarborYield_v1.VaultNotActive.selector, address(vault0))); + hy.deposit(address(asset0), 1 ether, alice); + vm.stopPrank(); + } + + /// @notice Deposit routes each asset to its mapped vault; shares are minted at the current exchange rate. + function test_deposit_routingTwoAssets() public { + _deposit(alice, asset0, 60 ether); + _deposit(bob, asset1, 40 ether); + + // Alice and Bob both deposited into empty vaults at 1:1 — each gets their deposit size in HY shares. + assertEq(hy.balanceOf(alice), 60 ether, "alice shares"); + assertEq(hy.balanceOf(bob), 40 ether, "bob shares"); + assertEq(hy.totalAssets(), 100 ether, "total assets sum"); + assertEq(asset0.balanceOf(address(vault0)), 60 ether, "vault0 holds asset0"); + assertEq(asset1.balanceOf(address(vault1)), 40 ether, "vault1 holds asset1"); + } + + /// @notice Existing user share value is not diluted by a subsequent deposit into another vault. + function test_deposit_doesNotDiluteExistingUsers() public { + _deposit(alice, asset0, 100 ether); + uint256 aliceShares = hy.balanceOf(alice); + + // Alice's share is worth the full 100 ether pool. + uint256 aliceAssetsBefore = (hy.totalAssets() * aliceShares) / hy.totalSupply(); + assertEq(aliceAssetsBefore, 100 ether); + + // Bob deposits into the other vault. + _deposit(bob, asset1, 50 ether); + + // Alice's implied assets should be unchanged. + uint256 aliceAssetsAfter = (hy.totalAssets() * aliceShares) / hy.totalSupply(); + assertEq(aliceAssetsAfter, aliceAssetsBefore, "alice not diluted"); + } + + /// @notice deposit(type(uint256).max, ...) consumes the caller's full balance. + function test_deposit_maxAmount_usesFullBalance() public { + asset0.mint(alice, 77 ether); + vm.startPrank(alice); + IERC20(address(asset0)).approve(address(hy), type(uint256).max); + uint256 shares = hy.deposit(address(asset0), type(uint256).max, alice); + vm.stopPrank(); + + assertEq(shares, 77 ether); + assertEq(asset0.balanceOf(alice), 0); + } + + /// @notice Yield accruing inside a managed vault increases HY.totalAssets and share price. + function test_totalAssets_reflectsVaultYield() public { + _deposit(alice, asset0, 100 ether); + uint256 assetsBefore = hy.totalAssets(); + + // Drop 10% yield into vault0. + vault0.addYield(10 ether); + + // OZ ERC4626 uses a virtual-share offset that introduces 1-wei rounding on convertToAssets. + assertApproxEqAbs(hy.totalAssets(), assetsBefore + 10 ether, 1, "totalAssets reflects yield"); + } + + /// @notice Redeem burns shares and pays out a proportional slice of every managed vault's holdings. + function test_redeem_proportionalAcrossVaults() public { + _deposit(alice, asset0, 60 ether); + _deposit(alice, asset1, 40 ether); + + uint256 shares = hy.balanceOf(alice); + // Redeem half of alice's shares. + vm.prank(alice); + hy.redeem(shares / 2, alice, alice); + + // Alice should have received half of each asset. + assertEq(asset0.balanceOf(alice), 30 ether, "got half of asset0"); + assertEq(asset1.balanceOf(alice), 20 ether, "got half of asset1"); + assertEq(hy.balanceOf(alice), shares - shares / 2); + } + + /// @notice Redeeming all shares drains both managed vaults. + function test_redeem_fullRedemptionDrainsVaults() public { + _deposit(alice, asset0, 60 ether); + _deposit(alice, asset1, 40 ether); + + uint256 shares = hy.balanceOf(alice); + vm.prank(alice); + hy.redeem(shares, alice, alice); + + assertEq(hy.balanceOf(alice), 0); + assertEq(IERC20(address(vault0)).balanceOf(address(hy)), 0, "vault0 shares drained"); + assertEq(IERC20(address(vault1)).balanceOf(address(hy)), 0, "vault1 shares drained"); + } + + /// @notice Redeeming on behalf of another account requires and consumes allowance. + function test_redeem_withAllowance() public { + _deposit(alice, asset0, 100 ether); + uint256 shares = hy.balanceOf(alice); + + vm.prank(alice); + hy.approve(bob, shares); + + vm.prank(bob); + hy.redeem(shares, bob, alice); + + assertEq(hy.balanceOf(alice), 0); + assertEq(asset0.balanceOf(bob), 100 ether, "bob received the underlying"); + assertEq(hy.allowance(alice, bob), 0, "allowance consumed"); + } + + /// @notice Redeem without allowance reverts. + function test_redeem_withoutAllowance_reverts() public { + _deposit(alice, asset0, 100 ether); + uint256 shares = hy.balanceOf(alice); + + vm.prank(bob); + vm.expectRevert(); // ERC20 allowance error + hy.redeem(shares, bob, alice); + } + + /*////////////////////////////////////////////////////////////////////////// + COMPOUND (swap path) + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice Owner can compound: redeem from one vault, swap asset, deposit into another vault. + function test_compound_ownerCanConvertAcrossVaults() public { + _deposit(alice, asset0, 60 ether); + _deposit(bob, asset1, 40 ether); + + uint256 v0SharesBefore = IERC20(address(vault0)).balanceOf(address(hy)); + uint256 v1SharesBefore = IERC20(address(vault1)).balanceOf(address(hy)); + uint256 totalAssetsBefore = hy.totalAssets(); + + // Move 10 vault0 shares -> asset0 -> swap to asset1 -> vault1. + hy.compound(address(vault0), address(vault1), 10 ether, 10 ether, ""); + + assertEq(IERC20(address(vault0)).balanceOf(address(hy)), v0SharesBefore - 10 ether, "vault0 shares down"); + assertGt(IERC20(address(vault1)).balanceOf(address(hy)), v1SharesBefore, "vault1 shares up"); + // At 1:1 rate and 1:1 vault exchange rate, total assets are preserved. + assertEq(hy.totalAssets(), totalAssetsBefore, "totalAssets preserved across 1:1 swap"); + } + + /// @notice A compound caller holding COMPOUNDER_ROLE succeeds. + function test_compound_compounderRoleCanCompound() public { + _deposit(alice, asset0, 60 ether); + _deposit(bob, asset1, 40 ether); + + hy.grantRoles(keeper, hy.COMPOUNDER_ROLE()); + + vm.prank(keeper); + hy.compound(address(vault0), address(vault1), 5 ether, 5 ether, ""); + } + + /// @notice A caller without COMPOUNDER_ROLE or ownership cannot compound. + function test_compound_unauthorized_reverts() public { + _deposit(alice, asset0, 60 ether); + _deposit(bob, asset1, 40 ether); + + vm.prank(alice); + vm.expectRevert(); // Unauthorized + hy.compound(address(vault0), address(vault1), 5 ether, 5 ether, ""); + } + + /// @notice Compound honours the minAmountOut slippage check via the swapper. + function test_compound_slippageRevertsFromSwapper() public { + _deposit(alice, asset0, 60 ether); + _deposit(bob, asset1, 40 ether); + + // minOut exceeds the fixed 1:1 swap output. + vm.expectRevert(bytes("MockSwapper: slippage")); + hy.compound(address(vault0), address(vault1), 5 ether, 6 ether, ""); + } + + /*////////////////////////////////////////////////////////////////////////// + REDISTRIBUTE + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice Redistribute moves value from the over-weight vault to the under-weight vault. + /// @dev Targets (60, 40) but we only deposit into asset0 (100, 0) — asset0 is 40 over, asset1 is 40 under. + function test_redistribute_rebalancesTowardTargetWeights() public { + _deposit(alice, asset0, 100 ether); + + uint256 v0Before = IERC4626(address(vault0)).convertToAssets(IERC20(address(vault0)).balanceOf(address(hy))); + uint256 v1Before = IERC4626(address(vault1)).convertToAssets(IERC20(address(vault1)).balanceOf(address(hy))); + assertEq(v0Before, 100 ether); + assertEq(v1Before, 0); + + // Permit up to the full source position; require 1:1 swap output. + hy.redistribute(type(uint256).max, 1, ""); + + uint256 v0After = IERC4626(address(vault0)).convertToAssets(IERC20(address(vault0)).balanceOf(address(hy))); + uint256 v1After = IERC4626(address(vault1)).convertToAssets(IERC20(address(vault1)).balanceOf(address(hy))); + + // Targets: (60, 40). A single rebalance moves exactly min(excess, deficit) = 40. + assertEq(v0After, 60 ether, "vault0 at target"); + assertEq(v1After, 40 ether, "vault1 at target"); + // Total preserved at 1:1 rates. + assertEq(hy.totalAssets(), 100 ether); + } + + /// @notice REDISTRIBUTOR_ROLE holder (not owner) can redistribute. + function test_redistribute_redistributorRoleCanCall() public { + _deposit(alice, asset0, 100 ether); + hy.grantRoles(keeper, hy.REDISTRIBUTOR_ROLE()); + + vm.prank(keeper); + hy.redistribute(type(uint256).max, 1, ""); + } + + /// @notice Non-owner without REDISTRIBUTOR_ROLE cannot redistribute. + function test_redistribute_unauthorized_reverts() public { + _deposit(alice, asset0, 100 ether); + vm.prank(alice); + vm.expectRevert(); + hy.redistribute(type(uint256).max, 1, ""); + } + + /// @notice When all vaults are already at their target weights, redistribute reverts. + function test_redistribute_alreadyBalanced_reverts() public { + // Deposit in exact 60/40 ratio -> already at target. + _deposit(alice, asset0, 60 ether); + _deposit(bob, asset1, 40 ether); + + vm.expectRevert(HarborYield_v1.NothingToRedistribute.selector); + hy.redistribute(type(uint256).max, 1, ""); + } + + /// @notice Empty vault (nothing deposited yet) reverts NothingToRedistribute. + function test_redistribute_emptyVault_reverts() public { + vm.expectRevert(HarborYield_v1.NothingToRedistribute.selector); + hy.redistribute(type(uint256).max, 1, ""); + } + + /// @notice The maxVaultSharesPerVault argument caps the amount moved in one call. + function test_redistribute_maxSharesCapsMovement() public { + _deposit(alice, asset0, 100 ether); + + // Cap source vault shares at 5 — less than the 40 needed to fully rebalance. + hy.redistribute(5 ether, 1, ""); + + uint256 v0After = IERC4626(address(vault0)).convertToAssets(IERC20(address(vault0)).balanceOf(address(hy))); + uint256 v1After = IERC4626(address(vault1)).convertToAssets(IERC20(address(vault1)).balanceOf(address(hy))); + + // Only 5 moved from v0 to v1, not the full 40. + assertEq(v0After, 95 ether); + assertEq(v1After, 5 ether); + } +} +// solhint-enable one-contract-per-file From 05d6846e3d76f4fbd27334d8383c83c64661c705 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Mon, 13 Apr 2026 11:20:31 +0100 Subject: [PATCH 032/232] fixed validation issues --- src/autocompounding/AutoCompounder_v1.sol | 5 +++++ src/autocompounding/HarborYield_v1.sol | 5 +++++ src/minter/StabilityPool_v3.sol | 4 ++++ 3 files changed, 14 insertions(+) diff --git a/src/autocompounding/AutoCompounder_v1.sol b/src/autocompounding/AutoCompounder_v1.sol index 51e47df2..472322a2 100644 --- a/src/autocompounding/AutoCompounder_v1.sol +++ b/src/autocompounding/AutoCompounder_v1.sol @@ -31,6 +31,10 @@ import {StringPacking_v1} from "src/minter/library/StringPacking_v1.sol"; /// and redeposits to the SP. /// totalAssets() includes the SP position plus unclaimed wrapped collateral valued via Minter dry run. /// Works for both collateral and leveraged stability pools. +/// @dev As openzeppelin's validator doesn't currently support external libraries +/// (see issue: https://github.com/OpenZeppelin/openzeppelin-upgrades/issues/52) +/// we add this: +/// @custom:oz-upgrades-unsafe-allow external-library-linking // solhint-disable-next-line contract-name-capwords contract AutoCompounder_v1 is Initializable, @@ -151,6 +155,7 @@ contract AutoCompounder_v1 is _initializeOwner(deployerOwner_, pendingOwner_); __UUPSUpgradeable_init(); __ReentrancyGuardTransient_init(); + __ERC20_init("", ""); __ERC4626_init(IERC20(STABILITY_POOL)); } diff --git a/src/autocompounding/HarborYield_v1.sol b/src/autocompounding/HarborYield_v1.sol index 69fe0f5a..ae7448aa 100644 --- a/src/autocompounding/HarborYield_v1.sol +++ b/src/autocompounding/HarborYield_v1.sol @@ -29,6 +29,10 @@ import {StringPacking_v1} from "src/minter/library/StringPacking_v1.sol"; /// `compound()` converts equivalent vault holdings into AC vault holdings via the swapper. /// /// All assets are assumed pegged 1:1. totalAssets() = SUM(IERC4626(v).convertToAssets(balance)). +/// @dev As openzeppelin's validator doesn't currently support external libraries +/// (see issue: https://github.com/OpenZeppelin/openzeppelin-upgrades/issues/52) +/// we add this: +/// @custom:oz-upgrades-unsafe-allow external-library-linking // solhint-disable-next-line contract-name-capwords contract HarborYield_v1 is Initializable, @@ -124,6 +128,7 @@ contract HarborYield_v1 is _initializeOwner(deployerOwner_, pendingOwner_); __UUPSUpgradeable_init(); __ReentrancyGuardTransient_init(); + __ERC20_init("", ""); } /*////////////////////////////////////////////////////////////////////////// diff --git a/src/minter/StabilityPool_v3.sol b/src/minter/StabilityPool_v3.sol index 70cfce5e..17c9eb2f 100644 --- a/src/minter/StabilityPool_v3.sol +++ b/src/minter/StabilityPool_v3.sol @@ -35,6 +35,10 @@ import {StringPacking_v1} from "src/minter/library/StringPacking_v1.sol"; /// @dev Uses UUPS proxy, erc7201 storage /// @custom:oz-upgrades /// @custom:oz-upgrades-from src/minter/StabilityPool_v2.sol:StabilityPool_v2 +/// @dev As openzeppelin's validator doesn't currently support external libraries +/// (see issue: https://github.com/OpenZeppelin/openzeppelin-upgrades/issues/52) +/// we add this: +/// @custom:oz-upgrades-unsafe-allow external-library-linking // solhint-disable-next-line contract-name-capwords contract StabilityPool_v3 is Initializable, From 14c34e396702fb06555692b85323adab55d17320 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Mon, 13 Apr 2026 15:35:56 +0100 Subject: [PATCH 033/232] reduced the space cost of name() and symbol() fixed the file name/ contract name misalignment fixed over tight tolerance in fee range fuzz tests --- .claude/settings.local.json | 3 +- regression/coverage.txt | 8 +- regression/gas.txt | 21 +- regression/sizes.txt | 8 +- src/autocompounding/AutoCompounder_v1.sol | 14 +- src/autocompounding/HarborYield_v1.sol | 14 +- src/minter/StabilityPool_v2.sol | 12 +- src/minter/StabilityPool_v3.sol | 14 +- src/minter/library/StringPacking_v1.sol | 69 ------- ...ultipleRewardCompoundingAccumulator_v2.sol | 12 +- .../LinearMultipleRewardDistributor_v2.sol | 3 +- src/util/ERC20MetadataLib_v1.sol | 93 +++++++++ test/ERC20MetadataLib_v1.t.sol | 191 ++++++++++++++++++ test/Minter_feeRange.t.sol | 4 +- test/StabilityPool_v3_ERC20.t.sol | 49 ++--- ...ultipleRewardCompoundingAccumulator_v2.sol | 2 +- ...MockLinearMultipleRewardDistributor_v2.sol | 2 +- 17 files changed, 360 insertions(+), 159 deletions(-) delete mode 100644 src/minter/library/StringPacking_v1.sol create mode 100644 src/util/ERC20MetadataLib_v1.sol create mode 100644 test/ERC20MetadataLib_v1.t.sol diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 43fe7e51..a2ed6b19 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -24,7 +24,8 @@ "Bash(script/check-blockchain:*)", "Bash(echo:*)", "WebFetch(domain:forum.gl-inet.com)", - "WebFetch(domain:workspace.google.com)" + "WebFetch(domain:workspace.google.com)", + "Bash(yarn validate:*)" ] } } diff --git a/regression/coverage.txt b/regression/coverage.txt index 970c3b03..d87229f1 100644 --- a/regression/coverage.txt +++ b/regression/coverage.txt @@ -40,8 +40,8 @@ | script/src/contracts/PeggedToken.sol | X 85% (23/27) | X 94% (34/36) | X 50% (3/6) | ✓ 100% (2/2) | | script/src/contracts/StabilityPool.sol | ✓ 100% (31/31) | ✓ 100% (50/50) | ✓ 100% (0/0) | ✓ 100% (3/3) | | script/src/contracts/StabilityPoolManager.sol | X 53% (17/32) | X 50% (18/36) | ✓ 100% (0/0) | X 50% (3/6) | -| src/autocompounding/AutoCompounder_v1.sol | X 96% (74/77) | X 97% (74/76) | X 60% (3/5) | X 94% (15/16) | -| src/autocompounding/HarborYield_v1.sol | X 91% (139/153) | X 92% (160/173) | X 86% (18/21) | X 76% (16/21) | +| src/autocompounding/AutoCompounder_v1.sol | X 96% (75/78) | X 97% (75/77) | X 60% (3/5) | X 94% (15/16) | +| src/autocompounding/HarborYield_v1.sol | X 91% (140/154) | X 93% (161/174) | X 86% (18/21) | X 76% (16/21) | | src/math/DecrementalFloatingPoint.sol | ✓ 100% (31/31) | ✓ 100% (33/33) | ✓ 100% (9/9) | ✓ 100% (6/6) | | src/minter/Genesis_v1.sol | X 99% (88/89) | ✓ 100% (88/88) | ✓ 100% (14/14) | X 92% (11/12) | | src/minter/Minter_v1.sol | X 0% (0/601) | X 0% (0/649) | X 0% (0/102) | X 0% (0/68) | @@ -55,7 +55,6 @@ | src/minter/TokenDistributor_v1.sol | X 96% (94/98) | X 97% (112/116) | X 77% (10/13) | X 93% (14/15) | | src/minter/library/ConfigIncentiveLib.sol | ✓ 100% (24/24) | ✓ 100% (17/17) | ✓ 100% (2/2) | ✓ 100% (9/9) | | src/minter/library/Config_v1.sol | X 96% (76/79) | X 97% (92/95) | X 90% (18/20) | ✓ 100% (6/6) | -| src/minter/library/StringPacking_v1.sol | ✓ 100% (33/33) | ✓ 100% (39/39) | ✓ 100% (8/8) | ✓ 100% (3/3) | | src/price/PriceOracle_v1.sol | X 97% (31/32) | X 97% (36/37) | X 85% (11/13) | ✓ 100% (3/3) | | src/price/StakedETHWrappedPriceOracle_v1.sol | X 0% (0/29) | X 0% (0/27) | X 0% (0/5) | X 0% (0/4) | | src/reward/accumulator/MultipleRewardCompoundingAccumulator.sol | X 91% (124/136) | X 90% (154/171) | X 75% (12/16) | X 90% (19/21) | @@ -65,6 +64,7 @@ | src/reward/distributor/LinearMultipleRewardDistributor_v2.sol | ✓ 100% (77/77) | ✓ 100% (86/86) | ✓ 100% (12/12) | ✓ 100% (14/14) | | src/reward/distributor/LinearMultipleRewardDistributor_v3.sol | X 96% (78/81) | X 97% (85/88) | X 75% (9/12) | ✓ 100% (16/16) | | src/reward/distributor/LinearReward.sol | ✓ 100% (25/25) | ✓ 100% (27/27) | ✓ 100% (6/6) | ✓ 100% (2/2) | +| src/util/ERC20MetadataLib_v1.sol | ✓ 100% (24/24) | ✓ 100% (22/22) | ✓ 100% (4/4) | ✓ 100% (4/4) | | src/util/FmtLib.sol | X 79% (15/19) | X 73% (19/26) | X 50% (2/4) | ✓ 100% (1/1) | | src/util/WordCodec.sol | ✓ 100% (15/15) | ✓ 100% (14/14) | ✓ 100% (0/0) | ✓ 100% (4/4) | -| Total | X 62% (5079/8207) | X 61% (5394/8856) | X 50% (460/919) | X 63% (761/1210) | +| Total | X 62% (5076/8204) | X 61% (5383/8845) | X 50% (456/915) | X 63% (764/1213) | diff --git a/regression/gas.txt b/regression/gas.txt index 65a022b2..5c358771 100644 --- a/regression/gas.txt +++ b/regression/gas.txt @@ -12,15 +12,15 @@ src/autocompounding/AutoCompounder_v1.sol:AutoCompounder_v1 | decimals | 2.880e+02 | | deposit | 2.114e+05 | | depositPeggedToken | 3.213e+05 | -| initialize | 1.017e+05 | +| initialize | 1.073e+05 | | maxFeeRatio | 2.391e+03 | -| name | 1.753e+04 | +| name | 5.050e+02 | | owner | 2.403e+03 | | previewRedeem | 3.630e+04 | | redeem | 9.756e+04 | | setMaxFeeRatio | 2.562e+04 | | sweep | 4.525e+04 | -| symbol | 1.876e+04 | +| symbol | 5.770e+02 | | totalAssets | 7.818e+04 | | transferOwnership | 1.202e+04 | @@ -38,7 +38,7 @@ src/autocompounding/HarborYield_v1.sol:HarborYield_v1 | deactivateVault | 1.347e+04 | | deposit | 1.872e+05 | | grantRoles | 2.637e+04 | -| initialize | 7.066e+04 | +| initialize | 7.626e+04 | | redeem | 1.307e+05 | | redistribute | 2.496e+05 | | setVaultWeight | 1.946e+04 | @@ -95,7 +95,7 @@ src/minter/Minter_v3.sol:Minter_v3 | mintLeveragedTokenIncentiveRatio | 3.108e+04 | | mintPeggedToken(uint256,address,uint256) | 1.911e+05 | | mintPeggedToken(uint256,address,uint256,uint256) | 1.251e+05 | -| mintPeggedTokenDryRun(uint256) | 6.402e+04 | +| mintPeggedTokenDryRun(uint256) | 6.399e+04 | | mintPeggedTokenDryRun(uint256,uint256) | 4.556e+04 | | mintPeggedTokenIncentiveRatio | 3.012e+04 | | owner | 2.402e+03 | @@ -207,14 +207,14 @@ src/minter/StabilityPool_v3.sol:StabilityPool_v3 | grantRoles | 2.638e+04 | | historicalRewardTokens | 5.180e+03 | | initialize | 2.042e+05 | -| name | 1.928e+04 | +| name | 5.720e+02 | | notifyLiquidation | 1.235e+05 | | owner | 2.424e+03 | | proxiableUUID | 3.640e+02 | | registerRewardToken | 8.857e+04 | | requestWithdrawal | 2.501e+04 | | sweep | 4.024e+04 | -| symbol | 1.950e+04 | +| symbol | 5.550e+02 | | totalAssetSupply | 2.489e+03 | | totalSupply | 2.424e+03 | | transfer | 1.880e+05 | @@ -243,10 +243,3 @@ src/minter/TokenDistributor_v1.sol:TokenDistributor_v1 | sweep | 4.108e+04 | | tokens | 7.448e+03 | | transferOwnership | 1.200e+04 | - -src/minter/library/StringPacking_v1.sol:StringPacking_v1 -| function name | max | -|-----------------|-----------| -| pack32 | 7.480e+02 | -| pack64 | 9.590e+02 | -| unpack64 | 1.582e+04 | diff --git a/regression/sizes.txt b/regression/sizes.txt index 748c409e..621bb8e1 100644 --- a/regression/sizes.txt +++ b/regression/sizes.txt @@ -1,6 +1,6 @@ | Contract | Runtime Size (B) | Runtime Margin (B) | Initcode Size (B) | Deploy Gas | Deploy Cost ($) | |-----------------------------------|--------------------|----------------------|---------------------|--------------|-------------------| -| AutoCompounder_v1 | 12,153 | 12,423 | 13,842 | 2,569,020 | 256.90 | +| AutoCompounder_v1 | 12,363 | 12,213 | 13,907 | 2,611,670 | 261.17 | | ConfigIncentiveLib | 85 | 24,491 | 135 | 18,350 | 1.84 | | ConfigMarket_BTC_fxUSD_mainnet | 6,856 | 17,720 | 6,884 | 1,440,040 | 144.00 | | ConfigMarket_BTC_stETH_mainnet | 6,882 | 17,694 | 6,910 | 1,445,500 | 144.55 | @@ -29,6 +29,7 @@ | ConfigPriceVolatility_130_stable | 3,019 | 21,557 | 3,047 | 634,270 | 63.43 | | Config_v1 | 3,789 | 20,787 | 3,841 | 796,210 | 79.62 | | DecrementalFloatingPoint | 85 | 24,491 | 135 | 18,350 | 1.84 | +| ERC20MetadataLib_v1 | 85 | 24,491 | 135 | 18,350 | 1.84 | | FakeAccessControl | 1,626 | 22,950 | 1,654 | 341,740 | 34.17 | | FakeBaoAccessControl | 1,487 | 23,089 | 1,515 | 312,550 | 31.26 | | FakeInitializable | 389 | 24,187 | 417 | 81,970 | 8.20 | @@ -36,7 +37,7 @@ | FakeUUPSUpgradeable | 3,236 | 21,340 | 3,293 | 680,130 | 68.01 | | FmtLib | 85 | 24,491 | 135 | 18,350 | 1.84 | | Genesis_v1 | 7,486 | 17,090 | 8,423 | 1,581,430 | 158.14 | -| HarborYield_v1 | 13,629 | 10,947 | 14,668 | 2,872,480 | 287.25 | +| HarborYield_v1 | 13,839 | 10,737 | 14,739 | 2,915,190 | 291.52 | | LinearReward | 85 | 24,491 | 135 | 18,350 | 1.84 | | MinterMarketConfigLib | 85 | 24,491 | 135 | 18,350 | 1.84 | | Minter_v1 | 23,681 | 895 | 25,515 | 4,991,350 | 499.14 | @@ -47,8 +48,7 @@ | StabilityPoolManager_v1 | 11,209 | 13,367 | 13,057 | 2,372,370 | 237.24 | | StabilityPool_v1 | 21,092 | 3,484 | 23,085 | 4,449,250 | 444.93 | | StabilityPool_v2 | 20,711 | 3,865 | 22,579 | 4,367,990 | 436.80 | -| StabilityPool_v3 | 23,304 | 1,272 | 25,945 | 4,920,250 | 492.03 | +| StabilityPool_v3 | 23,066 | 1,510 | 25,562 | 4,868,820 | 486.88 | | StakedETHWrappedPriceOracle_v1 | 3,695 | 20,881 | 4,653 | 785,530 | 78.55 | -| StringPacking_v1 | 1,345 | 23,231 | 1,397 | 282,970 | 28.30 | | TokenDistributor_v1 | 10,116 | 14,460 | 10,365 | 2,126,850 | 212.69 | | WordCodec | 85 | 24,491 | 135 | 18,350 | 1.84 | diff --git a/src/autocompounding/AutoCompounder_v1.sol b/src/autocompounding/AutoCompounder_v1.sol index 472322a2..e8588728 100644 --- a/src/autocompounding/AutoCompounder_v1.sol +++ b/src/autocompounding/AutoCompounder_v1.sol @@ -21,7 +21,7 @@ import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumula import {IMultipleRewardAccumulator_v3} from "src/interfaces/IMultipleRewardAccumulator_v3.sol"; import {IMinter} from "src/interfaces/IMinter.sol"; import {IMinter_v3} from "src/interfaces/IMinter_v3.sol"; -import {StringPacking_v1} from "src/minter/library/StringPacking_v1.sol"; +import {ERC20MetadataLib_v1} from "src/util/ERC20MetadataLib_v1.sol"; /// @title AutoCompounder_v1 /// @notice Level 1 auto-compounder: non-rebasing ERC4626 vault wrapping a rebasing stability pool position. @@ -31,10 +31,6 @@ import {StringPacking_v1} from "src/minter/library/StringPacking_v1.sol"; /// and redeposits to the SP. /// totalAssets() includes the SP position plus unclaimed wrapped collateral valued via Minter dry run. /// Works for both collateral and leveraged stability pools. -/// @dev As openzeppelin's validator doesn't currently support external libraries -/// (see issue: https://github.com/OpenZeppelin/openzeppelin-upgrades/issues/52) -/// we add this: -/// @custom:oz-upgrades-unsafe-allow external-library-linking // solhint-disable-next-line contract-name-capwords contract AutoCompounder_v1 is Initializable, @@ -144,8 +140,8 @@ contract AutoCompounder_v1 is WRAPPED_COLLATERAL = IMinter(minter_).WRAPPED_COLLATERAL_TOKEN(); PEGGED_TOKEN = IMinter(minter_).PEGGED_TOKEN(); assert(IStabilityPool(stabilityPool_).ASSET_TOKEN() == PEGGED_TOKEN); - (_ERC20_NAME_0, _ERC20_NAME_1) = StringPacking_v1.pack64(name_); - _ERC20_SYMBOL = StringPacking_v1.pack32(symbol_); + (_ERC20_NAME_0, _ERC20_NAME_1) = ERC20MetadataLib_v1.packName(name_); + _ERC20_SYMBOL = ERC20MetadataLib_v1.packSymbol(symbol_); } /// @notice Initialize the auto-compounder. @@ -195,12 +191,12 @@ contract AutoCompounder_v1 is /// @notice ERC20 name, packed into constructor immutables. function name() public view override(ERC20Upgradeable, IERC20Metadata) returns (string memory) { - return StringPacking_v1.unpack64(_ERC20_NAME_0, _ERC20_NAME_1); + return ERC20MetadataLib_v1.unpackName(_ERC20_NAME_0, _ERC20_NAME_1); } /// @notice ERC20 symbol, packed into constructor immutables. function symbol() public view override(ERC20Upgradeable, IERC20Metadata) returns (string memory) { - return StringPacking_v1.unpack64(_ERC20_SYMBOL, bytes32(0)); + return ERC20MetadataLib_v1.unpackSymbol(_ERC20_SYMBOL); } /// @dev Decimals match the SP token (18). diff --git a/src/autocompounding/HarborYield_v1.sol b/src/autocompounding/HarborYield_v1.sol index ae7448aa..58702083 100644 --- a/src/autocompounding/HarborYield_v1.sol +++ b/src/autocompounding/HarborYield_v1.sol @@ -17,7 +17,7 @@ import {TokenHolder, ITokenHolder} from "@bao/TokenHolder.sol"; import {IHarborYield} from "src/interfaces/IHarborYield.sol"; import {IAutoCompounder} from "src/interfaces/IAutoCompounder.sol"; import {ISwapper} from "src/interfaces/ISwapper.sol"; -import {StringPacking_v1} from "src/minter/library/StringPacking_v1.sol"; +import {ERC20MetadataLib_v1} from "src/util/ERC20MetadataLib_v1.sol"; /// @title HarborYield_v1 /// @notice Level 2 yield vault: one per peg. Manages multiple ERC4626 vaults (AutoCompounders, @@ -29,10 +29,6 @@ import {StringPacking_v1} from "src/minter/library/StringPacking_v1.sol"; /// `compound()` converts equivalent vault holdings into AC vault holdings via the swapper. /// /// All assets are assumed pegged 1:1. totalAssets() = SUM(IERC4626(v).convertToAssets(balance)). -/// @dev As openzeppelin's validator doesn't currently support external libraries -/// (see issue: https://github.com/OpenZeppelin/openzeppelin-upgrades/issues/52) -/// we add this: -/// @custom:oz-upgrades-unsafe-allow external-library-linking // solhint-disable-next-line contract-name-capwords contract HarborYield_v1 is Initializable, @@ -117,8 +113,8 @@ contract HarborYield_v1 is /// @custom:oz-upgrades-unsafe-allow constructor constructor(string memory name_, string memory symbol_, address swapper_) { _disableInitializers(); - (_ERC20_NAME_0, _ERC20_NAME_1) = StringPacking_v1.pack64(name_); - _ERC20_SYMBOL = StringPacking_v1.pack32(symbol_); + (_ERC20_NAME_0, _ERC20_NAME_1) = ERC20MetadataLib_v1.packName(name_); + _ERC20_SYMBOL = ERC20MetadataLib_v1.packSymbol(symbol_); Token.ensureNonZeroAddress(swapper_); // slither-disable-next-line missing-zero-check SWAPPER = swapper_; @@ -214,11 +210,11 @@ contract HarborYield_v1 is //////////////////////////////////////////////////////////////////////////*/ function name() public view override returns (string memory) { - return StringPacking_v1.unpack64(_ERC20_NAME_0, _ERC20_NAME_1); + return ERC20MetadataLib_v1.unpackName(_ERC20_NAME_0, _ERC20_NAME_1); } function symbol() public view override returns (string memory) { - return StringPacking_v1.unpack64(_ERC20_SYMBOL, bytes32(0)); + return ERC20MetadataLib_v1.unpackSymbol(_ERC20_SYMBOL); } function decimals() public pure override returns (uint8) { diff --git a/src/minter/StabilityPool_v2.sol b/src/minter/StabilityPool_v2.sol index 15849e72..2916f674 100644 --- a/src/minter/StabilityPool_v2.sol +++ b/src/minter/StabilityPool_v2.sol @@ -11,7 +11,7 @@ import {Token} from "@bao/Token.sol"; import {TokenHolder} from "@bao/TokenHolder.sol"; import {DecrementalFloatingPoint} from "src/math/DecrementalFloatingPoint.sol"; -import {MultipleRewardCompoundingAccumulator} from "src/reward/accumulator/MultipleRewardCompoundingAccumulator_v2.sol"; +import {MultipleRewardCompoundingAccumulator_v2} from "src/reward/accumulator/MultipleRewardCompoundingAccumulator_v2.sol"; import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; import {IMinter} from "src/interfaces/IMinter.sol"; @@ -37,7 +37,7 @@ import {IMinter} from "src/interfaces/IMinter.sol"; contract StabilityPool_v2 is Initializable, UUPSUpgradeable, - MultipleRewardCompoundingAccumulator, + MultipleRewardCompoundingAccumulator_v2, TokenHolder, IStabilityPool { @@ -194,7 +194,7 @@ contract StabilityPool_v2 is uint256 withdrawalStartDelay_, uint256 withdrawalEndWindow_, uint256 minTotalAssetSupply - ) MultipleRewardCompoundingAccumulator(_REWARD_MANAGER_ROLE, _REWARD_DEPOSITOR_ROLE, 1 weeks) { + ) MultipleRewardCompoundingAccumulator_v2(_REWARD_MANAGER_ROLE, _REWARD_DEPOSITOR_ROLE, 1 weeks) { _disableInitializers(); address asset = IMinter(minter_).PEGGED_TOKEN(); Token.sanityCheckERC20Token(asset); @@ -461,7 +461,7 @@ contract StabilityPool_v2 is * Internal Functions * **********************/ - /// @inheritdoc MultipleRewardCompoundingAccumulator + /// @inheritdoc MultipleRewardCompoundingAccumulator_v2 // slither-disable-next-line reentrancy-events,reentrancy-benign,reentrancy-no-eth // function is only called from nonReentrant external functions function _checkpoint(address account) internal virtual override { StabilityPoolStorage storage $ = _getStabilityPoolStorage(); @@ -481,7 +481,7 @@ contract StabilityPool_v2 is } } - /// @inheritdoc MultipleRewardCompoundingAccumulator + /// @inheritdoc MultipleRewardCompoundingAccumulator_v2 function _getTotalPoolShare() internal view virtual override returns (uint128 currentProd, uint256 totalShare) { StabilityPoolStorage storage $ = _getStabilityPoolStorage(); TokenBalance memory supply = $.totalAssetSupply; @@ -489,7 +489,7 @@ contract StabilityPool_v2 is totalShare = supply.amount; } - /// @inheritdoc MultipleRewardCompoundingAccumulator + /// @inheritdoc MultipleRewardCompoundingAccumulator_v2 function _getUserPoolShare( address account ) internal view virtual override returns (uint128 previousProd, uint256 share) { diff --git a/src/minter/StabilityPool_v3.sol b/src/minter/StabilityPool_v3.sol index 17c9eb2f..85ad4559 100644 --- a/src/minter/StabilityPool_v3.sol +++ b/src/minter/StabilityPool_v3.sol @@ -17,7 +17,7 @@ import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; import {IMinter} from "src/interfaces/IMinter.sol"; import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; -import {StringPacking_v1} from "src/minter/library/StringPacking_v1.sol"; +import {ERC20MetadataLib_v1} from "src/util/ERC20MetadataLib_v1.sol"; // solhint-disable not-rely-on-time // slither-disable-start timestamp @@ -35,10 +35,6 @@ import {StringPacking_v1} from "src/minter/library/StringPacking_v1.sol"; /// @dev Uses UUPS proxy, erc7201 storage /// @custom:oz-upgrades /// @custom:oz-upgrades-from src/minter/StabilityPool_v2.sol:StabilityPool_v2 -/// @dev As openzeppelin's validator doesn't currently support external libraries -/// (see issue: https://github.com/OpenZeppelin/openzeppelin-upgrades/issues/52) -/// we add this: -/// @custom:oz-upgrades-unsafe-allow external-library-linking // solhint-disable-next-line contract-name-capwords contract StabilityPool_v3 is Initializable, @@ -233,8 +229,8 @@ contract StabilityPool_v3 is string memory symbol_ ) MultipleRewardCompoundingAccumulator_v3(_REWARD_MANAGER_ROLE, _REWARD_DEPOSITOR_ROLE, 1 weeks) { _disableInitializers(); - (_ERC20_NAME_0, _ERC20_NAME_1) = StringPacking_v1.pack64(name_); - _ERC20_SYMBOL = StringPacking_v1.pack32(symbol_); + (_ERC20_NAME_0, _ERC20_NAME_1) = ERC20MetadataLib_v1.packName(name_); + _ERC20_SYMBOL = ERC20MetadataLib_v1.packSymbol(symbol_); address asset = IMinter(minter_).PEGGED_TOKEN(); _ERC20_DECIMALS = IERC20Metadata(asset).decimals(); Token.sanityCheckERC20Token(asset); @@ -635,12 +631,12 @@ contract StabilityPool_v3 is /// @inheritdoc IERC20Metadata function name() external view returns (string memory) { - return StringPacking_v1.unpack64(_ERC20_NAME_0, _ERC20_NAME_1); + return ERC20MetadataLib_v1.unpackName(_ERC20_NAME_0, _ERC20_NAME_1); } /// @inheritdoc IERC20Metadata function symbol() external view returns (string memory) { - return StringPacking_v1.unpack64(_ERC20_SYMBOL, bytes32(0)); + return ERC20MetadataLib_v1.unpackSymbol(_ERC20_SYMBOL); } /// @inheritdoc IERC20Metadata diff --git a/src/minter/library/StringPacking_v1.sol b/src/minter/library/StringPacking_v1.sol deleted file mode 100644 index 8dbdbd7c..00000000 --- a/src/minter/library/StringPacking_v1.sol +++ /dev/null @@ -1,69 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.30; - -/// @title StringPacking_v1 -/// @notice Library for packing strings into bytes32 pairs and unpacking them back. -/// @dev Functions are public (not internal) so they deploy as a linked library, -/// keeping bytecode out of contracts that use them. -// solhint-disable-next-line contract-name-capwords -library StringPacking_v1 { - error StringTooLong(); - - /// @notice Pack a string (up to 64 chars) into two bytes32 values. - function pack64(string memory s) public pure returns (bytes32 b0, bytes32 b1) { - bytes memory b = bytes(s); - if (b.length > 64) { - revert StringTooLong(); - } - // solhint-disable-next-line no-inline-assembly - assembly { - b0 := mload(add(b, 32)) - b1 := mload(add(b, 64)) - } - if (b.length < 32) { - b0 = bytes32(uint256(b0) & ~(type(uint256).max >> (b.length * 8))); - b1 = bytes32(0); - } else if (b.length < 64) { - b1 = bytes32(uint256(b1) & ~(type(uint256).max >> ((b.length - 32) * 8))); - } - } - - /// @notice Pack a string (up to 32 chars) into a single bytes32 value. - function pack32(string memory s) public pure returns (bytes32 b0) { - bytes memory b = bytes(s); - if (b.length > 32) { - revert StringTooLong(); - } - // solhint-disable-next-line no-inline-assembly - assembly { - b0 := mload(add(b, 32)) - } - if (b.length < 32) { - b0 = bytes32(uint256(b0) & ~(type(uint256).max >> (b.length * 8))); - } - } - - /// @notice Unpack two bytes32 values back into a string. - function unpack64(bytes32 b0, bytes32 b1) public pure returns (string memory) { - uint256 len0; - for (len0 = 32; len0 > 0; len0--) { - if (b0[len0 - 1] != 0) { - break; - } - } - uint256 len1; - for (len1 = 32; len1 > 0; len1--) { - if (b1[len1 - 1] != 0) { - break; - } - } - bytes memory result = new bytes(len0 + len1); - for (uint256 i = 0; i < len0; i++) { - result[i] = b0[i]; - } - for (uint256 i = 0; i < len1; i++) { - result[len0 + i] = b1[i]; - } - return string(result); - } -} diff --git a/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v2.sol b/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v2.sol index 93dfb6bc..a4249627 100644 --- a/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v2.sol +++ b/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v2.sol @@ -10,7 +10,7 @@ import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; import {DecrementalFloatingPoint} from "src/math/DecrementalFloatingPoint.sol"; -import {LinearMultipleRewardDistributor} from "src/reward/distributor/LinearMultipleRewardDistributor_v2.sol"; +import {LinearMultipleRewardDistributor_v2} from "src/reward/distributor/LinearMultipleRewardDistributor_v2.sol"; // solhint-disable not-rely-on-time @@ -111,10 +111,10 @@ import {LinearMultipleRewardDistributor} from "src/reward/distributor/LinearMult /// /// @dev The method comes from liquity's StabilityPool, the paper is in /// https://github.com/liquity/dev/blob/main/papers/Scalable_Reward_Distribution_with_Compounding_Stakes.pdf - -abstract contract MultipleRewardCompoundingAccumulator is +// solhint-disable-next-line contract-name-capwords +abstract contract MultipleRewardCompoundingAccumulator_v2 is ReentrancyGuardTransientUpgradeable, - LinearMultipleRewardDistributor, + LinearMultipleRewardDistributor_v2, IMultipleRewardAccumulator { using SafeERC20 for IERC20; @@ -271,7 +271,7 @@ abstract contract MultipleRewardCompoundingAccumulator is uint256 rewardManagerRole, uint256 rewardDepositorRole, uint40 periodLength - ) LinearMultipleRewardDistributor(rewardManagerRole, rewardDepositorRole, periodLength) {} + ) LinearMultipleRewardDistributor_v2(rewardManagerRole, rewardDepositorRole, periodLength) {} /************************* * Public View Functions * @@ -535,7 +535,7 @@ abstract contract MultipleRewardCompoundingAccumulator is return amount; } - /// @inheritdoc LinearMultipleRewardDistributor + /// @inheritdoc LinearMultipleRewardDistributor_v2 function _accumulateReward(address token, uint256 amount) internal virtual override { // slither-disable-next-line incorrect-equality if (amount == 0) { diff --git a/src/reward/distributor/LinearMultipleRewardDistributor_v2.sol b/src/reward/distributor/LinearMultipleRewardDistributor_v2.sol index 10cbe975..0236623b 100644 --- a/src/reward/distributor/LinearMultipleRewardDistributor_v2.sol +++ b/src/reward/distributor/LinearMultipleRewardDistributor_v2.sol @@ -34,7 +34,8 @@ import {LinearReward} from "./LinearReward.sol"; /// and supports immediate or time-based reward distribution depending on the /// configured period length. -abstract contract LinearMultipleRewardDistributor is +// solhint-disable-next-line contract-name-capwords +abstract contract LinearMultipleRewardDistributor_v2 is Initializable, ContextUpgradeable, BaoOwnableRoles, diff --git a/src/util/ERC20MetadataLib_v1.sol b/src/util/ERC20MetadataLib_v1.sol new file mode 100644 index 00000000..9903afd1 --- /dev/null +++ b/src/util/ERC20MetadataLib_v1.sol @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.30; + +import {LibString} from "@solady/utils/LibString.sol"; + +/// @title ERC20MetadataLib_v1 +/// @notice Pack and unpack ERC20 `name` (up to 63 chars) and `symbol` (up to 31 chars) +/// into immutable bytes32 storage. +/// @dev `name` uses a custom 2-word encoding with the length stored in the first byte; +/// this fits 63 chars across two bytes32 immutables. `symbol` delegates to Solady's +/// `LibString.packOne` / `unpackOne`, which fits 31 chars in one bytes32. +/// The length-prefix scheme is borrowed from Solady's LibString: +/// https://github.com/Vectorized/solady/blob/main/src/utils/LibString.sol +/// @dev All functions are `internal` so they inline into the consumer. Inlining the +/// mcopy-based unpack is smaller than calling a linked library, and inlining the +/// pack functions avoids putting `__$...$__` link placeholders in constructor +/// bytecode (which would break the validate script's `cast disassemble` step). +// solhint-disable-next-line contract-name-capwords +library ERC20MetadataLib_v1 { + error StringTooLong(); + error EmptyString(); + + /// @notice Pack an ERC20 name (1..63 chars) into two bytes32 immutables. + /// @dev Layout: b0 byte 0 = length, b0 bytes 1..31 = string bytes 0..30, + /// b1 bytes 0..31 = string bytes 31..62. Uses the same mload-at-offset trick + /// as Solady's `LibString.packOne`: loading at offset 0x1f puts the length + /// byte (the low byte of the string's length slot) in the result's high byte + /// and the first 31 data bytes in bytes 1..31, with no shifts. + function packName(string memory s) internal pure returns (bytes32 b0, bytes32 b1) { + bytes memory b = bytes(s); + if (b.length == 0) { + revert EmptyString(); + } + if (b.length > 63) { + revert StringTooLong(); + } + // solhint-disable-next-line no-inline-assembly + assembly { + // b0: load 32 bytes starting at the length slot's last byte. + // → [length, data[0..30]] + b0 := mload(add(b, 0x1f)) + // b1: load 32 bytes starting at data byte 31. + // → [data[31..62]] (bytes past len are memory garbage; unpackName clears them) + b1 := mload(add(b, 0x3f)) + } + } + + /// @notice Unpack two bytes32 values back into the original ERC20 name. + /// @dev Mirrors Solady's `LibString.unpackOne`: allocates memory manually and writes + /// the words at offsets 0x1f and 0x3f so the length aligns with the string's + /// length slot and the data flows into the data area. A trailing zero mstore + /// pads the area past the actual data length, so any garbage that pack loaded + /// from beyond the source string is wiped here. + function unpackName(bytes32 b0, bytes32 b1) internal pure returns (string memory result) { + // solhint-disable-next-line no-inline-assembly + assembly { + result := mload(0x40) + // Reserve 4 words: 1 length slot + 2 data words + 1 word of slack so the + // right-pad mstore below never writes outside the allocation. + mstore(0x40, add(result, 0x80)) + // Zero the length slot; it'll be partially overwritten by the b0 mstore. + mstore(result, 0) + // Length byte → result+0x1f, data[0..30] → result+0x20..0x3e + mstore(add(result, 0x1f), b0) + // data[31] → result+0x3f, data[32..62] → result+0x40..0x5e + mstore(add(result, 0x3f), b1) + // Right-pad: zero from the byte just past the actual data through the end + // of the second data word (and into the slack word), wiping any garbage + // that packName loaded from beyond the source string. + mstore(add(add(result, 0x20), mload(result)), 0) + } + } + + /// @notice Pack an ERC20 symbol (1..31 chars) into a single bytes32 immutable. + /// @dev Delegates to Solady's `LibString.packOne` after validating the length. + /// Solady silently returns `bytes32(0)` for empty or too-long input, so we + /// check explicitly here to fail fast at deploy time. + function packSymbol(string memory s) internal pure returns (bytes32) { + bytes memory b = bytes(s); + if (b.length == 0) { + revert EmptyString(); + } + if (b.length > 31) { + revert StringTooLong(); + } + return LibString.packOne(s); + } + + /// @notice Unpack a bytes32 value back into the original ERC20 symbol. + function unpackSymbol(bytes32 packed) internal pure returns (string memory) { + return LibString.unpackOne(packed); + } +} diff --git a/test/ERC20MetadataLib_v1.t.sol b/test/ERC20MetadataLib_v1.t.sol new file mode 100644 index 00000000..e7a7f2db --- /dev/null +++ b/test/ERC20MetadataLib_v1.t.sol @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.28 <0.9.0; + +import "forge-std/Test.sol"; + +import {ERC20MetadataLib_v1} from "src/util/ERC20MetadataLib_v1.sol"; + +/// @dev Tests ERC20MetadataLib_v1: round-trip of pack/unpack for the symbol (1..31 chars, +/// delegates to Solady) and name (1..63 chars, custom mload/mstore implementation). +/// pack* are internal so we wrap them in a mock to reach them across the call boundary. +contract MockERC20MetadataLib { + function packSymbol(string memory s) external pure returns (bytes32) { + return ERC20MetadataLib_v1.packSymbol(s); + } + + function packName(string memory s) external pure returns (bytes32, bytes32) { + return ERC20MetadataLib_v1.packName(s); + } +} + +contract ERC20MetadataLib_v1_Test is Test { + MockERC20MetadataLib internal mock; + + function setUp() public { + mock = new MockERC20MetadataLib(); + } + + /*////////////////////////////////////////////////////////////////////////// + packSymbol / unpackSymbol + //////////////////////////////////////////////////////////////////////////*/ + + /// @dev empty string is rejected (deployment guard against bricked tokens) + function test_packSymbol_empty_reverts() public { + vm.expectRevert(ERC20MetadataLib_v1.EmptyString.selector); + mock.packSymbol(""); + } + + /// @dev 1-char string round-trips + function test_packSymbol_oneChar() public view { + bytes32 packed = mock.packSymbol("A"); + assertEq(ERC20MetadataLib_v1.unpackSymbol(packed), "A"); + } + + /// @dev typical short symbol round-trips + function test_packSymbol_typical() public view { + bytes32 packed = mock.packSymbol("BAOUSD"); + assertEq(ERC20MetadataLib_v1.unpackSymbol(packed), "BAOUSD"); + } + + /// @dev 30-char string round-trips (one byte short of the limit) + function test_packSymbol_thirtyChars() public view { + string memory s = "123456789012345678901234567890"; + assertEq(bytes(s).length, 30); + assertEq(ERC20MetadataLib_v1.unpackSymbol(mock.packSymbol(s)), s); + } + + /// @dev exactly-31-char string round-trips (the maximum length) + function test_packSymbol_thirtyOneChars() public view { + string memory s = "1234567890123456789012345678901"; + assertEq(bytes(s).length, 31); + assertEq(ERC20MetadataLib_v1.unpackSymbol(mock.packSymbol(s)), s); + } + + /// @dev 32-char string is rejected (1 byte over the limit) + function test_packSymbol_tooLong_reverts() public { + string memory s = "12345678901234567890123456789012"; + assertEq(bytes(s).length, 32); + vm.expectRevert(ERC20MetadataLib_v1.StringTooLong.selector); + mock.packSymbol(s); + } + + /*////////////////////////////////////////////////////////////////////////// + packName / unpackName + //////////////////////////////////////////////////////////////////////////*/ + + /// @dev empty string is rejected (deployment guard against bricked tokens) + function test_packName_empty_reverts() public { + vm.expectRevert(ERC20MetadataLib_v1.EmptyString.selector); + mock.packName(""); + } + + /// @dev short name fits in word 0, word 1 unused + function test_packName_shortString() public view { + (bytes32 b0, bytes32 b1) = mock.packName("Harbor"); + assertEq(ERC20MetadataLib_v1.unpackName(b0, b1), "Harbor"); + } + + /// @dev exactly-31-char name fills word 0 entirely (length byte + 31 data bytes) + function test_packName_thirtyOneChars() public view { + string memory s = "1234567890123456789012345678901"; + assertEq(bytes(s).length, 31); + (bytes32 b0, bytes32 b1) = mock.packName(s); + assertEq(ERC20MetadataLib_v1.unpackName(b0, b1), s); + } + + /// @dev 32-char name is the first to spill into word 1 (1 byte in word 1) + function test_packName_thirtyTwoChars() public view { + string memory s = "12345678901234567890123456789012"; + assertEq(bytes(s).length, 32); + (bytes32 b0, bytes32 b1) = mock.packName(s); + assertEq(ERC20MetadataLib_v1.unpackName(b0, b1), s); + } + + /// @dev 62-char name round-trips (one byte short of the limit) + function test_packName_sixtyTwoChars() public view { + string memory s = "12345678901234567890123456789012345678901234567890123456789012"; + assertEq(bytes(s).length, 62); + (bytes32 b0, bytes32 b1) = mock.packName(s); + assertEq(ERC20MetadataLib_v1.unpackName(b0, b1), s); + } + + /// @dev exactly-63-char name round-trips (the maximum length) + function test_packName_sixtyThreeChars() public view { + string memory s = "123456789012345678901234567890123456789012345678901234567890123"; + assertEq(bytes(s).length, 63); + (bytes32 b0, bytes32 b1) = mock.packName(s); + assertEq(ERC20MetadataLib_v1.unpackName(b0, b1), s); + } + + /// @dev 64-char name is rejected (1 byte over the limit) + function test_packName_tooLong_reverts() public { + string memory s = "1234567890123456789012345678901234567890123456789012345678901234"; + assertEq(bytes(s).length, 64); + vm.expectRevert(ERC20MetadataLib_v1.StringTooLong.selector); + mock.packName(s); + } + + /// @dev a name containing 0x00 bytes round-trips intact, including at the word + /// boundary (string byte 31, where the encoding splits b0/b1). + function test_packName_embeddedNul() public view { + bytes memory raw = new bytes(46); + for (uint256 i = 0; i < 46; i++) { + raw[i] = bytes1(uint8(0x41 + (i % 26))); // A..Z repeating + } + raw[15] = 0x00; + raw[31] = 0x00; // word-boundary NUL — this is the case that broke the old encoding + raw[45] = 0x00; // last data byte is also NUL + string memory s = string(raw); + (bytes32 b0, bytes32 b1) = mock.packName(s); + assertEq(ERC20MetadataLib_v1.unpackName(b0, b1), s); + } + + /// @dev a symbol containing 0x00 bytes round-trips intact. + function test_packSymbol_embeddedNul() public view { + bytes memory raw = new bytes(31); + for (uint256 i = 0; i < 31; i++) { + raw[i] = bytes1(uint8(0x41 + (i % 26))); + } + raw[15] = 0x00; + raw[30] = 0x00; // last data byte is also NUL + string memory s = string(raw); + assertEq(ERC20MetadataLib_v1.unpackSymbol(mock.packSymbol(s)), s); + } + + /*////////////////////////////////////////////////////////////////////////// + fuzz + //////////////////////////////////////////////////////////////////////////*/ + + /// @dev any non-empty string up to 31 chars round-trips through packSymbol/unpackSymbol, + /// including strings with embedded NULs. + function testFuzz_packSymbol_roundTrip(bytes memory raw) public view { + vm.assume(raw.length > 0 && raw.length <= 31); + string memory s = string(raw); + assertEq(ERC20MetadataLib_v1.unpackSymbol(mock.packSymbol(s)), s); + } + + /// @dev any non-empty string up to 63 chars round-trips through packName/unpackName, + /// including strings with embedded NULs. + function testFuzz_packName_roundTrip(bytes memory raw) public view { + vm.assume(raw.length > 0 && raw.length <= 63); + string memory s = string(raw); + (bytes32 b0, bytes32 b1) = mock.packName(s); + assertEq(ERC20MetadataLib_v1.unpackName(b0, b1), s); + } + + /// @dev any string longer than 31 chars is rejected by packSymbol. + function testFuzz_packSymbol_tooLong(bytes memory raw) public { + vm.assume(raw.length > 31 && raw.length <= 256); + string memory s = string(raw); + vm.expectRevert(ERC20MetadataLib_v1.StringTooLong.selector); + mock.packSymbol(s); + } + + /// @dev any string longer than 63 chars is rejected by packName. + function testFuzz_packName_tooLong(bytes memory raw) public { + vm.assume(raw.length > 63 && raw.length <= 256); + string memory s = string(raw); + vm.expectRevert(ERC20MetadataLib_v1.StringTooLong.selector); + mock.packName(s); + } +} diff --git a/test/Minter_feeRange.t.sol b/test/Minter_feeRange.t.sol index 6a5d5738..ee4c4d7a 100644 --- a/test/Minter_feeRange.t.sol +++ b/test/Minter_feeRange.t.sol @@ -507,7 +507,7 @@ contract TestMinterFixedFeeRange_ is TestMinterFeeRange { assertNear( post.minterUnderlying, pre.minterUnderlying + ((wrapped - fee) * r) / 1e18, - 1, + _qR(p, r), 35, "mp minter underlying" ); @@ -819,7 +819,7 @@ contract TestMinterFixedFeeRange_ is TestMinterFeeRange { assertNear( post.minterUnderlying, pre.minterUnderlying - (wrapped * r) / 1e18, - 1, + _qR(p, r), 0, "rl minter underlying" ); diff --git a/test/StabilityPool_v3_ERC20.t.sol b/test/StabilityPool_v3_ERC20.t.sol index d1a501be..d02aec03 100644 --- a/test/StabilityPool_v3_ERC20.t.sol +++ b/test/StabilityPool_v3_ERC20.t.sol @@ -7,7 +7,7 @@ import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IER import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; import {StabilityPool_v3} from "src/minter/StabilityPool_v3.sol"; -import {StringPacking_v1} from "src/minter/library/StringPacking_v1.sol"; +import {ERC20MetadataLib_v1} from "src/util/ERC20MetadataLib_v1.sol"; import {DeployEURSetUp} from "test/deployment/DeployEURSetUp.t.sol"; @@ -78,22 +78,25 @@ contract TestStabilityPool_v3_ERC20 is DeployEURSetUp { // String packing: StringTooLong, short strings, medium strings // ═══════════════════════════════════════════════════════════════════════ - /// Intent: constructor reverts if name exceeds 64 characters (StringPacking_v1 limit). + /// Intent: constructor reverts if name exceeds 63 characters (pack64 limit). function test_stringTooLong_name_reverts() public { - // 65-char string exceeds 64-char limit - string memory longName = "12345678901234567890123456789012345678901234567890123456789012345"; - vm.expectRevert(StringPacking_v1.StringTooLong.selector); + // 64-char string is one over the 63-char limit + string memory longName = "1234567890123456789012345678901234567890123456789012345678901234"; + assertEq(bytes(longName).length, 64, "sanity"); + vm.expectRevert(ERC20MetadataLib_v1.StringTooLong.selector); new StabilityPool_v3(minterFxUSD, wrappedCollateralToken, 3600, 90000, 1 ether, longName, "s"); } - /// Intent: constructor reverts if symbol exceeds 64 characters (StringPacking_v1 limit). + /// Intent: constructor reverts if symbol exceeds 31 characters (pack32 limit). function test_stringTooLong_symbol_reverts() public { - string memory longSymbol = "12345678901234567890123456789012345678901234567890123456789012345"; - vm.expectRevert(StringPacking_v1.StringTooLong.selector); + // 32-char string is one over the 31-char limit + string memory longSymbol = "12345678901234567890123456789012"; + assertEq(bytes(longSymbol).length, 32, "sanity"); + vm.expectRevert(ERC20MetadataLib_v1.StringTooLong.selector); new StabilityPool_v3(minterFxUSD, wrappedCollateralToken, 3600, 90000, 1 ether, "n", longSymbol); } - /// Intent: short strings (<32 chars) round-trip through StringPacking_v1 correctly. + /// Intent: short strings (<32 chars) round-trip through ERC20MetadataLib_v1 correctly. function test_name_shortString() public { StabilityPool_v3 sp_ = new StabilityPool_v3( minterFxUSD, @@ -108,24 +111,24 @@ contract TestStabilityPool_v3_ERC20 is DeployEURSetUp { assertEq(sp_.symbol(), "S", "short symbol"); } - /// Intent: 32-char strings round-trip correctly (single bytes32 boundary). - function test_name_exactly32chars() public { - string memory name32 = "12345678901234567890123456789012"; - assertEq(bytes(name32).length, 32, "sanity"); + /// Intent: 31-char strings (fits entirely in word 0 after the length prefix) round-trip. + function test_name_exactly31chars() public { + string memory name31 = "1234567890123456789012345678901"; + assertEq(bytes(name31).length, 31, "sanity"); StabilityPool_v3 sp_ = new StabilityPool_v3( minterFxUSD, wrappedCollateralToken, 3600, 90000, 1 ether, - name32, + name31, "S" ); - assertEq(sp_.name(), name32, "32-char name"); + assertEq(sp_.name(), name31, "31-char name"); } - /// Intent: 33-64 char strings (need 2 bytes32 slots) round-trip correctly. - function test_name_between32and64chars() public { + /// Intent: 32..63 char strings (spill into word 1) round-trip correctly. + function test_name_between31and63chars() public { string memory name40 = "1234567890123456789012345678901234567890"; assertEq(bytes(name40).length, 40, "sanity"); StabilityPool_v3 sp_ = new StabilityPool_v3( @@ -140,20 +143,20 @@ contract TestStabilityPool_v3_ERC20 is DeployEURSetUp { assertEq(sp_.name(), name40, "40-char name"); } - /// Intent: 64-char strings (max length) round-trip correctly. - function test_name_exactly64chars() public { - string memory name64 = "1234567890123456789012345678901234567890123456789012345678901234"; - assertEq(bytes(name64).length, 64, "sanity"); + /// Intent: 63-char strings (max length) round-trip correctly. + function test_name_exactly63chars() public { + string memory name63 = "123456789012345678901234567890123456789012345678901234567890123"; + assertEq(bytes(name63).length, 63, "sanity"); StabilityPool_v3 sp_ = new StabilityPool_v3( minterFxUSD, wrappedCollateralToken, 3600, 90000, 1 ether, - name64, + name63, "S" ); - assertEq(sp_.name(), name64, "64-char name"); + assertEq(sp_.name(), name63, "63-char name"); } // ═══════════════════════════════════════════════════════════════════════ diff --git a/test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v2.sol b/test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v2.sol index a809be5a..7cdb833f 100644 --- a/test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v2.sol +++ b/test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v2.sol @@ -5,7 +5,7 @@ pragma solidity >=0.8.28 <0.9.0; // import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; -import {MultipleRewardCompoundingAccumulator as MultipleRewardCompoundingAccumulator_v2} from "src/reward/accumulator/MultipleRewardCompoundingAccumulator_v2.sol"; +import {MultipleRewardCompoundingAccumulator_v2} from "src/reward/accumulator/MultipleRewardCompoundingAccumulator_v2.sol"; contract MockMultipleRewardCompoundingAccumulator_v2 is Initializable, MultipleRewardCompoundingAccumulator_v2 { event AccumulateReward(address token, uint256 amount); diff --git a/test/mocks/reward/distributor/MockLinearMultipleRewardDistributor_v2.sol b/test/mocks/reward/distributor/MockLinearMultipleRewardDistributor_v2.sol index b6d9587b..c2ad498d 100644 --- a/test/mocks/reward/distributor/MockLinearMultipleRewardDistributor_v2.sol +++ b/test/mocks/reward/distributor/MockLinearMultipleRewardDistributor_v2.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; -import {LinearMultipleRewardDistributor as LinearMultipleRewardDistributor_v2} from "src/reward/distributor/LinearMultipleRewardDistributor_v2.sol"; +import {LinearMultipleRewardDistributor_v2} from "src/reward/distributor/LinearMultipleRewardDistributor_v2.sol"; import {LinearReward} from "src/reward/distributor/LinearReward.sol"; contract MockLinearMultipleRewardDistributor_v2 is LinearMultipleRewardDistributor_v2 { From 8d726f9d60c6df7ba5f768143409eb2c26ed8e77 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Mon, 13 Apr 2026 16:47:29 +0100 Subject: [PATCH 034/232] update autocompounding vault design doc to match shipped state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite §5 HarborYield to match the shipped contract: one vault per asset, proportional redeem, role-gated compound(fromVault,toVault,…) and redistribute(), no oracle dependency. Delete the reward-alias subsection (feature removed). Add §6.12 explaining why HY is neither ERC-4626 nor ERC-7575, §6.13 peg verification (pegId + swapper drift check), and §6.14 ERC-20 permit adoption. Refresh access-control and contracts tables to current roles and statuses. Co-Authored-By: Claude Opus 4.6 (1M context) --- doc/ideas/autocompounding-vault-design.md | 302 ++++++++++++---------- 1 file changed, 170 insertions(+), 132 deletions(-) diff --git a/doc/ideas/autocompounding-vault-design.md b/doc/ideas/autocompounding-vault-design.md index 15c54167..8197cd07 100644 --- a/doc/ideas/autocompounding-vault-design.md +++ b/doc/ideas/autocompounding-vault-design.md @@ -12,11 +12,11 @@ | **hpXXX.hsCOLn** | Rebasing SP token -- leveraged pool | hpUSD.hsstETH | | **hcXXX.COLn** | Auto-compounder share -- collateral pool | hcUSD.stETH | | **hcXXX.hsCOLn** | Auto-compounder share -- leveraged pool | hcUSD.hsstETH | -| **hyXXX** | Peg Vault share | hyUSD | +| **hyXXX** | Peg Vault share (HarborYield) | hyUSD | | **wXXXn** | Interest-bearing equivalent for peg XXX | fxSAVE (wUSD1) | | **SP** | Stability Pool | | | **AC** | Auto-Compounder (Level 1 ERC4626) | | -| **PV** | Peg Vault (Level 2 ERC4626/ERC-7575) | | +| **HY** | HarborYield — Peg Vault (Level 2 multi-asset ERC-20 basket) | | ## 2. Architecture Overview @@ -33,30 +33,30 @@ graph TD subgraph "Level 1: Auto-Compounders (one per SP)" AC_COL1["AC hcUSD.stETH
(non-rebasing ERC4626)"] AC_COL2["AC hcUSD.fxUSD
(non-rebasing ERC4626)"] - AC_LEV1["AC hcUSD.hsstETH
(non-rebasing ERC4626)
standalone, not in PV"] + AC_LEV1["AC hcUSD.hsstETH
(non-rebasing ERC4626)
standalone, not in HY"] end - subgraph "Level 2: Peg Vault" - PV["PV hyUSD
(ERC4626 / ERC-7575)
holds: AC shares + wXXXn"] + subgraph "Level 2: HarborYield (one per peg)" + HY["HY hyUSD
(custom multi-asset basket ERC-20)
holds: AC shares + wXXXn adapters"] end User_L0["User: full control"] -->|"deposit haUSD"| SP_COL1 User_L1["User: auto-compound"] -->|"deposit hpUSD.stETH"| AC_COL1 - User_L2["User: pooled + equivalents"] -->|"deposit haUSD / hpUSD.COLn / wCOLn / wXXXn"| PV + User_L2["User: pooled + equivalents"] -->|"deposit hcUSD.COLn / wXXXn-vault shares"| HY AC_COL1 --> SP_COL1 AC_COL2 --> SP_COL2 AC_LEV1 --> SP_LEV1 - PV -->|"holds hcUSD.stETH"| AC_COL1 - PV -->|"holds hcUSD.fxUSD"| AC_COL2 - PV -->|"holds wXXXn directly"| wXXXn_pool["wXXXn (e.g. fxSAVE)"] + HY -->|"holds hcUSD.stETH"| AC_COL1 + HY -->|"holds hcUSD.fxUSD"| AC_COL2 + HY -->|"holds wXXXn-vault shares"| wXXXn_pool["wXXXn wrapper (ERC4626)"] ``` **Level 0 -- Raw SP:** User chooses collateral type, manages claims manually. Rebasing ERC20. Full control. **Level 1 -- Auto-Compounder (AC):** User chooses collateral type, gets autocompounding. Non-rebasing ERC4626 (fixed share count, moving price -- same as stETH/wstETH). Losses and rewards within one SP only. Available for both collateral and leveraged SPs. -**Level 2 -- Peg Vault (PV):** User gives up collateral choice. Losses socialised across all collateral SPs. Holds AC shares + equivalent tokens (wXXXn). ERC-7575 multi-asset entry. Leveraged SPs NOT included (rebalance into hsXXX.COLn which is not liquid). +**Level 2 -- HarborYield (HY):** User gives up collateral choice. Losses socialised across all managed vaults. One HY per peg. Holds one or more ERC4626 vault positions (ACs + equivalent-token wrappers). Custom multi-asset ERC-20 share (hyXXX) — *not* ERC-4626 and *not* ERC-7575 (both single-asset redeem semantics conflict with HY's proportional-redeem fairness invariant). Exposes ERC-4626-style *views* priced in peg units for interop. Leveraged SPs NOT included (rebalance into hsXXX.COLn which is not liquid). ## 3. Level 0: Raw Stability Pool @@ -200,138 +200,150 @@ Standard ERC4626. `totalAssets()` includes all value (SP position + unclaimed qu ### No equivalents at AC level -The AC does NOT convert wCOLn to wXXXn. It either mints haXXX from wCOLn or leaves it unclaimed in the SP. No value transfers out of the AC. wXXXn equivalents exist only at the PV level (from direct user deposits). This resolves the fairness concern from the earlier options analysis -- no cross-subsidy between layers. +The AC does NOT convert wCOLn to wXXXn. It either mints haXXX from wCOLn or leaves it unclaimed in the SP. No value transfers out of the AC. wXXXn equivalents exist only at the HY level (from direct user deposits of the wXXXn wrapper). This resolves the fairness concern from the earlier options analysis -- no cross-subsidy between layers. ### Deposit convenience Core asset is hpXXX.COLn. Also accepts haXXX via `depositPeggedToken(amount, receiver)` which atomically deposits to SP then mints AC shares. Supports `type(uint256).max` for full balance. -## 5. Level 2: Peg Vault +## 5. Level 2: HarborYield (Peg Vault) ### What it does -Combines all collateral AC shares for a peg + equivalent tokens (wXXXn) into a single hyXXX share. ERC-7575: multiple entry assets, one share token. +One HarborYield per peg (e.g., hyUSD). Manages multiple ERC4626 vaults — one per asset — that share the same peg. Typical managed vaults for a single peg: -### Deposit flows +- `hcXXX.stETH` (AutoCompounder for stETH collateral SP) +- `hcXXX.fxUSD` (AutoCompounder for fxUSD collateral SP) +- `wXXXn` via a thin ERC4626 wrapper (e.g., fxSAVE adapter) -```mermaid -sequenceDiagram - participant User - participant PV as Peg Vault - participant AC as Auto-Compounder - participant SP as Stability Pool - participant Minter +Users deposit the *vault's asset* (e.g., hpXXX.stETH, hpXXX.fxUSD, or the wXXXn wrapper's asset), mint hyXXX shares at the current exchange rate, and later redeem for a proportional mix of every managed vault's holdings. Losses and rewards are socialised across all hyXXX holders. - alt deposit hpXXX.COLn - User->>PV: deposit(hpXXX.COLn, amount) - PV->>AC: deposit(hpXXX.COLn, amount) - AC-->>PV: hcXXX.COLn shares - PV-->>User: hyXXX shares - end +### Architecture invariants - alt deposit haXXX - User->>PV: deposit(haXXX, amount) - PV->>SP: deposit(haXXX, PV) - SP-->>PV: hpXXX.COLn - PV->>AC: deposit(hpXXX.COLn) - AC-->>PV: hcXXX.COLn shares - PV-->>User: hyXXX shares - end +- **One vault per asset** (enforced by an internal asset→vault index). Deposit routing is deterministic from the asset address. +- **Every managed vault is ERC-4626.** Non-ERC4626 yield sources are wrapped in thin 4626 adapters before being added. +- **No internal balance tracking.** HY reads `IERC20(vault).balanceOf(HY)` and `IERC4626(vault).convertToAssets(...)` each time; the vault is the source of truth. +- **No oracle.** All managed assets are assumed 1:1 pegged. The `ISwapper` and `IMinter_v3.mintPeggedTokenDryRun` serve as the only valuation primitives where needed. +- **Proportional redemption.** hyXXX redeem pays out a pro-rata slice of *every* managed vault — no single-asset redeem path. This is the central fairness invariant; it's why HY is not ERC-7575 (7575 per-asset redeem would let a user drain the best-performing component). +- **Upgradeable via UUPS**, HarborOwnableRoles, share-token name/symbol stored as constructor immutables via `StringPacking_v1`. - alt deposit wCOLn (wrapped collateral) - User->>PV: deposit(wCOLn, amount) - PV->>Minter: mintPeggedToken(wCOLn) - Minter-->>PV: haXXX - PV->>SP: deposit(haXXX, PV) - SP-->>PV: hpXXX.COLn - PV->>AC: deposit(hpXXX.COLn) - AC-->>PV: hcXXX.COLn shares - PV-->>User: hyXXX shares - end +### Deposit flow - alt deposit wXXXn (equivalent) - User->>PV: deposit(wXXXn, amount) - Note over PV: PV holds wXXXn directly,
priced via oracle - PV-->>User: hyXXX shares - end +```mermaid +sequenceDiagram + participant User + participant HY as HarborYield + participant Vault as ERC4626 vault
(AC or wrapper) + + Note over User,Vault: User deposits an asset mapped to a registered vault. + + User->>HY: deposit(asset, amount, receiver) + HY->>HY: look up vault for asset (revert if none / inactive) + HY->>HY: snapshot (assetsBefore, supplyBefore) + User->>HY: safeTransferFrom(user, HY, amount) + HY->>Vault: deposit(amount, HY) + Vault-->>HY: vault shares + Note over HY: shares = amount * (supplyBefore+1) / (assetsBefore+1) + HY-->>User: hyXXX shares minted ``` -### Withdrawal +Convenience paths like "mint from haXXX" or "mint from wCOLn" are **not** exposed on HY. Users who want to enter from a raw asset call the Minter → SP → AC path off-chain (or via a router contract), then deposit the resulting AC shares' underlying asset (hpXXX.COLn) into HY. -User receives proportional mix of all PV holdings: hpXXX.COLn (via AC redeem) for each collateral + wXXXn. +### Redeem flow ```mermaid sequenceDiagram participant User - participant PV as Peg Vault - participant AC1 as AC (COL1) - participant AC2 as AC (COL2) - participant SP1 as SP (COL1) - participant SP2 as SP (COL2) - - User->>PV: redeem(hyShares, user, user) - - Note over PV: For each AC, redeem proportional hcXXX.COLn shares - - PV->>AC1: redeem(hcAmount1, user, PV) - AC1->>SP1: transfer(user, hpAmount1) - SP1-->>User: hpXXX.COL1 - - PV->>AC2: redeem(hcAmount2, user, PV) - AC2->>SP2: transfer(user, hpAmount2) - SP2-->>User: hpXXX.COL2 - - Note over PV: Transfer proportional wXXXn directly - - PV-->>User: wXXXn (proportional share) - - Note over PV: Burn hyXXX shares - PV-->>User: Withdrawal complete:
hpXXX.COL1 + hpXXX.COL2 + wXXXn + participant HY as HarborYield + participant V1 as Vault 1 (e.g. AC_COL1) + participant V2 as Vault 2 (e.g. AC_COL2) + participant V3 as Vault 3 (e.g. wXXXn wrapper) + + User->>HY: redeem(shares, receiver, owner) + HY->>HY: spend allowance if caller != owner + HY->>HY: supply = totalSupply() + HY->>HY: burn(owner, shares) + + loop for each managed vault + HY->>V1: redeem(vaultShares * shares / supply, receiver, HY) + V1-->>User: vault's underlying asset + end + HY-->>User: proportional basket delivered ``` -### Compound +### Compound (equivalent → AC via swapper) + +HY's `compound()` is *not* a "compound each AC" loop — the ACs compound themselves (permissionless `AC.compound()`, also triggered by SPM harvest/rebalance). HY's `compound()` is a narrower operation: convert holdings from one managed vault into another, typically to route equivalent-token yield into the AC layer. ```mermaid sequenceDiagram - participant Bot as Compound caller - participant PV as Peg Vault - participant AC as Auto-Compounder (each) - participant Swapper as ISwapper - participant Minter - participant SP as Stability Pool + participant Keeper as Keeper (COMPOUNDER_ROLE) + participant HY as HarborYield + participant Src as fromVault (e.g. wXXXn wrapper) + participant Swap as ISwapper + participant Dst as toVault (e.g. AC_COLn) + + Keeper->>HY: compound(fromVault, toVault, vaultShareAmount, minOut, swapData) + HY->>Src: redeem(vaultShareAmount, HY, HY) + Src-->>HY: fromAsset amount + alt fromAsset != toAsset + HY->>Swap: swap(fromAsset, toAsset, amount, minOut, swapData) + Swap-->>HY: toAsset amount + else same asset + Note over HY: pass-through, no swap + end + HY->>Dst: deposit(amount, HY) + Dst-->>HY: toVault shares + Note over HY: emit Compounded(caller, fromVault, toVault, amountIn, amountOut) +``` - Bot->>PV: compound() +### Redistribute (rebalance toward target weights) - loop for each AC - PV->>AC: compound() - Note over AC: Claims profitable wCOLn,
mints haXXX, redeposits - end +Each managed vault has an arbitrary-unit `weight`. HY caches `totalWeight = SUM(weight)`. `redistribute()` finds the most over-weight vault (largest `currentValue − targetValue`) and the most under-weight vault, then moves `min(excess, deficit)` from source to target. - alt PV holds wXXXn and fees acceptable - PV->>Swapper: swap(wXXXn, wCOLn) - Swapper-->>PV: wCOLn - PV->>Minter: mintPeggedToken(wCOLn, maxFeeRatio) - Minter-->>PV: haXXX - PV->>SP: deposit(haXXX, PV) - SP-->>PV: hpXXX.COLn - PV->>AC: deposit(hpXXX.COLn) - Note over PV: wXXXn balance dropped,
AC shares increased - else fees too high - Note over PV: wXXXn stays, valued in totalAssets +```mermaid +sequenceDiagram + participant Keeper as Keeper (REDISTRIBUTOR_ROLE) + participant HY as HarborYield + participant Src as over-weight vault + participant Swap as ISwapper + participant Dst as under-weight vault + + Keeper->>HY: redistribute(maxSharesPerVault, minOut, swapData) + HY->>HY: compute target per vault = totalAssets * weight / totalWeight + HY->>HY: pick src (max excess) and dst (max deficit) + HY->>HY: moveValue = min(excess, deficit) + HY->>Src: redeem(min(convertToShares(moveValue), maxSharesPerVault), HY, HY) + Src-->>HY: srcAsset amount + opt src.asset != dst.asset + HY->>Swap: swap(srcAsset, dstAsset, amount, minOut, swapData) + Swap-->>HY: dstAsset amount end + HY->>Dst: deposit(amount, HY) + Note over HY: emit Redistributed(caller, src, dst, amountIn, amountOut) ``` +Reverts with `NothingToRedistribute` when `totalAssets == 0`, `totalWeight == 0`, or the basket is already exactly on target. + ### Share accounting ``` totalAssets() = - SUM( AC.convertToAssets(PV's hcXXX.COLn shares) ) // includes unclaimed queue - + SUM( wXXXn.balanceOf(PV) * oracle_price ) // direct equivalent holdings + SUM over managed vaults of IERC4626(vault).convertToAssets(IERC20(vault).balanceOf(HY)) ``` +No oracle. All components are assumed 1:1 pegged — see §6.13 (Peg Verification) for how that assumption is defended. + ### Fairness -Same ERC4626 accounting over a portfolio. Collateral SP rebalances don't cause loss (wCOLn offsets haXXX). wXXXn deposits priced at oracle value, socialised across all hyXXX holders. +- **Proportional redeem** prevents single-asset cherry-picking. +- **Weight-driven rebalance** keeps the basket close to governance targets without ad-hoc moves. +- **No dilution on deposit:** shares are priced at the *pre-deposit* exchange rate (`shares = amount * (supply+1) / (totalAssets+1)`), so new depositors can't claim a slice of existing pending yield. +- **Collateral SP rebalances are absorbed at the AC layer** (wCOLn offsets lost haXXX). HY sees a roughly unchanged per-vault value through a rebalance. + +### ERC-4626 compatibility (planned — view shim) + +HY will expose ERC-4626-style *views* priced in peg units — `asset()` returning the peg token (haXXX), plus `totalAssets`, `convertToShares/Assets`, `previewDeposit/Redeem` — to give aggregators and portfolio tools enough to value hyXXX. The mutation surface remains HY's own (`deposit(asset,…)`, `redeem`, `compound`, `redistribute`). See plan §B.4.2. ## 6. Design Decisions @@ -349,7 +361,7 @@ Unlike leveraged SPs, collateral SP rebalance returns liquid wCOLn. The AC's tot ### 6.4 Leveraged SPs standalone -Leveraged SPs rebalance into hsXXX.COLn which is not liquid. Leveraged AC only compounds harvest wCOLn. Not included in PV (different risk profile). +Leveraged SPs rebalance into hsXXX.COLn which is not liquid. Leveraged AC only compounds harvest wCOLn. Not included in HY (different risk profile). ### 6.5 Minting: maxFeeRatio @@ -357,60 +369,86 @@ Leveraged SPs rebalance into hsXXX.COLn which is not liquid. Leveraged AC only c ### 6.6 Unified Claim with Fractional Support -`claim(account, receiver, token, maxAmount)` on SP_v3 (via `IMultipleRewardAccumulator_v3`). Claims up to maxAmount from token (draining aliases in registration order first), leaves rest as pending. `token == address(0)` claims all active tokens. Array overload `claim(account, receiver, tokens[], maxAmount)` for batch/historical claims. +`claim(account, receiver, token, maxAmount)` on SP_v3 (via `IMultipleRewardAccumulator_v3`). Claims up to maxAmount from the token, leaves rest as pending. `token == address(0)` claims all active tokens. Array overload `claim(account, receiver, tokens[], maxAmount)` for batch/historical claims. -### 6.7 Reward Alias Registration +### 6.7 Oracle Coupling -Reward tokens are registered with an explicit ordered list of aliases: -```solidity -registerRewardToken(underlying, [harvestAlias, rebalanceAlias]) -``` +AC reads price and rate from `IMinter_v3(minter).mintPeggedTokenDryRun()` — always in sync with the Minter, no direct oracle dependency. HY has no oracle dependency at all (see §5 share accounting and §6.13 peg verification). + +### 6.8 Equivalent Token Management + +Equivalent yield sources (wXXXn) are held at the HY level only — never inside an AC (see §6.9). Each equivalent is registered as a managed ERC4626 vault; non-ERC4626 tokens are wrapped in a thin 4626 adapter first. There is no preference list and no internal bookkeeping: holdings are whatever `balanceOf(HY)` returns, and weights drive the rebalance target. Value can be routed back into AC positions via `HY.compound(fromVault, toVault, …)` which calls `ISwapper` to cross assets and deposits into the destination ERC4626. + +### 6.9 No Equivalents in AC + +The AC does NOT hold wXXXn. Unprofitable wCOLn stays as unclaimed rewards in the SP, valued in `totalAssets` via `claimable()`. This avoids the cross-subsidy fairness issue identified in the options analysis. + +### 6.10 Compound Trigger + +Two distinct "compound" operations live at different layers: + +- **AC.compound()** — permissionless. Claims profitable wCOLn, mints haXXX via the Minter, redeposits to the SP. Also triggered by SPM during harvest/rebalance (B.5, pending). +- **HY.compound(fromVault, toVault, vaultShares, minOut, swapData)** — role-gated (`COMPOUNDER_ROLE | owner`). Redeems from one managed vault, swaps via `ISwapper`, deposits into another managed vault. Used to route equivalent-token yield into the AC layer when profitable. + +### 6.11 Withdrawal + +AC uses EXEMPT_WITHDRAWAL_FEE_ROLE initially. Dynamic fees (CR-based) replace withdrawal delay in future SP version, enabling standard ERC4626 `withdraw` (plan B.6b). -Each alias must implement `IRewardAlias.underlying()` returning the underlying token address, validated at registration time. The `aliasToUnderlying` mapping (populated at registration) is used for token transfers — no runtime external calls. +### 6.12 HarborYield is not ERC-4626 / ERC-7575 -Claiming from an underlying drains its aliases in registration order first, then the underlying's own pending. Claiming from an alias drains only that alias. +HY's mutation surface is intentionally non-standard: -Unregistering an underlying also unregisters its aliases and cleans up the alias mappings. +- **ERC-4626** is single-asset (`asset()` returns one address, `deposit`/`redeem` transact in that asset). HY holds multiple assets by design. +- **ERC-7575** (multi-asset vaults with one share token) uses per-asset redeem semantics — each asset has its own ERC-4626 entry contract. That directly breaks HY's proportional-redeem fairness invariant: a user could redeem entirely through the highest-yielding component and leave the rest of hyXXX holders with a worse basket. -### 6.8 Oracle Coupling +HY will instead expose ERC-4626-style *views* priced in peg units (`asset()`, `totalAssets`, `convertTo*`, `preview*`) for interop with aggregators, indexers, and price feeds. The mutation API stays HY-specific (`deposit(asset, amount, receiver)`, `redeem(shares, receiver, owner)`, `compound`, `redistribute`). See plan B.4.2 for the exact view surface. -AC reads price and rate from `IMinter_v3(minter).mintPeggedTokenDryRun()` -- always in sync with the Minter, no direct oracle dependency. PV uses the same approach. No separate oracle config. +### 6.13 Peg Verification -### 6.9 Equivalent Token Management +HY assumes every managed vault's asset is pegged to the same RWA. Two failure modes: -wXXXn held at PV level only (not in ACs). Preference-ordered list, updatable by keeper. PV converts wXXXn -> wCOLn (via ISwapper) -> haXXX (via Minter) -> SP when fees acceptable. +1. **Config error** — admin registers a vault whose asset is pegged to the wrong RWA (or not pegged at all). Catastrophic valuation error. +2. **Market depeg** — a component trades below peg transiently. New depositors are diluted and redeemers get a worse mix than market value would suggest. -### 6.10 No Equivalents in AC +Defense in depth (plan B.4.3): -The AC does NOT hold wXXXn. Unprofitable wCOLn stays as unclaimed rewards in the SP, valued in totalAssets via claimable. This avoids the cross-subsidy fairness issue identified in the options analysis. +- **Config-time pegId** — HY stores an immutable `bytes32 pegId` (e.g. `keccak256("USD")`); `addVault` requires the vault to declare the same pegId. Prevents misconfig, zero runtime cost. +- **Swapper drift check** — inside `compound`/`redistribute`, call `ISwapper.previewSwap(from, to, 1e18)` and revert if the result diverges from 1e18 by more than `maxPegDrift` (owner-tunable, default e.g. 2%). Reuses the existing dep; catches market depeg at the moment it would lock in a bad rate. +- **Watchtower + deactivateVault** — owner freezes new deposits to a vault during sustained depegs. Proportional redeems still work; users see the depeg reflected in their basket. +- **Oracle-valued totalAssets** — deferred. Only add if the above proves insufficient in production. -### 6.11 Compound Trigger +### 6.14 ERC-20 Permit (EIP-2612) -Permissionless. Also triggered by SPM during harvest/rebalance. +All new ERC-20 contracts shipped in this work will support `permit(owner, spender, value, deadline, v, r, s)` for approve-and-act in a single transaction: -### 6.12 Withdrawal +- `HarborYield_v1` — freshly added via OZ `ERC20PermitUpgradeable`. +- `AutoCompounder_v1` — freshly added via OZ `ERC20PermitUpgradeable` (ERC4626Upgradeable's underlying ERC20). +- `StabilityPool_v4` — added alongside the accumulator cleanup (Campaign A2). Permit is orthogonal to rebasing: it only signs `approve()` authorizations, so a bespoke implementation that uses namespaced (ERC7201) storage for the `nonces` mapping and rebuilds the EIP-712 domain separator at runtime is straightforward. +- `PeggedToken` / `LeveragedToken` — audit first; migrate if not already using `PermittableERC20_v1` from bao-base. -AC uses EXEMPT_WITHDRAWAL_FEE_ROLE initially. Dynamic fees (CR-based) replace withdrawal delay in future SP version, enabling standard ERC4626 withdraw. +OZ is chosen over Solady because all four contracts are UUPS upgradeable — Solady's ERC20 is built around immutables and direct storage and would require a hand-rolled upgradeable adapter (new audit surface) for a modest bytecode saving. See plan Campaign H for the full tradeoff. ## 7. Access Control | Role | On Contract | Purpose | |------|------------|---------| -| `KEEPER_ROLE` | PV | Swap execution + equivalent list ordering | -| Owner | PV, AC | Configure maxFeeRatio, swapper, upgrade | -| `EXEMPT_WITHDRAWAL_FEE_ROLE` | SP | AC withdraws without delay | -| Anyone | All | deposit, withdraw, compound | +| Owner | HY, AC | Add/weight/deactivate vaults; configure `maxFeeRatio`; UUPS upgrade; sweep | +| `COMPOUNDER_ROLE` | HY | Call `HY.compound(fromVault, toVault, …)` | +| `REDISTRIBUTOR_ROLE` | HY | Call `HY.redistribute(…)` | +| `EXEMPT_WITHDRAWAL_FEE_ROLE` | SP | AC withdraws without fee/delay | +| Anyone | SP, AC, HY (deposit/redeem), `AC.compound()` | Public entrypoints | ## 8. Contracts | Contract | Status | Purpose | |----------|--------|---------| -| StabilityPool_v3 | Done | Rebasing ERC20, unified claim, fractional claim, reward aliases, StringPacking_v1 | -| Minter_v3 | Done | mintPeggedTokenCapped, private→internal | -| AutoCompounder | To build | ERC4626 per SP (Level 1) | -| PegVault | To build | ERC4626/ERC-7575 per peg (Level 2) | -| ISwapper / MockSwapper | To build | wXXXn conversion interface | -| StabilityPoolManager_v2 | To build | Compound triggers | +| StabilityPool_v3 | Done | Rebasing ERC20, unified claim, fractional claim, StringPacking_v1 | +| Minter_v3 | Done | `mintPeggedToken(maxFeeRatio)`, `mintPeggedTokenDryRun`, private→internal | +| AutoCompounder_v1 | Done | Non-rebasing ERC4626 wrapper per SP (Level 1) | +| HarborYield_v1 | Done (core) | Multi-asset ERC-20 basket per peg (Level 2). `compound`/`redistribute` role-gated | +| ISwapper / MockSwapper | Done | Generic swap interface; mock for tests | +| StabilityPoolManager_v2 | Pending (B.5) | SPM triggers `AC.compound()` during harvest/rebalance | +| StabilityPool_v4 | Pending (A2) | Accumulator cleanup, CR-based withdrawal fee (B.6b), ERC-20 permit (H) | ## 9. References From 2173b0b3da7d2467f7d967b00d08d8652013bc8e Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Tue, 14 Apr 2026 08:16:32 +0100 Subject: [PATCH 035/232] add harbor-deployment.md design doc Forward-looking companion to autocompounding-vault-design.md covering the peg/market/HY structure, the deployPeg/deployHY command-line switch pattern, first-market vs subsequent-market flows, seed deposit sequencing (AC_col before HY), pre-flight checklist and production vs test differences. Co-Authored-By: Claude Opus 4.6 (1M context) --- doc/harbor-deployment.md | 257 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 doc/harbor-deployment.md diff --git a/doc/harbor-deployment.md b/doc/harbor-deployment.md new file mode 100644 index 00000000..f0e7d66b --- /dev/null +++ b/doc/harbor-deployment.md @@ -0,0 +1,257 @@ +# Harbor Deployment Design + +Companion document to [`autocompounding-vault-design.md`](ideas/autocompounding-vault-design.md). + +This document describes **what we plan to implement** for the deployment flow once Campaign B (auto-compounding vaults) and Campaign H (ERC-20 permit) land. It's a forward-looking spec, not a description of the current deployer — see [`deployments/README.md`](../deployments/README.md) for the history of what's actually on-chain. + +--- + +## 1. Goals + +- **Deterministic addresses via CREATE3**: every contract can be referenced by its predicted address before deployment. +- **Incremental market addition**: a new market for a new collateral can be added to an existing peg without redeploying the peg's pegged token, HY, or any prior markets. +- **Per-vault grief protection**: every share-issuing vault (AC_col, AC_lev, HY) has a dead-share seed deposited at deploy time. +- **Fail fast**: pre-flight checks assert the deployer holds the required wCOLn before any on-chain work begins. +- **Pre-existing detection mirrored across all shared-across-markets contracts**: the pegged token, the HY, and any future peg-level shared contract all follow the same `deployXxx = true/false` command-line switch pattern. + +## 2. Deployment units + +### 2.1 Peg family + +A **peg family** is everything tied to one pegged token (e.g., `haEUR`): + +- `PeggedToken` (one per peg, shared by all markets for the peg) +- `HarborYield` (one per peg, registered against all collateral ACs for the peg) + +Peg-family contracts are deployed **once per peg**. Adding a new market to an existing peg does NOT re-deploy peg-family contracts — it references them at their predicted addresses and adds the new market's contracts as dependents. + +### 2.2 Market + +A **market** is the per-(peg, collateral) unit: + +- One `Minter` (wraps the wrapped-collateral token, mints/burns the pegged token) +- Two stability pools: `SP_col` (collateral pool, rebases into wCOLn on rebalance) and `SP_lev` (leveraged pool, rebases into the leveraged `hsXXX.COLn` token on rebalance) +- Two auto-compounders: `AC_col` wraps `SP_col`, `AC_lev` wraps `SP_lev`. Both are ERC-4626 non-rebasing share tokens. +- `Genesis`, `SPM`, per-market supporting contracts (same pattern as today) + +**Only `AC_col` is registered with the peg's HY.** `AC_lev` is standalone — leveraged SPs rebalance into an illiquid leveraged token and are intentionally not pooled with the collateral basket per the autocompounding vault design. + +### 2.3 Dependency graph + +``` + ┌────────────┐ + │ PeggedToken│ (one per peg — peg family) + └─────┬──────┘ + │ + ┌─────────────┼─────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌────────┐ ┌────────┐ ┌────────┐ + │ Minter │ │ Minter │ │ Minter │ (one per market) + │ col A │ │ col B │ │ col C │ + └───┬────┘ └───┬────┘ └───┬────┘ + │ │ │ + ┌────┼────┐ ┌────┼────┐ ┌────┼────┐ + │ │ │ │ │ │ │ │ │ + ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ + SP_ SP_ ... SP_ SP_ ... (col + lev per market) + col lev col lev + │ │ │ │ + ▼ ▼ ▼ ▼ + AC_ AC_ AC_ AC_ (col + lev per market) + col lev col lev + │ │ + └──────┬─────┘ + ▼ + ┌─────────────┐ + │ HarborYield │ (one per peg — peg family, holds AC_col only) + └─────────────┘ +``` + +## 3. Command-line switches (mirrored pattern) + +Both peg-family contracts share the same "deploy fresh vs reuse existing" switch pattern. The existing script already does this for the pegged token; we extend it to HY. + +```solidity +function deployForPeg( + string memory saltPrefix, + ConfigPeg peg, + Config_MinterMarket[] memory allMarkets, // all markets that will ever use this peg + string memory network, + bool deployPeg, // switch: deploy pegged token fresh? + bool deployHY, // NEW switch: deploy HY fresh? + Config_MinterMarket[] memory marketsToDeploy // subset being deployed this invocation +) internal; +``` + +| Flag | `true` | `false` | +|---|---|---| +| `deployPeg` | Deploy pegged token fresh, grant minter/burner roles directly | Detect at predicted address, log manual `grantRoles` TXs for the new markets | +| `deployHY` | Deploy HY fresh, call `addVault` directly, seed HY | Detect at predicted address, call or log `addVault` for the new markets' AC_col, SKIP seed | + +**Auto-detection via `code.length > 0`** is already used in `PeggedToken.deployPeggedTokenWithRoles`. The explicit command-line flag is still required (not replaced) because it documents intent and guards against accidentally using a mis-predicted address. Auto-detection acts as a sanity check on the flag. + +## 4. First-market deployment flow + +This is the deploy invocation for "new peg, one market." + +**Flags:** `deployPeg = true`, `deployHY = true`, `marketsToDeploy = [market_X]` + +``` +Pre-flight: + - Deployer holds ≥ 3×wCOL_seed of wCOL_X + - Deployer has ZERO_FEE_ROLE grantable on the about-to-be-deployed Minter + (granted by the script as part of the deploy flow) + +Deploy: + 1. Deploy PeggedToken (haXXX) + 2. Deploy Minter_X, SP_col_X, SP_lev_X, Genesis_X, SPM_X + 3. Deploy AC_col_X (wraps SP_col_X) + 4. Deploy AC_lev_X (wraps SP_lev_X) + 5. Grant: + - AC_col_X has EXEMPT_WITHDRAWAL_FEE_ROLE on SP_col_X + - AC_lev_X has EXEMPT_WITHDRAWAL_FEE_ROLE on SP_lev_X + - deployer has ZERO_FEE_ROLE on Minter_X (temporary) + - deployer approves AC_col_X, AC_lev_X, SP_col_X, HY to spend haXXX / SP_col_X + +Seed ACs (AC_col before HY): + 6. Seed AC_col_X via AC.depositPeggedToken(haAmt, 0xdead) + 7. Seed AC_lev_X via AC.depositPeggedToken(haAmt, 0xdead) + +Deploy and seed HY: + 8. Deploy HY (hyXXX) + 9. HY.addVault(AC_col_X, weight_X, isAutoCompounder=true) + 10. Seed HY via the multi-step sequence: + - Minter.freeMintPeggedToken(wCOL, deployer) → haXXX + - SP_col_X.deposit(haXXX, deployer, 0) → hpXXX + - HY.deposit(SP_col_X, hpAmt, 0xdead) + ↪ internally: HY forwards to AC_col_X.deposit; + AC_col_X mints hcXXX to HY + +Finalize: + 11. Revoke deployer's ZERO_FEE_ROLE on Minter_X + 12. Transfer ownership of all new contracts to the harbor multisig + +Post-deploy assertions: + - AC_col_X.totalSupply() > 0, AC_col_X.balanceOf(0xdead) > 0 + - AC_lev_X.totalSupply() > 0, AC_lev_X.balanceOf(0xdead) > 0 + - HY.totalSupply() > 0, HY.balanceOf(0xdead) > 0 + - HY.vaultCount() == 1 + - AC_col_X.balanceOf(address(HY)) > 0 +``` + +## 5. Additional-market deployment flow + +This is the deploy invocation for "existing peg, one new market." + +**Flags:** `deployPeg = false`, `deployHY = false`, `marketsToDeploy = [market_Y]`, `allMarkets = [market_X, market_Y, ...]` + +``` +Pre-flight: + - Deployer holds ≥ 2×wCOL_seed of wCOL_Y (no HY seed this time) + - PeggedToken at _predictAddress(pegKey, "pegged") has code + - HY at _predictAddress(pegKey, "harborYield") has code + - If either is missing, script fails fast: "expected pre-existing X, not found at Y" + +Deploy: + 1. SKIP PeggedToken — already exists + 2. Deploy Minter_Y, SP_col_Y, SP_lev_Y, Genesis_Y, SPM_Y + 3. Deploy AC_col_Y, AC_lev_Y + 4. Grant EXEMPT_WITHDRAWAL_FEE_ROLE on each SP to its corresponding AC + Grant deployer ZERO_FEE_ROLE on Minter_Y + 5. If PeggedToken ownership is on the multisig: + - LOG manual grantRoles TX for Minter_Y (minter + burner on PeggedToken) + - (Mirrors the existing `_logManualRoleGrant` pattern) + Otherwise: + - Call grantRoles directly (deployer still has ownership) + +Seed ACs (both for the new market): + 6. Seed AC_col_Y, AC_lev_Y via depositPeggedToken to 0xdead + +Add to existing HY (no HY re-seed): + 7. If HY ownership is on the multisig: + - LOG manual HY.addVault TX for AC_col_Y + Otherwise: + - Call HY.addVault(AC_col_Y, weight_Y, true) directly + +Finalize: + 8. Revoke deployer's ZERO_FEE_ROLE on Minter_Y + 9. Transfer ownership of new contracts + +Post-deploy assertions: + - AC_col_Y / AC_lev_Y seeded (new-market invariants) + - HY.totalSupply() unchanged from pre-deploy (no new seed) + - HY.vaultCount() incremented by 1 (if addVault called directly) + OR: manual TX list emitted for multisig to execute +``` + +## 6. Multi-market single-invocation flow + +The `marketsToDeploy` parameter already allows deploying multiple markets in one invocation. Seeding extends naturally: + +``` +For each market in marketsToDeploy: + - Deploy market's Minter/SP/AC pair + - Seed market's AC_col and AC_lev + - If first market for this peg AND deployHY = true: + - Deploy HY + - Seed HY via this market's SP_col + - addVault for this market's AC_col + - Else: + - addVault for this market's AC_col on the existing/just-deployed HY +``` + +Pre-flight wCOLn tally: +``` +required_per_market = 2 × wCOL_seed (AC_col + AC_lev) +required_hy_seed = 1 × wCOL_seed (if deployHY = true, first market only) + +total_required[collateral] = required_per_market × markets_using_that_collateral + + (required_hy_seed if this collateral is the first market's collateral for a new-peg deploy) +``` + +## 7. Seed mechanics + +Covered in detail in the plan at Campaign H.4.1. Summary: + +- **Seed size**: `~1e12` wei of wCOLn per seed. Dust at mainnet prices. +- **Recipient**: `address(0xdead)` for all seeds (not `address(0)` — solidity semantics differ for some tokens). +- **Rationale**: closes the first-depositor griefing window (solady's virtual shares defaults already prevent the profit-stealing flavor of the inflation attack). The seed also sanity-checks the full deposit path at deploy time, catching any wiring error before a real user transacts. +- **Ordering**: AC_col must seed before HY, so AC_col has its own independent dead-share floor rather than inheriting protection from HY's pass-through. + +## 8. Pre-flight checklist + +Before the script begins any on-chain work, it asserts: + +1. **Deployer wCOLn holdings**: per the tally formula above. +2. **Salt prefix uniqueness**: no existing contract at the predicted salt for contracts being freshly deployed. +3. **Pre-existing contract verification**: if `deployPeg = false`, `_predictAddress(peg, "pegged").code.length > 0`. Same for HY if `deployHY = false`. +4. **Role prerequisites**: the deployer can be granted `ZERO_FEE_ROLE` on the about-to-be-deployed Minters (which is always true because the deployer owns them at deploy time). +5. **Configured markets match peg**: every market in `marketsToDeploy` and `allMarkets` has `peg == pegKey` (existing check). + +Failing any pre-flight check reverts the entire deploy before any on-chain transactions. + +## 9. Production vs test + +| | Production (mainnet) | Test (fork, forge test) | +|---|---|---| +| Deployer | Multisig / deployer EOA | `address(this)` in the test | +| wCOLn funding | Pre-funded before deploy script runs | `deal()` cheat in test harness | +| Free-mint role | Granted and revoked by script | Granted via `vm.prank(HARBOR_MULTISIG)` in setup | +| Ownership transfer | Deployer → multisig, standard handoff | Left with `address(this)` for test assertions | +| Pre-existing detection | Auto-detect + explicit flag both checked | Explicit flag only (tests don't simulate prior deployments in-place) | +| Manual TX logging | Written to a console log the multisig executes | Captured as a list and asserted in tests | + +## 10. Open questions + +- **Seed size is per-market**, but different collaterals have wildly different decimals (wBTC is 8, wstETH is 18). Should `wCOL_seed` be `1e12` universally or `10^(decimals / 2)` per collateral? Preference: universal `1e12` and document the min-decimal handling if any underflow issues appear. +- **Weight choice for `HY.addVault`** when adding a new market to an existing HY: use the market's config value (if set) or fall back to a default (e.g., equal weight). Currently undefined — resolve during Campaign B.4.1e implementation. +- **Leveraged AC weight for HY**: N/A — AC_lev is not registered with HY by design. Document this explicitly in the first-market-for-peg deploy log. +- **Seed during upgrade**: not applicable here — an upgrade preserves existing storage so the seed from the original deploy is still there. No action needed on upgrades. + +## 11. References + +- Plan: [`quirky-booping-valley.md`](../../.claude/plans/quirky-booping-valley.md) §H.4.1 for seed mechanics +- Design: [`autocompounding-vault-design.md`](ideas/autocompounding-vault-design.md) for contract architecture +- Existing impl: [`script/src/DeployMintersShared.sol`](../script/src/DeployMintersShared.sol), [`script/src/contracts/PeggedToken.sol`](../script/src/contracts/PeggedToken.sol), [`script/src/contracts/HarborYield.sol`](../script/src/contracts/HarborYield.sol) +- Deployment history: [`deployments/README.md`](../deployments/README.md) From 7f57c2701c6776764cec09fe03a41a5eca6a48cc Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Tue, 14 Apr 2026 09:43:33 +0100 Subject: [PATCH 036/232] HarborYield: add ERC-4626 view shim, repack ManagedVault into one slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add asset() returning a new _PEG_TOKEN immutable; expose convertToShares, convertToAssets, previewDeposit, previewRedeem matching HY's internal (supply + 1) / (assets + 1) formula. Interop only — the mutation surface stays multi-asset and non-standard. - Drop the cached asset field from ManagedVault and compute via IERC4626(vault).asset() in vaultAt and redistribute, matching what compound() already does. - Pack ManagedVault into one storage slot by narrowing weight from uint96 to uint64. New layout: vault (20) + weight (8) + active (1) + isAutoCompounder (1) = 30 bytes. Saves one slot per vault forever. totalWeight stays uint256, so the cumulative cap is unaffected. - Promote the inline MockERC4626Vault test helper to test/mocks/ for reuse by future scenario tests. - 8 new tests for the view shim alongside the existing 28 (36 total). Co-Authored-By: Claude Opus 4.6 (1M context) --- script/src/contracts/HarborYield.sol | 12 ++- src/autocompounding/HarborYield_v1.sol | 96 +++++++++++++++----- src/interfaces/IHarborYield.sol | 33 ++++++- test/autocompounding/HarborYield.t.sol | 121 ++++++++++++++++++++----- test/mocks/MockERC4626Vault.sol | 22 +++++ 5 files changed, 229 insertions(+), 55 deletions(-) create mode 100644 test/mocks/MockERC4626Vault.sol diff --git a/script/src/contracts/HarborYield.sol b/script/src/contracts/HarborYield.sol index 1ad6810b..8a3d2e4f 100644 --- a/script/src/contracts/HarborYield.sol +++ b/script/src/contracts/HarborYield.sol @@ -29,11 +29,13 @@ abstract contract HarborYield is HarborFactoryDeployer { string memory tokenName = names.harborYieldName(); string memory tokenSymbol = names.harborYieldSymbol(); address swapper = _predictAddressFromFullSalt("harbor_v1::swapper"); + address pegToken = _predictAddress(_key(pegConfig.key(), "pegged")); - impl = address(new HarborYield_v1(tokenName, tokenSymbol, swapper)); - console.log(" Impl: %s", impl); - console.log(" Name: %s", tokenName); - console.log(" Symbol: %s", tokenSymbol); + impl = address(new HarborYield_v1(tokenName, tokenSymbol, swapper, pegToken)); + console.log(" Impl: %s", impl); + console.log(" Name: %s", tokenName); + console.log(" Symbol: %s", tokenSymbol); + console.log(" Asset: %s", pegToken); _recordImplementation( stateData, @@ -60,7 +62,7 @@ abstract contract HarborYield is HarborFactoryDeployer { /// @notice Vault registration config. struct VaultConfig { address vault; // ERC4626 vault address - uint96 weight; // target distribution weight + uint64 weight; // target distribution weight bool isAutoCompounder; // true if vault implements IAutoCompounder } diff --git a/src/autocompounding/HarborYield_v1.sol b/src/autocompounding/HarborYield_v1.sol index 58702083..f6d2a5a5 100644 --- a/src/autocompounding/HarborYield_v1.sol +++ b/src/autocompounding/HarborYield_v1.sol @@ -77,6 +77,12 @@ contract HarborYield_v1 is /// @custom:oz-upgrades-unsafe-allow state-variable-immutable address public immutable SWAPPER; // solhint-disable-line immutable-vars-naming + /// @notice The peg token (e.g. haEUR) that values the HarborYield share in peg units. + /// HY is not ERC-4626 — it holds multiple assets — but `asset()` returns this token + /// for interop with aggregators and price feeds. + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + address private immutable _PEG_TOKEN; + /*////////////////////////////////////////////////////////////////////////// STORAGE (ERC7201) //////////////////////////////////////////////////////////////////////////*/ @@ -87,8 +93,7 @@ contract HarborYield_v1 is struct ManagedVault { address vault; // ERC4626 vault (AutoCompounder, wstETH wrapper, fxSAVE wrapper, etc.) - address asset; // the vault's underlying asset - uint96 weight; // target distribution weight (arbitrary units, not BPS) + uint64 weight; // target distribution weight (arbitrary units) — packs into slot with vault + bools bool active; // accepts new deposits bool isAutoCompounder; // true if vault implements IAutoCompounder } @@ -111,13 +116,16 @@ contract HarborYield_v1 is //////////////////////////////////////////////////////////////////////////*/ /// @custom:oz-upgrades-unsafe-allow constructor - constructor(string memory name_, string memory symbol_, address swapper_) { + constructor(string memory name_, string memory symbol_, address swapper_, address pegToken_) { _disableInitializers(); (_ERC20_NAME_0, _ERC20_NAME_1) = ERC20MetadataLib_v1.packName(name_); _ERC20_SYMBOL = ERC20MetadataLib_v1.packSymbol(symbol_); Token.ensureNonZeroAddress(swapper_); + Token.ensureNonZeroAddress(pegToken_); // slither-disable-next-line missing-zero-check SWAPPER = swapper_; + // slither-disable-next-line missing-zero-check + _PEG_TOKEN = pegToken_; } function initialize(address deployerOwner_, address pendingOwner_) external initializer { @@ -142,31 +150,29 @@ contract HarborYield_v1 is /// @param weight Target distribution weight (arbitrary units, must be > 0). /// @param isAutoCompounder Whether the vault implements IAutoCompounder. // slither-disable-next-line reentrancy-no-eth,reentrancy-events - function addVault(address vault, uint96 weight, bool isAutoCompounder) external onlyOwner { + function addVault(address vault, uint64 weight, bool isAutoCompounder) external onlyOwner { if (weight == 0) { revert ZeroWeight(); } Token.ensureContract(vault); - address asset = IERC4626(vault).asset(); + address vaultAsset = IERC4626(vault).asset(); HarborYieldStorage storage $ = _getHarborYieldStorage(); - if ($.assetToVaultIndex[asset] != 0) { + if ($.assetToVaultIndex[vaultAsset] != 0) { revert VaultAlreadyRegistered(vault); } - $.vaults.push( - ManagedVault({vault: vault, asset: asset, weight: weight, active: true, isAutoCompounder: isAutoCompounder}) - ); - $.assetToVaultIndex[asset] = $.vaults.length; // 1-indexed + $.vaults.push(ManagedVault({vault: vault, weight: weight, active: true, isAutoCompounder: isAutoCompounder})); + $.assetToVaultIndex[vaultAsset] = $.vaults.length; // 1-indexed $.totalWeight += weight; - IERC20(asset).forceApprove(vault, type(uint256).max); + IERC20(vaultAsset).forceApprove(vault, type(uint256).max); - emit VaultAdded(vault, asset, weight); + emit VaultAdded(vault, vaultAsset, weight); } /// @notice Update a vault's target weight. Set to 0 to drain via redistribution. - function setVaultWeight(address vault, uint96 weight) external onlyOwner { + function setVaultWeight(address vault, uint64 weight) external onlyOwner { HarborYieldStorage storage $ = _getHarborYieldStorage(); for (uint256 i = 0; i < $.vaults.length; i++) { if ($.vaults[i].vault == vault) { @@ -222,9 +228,19 @@ contract HarborYield_v1 is } /*////////////////////////////////////////////////////////////////////////// - CORE: TOTAL ASSETS + ERC-4626 VIEW SHIM //////////////////////////////////////////////////////////////////////////*/ + /// @notice The peg token that values HarborYield shares. + /// @dev HarborYield is not a standard ERC-4626 vault (it holds multiple assets with + /// proportional redemption). This view exists for interop with aggregators, portfolio + /// trackers, and price feeds that expect an ERC-4626-style `asset()` getter. The + /// mutation surface (`deposit(asset, amount, receiver)`, `redeem`) is intentionally + /// non-standard. + function asset() public view returns (address) { + return _PEG_TOKEN; + } + /// @inheritdoc IHarborYield function totalAssets() public view returns (uint256 total) { HarborYieldStorage storage $ = _getHarborYieldStorage(); @@ -239,19 +255,50 @@ contract HarborYield_v1 is } } + /// @notice Convert an assets amount (in peg units) to HarborYield share units, rounded down. + /// @dev Matches the internal formula used in `deposit`: `shares * (supply + 1) / (assets + 1)`. + /// For interop only; the actual `deposit(asset, amount, receiver)` path uses the + /// vault-specific asset, not the peg token. + function convertToShares(uint256 assets) public view returns (uint256) { + return Math.mulDiv(assets, totalSupply() + 1, totalAssets() + 1); + } + + /// @notice Convert a HarborYield share amount to assets in peg units, rounded down. + function convertToAssets(uint256 shares) public view returns (uint256) { + return Math.mulDiv(shares, totalAssets() + 1, totalSupply() + 1); + } + + /// @notice Preview the shares that would be minted by depositing `assets` peg units. + /// @dev HY has no deposit entrypoint that takes the peg token directly; this preview + /// reflects the economic conversion rate, not a concrete deposit path. + function previewDeposit(uint256 assets) public view returns (uint256) { + return convertToShares(assets); + } + + /// @notice Preview the assets (in peg units) that `shares` would redeem for at the current rate. + /// @dev HY's actual `redeem` pays out a proportional mix of every managed vault's holdings, + /// not peg tokens. This preview reflects the share price in peg units for valuation only. + function previewRedeem(uint256 shares) public view returns (uint256) { + return convertToAssets(shares); + } + /*////////////////////////////////////////////////////////////////////////// CORE: DEPOSIT //////////////////////////////////////////////////////////////////////////*/ /// @inheritdoc IHarborYield // slither-disable-next-line reentrancy-no-eth - function deposit(address asset, uint256 amount, address receiver) external nonReentrant returns (uint256 shares) { - amount = Token.allOf(msg.sender, asset, amount); + function deposit( + address asset_, + uint256 amount, + address receiver + ) external nonReentrant returns (uint256 shares) { + amount = Token.allOf(msg.sender, asset_, amount); HarborYieldStorage storage $ = _getHarborYieldStorage(); - uint256 idx = $.assetToVaultIndex[asset]; + uint256 idx = $.assetToVaultIndex[asset_]; if (idx == 0) { - revert VaultNotRegistered(asset); + revert VaultNotRegistered(asset_); } ManagedVault storage mv = $.vaults[idx - 1]; if (!mv.active) { @@ -261,7 +308,7 @@ contract HarborYield_v1 is uint256 assetsBefore = totalAssets(); uint256 supplyBefore = totalSupply(); - IERC20(asset).safeTransferFrom(msg.sender, address(this), amount); + IERC20(asset_).safeTransferFrom(msg.sender, address(this), amount); // slither-disable-next-line unused-return IERC4626(mv.vault).deposit(amount, address(this)); @@ -403,8 +450,8 @@ contract HarborYield_v1 is } // Swap if needed, deposit to target uint256 deposited = _swapIfNeeded( - $.vaults[w.sourceIdx].asset, - $.vaults[w.targetIdx].asset, + IERC4626(srcVault).asset(), + IERC4626(dstVault).asset(), w.moveValue, minAmountOut, swapData @@ -444,10 +491,13 @@ contract HarborYield_v1 is } /// @inheritdoc IHarborYield - function vaultAt(uint256 index) external view returns (address vault, address asset, bool active, uint96 weight) { + function vaultAt( + uint256 index + ) external view returns (address vault, address asset_, bool active, uint64 weight) { ManagedVault storage mv = _getHarborYieldStorage().vaults[index]; vault = mv.vault; - asset = mv.asset; + // slither-disable-next-line calls-loop + asset_ = IERC4626(mv.vault).asset(); active = mv.active; weight = mv.weight; } diff --git a/src/interfaces/IHarborYield.sol b/src/interfaces/IHarborYield.sol index ff3af786..8fcc1aef 100644 --- a/src/interfaces/IHarborYield.sol +++ b/src/interfaces/IHarborYield.sol @@ -8,10 +8,10 @@ pragma solidity >=0.8.28 <0.9.0; interface IHarborYield { // ── Events ────────────────────────────────────────────────────────── - event VaultAdded(address indexed vault, address indexed asset, uint96 weight); + event VaultAdded(address indexed vault, address indexed asset, uint64 weight); event VaultDeactivated(address indexed vault); event VaultActivated(address indexed vault); - event VaultWeightUpdated(address indexed vault, uint96 weight); + event VaultWeightUpdated(address indexed vault, uint64 weight); event Compounded(address indexed caller, address fromVault, address toVault, uint256 amountIn, uint256 amountOut); event Redistributed( @@ -25,11 +25,11 @@ interface IHarborYield { // ── Deposit ───────────────────────────────────────────────────────── /// @notice Deposit an asset into the HarborYield. The asset must belong to a registered, active vault. - /// @param asset The asset token to deposit (e.g., stETH, fxUSD, hpETH.stETH). + /// @param asset_ The asset token to deposit (e.g., stETH, fxUSD, hpETH.stETH). /// @param amount The amount to deposit. Use type(uint256).max for full balance. /// @param receiver The address to receive hyXXX shares. /// @return shares The amount of hyXXX shares minted. - function deposit(address asset, uint256 amount, address receiver) external returns (uint256 shares); + function deposit(address asset_, uint256 amount, address receiver) external returns (uint256 shares); // ── Redeem ────────────────────────────────────────────────────────── @@ -68,14 +68,37 @@ interface IHarborYield { // ── Views ─────────────────────────────────────────────────────────── + /// @notice The peg token used to value HarborYield shares (e.g. haEUR). + /// @dev HarborYield is not a standard ERC-4626 vault (multi-asset, proportional redeem), + /// but exposes `asset()`/`totalAssets`/`convertTo*`/`preview*` for interop with + /// aggregators and price feeds. The mutation surface (`deposit`, `redeem`) is + /// non-standard and does not transact in the peg token directly. + function asset() external view returns (address); + /// @notice Total value of all managed holdings, in peg units. function totalAssets() external view returns (uint256); + /// @notice Convert a peg-unit amount to HarborYield share units at the current rate (rounded down). + function convertToShares(uint256 assets) external view returns (uint256); + + /// @notice Convert a HarborYield share amount to peg units at the current rate (rounded down). + function convertToAssets(uint256 shares) external view returns (uint256); + + /// @notice Preview the shares that would be minted by a hypothetical peg-unit deposit. + /// Informational only; HarborYield's actual `deposit` path uses a vault-specific asset. + function previewDeposit(uint256 assets) external view returns (uint256); + + /// @notice Preview the peg-unit value of redeeming `shares`. + /// Informational only; HarborYield's actual `redeem` pays a proportional mix. + function previewRedeem(uint256 shares) external view returns (uint256); + /// @notice The number of managed vaults. function vaultCount() external view returns (uint256); /// @notice Get the managed vault info at a given index. - function vaultAt(uint256 index) external view returns (address vault, address asset, bool active, uint96 weight); + function vaultAt( + uint256 index + ) external view returns (address vault, address asset_, bool active, uint64 weight); /// @notice The cached total of all vault weights. function totalWeight() external view returns (uint256); diff --git a/test/autocompounding/HarborYield.t.sol b/test/autocompounding/HarborYield.t.sol index 6a6a418b..f19cc8f0 100644 --- a/test/autocompounding/HarborYield.t.sol +++ b/test/autocompounding/HarborYield.t.sol @@ -1,31 +1,18 @@ // SPDX-License-Identifier: MIT -// solhint-disable one-contract-per-file pragma solidity >=0.8.28 <0.9.0; import "forge-std/Test.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; -import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; -import {ERC4626} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; import {MockERC20} from "@bao-test/mocks/MockERC20.sol"; import {HarborYield_v1} from "src/autocompounding/HarborYield_v1.sol"; +import {IHarborYield} from "src/interfaces/IHarborYield.sol"; import {MockSwapper} from "test/mocks/MockSwapper.sol"; - -/// @notice Minimal ERC4626 vault used as a managed vault inside HarborYield tests. -/// Yield is simulated by calling addYield(), which drops extra assets into the vault -/// and thereby increases convertToAssets() for existing shares. -contract MockERC4626Vault is ERC4626 { - constructor(IERC20 asset_, string memory name_, string memory symbol_) ERC4626(asset_) ERC20(name_, symbol_) {} - - /// @dev Drop extra underlying into the vault, simulating yield accrual. - function addYield(uint256 amount) external { - MockERC20(asset()).mint(address(this), amount); - } -} +import {MockERC4626Vault} from "test/mocks/MockERC4626Vault.sol"; /// @title HarborYield_v1 unit tests /// @notice Tests HarborYield in isolation using MockERC20 assets, MockERC4626Vault, and MockSwapper. @@ -39,6 +26,7 @@ contract HarborYieldTest is Test { address keeper = makeAddr("keeper"); // ── Tokens ───────────────────────────────────────────────────────── + MockERC20 pegToken; // e.g. haEUR (the HarborYield share's peg-unit asset) MockERC20 asset0; // e.g. stETH MockERC20 asset1; // e.g. fxSAVE @@ -51,10 +39,11 @@ contract HarborYieldTest is Test { HarborYield_v1 hy; // ── Constants ────────────────────────────────────────────────────── - uint96 constant WEIGHT_0 = 60; // 60% of target - uint96 constant WEIGHT_1 = 40; // 40% of target + uint64 constant WEIGHT_0 = 60; // 60% of target + uint64 constant WEIGHT_1 = 40; // 40% of target function setUp() public virtual { + pegToken = new MockERC20("Peg Token", "PEG", 18); asset0 = new MockERC20("Asset 0", "A0", 18); asset1 = new MockERC20("Asset 1", "A1", 18); @@ -68,7 +57,12 @@ contract HarborYieldTest is Test { // Deploy HarborYield_v1 impl + proxy. // address(this) is both deployer-owner and pending-owner: owner is address(this). - HarborYield_v1 impl = new HarborYield_v1("Harbor Yield Test", "hyTEST", address(swapper)); + HarborYield_v1 impl = new HarborYield_v1( + "Harbor Yield Test", + "hyTEST", + address(swapper), + address(pegToken) + ); bytes memory initData = abi.encodeCall(HarborYield_v1.initialize, (address(this), address(this))); hy = HarborYield_v1(address(new ERC1967Proxy(address(impl), initData))); @@ -96,13 +90,13 @@ contract HarborYieldTest is Test { assertEq(hy.vaultCount(), 2); assertEq(hy.totalWeight(), uint256(WEIGHT_0) + WEIGHT_1); - (address v0, address a0, bool active0, uint96 w0) = hy.vaultAt(0); + (address v0, address a0, bool active0, uint64 w0) = hy.vaultAt(0); assertEq(v0, address(vault0)); assertEq(a0, address(asset0)); assertTrue(active0); assertEq(w0, WEIGHT_0); - (address v1, , bool active1, uint96 w1) = hy.vaultAt(1); + (address v1, , bool active1, uint64 w1) = hy.vaultAt(1); assertEq(v1, address(vault1)); assertTrue(active1); assertEq(w1, WEIGHT_1); @@ -138,7 +132,7 @@ contract HarborYieldTest is Test { hy.setVaultWeight(address(vault0), 80); assertEq(hy.totalWeight(), 80 + WEIGHT_1); - (, , , uint96 w0) = hy.vaultAt(0); + (, , , uint64 w0) = hy.vaultAt(0); assertEq(w0, 80); } @@ -434,5 +428,88 @@ contract HarborYieldTest is Test { assertEq(v0After, 95 ether); assertEq(v1After, 5 ether); } + + /*////////////////////////////////////////////////////////////////////////// + ERC-4626 VIEW SHIM + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice `asset()` returns the peg token supplied at construction time. + function test_asset_returnsPegToken() public view { + assertEq(hy.asset(), address(pegToken)); + } + + /// @notice On an empty vault, convertToShares and convertToAssets return the input (1:1 rate + /// at supply = 0, totalAssets = 0 due to the virtual-share `+1` floor). + function test_convert_onEmptyVault_isOneToOne() public view { + assertEq(hy.totalSupply(), 0); + assertEq(hy.totalAssets(), 0); + assertEq(hy.convertToShares(100 ether), 100 ether); + assertEq(hy.convertToAssets(100 ether), 100 ether); + } + + /// @notice After a real deposit, convertToShares/Assets round-trip (within 1 wei). + function test_convert_roundTripAfterDeposit() public { + _deposit(alice, asset0, 100 ether); + + uint256 shares = hy.convertToShares(50 ether); + uint256 assetsBack = hy.convertToAssets(shares); + // Integer division in both directions can lose 1 wei. + assertApproxEqAbs(assetsBack, 50 ether, 1, "round-trip within 1 wei"); + } + + /// @notice `convertToAssets(shares)` tracks the internal share-price formula used in `deposit`. + /// If the formula were `(supply+1)/(assets+1)`, convertToAssets(totalSupply) should + /// equal totalAssets within the virtual-share floor. + function test_convert_matchesInternalFormula() public { + _deposit(alice, asset0, 100 ether); + _deposit(bob, asset1, 40 ether); + + uint256 supply = hy.totalSupply(); + uint256 assets = hy.totalAssets(); + + // convertToAssets(supply) = supply * (assets + 1) / (supply + 1) + // which differs from `assets` by at most 1 wei due to the virtual floor. + uint256 fromShim = hy.convertToAssets(supply); + assertApproxEqAbs(fromShim, assets, 1, "convertToAssets(supply) ~= totalAssets"); + } + + /// @notice previewDeposit matches convertToShares (both round down). + function test_previewDeposit_matchesConvertToShares() public { + _deposit(alice, asset0, 100 ether); + + uint256 preview = hy.previewDeposit(25 ether); + uint256 converted = hy.convertToShares(25 ether); + assertEq(preview, converted); + } + + /// @notice previewRedeem matches convertToAssets (both round down). + function test_previewRedeem_matchesConvertToAssets() public { + _deposit(alice, asset0, 100 ether); + + uint256 preview = hy.previewRedeem(10 ether); + uint256 converted = hy.convertToAssets(10 ether); + assertEq(preview, converted); + } + + /// @notice Share price (convertToAssets(1 ether)) grows as vault yield accrues. + function test_convertToAssets_reflectsVaultYield() public { + _deposit(alice, asset0, 100 ether); + uint256 priceBefore = hy.convertToAssets(1 ether); + + // Simulate 10% yield in vault0. + vault0.addYield(10 ether); + + uint256 priceAfter = hy.convertToAssets(1 ether); + assertGt(priceAfter, priceBefore, "share price increased with yield"); + } + + /// @notice The view shim is exposed via the IHarborYield interface. + function test_viewShim_reachableViaInterface() public view { + // Compile-time check: these calls compile if IHarborYield declares them. + assertEq(IHarborYield(address(hy)).asset(), address(pegToken)); + assertEq(IHarborYield(address(hy)).convertToShares(1 ether), hy.convertToShares(1 ether)); + assertEq(IHarborYield(address(hy)).convertToAssets(1 ether), hy.convertToAssets(1 ether)); + assertEq(IHarborYield(address(hy)).previewDeposit(1 ether), hy.previewDeposit(1 ether)); + assertEq(IHarborYield(address(hy)).previewRedeem(1 ether), hy.previewRedeem(1 ether)); + } } -// solhint-enable one-contract-per-file diff --git a/test/mocks/MockERC4626Vault.sol b/test/mocks/MockERC4626Vault.sol new file mode 100644 index 00000000..b9cee6e3 --- /dev/null +++ b/test/mocks/MockERC4626Vault.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.28 <0.9.0; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {ERC4626} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; + +import {MockERC20} from "@bao-test/mocks/MockERC20.sol"; + +/// @title MockERC4626Vault +/// @notice Minimal ERC4626 vault for tests. Wraps an underlying MockERC20 and exposes an +/// `addYield` hook that mints extra underlying into the vault, simulating yield accrual. +/// @dev Used by HarborYield_v1 unit tests and anywhere else an ERC4626 stand-in is needed without +/// pulling in the full Minter/SP/AC deployment. +contract MockERC4626Vault is ERC4626 { + constructor(IERC20 asset_, string memory name_, string memory symbol_) ERC4626(asset_) ERC20(name_, symbol_) {} + + /// @dev Drop extra underlying into the vault, simulating yield accrual. + function addYield(uint256 amount) external { + MockERC20(asset()).mint(address(this), amount); + } +} From c89e92addd3b3b947fd3f0bcd2dc76109972fe32 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Tue, 14 Apr 2026 17:48:15 +0100 Subject: [PATCH 037/232] HarborYield: B.4.3 peg verification and depeg-aware valuation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split addVault into two variants: - addAutoCompounderVault(vault, weight) — introspects IAutoCompounder(vault).PEGGED_TOKEN() and asserts it matches HY's _PEG_TOKEN. No oracle needed. - addEquivalentVault(vault, weight, oracle) — takes an IWrappedPriceOracle, verifies its mid-rate is within maxPegDriftBps of 1:1 at registration, and stores it in a sparse vaultValuationOracle mapping. Depeg-aware totalAssets via _fairRateInPegUnits(vault): - AC branch reads Minter.peggedTokenPrice() — under haXXX depeg, AC contributions are marked down to reflect actual collateral backing. - Equivalent branch averages min/max price × rate from the registered IWrappedPriceOracle. Runtime oracle-bounded swap floor in compound/redistribute: - Computes expected output from the fair rates of both ends. - Applies (10_000 - maxPegDriftBps)/10_000 as an oracle floor on top of the keeper-supplied minAmountOut. A compromised or lazy keeper passing 0 still hits HY's own floor. Supporting changes: - IAutoCompounder gains PEGGED_TOKEN() and MINTER() getters. - ISwapper.previewSwap removed — misleading for production adapters without on-chain quoting. MockSwapper loses its previewSwap helper. - MockERC4626Vault gains configureAsAutoCompounder(pegged, minter) so mocks can stand in as ACs in unit tests. - MockMinter gains peggedTokenPrice() with a setter for depeg scenarios. - Deploy script's VaultConfig gains a valuationOracle field; branches on isAutoCompounder to call the right addVault variant. 47 HarborYieldTest tests pass (36 existing + 11 new for B.4.3 covering wrong-peg AC rejection, excessive-drift equivalent rejection, within-drift acceptance, AC valuation at healthy and depegged peg, equivalent oracle valuation, maxPegDriftBps admin, compound oracle-floor accept/reject, and compound during oracle depeg). Co-Authored-By: Claude Opus 4.6 (1M context) --- script/src/contracts/HarborYield.sol | 10 +- src/autocompounding/HarborYield_v1.sol | 168 +++++++++++++++++++-- src/interfaces/IAutoCompounder.sol | 13 ++ src/interfaces/IHarborYield.sol | 4 + src/interfaces/ISwapper.sol | 19 +-- test/autocompounding/HarborYield.t.sol | 195 ++++++++++++++++++++++++- test/mocks/MockERC4626Vault.sol | 28 ++++ test/mocks/MockMinter.sol | 14 ++ test/mocks/MockSwapper.sol | 6 +- 9 files changed, 414 insertions(+), 43 deletions(-) diff --git a/script/src/contracts/HarborYield.sol b/script/src/contracts/HarborYield.sol index 8a3d2e4f..55f3f064 100644 --- a/script/src/contracts/HarborYield.sol +++ b/script/src/contracts/HarborYield.sol @@ -60,10 +60,14 @@ abstract contract HarborYield is HarborFactoryDeployer { } /// @notice Vault registration config. + /// @dev For AutoCompounder vaults, leave `valuationOracle` as `address(0)` — HY introspects + /// the AC directly. For equivalent-yield vaults, `valuationOracle` must be a deployed + /// `IWrappedPriceOracle` pricing the equivalent's asset against the peg token. struct VaultConfig { address vault; // ERC4626 vault address uint64 weight; // target distribution weight bool isAutoCompounder; // true if vault implements IAutoCompounder + address valuationOracle; // only for !isAutoCompounder; must be address(0) for ACs } /// @notice Register ERC4626 vaults with a deployed HarborYield. @@ -74,7 +78,11 @@ abstract contract HarborYield is HarborFactoryDeployer { address vault = configs[i].vault; address asset = IERC4626(vault).asset(); console.log(" addVault: %s (asset: %s, weight: %s)", vault, asset, configs[i].weight); - HarborYield_v1(hyProxy).addVault(vault, configs[i].weight, configs[i].isAutoCompounder); + if (configs[i].isAutoCompounder) { + HarborYield_v1(hyProxy).addAutoCompounderVault(vault, configs[i].weight); + } else { + HarborYield_v1(hyProxy).addEquivalentVault(vault, configs[i].weight, configs[i].valuationOracle); + } } } } diff --git a/src/autocompounding/HarborYield_v1.sol b/src/autocompounding/HarborYield_v1.sol index f6d2a5a5..ce7c5963 100644 --- a/src/autocompounding/HarborYield_v1.sol +++ b/src/autocompounding/HarborYield_v1.sol @@ -17,6 +17,8 @@ import {TokenHolder, ITokenHolder} from "@bao/TokenHolder.sol"; import {IHarborYield} from "src/interfaces/IHarborYield.sol"; import {IAutoCompounder} from "src/interfaces/IAutoCompounder.sol"; import {ISwapper} from "src/interfaces/ISwapper.sol"; +import {IWrappedPriceOracle} from "src/interfaces/IWrappedPriceOracle.sol"; +import {IMinter} from "src/interfaces/IMinter.sol"; import {ERC20MetadataLib_v1} from "src/util/ERC20MetadataLib_v1.sol"; /// @title HarborYield_v1 @@ -52,6 +54,14 @@ contract HarborYield_v1 is error ZeroWeight(); error NothingToRedistribute(); + /// @notice An AutoCompounder vault's `PEGGED_TOKEN` does not match this HarborYield's peg + /// token. The caller is trying to register an AC from the wrong market. + error WrongPegToken(address expected, address actual); + + /// @notice A vault's asset does not value 1:1 against the peg token within the allowed + /// drift. Either a config error (wrong asset) or a market depeg in progress. + error ExcessivePegDrift(uint256 expected, uint256 actual); + /*////////////////////////////////////////////////////////////////////////// CONSTANTS //////////////////////////////////////////////////////////////////////////*/ @@ -79,7 +89,10 @@ contract HarborYield_v1 is /// @notice The peg token (e.g. haEUR) that values the HarborYield share in peg units. /// HY is not ERC-4626 — it holds multiple assets — but `asset()` returns this token - /// for interop with aggregators and price feeds. + /// for interop with aggregators and price feeds. Also serves as the peg-identity + /// reference for `addVault` — AC vaults are checked against this via + /// `IAutoCompounder.PEGGED_TOKEN()`, and equivalent vaults are checked via the + /// swapper's value preview against this token. /// @custom:oz-upgrades-unsafe-allow state-variable-immutable address private immutable _PEG_TOKEN; @@ -102,6 +115,8 @@ contract HarborYield_v1 is ManagedVault[] vaults; mapping(address => uint256) assetToVaultIndex; // asset => index+1 (0 = not registered) uint256 totalWeight; // sum of all vault weights (cached for gas) + uint64 maxPegDriftBps; // max deviation from 1:1 for equivalent vaults, in bps (10000 = 100%) + mapping(address => address) vaultValuationOracle; // sparse: equivalents only; AC vaults use default address(0) } function _getHarborYieldStorage() private pure returns (HarborYieldStorage storage $) { @@ -145,16 +160,50 @@ contract HarborYield_v1 is ADMIN: VAULT MANAGEMENT //////////////////////////////////////////////////////////////////////////*/ - /// @notice Register a new ERC4626 vault with a target weight. - /// @param vault The ERC4626 vault address. + /// @notice Register an AutoCompounder vault. Verifies the AC's underlying pegged token + /// matches this HarborYield's peg token by introspecting `IAutoCompounder.PEGGED_TOKEN()`. + /// No oracle required — ACs hold the peg token directly via their underlying SP. + /// @param vault The AutoCompounder vault address. /// @param weight Target distribution weight (arbitrary units, must be > 0). - /// @param isAutoCompounder Whether the vault implements IAutoCompounder. // slither-disable-next-line reentrancy-no-eth,reentrancy-events - function addVault(address vault, uint64 weight, bool isAutoCompounder) external onlyOwner { + function addAutoCompounderVault(address vault, uint64 weight) external onlyOwner { + Token.ensureContract(vault); + address acPegged = IAutoCompounder(vault).PEGGED_TOKEN(); + if (acPegged != _PEG_TOKEN) { + revert WrongPegToken(_PEG_TOKEN, acPegged); + } + _addVault(vault, weight, true, address(0)); + } + + /// @notice Register an equivalent-yield ERC4626 vault with a required peg-value oracle. + /// The oracle's current mid-rate must be within `maxPegDriftBps` of 1:1 with the + /// peg token, or registration reverts. The oracle is stored per-vault and used by + /// `totalAssets` for valuation and by `compound`/`redistribute` for the runtime + /// oracle-bounded swap floor. + /// @param vault The equivalent-yield ERC4626 vault. + /// @param weight Target distribution weight. + /// @param valuationOracle IWrappedPriceOracle providing (price, rate) for the vault's + /// asset vs the peg token. + // slither-disable-next-line reentrancy-no-eth,reentrancy-events + function addEquivalentVault(address vault, uint64 weight, address valuationOracle) external onlyOwner { + Token.ensureContract(vault); + Token.ensureContract(valuationOracle); + + // Check that the oracle currently reports a rate close to 1:1. This catches wrong-class + // assets (oracle rate obviously not ~1e18) and currently-depegged assets (oracle rate + // > maxPegDriftBps away from 1e18). + uint256 rate = _oracleRatePegUnits(valuationOracle); + _requirePegDriftWithin(1 ether, rate); + + _addVault(vault, weight, false, valuationOracle); + } + + /// @dev Shared bookkeeping for both addVault variants. Both callers have already verified + /// the vault-class-specific peg check before reaching here. + function _addVault(address vault, uint64 weight, bool isAutoCompounder, address valuationOracle) private { if (weight == 0) { revert ZeroWeight(); } - Token.ensureContract(vault); address vaultAsset = IERC4626(vault).asset(); HarborYieldStorage storage $ = _getHarborYieldStorage(); @@ -165,12 +214,29 @@ contract HarborYield_v1 is $.vaults.push(ManagedVault({vault: vault, weight: weight, active: true, isAutoCompounder: isAutoCompounder})); $.assetToVaultIndex[vaultAsset] = $.vaults.length; // 1-indexed $.totalWeight += weight; + if (valuationOracle != address(0)) { + $.vaultValuationOracle[vault] = valuationOracle; + } IERC20(vaultAsset).forceApprove(vault, type(uint256).max); emit VaultAdded(vault, vaultAsset, weight); } + /// @notice Update the maximum peg drift allowed for equivalent-vault registration and + /// rebalance swaps, in basis points (e.g., 200 = 2%). + /// @dev Setting to 0 forces exact 1:1 parity, which will break for any real equivalent; + /// intended for deactivation / emergency freeze only. + function setMaxPegDriftBps(uint64 newMaxPegDriftBps) external onlyOwner { + _getHarborYieldStorage().maxPegDriftBps = newMaxPegDriftBps; + emit MaxPegDriftBpsUpdated(newMaxPegDriftBps); + } + + /// @notice The current maximum peg drift in basis points. + function maxPegDriftBps() external view returns (uint64) { + return _getHarborYieldStorage().maxPegDriftBps; + } + /// @notice Update a vault's target weight. Set to 0 to drain via redistribution. function setVaultWeight(address vault, uint64 weight) external onlyOwner { HarborYieldStorage storage $ = _getHarborYieldStorage(); @@ -246,12 +312,16 @@ contract HarborYield_v1 is HarborYieldStorage storage $ = _getHarborYieldStorage(); uint256 length = $.vaults.length; for (uint256 i = 0; i < length; i++) { + address vault = $.vaults[i].vault; // slither-disable-next-line calls-loop - uint256 vaultShares = IERC20($.vaults[i].vault).balanceOf(address(this)); - if (vaultShares > 0) { - // slither-disable-next-line calls-loop - total += IERC4626($.vaults[i].vault).convertToAssets(vaultShares); + uint256 vaultShares = IERC20(vault).balanceOf(address(this)); + if (vaultShares == 0) { + continue; } + // slither-disable-next-line calls-loop + uint256 vaultAssets = IERC4626(vault).convertToAssets(vaultShares); + // slither-disable-next-line calls-loop + total += Math.mulDiv(vaultAssets, _fairRateInPegUnits(vault), 1 ether); } } @@ -360,15 +430,19 @@ contract HarborYield_v1 is uint256 minAmountOut, bytes calldata swapData ) external nonReentrant onlyOwnerOrRoles(COMPOUNDER_ROLE) { - // Redeem from the source equivalent vault to get its underlying asset + // Redeem from the source vault to get its underlying asset uint256 assetAmount = IERC4626(fromVault).redeem(vaultShareAmount, address(this), address(this)); + // Apply HY's oracle-bounded floor on top of the keeper's minAmountOut. If the keeper + // is lazy or compromised and passes a low minAmountOut, HY's own floor kicks in. + uint256 effectiveMin = _effectiveMinOut(fromVault, toVault, assetAmount, minAmountOut); + // Swap the asset to the target vault's asset uint256 swappedAmount = _swapIfNeeded( IERC4626(fromVault).asset(), IERC4626(toVault).asset(), assetAmount, - minAmountOut, + effectiveMin, swapData ); @@ -448,12 +522,16 @@ contract HarborYield_v1 is } w.moveValue = IERC4626(srcVault).redeem(srcShares, address(this), address(this)); } + + // Apply HY's oracle-bounded floor on top of the keeper's minAmountOut. + uint256 effectiveMin = _effectiveMinOut(srcVault, dstVault, w.moveValue, minAmountOut); + // Swap if needed, deposit to target uint256 deposited = _swapIfNeeded( IERC4626(srcVault).asset(), IERC4626(dstVault).asset(), w.moveValue, - minAmountOut, + effectiveMin, swapData ); // slither-disable-next-line unused-return @@ -462,7 +540,69 @@ contract HarborYield_v1 is } /*////////////////////////////////////////////////////////////////////////// - INTERNAL + INTERNAL: PEG VALUATION + //////////////////////////////////////////////////////////////////////////*/ + + /// @dev Return the current fair rate (peg units per 1 asset unit, 18 decimals) for a + /// registered vault. + /// + /// The sparse `vaultValuationOracle` mapping is the branch discriminator: + /// - `address(0)` → AC vault. Read `peggedTokenPrice()` from the vault's Minter + /// (normally 1e18, lower during a peg-token depeg). No oracle lookup for the + /// asset side because an AC's asset is the SP token, which is 1:1 with the peg + /// token (haXXX) via the pool. + /// - non-zero → equivalent vault. Read the registered `IWrappedPriceOracle` and + /// combine min/max price and rate into a single mid value. + function _fairRateInPegUnits(address vault) private view returns (uint256) { + address oracle = _getHarborYieldStorage().vaultValuationOracle[vault]; + if (oracle == address(0)) { + address minter = IAutoCompounder(vault).MINTER(); + return IMinter(minter).peggedTokenPrice(); + } + return _oracleRatePegUnits(oracle); + } + + /// @dev Return the mid-rate reported by an IWrappedPriceOracle, expressed as + /// "peg units per 1 asset unit" in 18 decimals: `mid(price) * mid(rate) / 1e18`. + function _oracleRatePegUnits(address oracle) private view returns (uint256) { + (uint256 minP, uint256 maxP, uint256 minR, uint256 maxR) = IWrappedPriceOracle(oracle).latestAnswer(); + uint256 price = (minP + maxP) / 2; + uint256 rate = (minR + maxR) / 2; + return Math.mulDiv(price, rate, 1 ether); + } + + /// @dev Revert if `actual` is not within `maxPegDriftBps` of `expected`. Symmetric + /// range check used by `addEquivalentVault` to assert the oracle currently reports + /// a rate close to 1:1 with the peg token. + function _requirePegDriftWithin(uint256 expected, uint256 actual) private view { + uint256 tolerance = Math.mulDiv(expected, _getHarborYieldStorage().maxPegDriftBps, 10_000); + if (actual < expected - tolerance || actual > expected + tolerance) { + revert ExcessivePegDrift(expected, actual); + } + } + + /// @dev Compute the oracle-bounded minimum acceptable output for a swap from one managed + /// vault's asset into another's. The returned value is `max(keeperMinOut, oracleFloor)`, + /// so a compromised keeper passing `keeperMinOut = 0` still gets HY's own floor. + function _effectiveMinOut( + address fromVault, + address toVault, + uint256 amountIn, + uint256 keeperMinOut + ) private view returns (uint256) { + uint256 fromRate = _fairRateInPegUnits(fromVault); + uint256 toRate = _fairRateInPegUnits(toVault); + uint256 expectedOut = Math.mulDiv(amountIn, fromRate, toRate); + uint256 oracleFloor = Math.mulDiv( + expectedOut, + 10_000 - _getHarborYieldStorage().maxPegDriftBps, + 10_000 + ); + return keeperMinOut > oracleFloor ? keeperMinOut : oracleFloor; + } + + /*////////////////////////////////////////////////////////////////////////// + INTERNAL: SWAPPER //////////////////////////////////////////////////////////////////////////*/ /// @dev Swap fromAsset -> toAsset via SWAPPER, or pass through if same asset. diff --git a/src/interfaces/IAutoCompounder.sol b/src/interfaces/IAutoCompounder.sol index 07fb8ac7..38a18d10 100644 --- a/src/interfaces/IAutoCompounder.sol +++ b/src/interfaces/IAutoCompounder.sol @@ -15,4 +15,17 @@ interface IAutoCompounder { /// @param receiver Address to receive the AC shares. /// @return shares Amount of AC shares minted. function depositPeggedToken(uint256 peggedAmount, address receiver) external returns (uint256 shares); + + /// @notice The pegged token (e.g., haEUR) that the underlying StabilityPool holds. + /// @dev Exposed as a public immutable on the implementation; here to allow peg verification + /// from upstream holders (e.g., HarborYield) without coupling to the concrete type. + // solhint-disable-next-line func-name-mixedcase + function PEGGED_TOKEN() external view returns (address); + + /// @notice The Minter for this AC's market. + /// @dev Exposed as a public immutable on the implementation; used by upstream holders + /// (e.g., HarborYield) to read `peggedTokenPrice()` for depeg-aware valuation of + /// AC holdings. + // solhint-disable-next-line func-name-mixedcase + function MINTER() external view returns (address); } diff --git a/src/interfaces/IHarborYield.sol b/src/interfaces/IHarborYield.sol index 8fcc1aef..872ebf29 100644 --- a/src/interfaces/IHarborYield.sol +++ b/src/interfaces/IHarborYield.sol @@ -22,6 +22,10 @@ interface IHarborYield { uint256 amountOut ); + /// @notice Emitted when the owner updates the maximum peg drift allowed for + /// equivalent-vault registration and rebalance swaps. + event MaxPegDriftBpsUpdated(uint64 newMaxPegDriftBps); + // ── Deposit ───────────────────────────────────────────────────────── /// @notice Deposit an asset into the HarborYield. The asset must belong to a registered, active vault. diff --git a/src/interfaces/ISwapper.sol b/src/interfaces/ISwapper.sol index fa2d653e..3a3785ef 100644 --- a/src/interfaces/ISwapper.sol +++ b/src/interfaces/ISwapper.sol @@ -3,8 +3,10 @@ pragma solidity >=0.8.28 <0.9.0; /// @title ISwapper /// @notice Generic interface for token-to-token swaps. -/// @dev Implementations may wrap 1inch, Uniswap, Paraswap, or any DEX aggregator. -/// The caller provides adapter-specific route data via the `data` parameter. +/// @dev Implementations may wrap 1inch, Uniswap, Paraswap, or any DEX aggregator. The +/// caller provides adapter-specific route data via the `data` parameter, and uses +/// `minAmountOut` for slippage protection. There is no on-chain preview — real +/// aggregator routes are computed off-chain and passed in via `data`. interface ISwapper { /// @notice Swap one token for another. /// @param fromToken The token to swap from. @@ -20,17 +22,4 @@ interface ISwapper { uint256 minAmountOut, bytes calldata data ) external returns (uint256 amountOut); - - /// @notice Preview the expected output of a swap without executing it. - /// @dev Analogous to ERC4626's previewDeposit/previewRedeem. - /// May revert if the adapter does not support quoting. - /// @param fromToken The token to swap from. - /// @param toToken The token to swap to. - /// @param amountIn Amount of fromToken. - /// @return amountOut Expected amount of toToken. - function previewSwap( - address fromToken, - address toToken, - uint256 amountIn - ) external view returns (uint256 amountOut); } diff --git a/test/autocompounding/HarborYield.t.sol b/test/autocompounding/HarborYield.t.sol index f19cc8f0..f6d65e62 100644 --- a/test/autocompounding/HarborYield.t.sol +++ b/test/autocompounding/HarborYield.t.sol @@ -13,6 +13,8 @@ import {HarborYield_v1} from "src/autocompounding/HarborYield_v1.sol"; import {IHarborYield} from "src/interfaces/IHarborYield.sol"; import {MockSwapper} from "test/mocks/MockSwapper.sol"; import {MockERC4626Vault} from "test/mocks/MockERC4626Vault.sol"; +import {MockMinter} from "test/mocks/MockMinter.sol"; +import {MockWrappedPriceOracle} from "test/mocks/MockWrappedPriceOracle.sol"; /// @title HarborYield_v1 unit tests /// @notice Tests HarborYield in isolation using MockERC20 assets, MockERC4626Vault, and MockSwapper. @@ -38,7 +40,15 @@ contract HarborYieldTest is Test { MockSwapper swapper; HarborYield_v1 hy; + // ── AC vault (vault0) — requires a MockMinter so it can introspect PEGGED_TOKEN/MINTER ── + MockMinter minter0; + + // ── Equivalent vault (vault1) — requires an IWrappedPriceOracle ── + MockWrappedPriceOracle oracle1; + // ── Constants ────────────────────────────────────────────────────── + uint64 constant DEFAULT_DRIFT_BPS = 200; // 2% + uint64 constant WEIGHT_0 = 60; // 60% of target uint64 constant WEIGHT_1 = 40; // 40% of target @@ -47,8 +57,19 @@ contract HarborYieldTest is Test { asset0 = new MockERC20("Asset 0", "A0", 18); asset1 = new MockERC20("Asset 1", "A1", 18); + // vault0 stands in as an AutoCompounder. Its "asset" is asset0 (playing the role of an + // SP token 1:1 with the peg). Configure PEGGED_TOKEN and MINTER so HY can introspect it + // during `addAutoCompounderVault`. The MockMinter returns peggedTokenPrice = 1e18. vault0 = new MockERC4626Vault(IERC20(address(asset0)), "Vault 0", "V0"); + minter0 = new MockMinter(address(asset0), address(pegToken), makeAddr("lev0")); + vault0.configureAsAutoCompounder(address(pegToken), address(minter0)); + + // vault1 is an equivalent-yield vault. Its asset is asset1 (e.g. fxSAVE-analog). HY + // registers it via `addEquivalentVault` with a price oracle; the oracle reports 1:1 + // to satisfy the drift check. vault1 = new MockERC4626Vault(IERC20(address(asset1)), "Vault 1", "V1"); + oracle1 = new MockWrappedPriceOracle(); + oracle1.setLatestAnswer(1 ether, 1 ether); // price = 1, rate = 1 → 1:1 with peg // Swapper at 1:1 rate — pre-fund with enough of each token for tests. swapper = new MockSwapper(1 ether); @@ -66,8 +87,10 @@ contract HarborYieldTest is Test { bytes memory initData = abi.encodeCall(HarborYield_v1.initialize, (address(this), address(this))); hy = HarborYield_v1(address(new ERC1967Proxy(address(impl), initData))); - hy.addVault(address(vault0), WEIGHT_0, true); - hy.addVault(address(vault1), WEIGHT_1, false); + hy.setMaxPegDriftBps(DEFAULT_DRIFT_BPS); + + hy.addAutoCompounderVault(address(vault0), WEIGHT_0); + hy.addEquivalentVault(address(vault1), WEIGHT_1, address(oracle1)); } // ── Helpers ──────────────────────────────────────────────────────── @@ -102,29 +125,37 @@ contract HarborYieldTest is Test { assertEq(w1, WEIGHT_1); } - /// @notice addVault reverts when the weight is zero. + /// @notice addEquivalentVault reverts when the weight is zero. function test_addVault_zeroWeight_reverts() public { MockERC20 asset2 = new MockERC20("Asset 2", "A2", 18); MockERC4626Vault vault2 = new MockERC4626Vault(IERC20(address(asset2)), "Vault 2", "V2"); + MockWrappedPriceOracle oracle2 = new MockWrappedPriceOracle(); + oracle2.setLatestAnswer(1 ether, 1 ether); vm.expectRevert(HarborYield_v1.ZeroWeight.selector); - hy.addVault(address(vault2), 0, false); + hy.addEquivalentVault(address(vault2), 0, address(oracle2)); } - /// @notice addVault reverts when the asset is already registered by another vault. + /// @notice addEquivalentVault reverts when the asset is already registered by another vault. function test_addVault_duplicateAsset_reverts() public { MockERC4626Vault dup = new MockERC4626Vault(IERC20(address(asset0)), "Dup", "DUP"); + MockWrappedPriceOracle oracleDup = new MockWrappedPriceOracle(); + oracleDup.setLatestAnswer(1 ether, 1 ether); + vm.expectRevert(abi.encodeWithSelector(HarborYield_v1.VaultAlreadyRegistered.selector, address(dup))); - hy.addVault(address(dup), 10, false); + hy.addEquivalentVault(address(dup), 10, address(oracleDup)); } - /// @notice addVault is owner-only; non-owners revert with Unauthorized. + /// @notice addEquivalentVault is owner-only; non-owners revert with Unauthorized. function test_addVault_nonOwner_reverts() public { MockERC20 asset2 = new MockERC20("Asset 2", "A2", 18); MockERC4626Vault vault2 = new MockERC4626Vault(IERC20(address(asset2)), "Vault 2", "V2"); + MockWrappedPriceOracle oracle2 = new MockWrappedPriceOracle(); + oracle2.setLatestAnswer(1 ether, 1 ether); + vm.prank(alice); vm.expectRevert(); // HarborOwnable Unauthorized - hy.addVault(address(vault2), 10, false); + hy.addEquivalentVault(address(vault2), 10, address(oracle2)); } /// @notice setVaultWeight adjusts the cached totalWeight correctly. @@ -512,4 +543,152 @@ contract HarborYieldTest is Test { assertEq(IHarborYield(address(hy)).previewDeposit(1 ether), hy.previewDeposit(1 ether)); assertEq(IHarborYield(address(hy)).previewRedeem(1 ether), hy.previewRedeem(1 ether)); } + + /*////////////////////////////////////////////////////////////////////////// + B.4.3: PEG VERIFICATION AT addVault + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice `addAutoCompounderVault` reverts if the AC's PEGGED_TOKEN doesn't match HY's peg. + function test_addAutoCompounder_wrongPeg_reverts() public { + MockERC20 asset2 = new MockERC20("Asset 2", "A2", 18); + MockERC4626Vault acBad = new MockERC4626Vault(IERC20(address(asset2)), "Bad AC", "BAD"); + MockERC20 otherPeg = new MockERC20("Other Peg", "OPE", 18); + MockMinter minterBad = new MockMinter(address(asset2), address(otherPeg), makeAddr("levBad")); + acBad.configureAsAutoCompounder(address(otherPeg), address(minterBad)); + + vm.expectRevert( + abi.encodeWithSelector(HarborYield_v1.WrongPegToken.selector, address(pegToken), address(otherPeg)) + ); + hy.addAutoCompounderVault(address(acBad), 10); + } + + /// @notice `addEquivalentVault` reverts if the oracle reports a rate outside maxPegDriftBps. + function test_addEquivalent_excessiveDrift_reverts() public { + MockERC20 asset2 = new MockERC20("Asset 2", "A2", 18); + MockERC4626Vault equiv = new MockERC4626Vault(IERC20(address(asset2)), "Drift", "DFT"); + MockWrappedPriceOracle oracleDrift = new MockWrappedPriceOracle(); + // 5% depeg — way outside the default 2% drift tolerance + oracleDrift.setLatestAnswer(0.95 ether, 1 ether); + + vm.expectRevert( + abi.encodeWithSelector(HarborYield_v1.ExcessivePegDrift.selector, uint256(1 ether), uint256(0.95 ether)) + ); + hy.addEquivalentVault(address(equiv), 10, address(oracleDrift)); + } + + /// @notice A near-peg equivalent (within tolerance) registers successfully. + function test_addEquivalent_withinDrift_succeeds() public { + MockERC20 asset2 = new MockERC20("Asset 2", "A2", 18); + MockERC4626Vault equiv = new MockERC4626Vault(IERC20(address(asset2)), "OK", "OK"); + MockWrappedPriceOracle oracleOk = new MockWrappedPriceOracle(); + // 1% "depeg" — inside the 2% tolerance + oracleOk.setLatestAnswer(0.99 ether, 1 ether); + + hy.addEquivalentVault(address(equiv), 10, address(oracleOk)); + assertEq(hy.vaultCount(), 3); + } + + /*////////////////////////////////////////////////////////////////////////// + B.4.3: ORACLE-VALUED totalAssets + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice totalAssets for an AC-only holder equals convertToAssets at healthy peggedTokenPrice (1e18). + function test_totalAssets_ac_healthyPeg() public { + _deposit(alice, asset0, 100 ether); + uint256 total = hy.totalAssets(); + // At peggedTokenPrice = 1e18, AC contribution = convertToAssets unchanged. + assertApproxEqAbs(total, 100 ether, 1); + } + + /// @notice totalAssets drops when the Minter reports a haXXX depeg via peggedTokenPrice(). + function test_totalAssets_ac_depegReducesValue() public { + _deposit(alice, asset0, 100 ether); + + // Simulate haEUR depegging to 0.80 EUR — AC contribution to totalAssets drops 20%. + minter0.setPeggedTokenPrice(0.8 ether); + + uint256 total = hy.totalAssets(); + assertApproxEqAbs(total, 80 ether, 1); + } + + /// @notice totalAssets for an equivalent uses the oracle's mid rate. + function test_totalAssets_equivalent_usesOracle() public { + _deposit(alice, asset1, 100 ether); + uint256 total = hy.totalAssets(); + // Oracle reports 1:1, so totalAssets ≈ 100 ether. + assertApproxEqAbs(total, 100 ether, 1); + + // Move the oracle to 0.99 (1% depeg); totalAssets drops ~1%. + oracle1.setLatestAnswer(0.99 ether, 1 ether); + uint256 totalAfter = hy.totalAssets(); + assertApproxEqAbs(totalAfter, 99 ether, 1); + } + + /*////////////////////////////////////////////////////////////////////////// + B.4.3: maxPegDriftBps ADMIN + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice Setting a new drift value updates state and emits the event. + function test_setMaxPegDriftBps_updatesAndEmits() public { + vm.expectEmit(false, false, false, true); + emit IHarborYield.MaxPegDriftBpsUpdated(500); + hy.setMaxPegDriftBps(500); + assertEq(hy.maxPegDriftBps(), 500); + } + + /// @notice setMaxPegDriftBps is owner-only. + function test_setMaxPegDriftBps_nonOwner_reverts() public { + vm.prank(alice); + vm.expectRevert(); + hy.setMaxPegDriftBps(500); + } + + /*////////////////////////////////////////////////////////////////////////// + B.4.3: RUNTIME ORACLE-BOUNDED MIN-OUT + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice compound with keeperMinOut = 0 still enforces HY's oracle-bounded floor. + /// Here the swapper rate is 1:1, oracle rates are 1:1, so effectiveMin ≈ 0.98x amountIn. + /// The swap yields 1x which is above 0.98x → success. + function test_compound_oracleFloorAcceptsFairSwap() public { + _deposit(alice, asset0, 60 ether); + _deposit(bob, asset1, 40 ether); + + // keeperMinOut = 0 — HY overrides with its own floor based on oracle rates. + hy.compound(address(vault0), address(vault1), 10 ether, 0, ""); + } + + /// @notice When the swapper yields less than HY's oracle floor, compound reverts via the + /// swapper's slippage check (because HY passed the floor as effectiveMinOut). + function test_compound_oracleFloorRejectsBadSwap() public { + _deposit(alice, asset0, 60 ether); + _deposit(bob, asset1, 40 ether); + + // Swapper yields only 95% of input — below the 98% oracle floor. + swapper.setRate(0.95 ether); + + vm.expectRevert(bytes("MockSwapper: slippage")); + hy.compound(address(vault0), address(vault1), 10 ether, 0, ""); + } + + /// @notice If the oracle itself reflects a depeg, the floor follows the oracle down — + /// swaps at the depegged rate continue to succeed. + function test_compound_floorFollowsOracleDuringDepeg() public { + _deposit(alice, asset0, 60 ether); + _deposit(bob, asset1, 40 ether); + + // Oracle reflects a 3% depeg on asset1; swapper also yields 97% (matching). + oracle1.setLatestAnswer(0.97 ether, 1 ether); + swapper.setRate(0.97 ether); + + // Oracle floor: expected ≈ from/to = 1/0.97 ≈ 1.031 ether per 1 ether + // with 2% drift → floor ≈ 1.0106. Swap yields amountIn * 0.97 = 0.97 — BELOW floor. + // So this SHOULD revert. + // + // The point is: the oracle-floor tracks the oracle BUT asset1's rate being 0.97 + // increases the expected out for from→to swaps, not decreases it. (You need more of a + // "cheaper" asset to match a "full" output.) So the swap still has to beat the floor. + vm.expectRevert(bytes("MockSwapper: slippage")); + hy.compound(address(vault0), address(vault1), 10 ether, 0, ""); + } } diff --git a/test/mocks/MockERC4626Vault.sol b/test/mocks/MockERC4626Vault.sol index b9cee6e3..64c6c81b 100644 --- a/test/mocks/MockERC4626Vault.sol +++ b/test/mocks/MockERC4626Vault.sol @@ -12,11 +12,39 @@ import {MockERC20} from "@bao-test/mocks/MockERC20.sol"; /// `addYield` hook that mints extra underlying into the vault, simulating yield accrual. /// @dev Used by HarborYield_v1 unit tests and anywhere else an ERC4626 stand-in is needed without /// pulling in the full Minter/SP/AC deployment. +/// +/// Optionally implements the `IAutoCompounder` introspection surface (`PEGGED_TOKEN()` and +/// `MINTER()`) so tests can register a mock as an AC via `HarborYield.addAutoCompounderVault`. +/// Configure via `configureAsAutoCompounder` after construction; both fields default to +/// `address(0)`, i.e. "not an AC" (the getters return zero, which triggers `WrongPegToken` +/// at registration — correct behaviour for a non-AC vault). contract MockERC4626Vault is ERC4626 { + address public _pegged; + address public _minter; + constructor(IERC20 asset_, string memory name_, string memory symbol_) ERC4626(asset_) ERC20(name_, symbol_) {} /// @dev Drop extra underlying into the vault, simulating yield accrual. function addYield(uint256 amount) external { MockERC20(asset()).mint(address(this), amount); } + + /// @notice Set the values returned by `PEGGED_TOKEN()` and `MINTER()`, so this mock can + /// stand in as an AutoCompounder in HarborYield tests. + function configureAsAutoCompounder(address pegged_, address minter_) external { + _pegged = pegged_; + _minter = minter_; + } + + // solhint-disable func-name-mixedcase + /// @notice `IAutoCompounder.PEGGED_TOKEN()` getter for test registration as an AC. + function PEGGED_TOKEN() external view returns (address) { + return _pegged; + } + + /// @notice `IAutoCompounder.MINTER()` getter for test registration as an AC. + function MINTER() external view returns (address) { + return _minter; + } + // solhint-enable func-name-mixedcase } diff --git a/test/mocks/MockMinter.sol b/test/mocks/MockMinter.sol index 772da9e3..8e573582 100644 --- a/test/mocks/MockMinter.sol +++ b/test/mocks/MockMinter.sol @@ -20,6 +20,10 @@ contract MockMinter is BaoOwnableRoles /*, IMinter */ { address public immutable LEVERAGED_TOKEN; // the type of burn signature for burning pegged tokens + /// @notice Configurable pegged-token price. Defaults to 1 ether (pegged 1:1). + /// Tests can lower this to simulate a haXXX depeg. + uint256 private _peggedTokenPrice = 1 ether; + constructor(address _wrappedCollateralToken, address _peggedToken, address _leveragedToken) { require(_wrappedCollateralToken != address(0), "MockMinter: zero wrapped collateral"); require(_peggedToken != address(0), "MockMinter: zero pegged token"); @@ -28,4 +32,14 @@ contract MockMinter is BaoOwnableRoles /*, IMinter */ { PEGGED_TOKEN = _peggedToken; LEVERAGED_TOKEN = _leveragedToken; } + + /// @notice Return the current pegged-token price (18 decimals). Matches `IMinter.peggedTokenPrice`. + function peggedTokenPrice() external view returns (uint256) { + return _peggedTokenPrice; + } + + /// @notice Configure the pegged-token price for test scenarios. + function setPeggedTokenPrice(uint256 price) external { + _peggedTokenPrice = price; + } } diff --git a/test/mocks/MockSwapper.sol b/test/mocks/MockSwapper.sol index ae7ba0a1..2c67844b 100644 --- a/test/mocks/MockSwapper.sol +++ b/test/mocks/MockSwapper.sol @@ -29,10 +29,6 @@ contract MockSwapper is ISwapper { shouldRevert = shouldRevert_; } - function previewSwap(address, address, uint256 amountIn) public view override returns (uint256 amountOut) { - amountOut = (amountIn * rate) / 1e18; - } - function swap( address fromToken, address toToken, @@ -44,7 +40,7 @@ contract MockSwapper is ISwapper { revert("MockSwapper: forced revert"); } - amountOut = previewSwap(fromToken, toToken, amountIn); + amountOut = (amountIn * rate) / 1e18; require(amountOut >= minAmountOut, "MockSwapper: slippage"); IERC20(fromToken).safeTransferFrom(msg.sender, address(this), amountIn); From ce86a58545d80b4b2fcd19cee78f605ea14f8525 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Tue, 14 Apr 2026 18:10:48 +0100 Subject: [PATCH 038/232] HarborYield deploy: split configureHarborYield into AC + equivalent config arrays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the isAutoCompounder flag on VaultConfig; the flag was a code smell. Replace with two distinct struct types: - AutoCompounderVaultConfig { marketKey, weight } — the script predicts the collateral AC address from the existing market salt namespace, so callers don't need to know the vault address. - EquivalentVaultConfig { vault, weight, valuationOracle } — external vaults and their peg-value oracles, supplied directly since they don't come from the market infrastructure. configureHarborYield takes both arrays and iterates each, calling the matching HY variant. Structural branching via types, not a flag. Co-Authored-By: Claude Opus 4.6 (1M context) --- script/src/contracts/HarborYield.sol | 63 ++++++++++++++++++---------- 1 file changed, 42 insertions(+), 21 deletions(-) diff --git a/script/src/contracts/HarborYield.sol b/script/src/contracts/HarborYield.sol index 55f3f064..5cbcd94d 100644 --- a/script/src/contracts/HarborYield.sol +++ b/script/src/contracts/HarborYield.sol @@ -15,6 +15,10 @@ import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; /// collateral, equivalents) that share the same peg. Standalone -- minimal dependencies /// on the minter deployment infrastructure. abstract contract HarborYield is HarborFactoryDeployer { + // Salt-type constant for the collateral AC — mirrors the value in + // script/src/contracts/AutoCompounder.sol. Kept local to avoid cross-abstract inheritance. + string private constant _AUTOCOMPOUNDER_COLLATERAL = "autoCompounderCollateral"; + // ========== HARBOR YIELD DEPLOYMENT ========== /// @notice Deploy HarborYield_v1 impl only, record in state. @@ -59,30 +63,47 @@ abstract contract HarborYield is HarborFactoryDeployer { proxy = _deployProxyAndRecord(stateData, yieldKey, impl, initData); } - /// @notice Vault registration config. - /// @dev For AutoCompounder vaults, leave `valuationOracle` as `address(0)` — HY introspects - /// the AC directly. For equivalent-yield vaults, `valuationOracle` must be a deployed - /// `IWrappedPriceOracle` pricing the equivalent's asset against the peg token. - struct VaultConfig { - address vault; // ERC4626 vault address - uint64 weight; // target distribution weight - bool isAutoCompounder; // true if vault implements IAutoCompounder - address valuationOracle; // only for !isAutoCompounder; must be address(0) for ACs + /// @notice Register an AutoCompounder for a market as a managed vault in HY. + /// @param marketKey Salt key of the market whose collateral AutoCompounder is being registered + /// (e.g., "EUR::fxUSD"). The collateral AC address is derived from this. + /// @param weight Target distribution weight for this AC in the HY basket. + struct AutoCompounderVaultConfig { + string marketKey; + uint64 weight; + } + + /// @notice Register an equivalent-yield ERC4626 as a managed vault in HY. + /// @param vault The ERC4626 vault address (e.g., an fxSAVE wrapper). + /// @param weight Target distribution weight. + /// @param valuationOracle IWrappedPriceOracle pricing the vault's asset against the peg token. + struct EquivalentVaultConfig { + address vault; + uint64 weight; + address valuationOracle; } - /// @notice Register ERC4626 vaults with a deployed HarborYield. + /// @notice Register a set of collateral AutoCompounders and equivalents with a deployed HY. + /// @dev Two distinct config arrays — no flag. ACs are identified by market key (the script + /// predicts their address from the existing salt namespace); equivalents carry their + /// own vault address and oracle because they're external to the market infrastructure. /// @param hyProxy The HarborYield proxy address. - /// @param configs Array of vault configurations to register. - function configureHarborYield(address hyProxy, VaultConfig[] memory configs) internal { - for (uint256 i = 0; i < configs.length; i++) { - address vault = configs[i].vault; - address asset = IERC4626(vault).asset(); - console.log(" addVault: %s (asset: %s, weight: %s)", vault, asset, configs[i].weight); - if (configs[i].isAutoCompounder) { - HarborYield_v1(hyProxy).addAutoCompounderVault(vault, configs[i].weight); - } else { - HarborYield_v1(hyProxy).addEquivalentVault(vault, configs[i].weight, configs[i].valuationOracle); - } + /// @param autoCompounders ACs to register — one per collateral market in this peg. + /// @param equivalents Equivalent-yield vaults to register alongside the ACs. + function configureHarborYield( + address hyProxy, + AutoCompounderVaultConfig[] memory autoCompounders, + EquivalentVaultConfig[] memory equivalents + ) internal { + for (uint256 i = 0; i < autoCompounders.length; i++) { + address acVault = _predictAddress(_key(autoCompounders[i].marketKey, _AUTOCOMPOUNDER_COLLATERAL)); + console.log(" addAutoCompounderVault: %s (weight: %s)", acVault, autoCompounders[i].weight); + HarborYield_v1(hyProxy).addAutoCompounderVault(acVault, autoCompounders[i].weight); + } + for (uint256 i = 0; i < equivalents.length; i++) { + address eqVault = equivalents[i].vault; + address asset = IERC4626(eqVault).asset(); + console.log(" addEquivalentVault: %s (asset: %s, weight: %s)", eqVault, asset, equivalents[i].weight); + HarborYield_v1(hyProxy).addEquivalentVault(eqVault, equivalents[i].weight, equivalents[i].valuationOracle); } } } From aa5eb458c0abb7fed5d03f476adae901ba7aa789 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Tue, 14 Apr 2026 18:21:03 +0100 Subject: [PATCH 039/232] fix spurious error --- .claude/settings.local.json | 24 +++++++++++++++++++++++- test/Minter_feeRange.t.sol | 5 +++-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index a2ed6b19..d3126c76 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -25,7 +25,29 @@ "Bash(echo:*)", "WebFetch(domain:forum.gl-inet.com)", "WebFetch(domain:workspace.google.com)", - "Bash(yarn validate:*)" + "Bash(yarn validate:*)", + "Bash(git -C ../harbor-yield.wip-hytoken branch -a)", + "Bash(git -C ../harbor-yield.wip-hytoken log --oneline -10)", + "Bash(git -C ../harbor-yield.wip-hytoken log --oneline wip-hytoken ^main)", + "Bash(git -C ../harbor-yield.wip-hytoken log --oneline wip-yieldToken ^main)", + "Bash(git -C ../harbor-yield.wip-hytoken log --oneline wip-yieldToken ^wip-hytoken)", + "Bash(git -C ../harbor-yield.wip-hytoken log --oneline main -3)", + "Bash(git -C ../harbor-yield.wip-hytoken log --oneline origin/main -5)", + "Bash(git -C ../harbor-yield.wip-hytoken show --stat wip-hytoken)", + "Bash(git -C ../harbor-yield.wip-hytoken show --stat wip-yieldToken)", + "Bash(git -C ../harbor-yield.wip-hytoken diff --stat wip-hytoken wip-yieldToken)", + "Bash(git -C ../harbor-yield.wip-hytoken diff wip-hytoken wip-yieldToken -- src)", + "Bash(git -C ../harbor-yield.wip-hytoken diff --stat wip-hytoken wip-yieldToken -- src)", + "Bash(ls /home/tfras/github/baofinance/harbor/deployments/*.state.json)", + "Bash(git -C ../harbor-yield.wip-hytoken ls-tree -r --name-only wip-yieldToken -- test/ src/)", + "Bash(git -C ../harbor-yield.wip-hytoken show wip-yieldToken:test/HarborAnchoredVault_v1.t.sol)", + "Bash(git -C ../harbor-yield.wip-hytoken show wip-yieldToken:test/base/HarborAnchoredVault_v1TestBase.sol)", + "Bash(git -C ../harbor-yield.wip-hytoken show wip-yieldToken:test/hyToken_v1.advanced.t.sol)", + "Bash(git -C ../harbor-yield.wip-hytoken show wip-yieldToken:test/hyToken_v1.t.sol)", + "Bash(git -C ../harbor-yield.wip-hytoken show wip-yieldToken:test/mocks/Mock1inchRouter.sol)", + "Bash(git -C ~/.claude/plans add -A)", + "Bash(git -C ~/.claude/plans commit -m \"SP_v3 not deployed, permit all-at-once, bao-base ERC20Metadata, Campaign I migration\")", + "Bash(git -C ../harbor-yield.wip-hytoken show wip-hytoken:src/hyToken_v1.sol)" ] } } diff --git a/test/Minter_feeRange.t.sol b/test/Minter_feeRange.t.sol index ee4c4d7a..26106a97 100644 --- a/test/Minter_feeRange.t.sol +++ b/test/Minter_feeRange.t.sol @@ -646,7 +646,6 @@ contract TestMinterFixedFeeRange_ is TestMinterFeeRange { (uint256 p, , uint256 r, ) = IWrappedPriceOracle(priceOracle).latestAnswer(); Measures memory pre; - uint256 lp = IMinter(minter).leveragedTokenPrice(); uint256 fee; uint256 discount; @@ -708,9 +707,11 @@ contract TestMinterFixedFeeRange_ is TestMinterFeeRange { assertNear(post.minterWrapped, pre.minterWrapped + wrapped - fee + discount, 1, "ml minter wrapped"); assertEq(post.userLeveraged, pre.userLeveraged + minted, "ml user leveraged returned"); + // Use full-precision E36 leveraged price rather than the truncated-to-wei public view; + // in depeg scenarios lp can shrink to a few wei and the truncation becomes the dominant error. assertNear( minted, - Math.mulDiv((wrapped - fee + discount) * r /*underlying collateral */, p, lp * 1e18), + Math.mulDiv((wrapped - fee + discount) * r /*underlying collateral */, p, _leveragedPriceE36(p)), q + 2, 0.00000011 ether, // the test calculation is far less accurate than the contract one "ml user leveraged" From 594d6d471152affd00dacd71ded77b9e21ce6b59 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Tue, 14 Apr 2026 18:22:12 +0100 Subject: [PATCH 040/232] sizes update --- regression/sizes.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/regression/sizes.txt b/regression/sizes.txt index 621bb8e1..9830dcb2 100644 --- a/regression/sizes.txt +++ b/regression/sizes.txt @@ -37,7 +37,7 @@ | FakeUUPSUpgradeable | 3,236 | 21,340 | 3,293 | 680,130 | 68.01 | | FmtLib | 85 | 24,491 | 135 | 18,350 | 1.84 | | Genesis_v1 | 7,486 | 17,090 | 8,423 | 1,581,430 | 158.14 | -| HarborYield_v1 | 13,839 | 10,737 | 14,739 | 2,915,190 | 291.52 | +| HarborYield_v1 | 15,999 | 8,577 | 16,966 | 3,369,460 | 336.95 | | LinearReward | 85 | 24,491 | 135 | 18,350 | 1.84 | | MinterMarketConfigLib | 85 | 24,491 | 135 | 18,350 | 1.84 | | Minter_v1 | 23,681 | 895 | 25,515 | 4,991,350 | 499.14 | From eb4bc3dce17f4a9982a12ef4592888f758d96494 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Tue, 14 Apr 2026 18:28:40 +0100 Subject: [PATCH 041/232] HarborYield: migrate to Solady ERC20, add EIP-2612 permit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swap ERC20Upgradeable -> solady/ERC20. HY's existing name/symbol/decimals overrides (backed by ERC20MetadataLib_v1 immutables) resolve against Solady's virtual getters without further changes. Drop __ERC20_init from initialize — Solady has no init hook; all ERC20 state lives in magic storage slots that start zero. Permit, nonces, and DOMAIN_SEPARATOR are inherited from Solady ERC20 with no additional code. Solady's DOMAIN_SEPARATOR uses address() at runtime so it's proxy-correct — two HY proxies behind the same implementation produce distinct domain separators. Add 6 EIP-2612 tests: - happy path: valid signature sets allowance and advances nonce - expired deadline: reverts - wrong signer: attacker-signed signature reverts - replay: second call with same signature reverts (nonce advanced) - proxy-specific domain: two proxies produce different domains - explicit domain format: reconstructs the EIP-712 layout (typehash, name hash, version="1", chainid, proxy address) and asserts equality to catch any regression in name/version/address binding 53 HarborYieldTest tests pass (47 existing + 6 new permit tests). Co-Authored-By: Claude Opus 4.6 (1M context) --- regression/sizes.txt | 2 +- src/autocompounding/HarborYield_v1.sol | 7 +- test/autocompounding/HarborYield.t.sol | 127 +++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 4 deletions(-) diff --git a/regression/sizes.txt b/regression/sizes.txt index 9830dcb2..c9bc2e50 100644 --- a/regression/sizes.txt +++ b/regression/sizes.txt @@ -37,7 +37,7 @@ | FakeUUPSUpgradeable | 3,236 | 21,340 | 3,293 | 680,130 | 68.01 | | FmtLib | 85 | 24,491 | 135 | 18,350 | 1.84 | | Genesis_v1 | 7,486 | 17,090 | 8,423 | 1,581,430 | 158.14 | -| HarborYield_v1 | 15,999 | 8,577 | 16,966 | 3,369,460 | 336.95 | +| HarborYield_v1 | 15,982 | 8,594 | 16,977 | 3,366,170 | 336.62 | | LinearReward | 85 | 24,491 | 135 | 18,350 | 1.84 | | MinterMarketConfigLib | 85 | 24,491 | 135 | 18,350 | 1.84 | | Minter_v1 | 23,681 | 895 | 25,515 | 4,991,350 | 499.14 | diff --git a/src/autocompounding/HarborYield_v1.sol b/src/autocompounding/HarborYield_v1.sol index ce7c5963..771a1d70 100644 --- a/src/autocompounding/HarborYield_v1.sol +++ b/src/autocompounding/HarborYield_v1.sol @@ -3,7 +3,7 @@ pragma solidity 0.8.30; import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; -import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; +import {ERC20} from "@solady/tokens/ERC20.sol"; import {ReentrancyGuardTransientUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardTransientUpgradeable.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; @@ -35,7 +35,7 @@ import {ERC20MetadataLib_v1} from "src/util/ERC20MetadataLib_v1.sol"; contract HarborYield_v1 is Initializable, UUPSUpgradeable, - ERC20Upgradeable, + ERC20, ReentrancyGuardTransientUpgradeable, HarborOwnableRoles, TokenHolder, @@ -147,7 +147,8 @@ contract HarborYield_v1 is _initializeOwner(deployerOwner_, pendingOwner_); __UUPSUpgradeable_init(); __ReentrancyGuardTransient_init(); - __ERC20_init("", ""); + // Solady ERC20 has no init hook — name/symbol are resolved via virtual overrides + // backed by ERC20MetadataLib_v1 immutables in the constructor. Permit is built in. } /*////////////////////////////////////////////////////////////////////////// diff --git a/test/autocompounding/HarborYield.t.sol b/test/autocompounding/HarborYield.t.sol index f6d65e62..e37b1a58 100644 --- a/test/autocompounding/HarborYield.t.sol +++ b/test/autocompounding/HarborYield.t.sol @@ -691,4 +691,131 @@ contract HarborYieldTest is Test { vm.expectRevert(bytes("MockSwapper: slippage")); hy.compound(address(vault0), address(vault1), 10 ether, 0, ""); } + + /*////////////////////////////////////////////////////////////////////////// + H: ERC-20 PERMIT (EIP-2612) + //////////////////////////////////////////////////////////////////////////*/ + + /// @dev Compute the EIP-2612 permit digest for the current HY instance. + function _permitDigest( + address owner, + address spender, + uint256 value, + uint256 nonce, + uint256 deadline + ) internal view returns (bytes32) { + bytes32 structHash = keccak256( + abi.encode( + keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"), + owner, + spender, + value, + nonce, + deadline + ) + ); + return keccak256(abi.encodePacked("\x19\x01", hy.DOMAIN_SEPARATOR(), structHash)); + } + + /// @notice Happy path: valid permit signature sets allowance and increments the nonce. + function test_permit_happyPath() public { + (address signer, uint256 pk) = makeAddrAndKey("signer"); + address spender = makeAddr("spender"); + uint256 value = 123 ether; + uint256 deadline = block.timestamp + 1 hours; + uint256 nonceBefore = hy.nonces(signer); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, _permitDigest(signer, spender, value, nonceBefore, deadline)); + + hy.permit(signer, spender, value, deadline, v, r, s); + + assertEq(hy.allowance(signer, spender), value, "allowance set"); + assertEq(hy.nonces(signer), nonceBefore + 1, "nonce incremented"); + } + + /// @notice An expired deadline reverts. + function test_permit_expiredDeadline_reverts() public { + (address signer, uint256 pk) = makeAddrAndKey("signer"); + address spender = makeAddr("spender"); + + // Advance to a non-zero timestamp so `block.timestamp - 1` is meaningful. + vm.warp(1000); + uint256 deadline = block.timestamp - 1; + uint256 nonce = hy.nonces(signer); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, _permitDigest(signer, spender, 1 ether, nonce, deadline)); + + vm.expectRevert(); // Solady emits its own error; any revert is fine here + hy.permit(signer, spender, 1 ether, deadline, v, r, s); + } + + /// @notice A signature from the wrong signer reverts. + function test_permit_wrongSigner_reverts() public { + (address signer, ) = makeAddrAndKey("signer"); + (, uint256 attackerPk) = makeAddrAndKey("attacker"); + address spender = makeAddr("spender"); + uint256 value = 1 ether; + uint256 deadline = block.timestamp + 1 hours; + uint256 nonce = hy.nonces(signer); + + // Attacker signs for `signer`'s permit — signature recovers to attacker, not signer. + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + attackerPk, + _permitDigest(signer, spender, value, nonce, deadline) + ); + + vm.expectRevert(); + hy.permit(signer, spender, value, deadline, v, r, s); + } + + /// @notice A used signature cannot be replayed — the nonce has advanced. + function test_permit_replay_reverts() public { + (address signer, uint256 pk) = makeAddrAndKey("signer"); + address spender = makeAddr("spender"); + uint256 value = 1 ether; + uint256 deadline = block.timestamp + 1 hours; + uint256 nonce = hy.nonces(signer); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, _permitDigest(signer, spender, value, nonce, deadline)); + + hy.permit(signer, spender, value, deadline, v, r, s); + // Second call with the same signature: nonce has advanced, digest no longer matches. + vm.expectRevert(); + hy.permit(signer, spender, value, deadline, v, r, s); + } + + /// @notice `DOMAIN_SEPARATOR` encodes the proxy's own address at runtime — two HY proxies + /// behind the same implementation produce distinct domain separators. + function test_permit_domainSeparatorIsProxySpecific() public { + bytes32 proxy1Domain = hy.DOMAIN_SEPARATOR(); + + // Deploy a second HY behind a fresh proxy (same impl logic, different address). + HarborYield_v1 impl = new HarborYield_v1( + "Harbor Yield Other", + "hyOTHER", + address(swapper), + address(pegToken) + ); + bytes memory initData = abi.encodeCall(HarborYield_v1.initialize, (address(this), address(this))); + HarborYield_v1 other = HarborYield_v1(address(new ERC1967Proxy(address(impl), initData))); + bytes32 proxy2Domain = other.DOMAIN_SEPARATOR(); + + assertTrue(proxy1Domain != proxy2Domain, "two proxies produce distinct domain separators"); + } + + /// @notice `DOMAIN_SEPARATOR` matches the EIP-712 layout: hash of the domain typehash, + /// name, version, chainid, and verifying contract (the proxy). Reconstructed here + /// independently to catch any regression in name, version, or address binding. + function test_permit_domainSeparatorFormat() public view { + bytes32 expected = keccak256( + abi.encode( + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + keccak256(bytes(hy.name())), + keccak256("1"), + block.chainid, + address(hy) + ) + ); + assertEq(hy.DOMAIN_SEPARATOR(), expected, "domain separator matches EIP-712 layout"); + } } From 67e1a5224bc291180fb902ed7c1682a7cb6dcb1b Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Tue, 14 Apr 2026 19:13:58 +0100 Subject: [PATCH 042/232] StabilityPool_v3: adopt Solady ERC20, add EIP-2612 permit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inherit solady/ERC20 alongside the existing rebasing custom state. The rebasing balance/transfer machinery stays — balanceOf, totalSupply, transfer, transferFrom are overridden to route through the compounded- balance accounting in _transferBalance. allowance, approve, permit, nonces, DOMAIN_SEPARATOR, _spendAllowance, and _approve come from Solady ERC20 directly; they use Solady's hand-picked magic slots which don't collide with this contract's ERC7201 namespace (ERC7201 always ends in 0x00, Solady's slots end in non-zero bytes). Remove the StabilityPoolStorage.allowances mapping (superseded by Solady's magic slot). Remove the custom InsufficientAllowance error (Solady provides its own with the same selector 0x13be252b). Replace TransferExceedsBalance with Solady's InsufficientBalance() (selector 0xf4d678b8) so the decoded error is consistent across code paths. Drop the IERC20Metadata inheritance and the _ERC20_DECIMALS immutable — Solady's default decimals() returns 18, which matches Harbor's pegged tokens. One fewer immutable, one fewer override. Bytecode impact: SP_v3 grew by +694 bytes (23,066 -> 23,760) because Solady's transfer/transferFrom/balanceOf/totalSupply implementations are kept in the compiled bytecode (overrides don't eliminate them). Accepted — still 816 bytes under the 24,576 ceiling, and the savings path (H.5: v1 legacy storage removal in the accumulator) is deferred to a separate plan item to minimise risk. Add 6 EIP-2612 permit tests: - happy path: valid signature sets allowance and advances nonce - expired deadline reverts - wrong signer reverts - replay reverts (nonce advanced) - domain separator matches EIP-712 layout (explicit reconstruction) - allowance survives a rebase (loss) — confirms permit allowance is on Solady's magic allowance slot, independent of the rebasing compounded balance accounting Update 2 existing tests: test_transfer_exceedsBalance_reverts now expects ERC20.InsufficientBalance, test_transferFrom_insufficientAllowance_reverts now expects ERC20.InsufficientAllowance (both provided by Solady). 41/41 TestStabilityPool_v3_ERC20 tests pass (35 existing + 6 new permit tests). Full suite: 828 passing, 0 failing. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/minter/StabilityPool_v3.sol | 90 +++++++----------- test/StabilityPool_v3_ERC20.t.sol | 146 ++++++++++++++++++++++++++++-- 2 files changed, 171 insertions(+), 65 deletions(-) diff --git a/src/minter/StabilityPool_v3.sol b/src/minter/StabilityPool_v3.sol index 85ad4559..53dec420 100644 --- a/src/minter/StabilityPool_v3.sol +++ b/src/minter/StabilityPool_v3.sol @@ -6,6 +6,7 @@ import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/U import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {ERC20} from "@solady/tokens/ERC20.sol"; import {Token} from "@bao/Token.sol"; import {TokenHolder} from "@bao/TokenHolder.sol"; @@ -15,8 +16,6 @@ import {MultipleRewardCompoundingAccumulator_v3} from "src/reward/accumulator/Mu import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; import {IMinter} from "src/interfaces/IMinter.sol"; -import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; -import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; import {ERC20MetadataLib_v1} from "src/util/ERC20MetadataLib_v1.sol"; // solhint-disable not-rely-on-time // slither-disable-start timestamp @@ -39,10 +38,10 @@ import {ERC20MetadataLib_v1} from "src/util/ERC20MetadataLib_v1.sol"; contract StabilityPool_v3 is Initializable, UUPSUpgradeable, + ERC20, MultipleRewardCompoundingAccumulator_v3, TokenHolder, - IStabilityPool, - IERC20Metadata + IStabilityPool { using SafeERC20 for IERC20; using DecrementalFloatingPoint for uint128; @@ -101,9 +100,6 @@ contract StabilityPool_v3 is /// @custom:oz-upgrades-unsafe-allow state-variable-immutable bytes32 private immutable _ERC20_SYMBOL; - /// @dev ERC20 decimals, matching the ASSET_TOKEN - /// @custom:oz-upgrades-unsafe-allow state-variable-immutable - uint8 private immutable _ERC20_DECIMALS; /*********** * Structs * @@ -141,6 +137,10 @@ contract StabilityPool_v3 is // Share-with-proxy Storage // ------------------------ /// @custom:storage-location erc7201:bao.storage.StabilityPool + /// @dev ERC20 allowances and nonces (for EIP-2612 permit) are stored in Solady ERC20's + /// hand-picked magic slots (see `@solady/tokens/ERC20.sol`). They do not collide with + /// this ERC7201 namespace — Solady's slots end in non-zero bytes while ERC7201 slots + /// always end in `0x00`. No storage field for allowances here. struct StabilityPoolStorage { /// @dev The TokenBalance struct for current total supply. TokenBalance totalAssetSupply; @@ -158,8 +158,6 @@ contract StabilityPool_v3 is mapping(address => WithdrawalRequest) withdrawalRequests; /// @dev Packed fee configuration (address + uint96) FeePayment feePayment; - /// @dev ERC20 allowances: owner => spender => amount - mapping(address => mapping(address => uint256)) allowances; } // chisel eval 'keccak256(abi.encode(uint256(keccak256("bao.storage.StabilityPool")) - 1)) & ~bytes32(uint256(0xff))' @@ -178,8 +176,10 @@ contract StabilityPool_v3 is * Errors * **********/ - error TransferExceedsBalance(address from, uint256 amount, uint256 balance); - error InsufficientAllowance(address spender, uint256 currentAllowance, uint256 needed); + // `InsufficientBalance()` and `InsufficientAllowance()` are provided by Solady's ERC20 + // (selectors 0xf4d678b8 and 0x13be252b respectively). `_transferBalance` reverts with + // `InsufficientBalance()`; `_spendAllowance` reverts with `InsufficientAllowance()`. + // Both are in scope via the ERC20 inheritance. /*************** * Constructor * @@ -232,7 +232,6 @@ contract StabilityPool_v3 is (_ERC20_NAME_0, _ERC20_NAME_1) = ERC20MetadataLib_v1.packName(name_); _ERC20_SYMBOL = ERC20MetadataLib_v1.packSymbol(symbol_); address asset = IMinter(minter_).PEGGED_TOKEN(); - _ERC20_DECIMALS = IERC20Metadata(asset).decimals(); Token.sanityCheckERC20Token(asset); // slither-disable-next-line missing-zero-check ASSET_TOKEN = asset; @@ -626,32 +625,27 @@ contract StabilityPool_v3 is } // ═══════════════════════════════════════════════════════════════════════ - // ERC20 View Functions + // ERC20 Surface (Solady overrides where needed) // ═══════════════════════════════════════════════════════════════════════ - - /// @inheritdoc IERC20Metadata - function name() external view returns (string memory) { + // + // name/symbol/balanceOf/totalSupply/transfer/transferFrom override Solady's virtuals + // to route through the rebasing balance state. Everything else — decimals (18), + // allowance, approve, permit, nonces, DOMAIN_SEPARATOR, _spendAllowance, _approve — + // comes from Solady ERC20 directly, operating on Solady's hand-picked magic slots + // that don't collide with this contract's ERC7201 namespace. + + function name() public view override returns (string memory) { return ERC20MetadataLib_v1.unpackName(_ERC20_NAME_0, _ERC20_NAME_1); } - /// @inheritdoc IERC20Metadata - function symbol() external view returns (string memory) { + function symbol() public view override returns (string memory) { return ERC20MetadataLib_v1.unpackSymbol(_ERC20_SYMBOL); } - /// @inheritdoc IERC20Metadata - function decimals() external view returns (uint8) { - return _ERC20_DECIMALS; - } - - /// @inheritdoc IERC20 - function allowance(address owner_, address spender) external view returns (uint256) { - StabilityPoolStorage storage $ = _getStabilityPoolStorage(); - return $.allowances[owner_][spender]; - } - - /// @inheritdoc IERC20 - function balanceOf(address account) external view returns (uint256 amount) { + /// @dev Rebasing balance — computed from the user's stored amount+product against the + /// current total-supply product. Solady's magic balance slot is never written to; + /// this override is the sole source of truth. + function balanceOf(address account) public view override returns (uint256 amount) { StabilityPoolStorage storage $ = _getStabilityPoolStorage(); amount = _getCompoundedBalance( $.assetBalances[account].amount, @@ -660,43 +654,23 @@ contract StabilityPool_v3 is ); } - /// @inheritdoc IERC20 - function totalSupply() external view returns (uint256 totalSupply_) { + function totalSupply() public view override returns (uint256 totalSupply_) { totalSupply_ = _getStabilityPoolStorage().totalAssetSupply.amount; } - // ═══════════════════════════════════════════════════════════════════════ - // ERC20 Mutator Functions - // ═══════════════════════════════════════════════════════════════════════ - - function transfer(address to, uint256 amount) external nonReentrant returns (bool) { + function transfer(address to, uint256 amount) public override nonReentrant returns (bool) { _transferBalance(_msgSender(), to, amount); return true; } - function transferFrom(address from, address to, uint256 amount) external nonReentrant returns (bool) { - StabilityPoolStorage storage $ = _getStabilityPoolStorage(); - address spender = _msgSender(); - uint256 currentAllowance = $.allowances[from][spender]; - if (currentAllowance != type(uint256).max) { - if (currentAllowance < amount) { - revert InsufficientAllowance(spender, currentAllowance, amount); - } - unchecked { - $.allowances[from][spender] = currentAllowance - amount; - } - } + function transferFrom(address from, address to, uint256 amount) public override nonReentrant returns (bool) { + // Solady handles the allowance check and decrement (max-allowance short-circuit, + // InsufficientAllowance revert). Then perform the rebasing-aware transfer. + _spendAllowance(from, _msgSender(), amount); _transferBalance(from, to, amount); return true; } - function approve(address spender, uint256 amount) external returns (bool) { - StabilityPoolStorage storage $ = _getStabilityPoolStorage(); - $.allowances[_msgSender()][spender] = amount; - emit Approval(_msgSender(), spender, amount); - return true; - } - // ═══════════════════════════════════════════════════════════════════════ // ERC20 Internal Helpers // ═══════════════════════════════════════════════════════════════════════ @@ -716,7 +690,7 @@ contract StabilityPool_v3 is TokenBalance memory fromBalance = $.assetBalances[from]; if (amount > fromBalance.amount) { - revert TransferExceedsBalance(from, amount, fromBalance.amount); + revert InsufficientBalance(); } unchecked { fromBalance.amount -= uint104(amount); diff --git a/test/StabilityPool_v3_ERC20.t.sol b/test/StabilityPool_v3_ERC20.t.sol index d02aec03..634a1aaa 100644 --- a/test/StabilityPool_v3_ERC20.t.sol +++ b/test/StabilityPool_v3_ERC20.t.sol @@ -3,6 +3,7 @@ pragma solidity >=0.8.28 <0.9.0; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import {ERC20} from "@solady/tokens/ERC20.sol"; import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; @@ -209,14 +210,13 @@ contract TestStabilityPool_v3_ERC20 is DeployEURSetUp { assertEq(IERC20(sp).balanceOf(user1), 0, "sender zero"); } - /// Intent: transferring more than balance reverts with TransferExceedsBalance. + /// Intent: transferring more than balance reverts with InsufficientBalance. + /// The error selector matches Solady's own `InsufficientBalance()` convention. function test_transfer_exceedsBalance_reverts() public { _deposit(user1, 10 ether); vm.prank(user1); - vm.expectRevert( - abi.encodeWithSelector(StabilityPool_v3.TransferExceedsBalance.selector, user1, 11 ether, 10 ether) - ); + vm.expectRevert(ERC20.InsufficientBalance.selector); IERC20(sp).transfer(user2, 11 ether); } @@ -314,6 +314,7 @@ contract TestStabilityPool_v3_ERC20 is DeployEURSetUp { } /// Intent: transferFrom with insufficient allowance reverts with InsufficientAllowance. + /// The selector matches Solady's built-in `InsufficientAllowance()` convention. function test_transferFrom_insufficientAllowance_reverts() public { _deposit(user1, 10 ether); @@ -321,9 +322,7 @@ contract TestStabilityPool_v3_ERC20 is DeployEURSetUp { IERC20(sp).approve(user2, 2 ether); vm.prank(user2); - vm.expectRevert( - abi.encodeWithSelector(StabilityPool_v3.InsufficientAllowance.selector, user2, 2 ether, 3 ether) - ); + vm.expectRevert(ERC20.InsufficientAllowance.selector); IERC20(sp).transferFrom(user1, user2, 3 ether); } @@ -574,4 +573,137 @@ contract TestStabilityPool_v3_ERC20 is DeployEURSetUp { assertApproxEqAbs(total, balanceAfterLoss, 2, "total conserved across 3 addresses"); assertApproxEqAbs(remaining, balanceAfterLoss - firstTransfer - secondTransfer, 1, "sender remainder correct"); } + + // ═══════════════════════════════════════════════════════════════════════ + // H: EIP-2612 Permit (Solady-provided) + // ═══════════════════════════════════════════════════════════════════════ + + /// @dev Compute the EIP-2612 permit digest for the SP contract. + function _permitDigest( + address owner, + address spender, + uint256 value, + uint256 nonce, + uint256 deadline + ) internal view returns (bytes32) { + bytes32 structHash = keccak256( + abi.encode( + keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"), + owner, + spender, + value, + nonce, + deadline + ) + ); + return keccak256(abi.encodePacked("\x19\x01", StabilityPool_v3(sp).DOMAIN_SEPARATOR(), structHash)); + } + + /// @notice Happy path: valid permit signature sets allowance and increments the nonce. + /// Permit approves an allowance that survives rebases — the allowance is on + /// the ERC20 share token, not on the compounded balance. + function test_permit_happyPath() public { + (address signer, uint256 pk) = makeAddrAndKey("signer"); + address spender = makeAddr("spender"); + uint256 value = 5 ether; + uint256 deadline = block.timestamp + 1 hours; + uint256 nonceBefore = StabilityPool_v3(sp).nonces(signer); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, _permitDigest(signer, spender, value, nonceBefore, deadline)); + + StabilityPool_v3(sp).permit(signer, spender, value, deadline, v, r, s); + + assertEq(IERC20(sp).allowance(signer, spender), value, "allowance set"); + assertEq(StabilityPool_v3(sp).nonces(signer), nonceBefore + 1, "nonce incremented"); + } + + /// @notice An expired deadline reverts. + function test_permit_expiredDeadline_reverts() public { + (address signer, uint256 pk) = makeAddrAndKey("signer"); + address spender = makeAddr("spender"); + + uint256 deadline = block.timestamp - 1; + uint256 nonce = StabilityPool_v3(sp).nonces(signer); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, _permitDigest(signer, spender, 1 ether, nonce, deadline)); + + vm.expectRevert(); + StabilityPool_v3(sp).permit(signer, spender, 1 ether, deadline, v, r, s); + } + + /// @notice A signature from the wrong signer reverts. + function test_permit_wrongSigner_reverts() public { + (address signer, ) = makeAddrAndKey("signer"); + (, uint256 attackerPk) = makeAddrAndKey("attacker"); + address spender = makeAddr("spender"); + uint256 value = 1 ether; + uint256 deadline = block.timestamp + 1 hours; + uint256 nonce = StabilityPool_v3(sp).nonces(signer); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + attackerPk, + _permitDigest(signer, spender, value, nonce, deadline) + ); + + vm.expectRevert(); + StabilityPool_v3(sp).permit(signer, spender, value, deadline, v, r, s); + } + + /// @notice A used signature cannot be replayed — the nonce has advanced. + function test_permit_replay_reverts() public { + (address signer, uint256 pk) = makeAddrAndKey("signer"); + address spender = makeAddr("spender"); + uint256 value = 1 ether; + uint256 deadline = block.timestamp + 1 hours; + uint256 nonce = StabilityPool_v3(sp).nonces(signer); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, _permitDigest(signer, spender, value, nonce, deadline)); + + StabilityPool_v3(sp).permit(signer, spender, value, deadline, v, r, s); + vm.expectRevert(); + StabilityPool_v3(sp).permit(signer, spender, value, deadline, v, r, s); + } + + /// @notice `DOMAIN_SEPARATOR` matches the EIP-712 layout: hash of the domain typehash, + /// name, version, chainid, and verifying contract (the proxy). + function test_permit_domainSeparatorFormat() public view { + bytes32 expected = keccak256( + abi.encode( + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + keccak256(bytes(IERC20Metadata(sp).name())), + keccak256("1"), + block.chainid, + sp + ) + ); + assertEq(StabilityPool_v3(sp).DOMAIN_SEPARATOR(), expected, "domain separator matches EIP-712 layout"); + } + + /// @notice A permit approval persists across a rebase (loss) — the allowance is on the + /// share token's allowance slot, which is independent of the compounded balance + /// accounting that rebases reduce. + function test_permit_allowanceSurvivesRebase() public { + (address signer, uint256 pk) = makeAddrAndKey("signer"); + address spender = makeAddr("spender"); + + _deposit(signer, 10 ether); + _grantPermit(signer, pk, spender, 5 ether); + + assertEq(IERC20(sp).allowance(signer, spender), 5 ether, "allowance set"); + + // Trigger a rebase (50% loss). + _applyLoss(5 ether, 5 ether); + + // Signer's balance should have dropped, but the allowance is unchanged. + assertLt(IERC20(sp).balanceOf(signer), 10 ether, "signer balance reduced by rebase"); + assertEq(IERC20(sp).allowance(signer, spender), 5 ether, "allowance unchanged by rebase"); + } + + /// @dev Sign and submit a permit for `value` with a 1-hour deadline. + function _grantPermit(address signer, uint256 pk, address spender, uint256 value) internal { + uint256 deadline = block.timestamp + 1 hours; + uint256 nonce = StabilityPool_v3(sp).nonces(signer); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, _permitDigest(signer, spender, value, nonce, deadline)); + StabilityPool_v3(sp).permit(signer, spender, value, deadline, v, r, s); + } } From 90568460484ed3891f4e3b66bfacd4acec630406 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Tue, 14 Apr 2026 19:48:10 +0100 Subject: [PATCH 043/232] SP_v3: document rebasing-allowance semantic; regression sizes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add NatSpec on StabilityPool_v3 explaining that balances rebase on loss but allowances do NOT — allowances are nominal uint256, same as stETH. A pre-rebase approval represents a larger fraction of the post-rebase balance. Users who want proportional authority should use `approve(spender, type(uint256).max)`. Update regression/sizes.txt with the post-Solady size (23,760 bytes, 816 under the ceiling, +694 from baseline — accounted for in detail in the commit that introduced the Solady migration). Co-Authored-By: Claude Opus 4.6 (1M context) --- regression/sizes.txt | 2 +- src/minter/StabilityPool_v3.sol | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/regression/sizes.txt b/regression/sizes.txt index c9bc2e50..f10cef98 100644 --- a/regression/sizes.txt +++ b/regression/sizes.txt @@ -48,7 +48,7 @@ | StabilityPoolManager_v1 | 11,209 | 13,367 | 13,057 | 2,372,370 | 237.24 | | StabilityPool_v1 | 21,092 | 3,484 | 23,085 | 4,449,250 | 444.93 | | StabilityPool_v2 | 20,711 | 3,865 | 22,579 | 4,367,990 | 436.80 | -| StabilityPool_v3 | 23,066 | 1,510 | 25,562 | 4,868,820 | 486.88 | +| StabilityPool_v3 | 23,760 | 816 | 26,139 | 5,013,390 | 501.34 | | StakedETHWrappedPriceOracle_v1 | 3,695 | 20,881 | 4,653 | 785,530 | 78.55 | | TokenDistributor_v1 | 10,116 | 14,460 | 10,365 | 2,126,850 | 212.69 | | WordCodec | 85 | 24,491 | 135 | 18,350 | 1.84 | diff --git a/src/minter/StabilityPool_v3.sol b/src/minter/StabilityPool_v3.sol index 53dec420..dcb9c410 100644 --- a/src/minter/StabilityPool_v3.sol +++ b/src/minter/StabilityPool_v3.sol @@ -30,6 +30,10 @@ import {ERC20MetadataLib_v1} from "src/util/ERC20MetadataLib_v1.sol"; /// drops below a threshold. In that event some, ro even all, deposited assets are converted to wrapped collatersl /// or to leveage tokens, depending on what the LIQUIDATION_TOKEN is. /// +/// @dev Balances rebase on loss; allowances do NOT (they're nominal uint256, same as stETH). +/// A pre-rebase approval represents a larger fraction of the post-rebase balance. +/// Use `approve(spender, type(uint256).max)` for proportional authority. +/// /// @author rootminus0x1 forked from Aladdin's Fx framework and significantly changed /// @dev Uses UUPS proxy, erc7201 storage /// @custom:oz-upgrades From f8cc8d563b78a4b2fe2756a103ab4630b790c380 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Wed, 15 Apr 2026 09:15:51 +0100 Subject: [PATCH 044/232] migrate to Solady ERC4626; adopt shared PermitTestBase --- lib/bao-base | 2 +- regression/sizes.txt | 2 +- src/autocompounding/AutoCompounder_v1.sol | 52 ++------- test/StabilityPool_v3_ERC20.t.sol | 128 ++-------------------- test/autocompounding/HarborYield.t.sol | 119 ++------------------ test/deployment/AutoCompounderTest.t.sol | 7 +- 6 files changed, 36 insertions(+), 274 deletions(-) diff --git a/lib/bao-base b/lib/bao-base index e1813a83..075141a8 160000 --- a/lib/bao-base +++ b/lib/bao-base @@ -1 +1 @@ -Subproject commit e1813a8362a19ef0e605f3a9fd0471ab3474e871 +Subproject commit 075141a890ae66b606367ea25dc85456f2ffb7a6 diff --git a/regression/sizes.txt b/regression/sizes.txt index f10cef98..90189ae4 100644 --- a/regression/sizes.txt +++ b/regression/sizes.txt @@ -1,6 +1,6 @@ | Contract | Runtime Size (B) | Runtime Margin (B) | Initcode Size (B) | Deploy Gas | Deploy Cost ($) | |-----------------------------------|--------------------|----------------------|---------------------|--------------|-------------------| -| AutoCompounder_v1 | 12,363 | 12,213 | 13,907 | 2,611,670 | 261.17 | +| AutoCompounder_v1 | 11,885 | 12,691 | 13,457 | 2,511,570 | 251.16 | | ConfigIncentiveLib | 85 | 24,491 | 135 | 18,350 | 1.84 | | ConfigMarket_BTC_fxUSD_mainnet | 6,856 | 17,720 | 6,884 | 1,440,040 | 144.00 | | ConfigMarket_BTC_stETH_mainnet | 6,882 | 17,694 | 6,910 | 1,445,500 | 144.55 | diff --git a/src/autocompounding/AutoCompounder_v1.sol b/src/autocompounding/AutoCompounder_v1.sol index e8588728..49d56436 100644 --- a/src/autocompounding/AutoCompounder_v1.sol +++ b/src/autocompounding/AutoCompounder_v1.sol @@ -3,11 +3,9 @@ pragma solidity 0.8.30; import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; -import {ERC4626Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC4626Upgradeable.sol"; -import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; +import {ERC4626} from "@solady/tokens/ERC4626.sol"; import {ReentrancyGuardTransientUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardTransientUpgradeable.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; @@ -35,7 +33,7 @@ import {ERC20MetadataLib_v1} from "src/util/ERC20MetadataLib_v1.sol"; contract AutoCompounder_v1 is Initializable, UUPSUpgradeable, - ERC4626Upgradeable, + ERC4626, ReentrancyGuardTransientUpgradeable, HarborOwnable, TokenHolder, @@ -151,8 +149,6 @@ contract AutoCompounder_v1 is _initializeOwner(deployerOwner_, pendingOwner_); __UUPSUpgradeable_init(); __ReentrancyGuardTransient_init(); - __ERC20_init("", ""); - __ERC4626_init(IERC20(STABILITY_POOL)); } /*////////////////////////////////////////////////////////////////////////// @@ -186,24 +182,24 @@ contract AutoCompounder_v1 is } /*////////////////////////////////////////////////////////////////////////// - ERC20 METADATA (IMMUTABLE) + ERC20 / ERC4626 METADATA //////////////////////////////////////////////////////////////////////////*/ + /// @notice The ERC4626 asset — the underlying rebasing StabilityPool share token. + function asset() public view override returns (address) { + return STABILITY_POOL; + } + /// @notice ERC20 name, packed into constructor immutables. - function name() public view override(ERC20Upgradeable, IERC20Metadata) returns (string memory) { + function name() public view override returns (string memory) { return ERC20MetadataLib_v1.unpackName(_ERC20_NAME_0, _ERC20_NAME_1); } /// @notice ERC20 symbol, packed into constructor immutables. - function symbol() public view override(ERC20Upgradeable, IERC20Metadata) returns (string memory) { + function symbol() public view override returns (string memory) { return ERC20MetadataLib_v1.unpackSymbol(_ERC20_SYMBOL); } - /// @dev Decimals match the SP token (18). - function decimals() public pure override(ERC4626Upgradeable) returns (uint8) { - return 18; - } - /*////////////////////////////////////////////////////////////////////////// ERC4626 OVERRIDES //////////////////////////////////////////////////////////////////////////*/ @@ -311,32 +307,4 @@ contract AutoCompounder_v1 is function _checkSweeper() internal view override(TokenHolder) { _checkOwner(); } - - /*////////////////////////////////////////////////////////////////////////// - INTERNAL OVERRIDES - //////////////////////////////////////////////////////////////////////////*/ - - /// @dev Deposit SP tokens from caller into the vault. - function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal override { - IERC20(STABILITY_POOL).safeTransferFrom(caller, address(this), assets); - _mint(receiver, shares); - emit Deposit(caller, receiver, assets, shares); - } - - /// @dev Withdraw SP tokens from the vault to receiver. - /// Uses SP.transfer (not SP.withdraw) - the user receives the rebasing SP token directly. - function _withdraw( - address caller, - address receiver, - address tokenOwner, - uint256 assets, - uint256 shares - ) internal override { - if (caller != tokenOwner) { - _spendAllowance(tokenOwner, caller, shares); - } - _burn(tokenOwner, shares); - IERC20(STABILITY_POOL).safeTransfer(receiver, assets); - emit Withdraw(caller, receiver, tokenOwner, assets, shares); - } } diff --git a/test/StabilityPool_v3_ERC20.t.sol b/test/StabilityPool_v3_ERC20.t.sol index 634a1aaa..780d224d 100644 --- a/test/StabilityPool_v3_ERC20.t.sol +++ b/test/StabilityPool_v3_ERC20.t.sol @@ -11,12 +11,17 @@ import {StabilityPool_v3} from "src/minter/StabilityPool_v3.sol"; import {ERC20MetadataLib_v1} from "src/util/ERC20MetadataLib_v1.sol"; import {DeployEURSetUp} from "test/deployment/DeployEURSetUp.t.sol"; +import {PermitTestBase} from "@bao-test/helpers/PermitTestBase.t.sol"; /// @title TestStabilityPool_v3_ERC20 /// @notice Coverage tests for StabilityPool_v3 ERC20 functions and transfer equivalence. /// Uses IERC20/IERC20Metadata interfaces per CLAUDE.md. /// Inherits production deployment infrastructure (DeployEURSetUp) for realistic test setup. -contract TestStabilityPool_v3_ERC20 is DeployEURSetUp { +contract TestStabilityPool_v3_ERC20 is DeployEURSetUp, PermitTestBase { + function _permitTarget() internal view override returns (address) { + return sp; + } + address user1; address user2; address user3; @@ -574,117 +579,12 @@ contract TestStabilityPool_v3_ERC20 is DeployEURSetUp { assertApproxEqAbs(remaining, balanceAfterLoss - firstTransfer - secondTransfer, 1, "sender remainder correct"); } - // ═══════════════════════════════════════════════════════════════════════ - // H: EIP-2612 Permit (Solady-provided) - // ═══════════════════════════════════════════════════════════════════════ - - /// @dev Compute the EIP-2612 permit digest for the SP contract. - function _permitDigest( - address owner, - address spender, - uint256 value, - uint256 nonce, - uint256 deadline - ) internal view returns (bytes32) { - bytes32 structHash = keccak256( - abi.encode( - keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"), - owner, - spender, - value, - nonce, - deadline - ) - ); - return keccak256(abi.encodePacked("\x19\x01", StabilityPool_v3(sp).DOMAIN_SEPARATOR(), structHash)); - } - - /// @notice Happy path: valid permit signature sets allowance and increments the nonce. - /// Permit approves an allowance that survives rebases — the allowance is on - /// the ERC20 share token, not on the compounded balance. - function test_permit_happyPath() public { - (address signer, uint256 pk) = makeAddrAndKey("signer"); - address spender = makeAddr("spender"); - uint256 value = 5 ether; - uint256 deadline = block.timestamp + 1 hours; - uint256 nonceBefore = StabilityPool_v3(sp).nonces(signer); - - (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, _permitDigest(signer, spender, value, nonceBefore, deadline)); - - StabilityPool_v3(sp).permit(signer, spender, value, deadline, v, r, s); - - assertEq(IERC20(sp).allowance(signer, spender), value, "allowance set"); - assertEq(StabilityPool_v3(sp).nonces(signer), nonceBefore + 1, "nonce incremented"); - } - - /// @notice An expired deadline reverts. - function test_permit_expiredDeadline_reverts() public { - (address signer, uint256 pk) = makeAddrAndKey("signer"); - address spender = makeAddr("spender"); - - uint256 deadline = block.timestamp - 1; - uint256 nonce = StabilityPool_v3(sp).nonces(signer); - - (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, _permitDigest(signer, spender, 1 ether, nonce, deadline)); - - vm.expectRevert(); - StabilityPool_v3(sp).permit(signer, spender, 1 ether, deadline, v, r, s); - } - - /// @notice A signature from the wrong signer reverts. - function test_permit_wrongSigner_reverts() public { - (address signer, ) = makeAddrAndKey("signer"); - (, uint256 attackerPk) = makeAddrAndKey("attacker"); - address spender = makeAddr("spender"); - uint256 value = 1 ether; - uint256 deadline = block.timestamp + 1 hours; - uint256 nonce = StabilityPool_v3(sp).nonces(signer); - - (uint8 v, bytes32 r, bytes32 s) = vm.sign( - attackerPk, - _permitDigest(signer, spender, value, nonce, deadline) - ); - - vm.expectRevert(); - StabilityPool_v3(sp).permit(signer, spender, value, deadline, v, r, s); - } - - /// @notice A used signature cannot be replayed — the nonce has advanced. - function test_permit_replay_reverts() public { - (address signer, uint256 pk) = makeAddrAndKey("signer"); - address spender = makeAddr("spender"); - uint256 value = 1 ether; - uint256 deadline = block.timestamp + 1 hours; - uint256 nonce = StabilityPool_v3(sp).nonces(signer); - - (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, _permitDigest(signer, spender, value, nonce, deadline)); - - StabilityPool_v3(sp).permit(signer, spender, value, deadline, v, r, s); - vm.expectRevert(); - StabilityPool_v3(sp).permit(signer, spender, value, deadline, v, r, s); - } - - /// @notice `DOMAIN_SEPARATOR` matches the EIP-712 layout: hash of the domain typehash, - /// name, version, chainid, and verifying contract (the proxy). - function test_permit_domainSeparatorFormat() public view { - bytes32 expected = keccak256( - abi.encode( - keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), - keccak256(bytes(IERC20Metadata(sp).name())), - keccak256("1"), - block.chainid, - sp - ) - ); - assertEq(StabilityPool_v3(sp).DOMAIN_SEPARATOR(), expected, "domain separator matches EIP-712 layout"); - } - - /// @notice A permit approval persists across a rebase (loss) — the allowance is on the - /// share token's allowance slot, which is independent of the compounded balance + /// @notice SP-specific: permit approval persists across a rebase (loss). The allowance + /// sits on the share token's allowance slot, independent of the compounded balance /// accounting that rebases reduce. function test_permit_allowanceSurvivesRebase() public { - (address signer, uint256 pk) = makeAddrAndKey("signer"); - address spender = makeAddr("spender"); + (address signer, uint256 pk) = makeAddrAndKey("permit.signer"); + address spender = makeAddr("permit.spender"); _deposit(signer, 10 ether); _grantPermit(signer, pk, spender, 5 ether); @@ -698,12 +598,4 @@ contract TestStabilityPool_v3_ERC20 is DeployEURSetUp { assertLt(IERC20(sp).balanceOf(signer), 10 ether, "signer balance reduced by rebase"); assertEq(IERC20(sp).allowance(signer, spender), 5 ether, "allowance unchanged by rebase"); } - - /// @dev Sign and submit a permit for `value` with a 1-hour deadline. - function _grantPermit(address signer, uint256 pk, address spender, uint256 value) internal { - uint256 deadline = block.timestamp + 1 hours; - uint256 nonce = StabilityPool_v3(sp).nonces(signer); - (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, _permitDigest(signer, spender, value, nonce, deadline)); - StabilityPool_v3(sp).permit(signer, spender, value, deadline, v, r, s); - } } diff --git a/test/autocompounding/HarborYield.t.sol b/test/autocompounding/HarborYield.t.sol index e37b1a58..e3e3dea6 100644 --- a/test/autocompounding/HarborYield.t.sol +++ b/test/autocompounding/HarborYield.t.sol @@ -15,13 +15,18 @@ import {MockSwapper} from "test/mocks/MockSwapper.sol"; import {MockERC4626Vault} from "test/mocks/MockERC4626Vault.sol"; import {MockMinter} from "test/mocks/MockMinter.sol"; import {MockWrappedPriceOracle} from "test/mocks/MockWrappedPriceOracle.sol"; +import {PermitTestBase} from "@bao-test/helpers/PermitTestBase.t.sol"; /// @title HarborYield_v1 unit tests /// @notice Tests HarborYield in isolation using MockERC20 assets, MockERC4626Vault, and MockSwapper. /// Avoids the full Minter+SP+AC deployment to keep tests fast and focused on HY behaviour. /// /// Run: forge test --mc HarborYieldTest -vv -contract HarborYieldTest is Test { +contract HarborYieldTest is PermitTestBase { + function _permitTarget() internal view override returns (address) { + return address(hy); + } + // ── Actors ───────────────────────────────────────────────────────── address alice = makeAddr("alice"); address bob = makeAddr("bob"); @@ -692,100 +697,8 @@ contract HarborYieldTest is Test { hy.compound(address(vault0), address(vault1), 10 ether, 0, ""); } - /*////////////////////////////////////////////////////////////////////////// - H: ERC-20 PERMIT (EIP-2612) - //////////////////////////////////////////////////////////////////////////*/ - - /// @dev Compute the EIP-2612 permit digest for the current HY instance. - function _permitDigest( - address owner, - address spender, - uint256 value, - uint256 nonce, - uint256 deadline - ) internal view returns (bytes32) { - bytes32 structHash = keccak256( - abi.encode( - keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"), - owner, - spender, - value, - nonce, - deadline - ) - ); - return keccak256(abi.encodePacked("\x19\x01", hy.DOMAIN_SEPARATOR(), structHash)); - } - - /// @notice Happy path: valid permit signature sets allowance and increments the nonce. - function test_permit_happyPath() public { - (address signer, uint256 pk) = makeAddrAndKey("signer"); - address spender = makeAddr("spender"); - uint256 value = 123 ether; - uint256 deadline = block.timestamp + 1 hours; - uint256 nonceBefore = hy.nonces(signer); - - (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, _permitDigest(signer, spender, value, nonceBefore, deadline)); - - hy.permit(signer, spender, value, deadline, v, r, s); - - assertEq(hy.allowance(signer, spender), value, "allowance set"); - assertEq(hy.nonces(signer), nonceBefore + 1, "nonce incremented"); - } - - /// @notice An expired deadline reverts. - function test_permit_expiredDeadline_reverts() public { - (address signer, uint256 pk) = makeAddrAndKey("signer"); - address spender = makeAddr("spender"); - - // Advance to a non-zero timestamp so `block.timestamp - 1` is meaningful. - vm.warp(1000); - uint256 deadline = block.timestamp - 1; - uint256 nonce = hy.nonces(signer); - - (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, _permitDigest(signer, spender, 1 ether, nonce, deadline)); - - vm.expectRevert(); // Solady emits its own error; any revert is fine here - hy.permit(signer, spender, 1 ether, deadline, v, r, s); - } - - /// @notice A signature from the wrong signer reverts. - function test_permit_wrongSigner_reverts() public { - (address signer, ) = makeAddrAndKey("signer"); - (, uint256 attackerPk) = makeAddrAndKey("attacker"); - address spender = makeAddr("spender"); - uint256 value = 1 ether; - uint256 deadline = block.timestamp + 1 hours; - uint256 nonce = hy.nonces(signer); - - // Attacker signs for `signer`'s permit — signature recovers to attacker, not signer. - (uint8 v, bytes32 r, bytes32 s) = vm.sign( - attackerPk, - _permitDigest(signer, spender, value, nonce, deadline) - ); - - vm.expectRevert(); - hy.permit(signer, spender, value, deadline, v, r, s); - } - - /// @notice A used signature cannot be replayed — the nonce has advanced. - function test_permit_replay_reverts() public { - (address signer, uint256 pk) = makeAddrAndKey("signer"); - address spender = makeAddr("spender"); - uint256 value = 1 ether; - uint256 deadline = block.timestamp + 1 hours; - uint256 nonce = hy.nonces(signer); - - (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, _permitDigest(signer, spender, value, nonce, deadline)); - - hy.permit(signer, spender, value, deadline, v, r, s); - // Second call with the same signature: nonce has advanced, digest no longer matches. - vm.expectRevert(); - hy.permit(signer, spender, value, deadline, v, r, s); - } - - /// @notice `DOMAIN_SEPARATOR` encodes the proxy's own address at runtime — two HY proxies - /// behind the same implementation produce distinct domain separators. + /// @notice HY-specific: `DOMAIN_SEPARATOR` encodes the proxy's own address at runtime — + /// two HY proxies behind the same implementation produce distinct domain separators. function test_permit_domainSeparatorIsProxySpecific() public { bytes32 proxy1Domain = hy.DOMAIN_SEPARATOR(); @@ -802,20 +715,4 @@ contract HarborYieldTest is Test { assertTrue(proxy1Domain != proxy2Domain, "two proxies produce distinct domain separators"); } - - /// @notice `DOMAIN_SEPARATOR` matches the EIP-712 layout: hash of the domain typehash, - /// name, version, chainid, and verifying contract (the proxy). Reconstructed here - /// independently to catch any regression in name, version, or address binding. - function test_permit_domainSeparatorFormat() public view { - bytes32 expected = keccak256( - abi.encode( - keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), - keccak256(bytes(hy.name())), - keccak256("1"), - block.chainid, - address(hy) - ) - ); - assertEq(hy.DOMAIN_SEPARATOR(), expected, "domain separator matches EIP-712 layout"); - } } diff --git a/test/deployment/AutoCompounderTest.t.sol b/test/deployment/AutoCompounderTest.t.sol index 99773308..63cce969 100644 --- a/test/deployment/AutoCompounderTest.t.sol +++ b/test/deployment/AutoCompounderTest.t.sol @@ -8,13 +8,18 @@ import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumula import {IAutoCompounder} from "src/interfaces/IAutoCompounder.sol"; import {AutoCompounder_v1} from "src/autocompounding/AutoCompounder_v1.sol"; import {DeployEURSetUp} from "test/deployment/DeployEURSetUp.t.sol"; +import {PermitTestBase} from "@bao-test/helpers/PermitTestBase.t.sol"; /// @title AutoCompounder tests using EUR peg (fxUSD + stETH collateral). /// Run: forge test --mc AutoCompounderTest --fork-url mainnet -vv -contract AutoCompounderTest is DeployEURSetUp { +contract AutoCompounderTest is DeployEURSetUp, PermitTestBase { address alice = makeAddr("alice"); address bob = makeAddr("bob"); + function _permitTarget() internal view override returns (address) { + return acCollFxUSD; + } + // ── Deployment verification ──────────────────────────────────────── function test_deployment_immutables() public view { From 6716bb38bb582f3236fbaf029b6cc66e0cda8197 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Wed, 15 Apr 2026 10:37:10 +0100 Subject: [PATCH 045/232] doc updates --- deployments/README.md | 2 +- .../autocompounding-vault-design.md | 56 ++- doc/harbor-deployment.md | 4 +- doc/ideas/harbor-yield-comparison.md | 176 ------- doc/ideas/proposed-fee-mechanism.md | 134 ------ doc/ideas/rebalance-fairness.md | 29 +- doc/ideas/sp-auto-compounding-harvests.md | 203 --------- doc/ideas/sp-dynamic-fees.md | 431 ------------------ lib/bao-base | 2 +- test/deployment/RebalanceFairness.t.sol | 2 +- 10 files changed, 61 insertions(+), 978 deletions(-) rename doc/{ideas => }/autocompounding-vault-design.md (79%) delete mode 100644 doc/ideas/harbor-yield-comparison.md delete mode 100644 doc/ideas/proposed-fee-mechanism.md delete mode 100644 doc/ideas/sp-auto-compounding-harvests.md delete mode 100644 doc/ideas/sp-dynamic-fees.md diff --git a/deployments/README.md b/deployments/README.md index 06b8803f..9ff97827 100644 --- a/deployments/README.md +++ b/deployments/README.md @@ -160,4 +160,4 @@ See `script/verify/spl-remediation/remediation-ETH-fxUSD-SPL.md` for full analys ## deploy/1.3 — In Development -SP_v3, Minter_v3, reward aliases, autocompounding infrastructure. See `doc/ideas/autocompounding-vault-design.md`. +SP_v3, Minter_v3, reward aliases, autocompounding infrastructure. See `doc/autocompounding-vault-design.md`. diff --git a/doc/ideas/autocompounding-vault-design.md b/doc/autocompounding-vault-design.md similarity index 79% rename from doc/ideas/autocompounding-vault-design.md rename to doc/autocompounding-vault-design.md index 8197cd07..5ec02533 100644 --- a/doc/ideas/autocompounding-vault-design.md +++ b/doc/autocompounding-vault-design.md @@ -392,7 +392,9 @@ Two distinct "compound" operations live at different layers: ### 6.11 Withdrawal -AC uses EXEMPT_WITHDRAWAL_FEE_ROLE initially. Dynamic fees (CR-based) replace withdrawal delay in future SP version, enabling standard ERC4626 `withdraw` (plan B.6b). +**Current (shipped):** AC uses `EXEMPT_WITHDRAWAL_FEE_ROLE` and SP_v3 still has the request/wait withdrawal window. AC withdrawals route through the AC contract and bypass both fee and window. + +**Planned (B.6b, NOT YET SHIPPED):** Replace the withdrawal window with a CR-based dynamic fee derived from the Minter's incentive ratios (`fee = mintPeggedRatio - redeemPeggedRatio`, clamped to `[0, MAX_WITHDRAWAL_FEE]`). Naturally zero at healthy CR. Enables atomic ERC4626 `withdraw()`. See [rebalance-fairness.md §5A](ideas/rebalance-fairness.md) for the full design. ### 6.12 HarborYield is not ERC-4626 / ERC-7575 @@ -403,30 +405,39 @@ HY's mutation surface is intentionally non-standard: HY will instead expose ERC-4626-style *views* priced in peg units (`asset()`, `totalAssets`, `convertTo*`, `preview*`) for interop with aggregators, indexers, and price feeds. The mutation API stays HY-specific (`deposit(asset, amount, receiver)`, `redeem(shares, receiver, owner)`, `compound`, `redistribute`). See plan B.4.2 for the exact view surface. -### 6.13 Peg Verification +### 6.13 Peg Verification (shipped) HY assumes every managed vault's asset is pegged to the same RWA. Two failure modes: 1. **Config error** — admin registers a vault whose asset is pegged to the wrong RWA (or not pegged at all). Catastrophic valuation error. 2. **Market depeg** — a component trades below peg transiently. New depositors are diluted and redeemers get a worse mix than market value would suggest. -Defense in depth (plan B.4.3): +The shipped design uses two `addVault` variants and a single `maxPegDriftBps` tunable applied at both registration and runtime swap time: + +- **`addAutoCompounderVault(vault, weight)`** — verifies `IAutoCompounder(vault).PEGGED_TOKEN() == _PEG_TOKEN` via direct introspection. No oracle parameter needed; the AC's own immutable proves which peg it serves. Reverts with `WrongPegToken(expected, actual)` on mismatch. + +- **`addEquivalentVault(vault, weight, valuationOracle)`** — takes an `IWrappedPriceOracle` address and verifies the oracle's mid-rate is within `maxPegDriftBps` of `1e18` at registration. Stores the oracle in a sparse `vaultValuationOracle` mapping for runtime use. Reverts with `ExcessivePegDrift(expected, actual)` on mismatch. + +- **Depeg-aware `totalAssets`** — `_fairRateInPegUnits(vault)` branches on the sparse mapping: AC vaults read `IMinter(AC.MINTER()).peggedTokenPrice()` (fair valuation under haXXX depegs), equivalent vaults read their registered oracle. Each vault's `convertToAssets(balance)` is multiplied by its fair rate before being summed. + +- **Oracle-bounded runtime swap floor** — `compound`/`redistribute` compute `_effectiveMinOut(from, to, amountIn, keeperMinOut)` = max of the keeper's `minOut` and `amountIn × fromRate / toRate × (1 - maxPegDriftBps/10_000)`. A compromised keeper passing `minAmountOut = 0` still gets HY's own oracle-derived floor. During a real market depeg, the oracle reflects the depeg and the floor drops with the market — no spurious blocks. + +- **Watchtower + deactivateVault** — owner freezes new deposits to a vault during sustained depegs (off-chain governance). Proportional redeems still work. + +`maxPegDriftBps` is owner-settable via `setMaxPegDriftBps`. The unified parameter is operational simplicity; it can be split into separate registration and runtime tunables later if needed. -- **Config-time pegId** — HY stores an immutable `bytes32 pegId` (e.g. `keccak256("USD")`); `addVault` requires the vault to declare the same pegId. Prevents misconfig, zero runtime cost. -- **Swapper drift check** — inside `compound`/`redistribute`, call `ISwapper.previewSwap(from, to, 1e18)` and revert if the result diverges from 1e18 by more than `maxPegDrift` (owner-tunable, default e.g. 2%). Reuses the existing dep; catches market depeg at the moment it would lock in a bad rate. -- **Watchtower + deactivateVault** — owner freezes new deposits to a vault during sustained depegs. Proportional redeems still work; users see the depeg reflected in their basket. -- **Oracle-valued totalAssets** — deferred. Only add if the above proves insufficient in production. +`ISwapper.previewSwap` was deliberately removed from the interface — production swap adapters (1inch, etc.) don't have on-chain quoting, and any consumer that called `previewSwap` for security purposes was a trap. The oracle-bounded floor supersedes it. -### 6.14 ERC-20 Permit (EIP-2612) +### 6.14 ERC-20 Permit (EIP-2612) — shipped -All new ERC-20 contracts shipped in this work will support `permit(owner, spender, value, deadline, v, r, s)` for approve-and-act in a single transaction: +All harbor-side ERC-20 contracts in this work support `permit(owner, spender, value, deadline, v, r, s)` for approve-and-act in a single transaction: -- `HarborYield_v1` — freshly added via OZ `ERC20PermitUpgradeable`. -- `AutoCompounder_v1` — freshly added via OZ `ERC20PermitUpgradeable` (ERC4626Upgradeable's underlying ERC20). -- `StabilityPool_v4` — added alongside the accumulator cleanup (Campaign A2). Permit is orthogonal to rebasing: it only signs `approve()` authorizations, so a bespoke implementation that uses namespaced (ERC7201) storage for the `nonces` mapping and rebuilds the EIP-712 domain separator at runtime is straightforward. -- `PeggedToken` / `LeveragedToken` — audit first; migrate if not already using `PermittableERC20_v1` from bao-base. +- `HarborYield_v1` — Solady ERC20 with built-in EIP-2612. +- `AutoCompounder_v1` — Solady ERC4626 (which inherits Solady ERC20) with built-in EIP-2612. +- `StabilityPool_v3` — Solady ERC20 with built-in EIP-2612. Custom rebasing balance/total-supply accounting overrides Solady's `balanceOf` / `totalSupply`; allowance / nonces / permit / DOMAIN_SEPARATOR are inherited from Solady unchanged. Allowances are nominal (do NOT scale with rebases — same semantic as stETH). +- `PeggedToken` / `LeveragedToken` — already use `PermittableERC20_v1` / `MintableBurnableERC20_v1` from bao-base; both have permit. Both inherit the new shared `PermitTestBase` test suite. -OZ is chosen over Solady because all four contracts are UUPS upgradeable — Solady's ERC20 is built around immutables and direct storage and would require a hand-rolled upgradeable adapter (new audit surface) for a modest bytecode saving. See plan Campaign H for the full tradeoff. +**Solady was chosen over OZ** after analysis showed Solady's ERC20 / ERC4626 are trivially compatible with UUPS proxies: ERC20 uses hand-picked magic storage slots that cannot collide with ERC7201, ERC4626 has zero storage of its own, and both expose the abstract / virtual hooks needed to wire in upgradeable name/symbol/asset via constructor immutables. The migration freed bytecode on AC (-478 B) while gaining permit on all three contracts. SP_v3 grew ~694 B (accepted, since the bytecode budget still has headroom and the alternative refactors carried more risk than they saved). All five permit-bearing contracts share the `bao-base/test/helpers/PermitTestBase.t.sol` test suite — five canonical permit tests via a single `_permitTarget()` override. ## 7. Access Control @@ -442,16 +453,17 @@ OZ is chosen over Solady because all four contracts are UUPS upgradeable — Sol | Contract | Status | Purpose | |----------|--------|---------| -| StabilityPool_v3 | Done | Rebasing ERC20, unified claim, fractional claim, StringPacking_v1 | +| StabilityPool_v3 | Done | Rebasing ERC20 (Solady + EIP-2612 permit), unified claim, fractional claim, StringPacking_v1 | | Minter_v3 | Done | `mintPeggedToken(maxFeeRatio)`, `mintPeggedTokenDryRun`, private→internal | -| AutoCompounder_v1 | Done | Non-rebasing ERC4626 wrapper per SP (Level 1) | -| HarborYield_v1 | Done (core) | Multi-asset ERC-20 basket per peg (Level 2). `compound`/`redistribute` role-gated | -| ISwapper / MockSwapper | Done | Generic swap interface; mock for tests | +| AutoCompounder_v1 | Done | Non-rebasing ERC4626 wrapper per SP (Level 1), Solady ERC4626 + EIP-2612 permit | +| HarborYield_v1 | Done (core) | Multi-asset ERC-20 basket per peg (Level 2), Solady ERC20 + EIP-2612 permit. Two `addVault` variants (AC introspection vs equivalent + oracle); depeg-aware `totalAssets`; oracle-bounded swap floor; `compound`/`redistribute` role-gated | +| ISwapper / MockSwapper | Done | Generic swap interface; mock for tests. (`previewSwap` deliberately removed — see §6.13.) | | StabilityPoolManager_v2 | Pending (B.5) | SPM triggers `AC.compound()` during harvest/rebalance | -| StabilityPool_v4 | Pending (A2) | Accumulator cleanup, CR-based withdrawal fee (B.6b), ERC-20 permit (H) | +| SP_v3 — CR-based withdrawal fee | Pending (B.6b) | Replace withdrawal window with `fee = mintPeggedRatio - redeemPeggedRatio` clamped to `[0, MAX_WITHDRAWAL_FEE]`. Enables atomic ERC4626 `withdraw()`. See [rebalance-fairness.md §5A](ideas/rebalance-fairness.md). | +| SP_v3 — accumulator cleanup | Pending (A2 / H.5) | Drop v1/v2 legacy accumulator storage fallback; one-shot migration via separate `ForceMigrateAccumulator_v1` | ## 9. References -- [Aladdin fxSAVE analysis](../aladdin/fxSAVE.md) -- ERC4626 wrapping stability pool, proven pattern -- [SP dynamic fees](sp-dynamic-fees.md) -- CR-based fees replacing withdrawal delay -- [SP auto-compounding](sp-auto-compounding-harvests.md) -- deferred: two-product factor for SP-internal compounding +- [Aladdin fxSAVE analysis](aladdin/fxSAVE.md) -- ERC4626 wrapping stability pool, proven pattern +- [Rebalance fairness](ideas/rebalance-fairness.md) -- worked examples, CR-based withdrawal fee design (B.6b), effective share deferred (B.6c) +- [Harbor deployment design](harbor-deployment.md) -- pre-flight, seed deposits, deployHY/deployPeg switches diff --git a/doc/harbor-deployment.md b/doc/harbor-deployment.md index f0e7d66b..bc8b92be 100644 --- a/doc/harbor-deployment.md +++ b/doc/harbor-deployment.md @@ -1,6 +1,6 @@ # Harbor Deployment Design -Companion document to [`autocompounding-vault-design.md`](ideas/autocompounding-vault-design.md). +Companion document to [`autocompounding-vault-design.md`](autocompounding-vault-design.md). This document describes **what we plan to implement** for the deployment flow once Campaign B (auto-compounding vaults) and Campaign H (ERC-20 permit) land. It's a forward-looking spec, not a description of the current deployer — see [`deployments/README.md`](../deployments/README.md) for the history of what's actually on-chain. @@ -252,6 +252,6 @@ Failing any pre-flight check reverts the entire deploy before any on-chain trans ## 11. References - Plan: [`quirky-booping-valley.md`](../../.claude/plans/quirky-booping-valley.md) §H.4.1 for seed mechanics -- Design: [`autocompounding-vault-design.md`](ideas/autocompounding-vault-design.md) for contract architecture +- Design: [`autocompounding-vault-design.md`](autocompounding-vault-design.md) for contract architecture - Existing impl: [`script/src/DeployMintersShared.sol`](../script/src/DeployMintersShared.sol), [`script/src/contracts/PeggedToken.sol`](../script/src/contracts/PeggedToken.sol), [`script/src/contracts/HarborYield.sol`](../script/src/contracts/HarborYield.sol) - Deployment history: [`deployments/README.md`](../deployments/README.md) diff --git a/doc/ideas/harbor-yield-comparison.md b/doc/ideas/harbor-yield-comparison.md deleted file mode 100644 index 7a04e419..00000000 --- a/doc/ideas/harbor-yield-comparison.md +++ /dev/null @@ -1,176 +0,0 @@ -# Harbor Yield: Implementation Comparison - -**harbor** (AutoCompounder_v1 + HarborYield_v1) vs **harbor-yield.wip-hytoken** (hyToken_v1 + HarborAnchoredVault_v1) - -## 1. Architecture - -### harbor: Two-Layer Separation - -``` -User → HarborYield_v1 (holds ERC4626 vault shares) - ├→ AutoCompounder_v1 (ERC4626, wraps one SP) - │ └→ StabilityPool_v3 - ├→ AutoCompounder_v1 (ERC4626, wraps another SP) - │ └→ StabilityPool_v3 - └→ wstETH/fxSAVE (ERC4626, external) -``` - -- **AutoCompounder_v1**: One per SP. Non-rebasing ERC4626. Wraps a rebasing SP token. Compounds harvest rewards (claim wCOL → mint pegged → redeposit). 12KB. -- **HarborYield_v1**: One per peg. Manages multiple ERC4626 vaults (ACs + equivalents). 10KB. -- Total: ~22KB across two contracts, plus the SP. - -### harbor-yield.wip-hytoken: Monolith + Distributor - -``` -User → hyToken_v1 (talks directly to one SP + one secondary asset) - └→ StabilityPool (v1 or v2) - -User → HarborAnchoredVault_v1 (distributes across multiple SPs by weight) - ├→ StabilityPool (collateral 1) - └→ StabilityPool (collateral 2) -``` - -- **hyToken_v1**: One per SP×peg combination. Monolith: handles deposits, withdrawals, claiming, compounding, 1inch swapping, rebalancing, withdrawal requests, oracle pricing, multi-asset accounting. 1175 lines. All-in-one. Swap logic is at this lower level. -- **HarborAnchoredVault_v1**: Weighted distributor across multiple SPs for one pegged asset. Separate concern from compounding/swapping. 358 lines. Clean ERC4626. -- Both are two-level architectures, but they don't compose: HarborAnchoredVault distributes deposits across SPs but has no compounding, while hyToken compounds but only handles one SP. A user wanting multi-SP + compounding cannot get both from either system alone. - ---- - -## 2. Feature Comparison - -| Feature | harbor AC + HY | hyToken_v1 | Winner | -|---------|---------------|------------|--------| -| **ERC4626 compliance** | AC is standard ERC4626. HY is custom (multi-asset deposit/redeem). | ERC4626 with overrides. `asset()` = primary asset. | **harbor** (AC is cleaner ERC4626) | -| **Multi-collateral** | HY manages N vaults (ACs + equivalents). Dynamic add/remove. | hyToken is 1:1 with SP. HarborAnchoredVault distributes across N SPs by weight. | **harbor** (single contract manages all) | -| **Compounding** | AC claims wCOL from SP, mints pegged, redeposits. Uses Minter_v3 fee-capped mint. Permissionless. | `claim()` claims from SP. If CR favorable: mint + redeposit. If not: emit SwapRequested, keeper executes 1inch swap. | **hyToken** (handles unfavorable CR via swap to secondary asset) | -| **Secondary asset management** | HY holds ERC4626 vault shares (wstETH wrapper, fxSAVE wrapper). Values via `convertToAssets`. No swap logic yet. | hyToken holds wstETH directly. Uses 1inch for swaps. Keeper-operated. Price oracles for valuation. | **hyToken** (swap logic implemented, but tightly coupled) | -| **Withdrawal** | AC: standard ERC4626 redeem (returns SP tokens). HY: proportional redeem from all vaults. | Withdrawal request + time window + early fee. Multi-asset withdrawal (secondary first, then primary). | **Tie** — both work but differently. hyToken's withdrawal window is being deprecated. | -| **Oracle dependency** | AC uses `mintPeggedTokenDryRun` for valuation (Minter is the oracle). No external oracle. HY uses `convertToAssets` from each vault. | Uses `IHarborSingleFeedAndRateAggregator` for both primary and secondary assets. Required for non-ETH pegs. | **harbor** (less oracle surface) | -| **1inch integration** | None. | Built into hyToken. `executeSwapWith1inch()` with keeper role. | **hyToken** (has it, harbor doesn't) | -| **Access control** | AC: owner only (setMaxFeeRatio, approveCompoundTokens). HY: owner (addVault, activate/deactivate). Compound is permissionless. | KEEPER_ROLE, EMERGENCY_ROLE, MAINTENANCE_ROLE + owner. More granular. | **hyToken** (more roles, but more complexity) | -| **Emergency functions** | AC: sweep (owner). HY: sweep (owner). | `emergencyWithdrawFromStabilityPool()` + maintenance mode toggle. | **hyToken** (dedicated emergency) | -| **Balance tracking** | AC trusts SP.balanceOf + claimable. HY trusts vault.balanceOf + convertToAssets. No internal balance tracking. | Internal `primaryAssetBalance` + `secondaryAssetBalance` tracking alongside actual balances. | **harbor** (simpler, no divergence risk) | -| **Rebalance handling** | AC: absorbs loss passively (SP product mechanism). `totalAssets` reflects it. No active rebalancing. | hyToken: active rebalancing via MAINTENANCE_ROLE. Can withdraw from SP, redeem pegged, swap to secondary. | **hyToken** (active rebalance) | -| **Upgrade path** | Both UUPS. AC+HY are separate — can upgrade independently. | UUPS. Single contract upgrade. | **harbor** (independent upgrades) | - ---- - -## 3. Critical Analysis - -### harbor (AC + HY) — Honest Assessment - -**Strengths:** -- Clean separation of concerns. AC does one thing (compound one SP). HY does one thing (manage multiple ERC4626 vaults). -- Each contract is independently testable, deployable, upgradeable. -- ERC4626 all the way down — composable with any ERC4626 tooling. -- No internal balance tracking — trusts the underlying vaults. Less state, less divergence risk. -- No oracle dependency for the AC (uses Minter dry run). -- Permissionless compounding — anyone can trigger. -- Small contracts (10-12KB each) with room to grow. - -**Weaknesses:** -- **No swap logic.** When CR is unfavorable and the AC can't profitably mint, it just... waits. The rewards sit as unclaimed wCOL in the SP, valued in `totalAssets` via dry run. There's no mechanism to convert them to a secondary asset. -- **No active rebalance.** If the SP rebalances, the AC passively absorbs the loss. No ability to proactively move assets to a safer position. -- **HarborYield is thin.** It adds/removes ERC4626 vaults and does proportional redeem. No pricing logic beyond `convertToAssets`. No compound logic (removed). The "Level 2" in the design doc promised wXXXn → wCOLn → haXXX conversion, which requires swap infrastructure that doesn't exist yet. -- **No keeper/bounty system.** Compounding is permissionless but there's no incentive to trigger it. In hyToken, the 0.25% bounty incentivizes bots. -- **No emergency functions** beyond sweep. If the SP is in distress, there's no emergency withdraw. -- **Withdrawal window inheritance.** The AC uses `EXEMPT_WITHDRAWAL_FEE_ROLE` to bypass the SP's withdrawal window. This couples the AC to the SP's fee mechanism. When the SP moves to CR-based fees, this needs updating. - -### harbor-yield.wip-hytoken (hyToken_v1) — Honest Assessment - -**Strengths:** -- **End-to-end.** Handles the full lifecycle: deposit, compound, swap, rebalance, emergency, withdrawal. Nothing is deferred. -- **1inch integration.** Can swap wCOL to secondary assets when CR is unfavorable. This is a real operational need. -- **Bounty system.** 0.25% bounty on `claim()` incentivizes keepers. -- **Emergency functions.** Dedicated emergency withdrawal and maintenance mode. -- **Granular roles.** KEEPER, EMERGENCY, MAINTENANCE — clear separation of operational concerns. -- **Multi-asset withdrawal.** Can return secondary asset first (wstETH), then primary. - -**Weaknesses:** -- **Monolith.** 1175 lines in one contract. Compounding, swapping, oracle pricing, withdrawal requests, balance tracking, rebalancing — all in one place. Hard to test individual pieces. Hard to upgrade one concern without touching everything. -- **Internal balance tracking.** `primaryAssetBalance` and `secondaryAssetBalance` are maintained alongside actual token balances. If they diverge (bug, unexpected transfer, token rebasing), the vault misbehaves. This is a significant risk vector. -- **Not ERC4626-composable.** The `asset()` is PRIMARY_ASSET but the vault actually holds two assets. `totalAssets()` sums both in "ETH terms" via oracles. Standard ERC4626 tooling expects `totalAssets()` to be in units of `asset()`. This breaks composability. -- **Oracle-heavy.** Needs `primaryAssetPriceOracle` + `secondaryAssetPriceOracle`. More oracle surface = more manipulation risk + more operational burden. -- **1:1 with SP.** One hyToken per SP. For N collateral types, you need N hyTokens + a separate HarborAnchoredVault to combine them. harbor's architecture handles this with N ACs + 1 HY. -- **1inch dependency.** Off-chain route calculation required. Keeper must call `executeSwapWith1inch()` with pre-computed route data. Two-step async flow for what could be a simple swap. -- **HarborAnchoredVault is disconnected.** It distributes deposits across SPs by weight but has no compounding, no swap logic, no secondary asset handling. It's a dumb distributor. The interesting logic is all in hyToken, which only handles one SP. There's a gap: no single contract combines multi-SP management with compounding/swapping. -- **Withdrawal window duplicated.** hyToken re-implements the SP's withdrawal window logic internally, including request/cancel/timing. This duplicates what the SP already does and will be further duplicated when the SP moves to CR-based fees. -- **StabilityPool_v2 dependency.** Uses an older SP that doesn't have ERC20 transfers, unified claim, or aliases. The `claim()` uses a raw `call` with `encodeWithSignature("claim(address)")` — fragile. -- **`_HYTOKEN_STORAGE` hash is incorrect.** The storage slot `0x8a4c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c00` is a placeholder, not a computed ERC7201 hash. This would cause storage collisions in a real deployment. -- **Constants like `COLLATERAL_RATIO_BUFFER = 0.05 ether`** are hardcoded. Should be configurable or at least constructor args. -- **`_shouldMintPrimaryAsset()` uses `rebalanceThreshold + 5% buffer`** — but this buffer is arbitrary and doesn't account for the fee-capped minting that the harbor AC uses. The harbor AC uses `maxFeeRatio` to let the Minter decide, which is more precise. - -### HarborAnchoredVault_v1 — Honest Assessment - -**Strengths:** -- Simple, clean ERC4626. -- Weighted distribution is clear and correct. -- Uses Solady's ERC4626 (gas-efficient). - -**Weaknesses:** -- No compounding, no reward claiming, no swap logic. It's a deposit router, not a yield vault. -- Fixed weights at initialization, no ability to rebalance or update. -- Calls `IStabilityPool.assetBalanceOf` which doesn't exist on SP_v3 (renamed to `balanceOf`). Would need updating. -- Deposits haToken directly to SPs — no fee-capped minting, no wCOL handling. Assumes user already has the pegged token. -- No relationship with hyToken — they don't compose. A complete solution would need both, but they don't share any infrastructure. - ---- - -## 4. Should They Merge? - -**Yes.** Neither codebase is complete on its own: -- harbor has clean architecture but no swap logic and a thin HarborYield. -- hyToken has swap logic and operational features but is a monolith that doesn't scale to multi-collateral. - -### Recommended Merge Approach - -**Keep harbor's two-layer architecture (AC + HY) but bring hyToken's operational features into it:** - -1. **AutoCompounder_v1** — keep as-is. Clean ERC4626 wrapper for one SP. Add: - - Bounty system (0.25% to caller on compound — from hyToken) - - Nothing else. The AC stays simple. - -2. **HarborYield_v1** — this is where hyToken's features belong. Extend with: - - **`compound()`**: For each managed vault that's an AC, call `ac.compound()`. For equivalent token vaults, convert holdings to AC shares when fees are acceptable (this needs the swapper). - - **Swapper integration**: `ISwapper` interface for converting between tokens (wXXXn → wCOLn, or wCOLn → haXXX). Initially a simple wrapper around a DEX aggregator; later can be 1inch, Paraswap, or any router. Keep the swap execution in the HY (not the AC) because equivalent token management is the HY's concern. - - **Keeper role + bounty**: KEEPER_ROLE can trigger compound + swaps. Bounty incentivizes keepers. - - **Emergency withdraw**: Pull all AC shares back to HY, optionally redeem to underlying SP tokens. - - **No withdrawal window**: Rely on the SP's upcoming CR-based fees. The HY just calls AC.redeem(), which calls SP.withdraw(). Fees are handled at the SP level. - - **No internal balance tracking**: Trust ERC4626 `balanceOf` and `convertToAssets` for all valuations. No `primaryAssetBalance` / `secondaryAssetBalance` shadowing. - - **No oracle**: Value everything via ERC4626 `convertToAssets`. For non-ERC4626 tokens, wrap them in an ERC4626 adapter first (as already decided). - -3. **Drop HarborAnchoredVault_v1.** Its weighted distribution is subsumed by HarborYield's managed vault list. The HY can route a haXXX deposit to any registered AC (user specifies which, or HY picks the largest). - -4. **Drop the 1inch-specific integration.** Replace with a generic `ISwapper` interface that can be backed by 1inch, a simple DEX swap, or any other router. The swap execution should be a separate contract (the Swapper), not embedded in the yield vault. This makes the HY repo-portable. - -### What Moves to the New Repo - -If HarborYield moves to another repo: -- `HarborYield_v1.sol` + `IHarborYield.sol` -- `ISwapper.sol` (interface only — implementation is separate) -- Deployment script (`script/src/v3/contracts/HarborYield.sol`) -- Tests - -What stays in harbor: -- `AutoCompounder_v1.sol` + `IAutoCompounder.sol` (depends on SP and Minter) -- All SP and Minter contracts -- Deployment infrastructure - -The AC is a dependency of the HY (the HY holds AC shares), but the AC doesn't know about the HY. Clean dependency direction. - ---- - -## 5. Summary - -| Aspect | harbor (AC+HY) | hyToken | Recommendation | -|--------|----------------|---------|----------------| -| Architecture | Clean layers, composable | Monolith, complete | Keep harbor's layers | -| Swap/rebalance | Missing | Implemented (1inch) | Add to HY via ISwapper | -| ERC4626 | Clean compliance | Broken (multi-asset totalAssets) | Keep harbor's approach | -| Operational features | Basic | Bounty, emergency, keeper roles | Add to HY from hyToken | -| Oracle dependency | Minimal | Heavy | Keep harbor's approach | -| Multi-collateral | Native (N vaults per HY) | One hyToken per SP | Keep harbor's approach | -| Balance tracking | Trust underlying vaults | Internal + actual (divergence risk) | Keep harbor's approach | -| Contract size | 10-12KB each | Large monolith | Keep harbor's approach | -| Testability | Each layer independent | Must test everything together | Keep harbor's approach | -| Completeness | Incomplete (no swap, thin HY) | More complete | Add missing pieces to HY | diff --git a/doc/ideas/proposed-fee-mechanism.md b/doc/ideas/proposed-fee-mechanism.md deleted file mode 100644 index 690fb80d..00000000 --- a/doc/ideas/proposed-fee-mechanism.md +++ /dev/null @@ -1,134 +0,0 @@ -# Proposed Withdrawal Fee Mechanism - -**Status: Proposal** - -## Problem - -When a rebalance is anticipated, a depositor can withdraw from the stability pool before the rebalance fires, avoid the loss, and re-deposit afterwards with a larger share of the now-smaller pool. This "dodge" gives the returner a disproportionate harvest share at the expense of those who stayed. - -The existing withdrawal window mechanism (request → wait → withdraw fee-free) is clumsy: it adds UX friction for legitimate withdrawals and doesn't scale the penalty with systemic risk. A CR-dependent fee addresses both. - -## Proposed Formula - -Use the **negated `redeemPeggedTokenIncentiveRatio()`** from the Minter as the withdrawal fee: - -``` -fee = max(0, -redeemPeggedTokenIncentiveRatio()) -``` - -The `redeemPeggedTokenIncentiveRatio()` is an on-chain view function that returns the current minter fee/discount for redeeming pegged tokens, which varies with collateral ratio. At low CR, the minter offers a *discount* on redemption (negative ratio) to encourage shrinking the pegged supply. Negating this discount produces a *fee* on SP withdrawals that rises as CR falls. - -### Fee Schedule (ETH::fxUSD market, 130% rebalance threshold) - -| CR range | Minter redeemPegged ratio | SP withdrawal fee | -|----------|--------------------------|-------------------| -| < 1.00 | -1.00% (discount) | **1.00%** | -| 1.00 – 1.10 | -0.75% (discount) | **0.75%** | -| 1.10 – 1.29 | -0.30% (discount) | **0.30%** | -| 1.29 – 1.40 | 0% (neutral) | **0%** | -| > 1.40 | +0.25% to +0.50% (fee) | **0%** | - -Key properties: -- **Zero fee at healthy CR** (above 1.29): no friction for normal withdrawals -- **Highest fee at lowest CR** (1.00%): maximum deterrence when the system most needs deposits -- **Never blocks withdrawal**: the fee caps at 1.00%, depositors can always exit -- **No new parameters**: reads the existing minter config, which is already tuned per market -- **Scales automatically** with market volatility settings (different thresholds use different configs) - -### Conceptual Justification - -At low CR, two things are simultaneously true: -1. The minter *discounts* pegged redemption — it wants the pegged supply to shrink (the system is stressed) -2. The stability pool *needs* deposits — withdrawals weaken the rebalance buffer - -The minter's redemption discount is a signal of how stressed the system is. Negating it as a withdrawal fee means: "the cost of leaving the SP during stress equals the discount the system offers for Minter-level redemption". Both are the same CR stress signal, applied in opposite directions. - -## Quantitative Support - -Analysis from `test/deployment/RebalanceFairnessScan.t.sol` using the design case (10% price drop, 25% leveraged fraction, 37.5% liquidation, 10% APR): - -### Break-even fees (with weekly auto-compounding) - -The minimum fee that makes the dodge unprofitable over a 12-week horizon with weekly compounding: - -| Pool | Break-even fee | Proposed fee at CR=1.20 | -|------|---------------|------------------------| -| Coll SP | 0.17% (17 bp) | 0.30% | -| Lev SP | 0.60% (60 bp) | 0.30% | - -The proposed 0.30% fee at the design-case CR (1.20, in the 1.10–1.29 band) exceeds both Coll SP and Lev SP break-evens. The Lev SP break-even is higher because compounding leveraged tokens back to pegged is less efficient — but 0.30% still covers it with margin. - -### Why the break-even is so small - -The dodge advantage disappears quickly with compounding. Over 12 weeks with weekly auto-compounding: - -- **Coll SP**: Alice (stayer) starts at 62.5 haXXX deposit + 166,667 wCOL rebalance reward. Each week she compounds (claim wCOL → freeMint haXXX → re-deposit). By week 12, her haXXX-equivalent is 103.22 vs Bob's 103.23 — a gap of 0.01 haXXX (0.01%). -- **Lev SP**: Charlie (stayer) starts at 62.5 haXXX deposit + 62.5 hsXXX rebalance reward. He compounds via redeem hsXXX → wCOL → freeMint haXXX → re-deposit. By week 12: 103.15 vs Dave's 103.23 — a gap of 0.08 haXXX (0.08%). - -The total dodge profit over 12 weeks is ~0.17 haXXX for Coll SP and ~0.60 haXXX for Lev SP (out of 100 haXXX starting position). A 0.30% fee (0.30 haXXX from Bob's 100 haXXX) wipes out this advantage. - -### Without compounding (steady-state gap) - -If there were no compounding, the income gap would persist indefinitely: - -| Pool | Steady-state income gap (no fee) | With 0.30% fee | -|------|----------------------------------|----------------| -| Coll SP | 8.65% | ~6.5% (reduced but not eliminated) | -| Lev SP | 37.50% | ~36.2% (barely affected) | - -The fee alone doesn't eliminate the steady-state gap — compounding is essential. The fee's role is to cover the transient cost during the first few weeks before compounding catches up. - -## Implementation: Auto-Compounder - -The fee lives on the **auto-compounder (AC) contract**, not the stability pool. Rationale: - -### Why the AC, not the SP - -1. **The fee mechanism assumes auto-compounding.** The 0.30% fee is calibrated for a world where the AC compounds weekly. Without compounding, the fee would need to be much larger (closer to 10%) or supplemented with an effective-share mechanism. Placing the fee on the AC makes the dependency explicit. - -2. **The AC already has `EXEMPT_WITHDRAWAL_FEE_ROLE`** on the SP. Users who deposit via the AC (the recommended path) have their withdrawals routed through the AC contract, which can apply the CR-dependent fee. Users who deposit directly into the SP use the existing withdrawal window mechanism. - -3. **The SP's existing fee mechanism remains as a fallback.** Direct SP depositors still face the fixed early-withdrawal fee and the request/wait window. The AC fee is a better-calibrated alternative for the AC path. - -### AC withdrawal flow with CR-dependent fee - -Current: user calls `AC.withdraw(shares)` → AC calls `SP.withdraw(assets)` (exempt from SP fee) → AC sends pegged to user. - -Proposed: user calls `AC.withdraw(shares)` → AC reads `IMinter(minter).redeemPeggedTokenIncentiveRatio()` → computes `fee = max(0, -ratio)` → AC calls `SP.withdraw(assets)` → AC sends `assets × (1 - fee)` to user, retains `assets × fee`. - -The retained fee stays in the AC (increasing the exchange rate for remaining depositors) or is sent to the treasury. Sending it to remaining depositors is fairer — it partially compensates stayers. - -### Future: Moving the fee to the SP - -If the fee proves effective, a future upgrade could move it into the SP directly, replacing the withdrawal window entirely. This would: -- Apply the fee to all withdrawals (not just AC-routed ones) -- Remove the request/wait UX friction -- Enable clean ERC4626 integration (atomic withdraw with fee) - -This is a larger change (SP contract upgrade) and should be considered separately. The AC-based fee can be deployed immediately without modifying deployed contracts. - -## Fee Destination - -Three options for the fee proceeds: - -1. **Remaining AC depositors** (recommended): fee stays in the AC vault, increasing the share price. Stayers are directly compensated for the transient harvest disadvantage. This is the simplest and most aligned with the fairness goal. - -2. **Treasury**: fee is sent to the protocol treasury. Doesn't help stayers directly but funds protocol operations. - -3. **Burned**: fee is removed from circulation. Reduces haXXX supply, benefiting all holders equally. Doesn't specifically help stayers. - -Option 1 is recommended because the fee is designed to compensate for the *specific* disadvantage that stayers face. Sending it to stayers closes the loop. - -## Summary - -| Aspect | Detail | -|--------|--------| -| **Formula** | `fee = max(0, -redeemPeggedTokenIncentiveRatio())` | -| **Range** | 0% (healthy CR) to 1.0% (depegged) | -| **At design case (CR=1.20)** | 0.30% | -| **Break-even (Coll SP, 12wk)** | 0.17% — covered | -| **Break-even (Lev SP, 12wk)** | 0.60% — marginal, relies on compounding | -| **Where** | Auto-compounder contract | -| **Parameters** | None new — reads existing minter config | -| **Blocks withdrawal?** | Never | -| **Requires compounding?** | Yes — the fee is calibrated assuming weekly auto-compounding | diff --git a/doc/ideas/rebalance-fairness.md b/doc/ideas/rebalance-fairness.md index bafe3dd0..2164b2a3 100644 --- a/doc/ideas/rebalance-fairness.md +++ b/doc/ideas/rebalance-fairness.md @@ -507,7 +507,22 @@ if (combined >= int256(MAX_WITHDRAWAL_FEE)) { **Note on deposit fees:** Only withdrawals are penalised. Deposit fees would penalise the AC's redeposit step and legitimate new entrants. The AC restores the stayer's position via compound (deposit pegged), which should be fee-free. -**Bytecode impact on SP_v4:** Net -200 to -400 bytes (removing withdrawal window saves ~500-800, adding the two view calls + fee logic costs ~200-300). +**Bytecode impact on SP_v3:** Net -200 to -400 bytes (removing withdrawal window saves ~500-800, adding the two view calls + fee logic costs ~200-300). + +#### Quantitative calibration + +From `test/deployment/RebalanceFairnessScan.t.sol` using the design case (10% price drop, 25% leveraged fraction, 37.5% liquidation, 10% APR) — the minimum fee that makes the dodge unprofitable over a 12-week horizon with weekly auto-compounding: + +| Pool | Break-even fee | Fee at CR=1.20 (this mechanism) | +|------|---------------|---------------------------------| +| Coll SP | 0.17% (17 bp) | ~2.0% | +| Lev SP | 0.60% (60 bp) | ~2.0% | + +The mechanism's ~2.0% fee at the design-case CR (1.20) clears both break-evens with margin. The Lev SP break-even is higher because compounding leveraged tokens back to pegged is less efficient. + +**Why the break-even is so small.** The dodge advantage disappears quickly once auto-compounding kicks in. Over 12 weeks with weekly compounding, the stayer's haXXX-equivalent reaches 103.22 (Coll SP) or 103.15 (Lev SP) vs the dodger's 103.23 — a residual gap of 0.01–0.08 haXXX out of a 100 haXXX starting position. The fee's job is to cover the *transient* cost during the first few weeks before compounding catches up, not to close a permanent gap. + +**Without compounding** the income gap would persist indefinitely (8.65% Coll SP, 37.50% Lev SP at steady state). The fee alone doesn't close the steady-state gap — the AC does. The fee deters the attack; the AC restores the stayer. ### B. Auto-Compounding + Withdrawal Fees (Practical Fairness) @@ -594,9 +609,9 @@ The AC changes the dynamics fundamentally. Without the AC, stayers must manually ## 7. Open Questions -1. **Fee curve magnitude:** The Minter's `mintPeggedTokenIncentiveRatio` reaches ~1.5% near the rebalance threshold. Is this sufficient deterrent? If not, the SP could apply a multiplier (e.g., 10× the Minter fee), but this introduces a parameter. +1. **Fee curve magnitude:** The Minter's `mintPeggedTokenIncentiveRatio` reaches ~1.5% near the rebalance threshold; combined with the redeem ratio it gives ~2.0%. The §5A break-even analysis shows this clears both Coll SP (0.17%) and Lev SP (0.60%) thresholds with margin, so a multiplier is not needed for the design case. Revisit only if production data shows the assumption (10% drop / 25% leveraged) is wrong. 2. **Post-rebalance gap:** After rebalance, CR jumps back to threshold and the fee drops immediately. An attacker who can re-enter in the same block faces a low fee. Mitigation: private mempool for rebalance tx, or a brief cooldown (simpler than the full withdrawal window). -3. **Leveraged SP fairness:** Auto-compounding doesn't help Charlie. The effective share mechanism would, but adds implementation complexity. Is the leveraged SP gap acceptable as a known risk trade-off, or must it be addressed before deployment? +3. **Leveraged SP fairness:** Auto-compounding doesn't help Charlie. The effective share mechanism would, but adds accumulator complexity. **Decision: deferred (B.6c)** — accepted as a known risk trade-off of the leveraged pool. The accumulator architecture is forward-compatible with adding effective-share later (via virtual `_getEffectiveTotalPoolShare` / `_getEffectiveUserPoolShare`) without breaking the AC or fee mechanisms. 4. **Multiple rapid rebalances:** Production has seen 5 rebalances in succession. The AC compounds after the series ends. The unfairness window spans the full series. Is this acceptable? 5. **BOLD B-sum as future enhancement:** Proven not to help with the same denominator (Section 3), but a `totalOriginalDeposits` denominator variant (discussed in earlier analysis) could provide precise fairness. Worth revisiting if the practical approach proves insufficient? @@ -606,10 +621,10 @@ The AC changes the dynamics fundamentally. Without the AC, stayers must manually | Layer | Mechanism | Addresses | Status | |-------|-----------|-----------|--------| -| **Withdrawal fee** | Derived from Minter's `mintPeggedTokenIncentiveRatio()` | Deters frontrun withdrawal | Implement in SP_v4 | -| **Auto-compounding** | AC claims wCOL, mints pegged, redeposits | Restores stayer's harvest share (collateral SP only) | Implemented (AutoCompounder_v1) | -| **Private mempool** (off-chain) | Submit rebalance via Flashbots Protect | Mempool frontrunning specifically | Operational | -| **Effective share boost** (future) | Unclaimed rebalance reward counts toward harvest share | Corrects harvest distribution (needed for leveraged SP) | Deferred | +| **Withdrawal fee** | `fee = mintPeggedRatio - redeemPeggedRatio` clamped to `[0, MAX_WITHDRAWAL_FEE]` | Deters frontrun withdrawal | **Active target (B.6b)** — replaces withdrawal window in SP_v3 (pre-deployment) | +| **Auto-compounding** | AC claims wCOL, mints pegged, redeposits | Restores stayer's harvest share (collateral SP only) | **Shipped** (AutoCompounder_v1) | +| **Private mempool** (off-chain) | Submit rebalance via Flashbots Protect | Mempool frontrunning specifically | **Operational** | +| **Effective share boost** | Unclaimed rebalance reward counts toward harvest share | Corrects harvest distribution (needed for leveraged SP) | **Deferred (B.6c)** — accumulator architecture remains forward-compatible | For collateral SPs, withdrawal fees + auto-compounding provide practical fairness: fees deter the attack, the AC restores the stayer's position. The unfairness window is bounded by the time between rebalance and compound. diff --git a/doc/ideas/sp-auto-compounding-harvests.md b/doc/ideas/sp-auto-compounding-harvests.md deleted file mode 100644 index c558b0e2..00000000 --- a/doc/ideas/sp-auto-compounding-harvests.md +++ /dev/null @@ -1,203 +0,0 @@ -# Auto-Compounding of Harvests Within a Stability Pool - -**Status: Possible implementation — under consideration** - -## 1. Goal - -Auto-compound harvest rewards for ALL stability pool depositors directly within the SP, without requiring a wrapper or vault. When a harvest is deposited, the SP converts the collateral to haXXX and grows everyone's balance proportionally. - -## 2. Problem - -Today, harvest rewards are distributed as wrapped collateral via the reward integral, linearly over 1 week. Depositors must manually claim the collateral, mint haXXX, and deposit back. This delivers simple interest — rewards don't earn further rewards. - -Auto-compounding within the SP would give all depositors compound interest automatically. No claiming, no wrapper, no user action needed. - -## 3. Mechanism - -### Overview - -On harvest, the SP: -1. Receives wrapped collateral from the StabilityPoolManager -2. Mints haXXX from the collateral (via the Minter, with fees) -3. Distributes the minted haXXX as a reward via the integral -4. On each user's next interaction (checkpoint), the haXXX reward is collapsed into their stored balance — effectively depositing it for them - -### Two-Product Factor - -The SP currently uses a **loss product** (DecrementalFloatingPoint) to track cumulative losses. A user's compounded balance after losses is: - -``` -balance = storedAmount * currentLossProduct / userLossProduct -``` - -To support compounding, a second product is added — the **compound product** (simple uint256, scaled by 1e18). It tracks cumulative growth from auto-compounded harvests: - -``` -balance = storedAmount - * currentCompoundProduct / userCompoundProduct - * currentLossProduct / userLossProduct -``` - -### Why Two Products - -| | Loss Product | Compound Product | -|---|---|---| -| Direction | Decreases toward zero | Increases away from zero | -| Encoding | DecrementalFloatingPoint (uint128) | Simple uint256 (1e18 scaled) | -| Precision concern | Yes — approaches zero after extreme losses | No — grows, naturally precise | -| Range | ~1e-108 to 1.0 (108 decades, 36-digit precision) | 1.0 to ~1e59 (uint256/1e18 headroom) | -| Event | Rebalance (notifyLoss) | Harvest (depositRewardAndCompound) | - -The loss product requires DecrementalFloatingPoint because repeated large losses drive it toward zero where integer math loses precision. The compound product only grows — a simple uint256 has more than enough range and precision. - -### Storage - -`TokenBalance` struct gains one field: - -```solidity -struct TokenBalance { - uint128 product; // loss product (DFP, existing) - uint104 amount; // stored balance - uint40 updatedAt; // timestamp - uint256 compoundProduct; // compound product (new) -} // 2 slots (was 1) -``` - -Global `totalAssetSupply` and per-user `assetBalances` both store the compound product. - -### Balance Calculation - -```solidity -function _getCompoundedBalance( - uint256 storedAmount, - uint128 userLossProduct, - uint128 currentLossProduct, - uint256 userCompoundProduct, - uint256 currentCompoundProduct -) internal pure returns (uint256) { - // Apply compound growth - uint256 afterCompound = Math.mulDiv(storedAmount, currentCompoundProduct, userCompoundProduct); - // Apply loss shrinkage (existing DFP math) - return _scaleAdjustedValue(afterCompound, currentLossProduct, userLossProduct); -} -``` - -### Checkpoint - -On checkpoint, both products are collapsed into the stored amount: - -```solidity -function _checkpoint(address account) internal override { - // ... existing reward distribution ... - - TokenBalance memory balance = $.assetBalances[account]; - TokenBalance memory supply = $.totalAssetSupply; - - uint256 newBalance = _getCompoundedBalance( - balance.amount, - balance.product, supply.product, - balance.compoundProduct, supply.compoundProduct - ); - - balance.amount = uint104(newBalance); - balance.product = supply.product; - balance.compoundProduct = supply.compoundProduct; - balance.updatedAt = uint40(block.timestamp); - - $.assetBalances[account] = balance; -} -``` - -### Compound Event (on harvest) - -```solidity -function depositRewardAndCompound(address token, uint256 amount) external { - // Transfer collateral in - IERC20(token).safeTransferFrom(msg.sender, address(this), amount); - - // Mint haXXX from the collateral - IERC20(token).approve(MINTER, amount); - (uint256 peggedMinted, uint256 collateralUsed) = - IMinter(MINTER).mintPeggedTokenCapped(amount, address(this), 0, maxFeeRatio); - - if (peggedMinted > 0) { - // Update compound product: everyone's balance grows proportionally - TokenBalance memory supply = $.totalAssetSupply; - supply.compoundProduct = Math.mulDiv( - supply.compoundProduct, - supply.amount + uint104(peggedMinted), - supply.amount - ); - supply.amount += uint104(peggedMinted); - _recordTotalSupply(supply); - } - - // Unminted collateral: distribute as normal reward (linear, 1 week) - // Attributed to current depositors at this point in the integral - uint256 remainder = amount - collateralUsed; - if (remainder > 0) { - _notifyReward(token, remainder); - } -} -``` - -### Unminted Collateral Handling - -When minting fails partially or fully (fee too high): - -- The unminted collateral is distributed via `_notifyReward(WRAPPED_COLLATERAL, remainder)` — linear over 1 week -- This attributes it to depositors at the current point in the integral (fair to original depositors, new depositors after this point don't benefit) -- On the next harvest, the SP tries again with whatever new collateral arrives -- The previously distributed collateral is claimable by depositors (or a wrapper/vault layer converts it to equivalent tokens) - -### Interaction with assetBalanceOf - -`assetBalanceOf(account)` must include the compound product: - -```solidity -function assetBalanceOf(address account) external view returns (uint256) { - TokenBalance memory balance = $.assetBalances[account]; - TokenBalance memory supply = $.totalAssetSupply; - return _getCompoundedBalance( - balance.amount, - balance.product, supply.product, - balance.compoundProduct, supply.compoundProduct - ); -} -``` - -This means: -- The SP's position as seen by the SPM (for proportional harvest distribution) includes compound growth -- The SP gets a fair share of subsequent harvests because its effective total balance reflects compounding -- `balanceOf` (ERC20, same as `assetBalanceOf`) also reflects compound growth - -### Interaction with Reward Aliases - -Aliases are NOT needed for compound logic. The minter fee mechanism handles harvest vs liquidation naturally — the fee at mint time depends on the current CR, not the reward source. - -Aliases remain useful for **observability** — separating harvest APR from rebalance APR in a UI. - -## 4. Interaction with Wrapper / Peg Vault - -With auto-compounding in the SP: - -- **SP Wrapper** becomes thinner — it only wraps the rebasing SP token into a non-rebasing ERC4626 share. No compound logic needed since the SP compounds internally. -- **Peg Vault** only handles equivalent token management — converting unminted collateral (the fallback case) to interest-bearing tokens (wXXX). -- **Leveraged SPs** — harvest rewards are auto-compounded in the SP. Leveraged token rewards are handled separately (selective claim, wrapper/user manages them). - -## 5. Contract Size Considerations - -The SP gains: -- `depositRewardAndCompound` function (minter interaction + product update) -- Modified `_getCompoundedBalance` (one additional mulDiv) -- Modified `_checkpoint` (one additional product update) -- `compoundProduct` in TokenBalance (extra slot) - -Current SP v3: 22,534 bytes with 2,042 spare. The additional code may fit within the headroom. If not, the minting logic could be in a separate helper contract called via delegatecall, or the compound event could be triggered externally (SPM calls compound after depositReward). - -## 6. Open Questions - -- **maxFeeRatio configuration:** who sets it, how is it stored, can it be updated? -- **Selective claim:** needed for leveraged SPs so the wrapper can claim only WRAPPED_COLLATERAL for compounding. Requires adding `claim(address token)` to the SP. -- **Gas impact:** the extra storage slot per user (2 slots vs 1) increases gas for every SP interaction. Worth measuring. -- **Migration:** existing users have no `compoundProduct` stored. Default to `currentCompoundProduct` on first checkpoint (equivalent to "just joined, no compound history"). diff --git a/doc/ideas/sp-dynamic-fees.md b/doc/ideas/sp-dynamic-fees.md deleted file mode 100644 index 3c1e4014..00000000 --- a/doc/ideas/sp-dynamic-fees.md +++ /dev/null @@ -1,431 +0,0 @@ -# Stability Pool Dynamic Fees and Harvest Fairness - -**Status: Under discussion** - -## 1. The Problem - -A user who anticipates a rebalance can profit by withdrawing pegged tokens beforehand and re-depositing afterwards. This works whether they frontrun a mempool transaction or simply monitor the collateral ratio. The attacker dodges the rebalance loss and re-enters with a larger share of the now-smaller pool, capturing more future harvest rewards. - -The system should be "fire and forget" -- fairness enforced on-chain, no manual intervention, no dependence on private mempools. - -### How Harvests Work - -Harvests come from the yield on wrapped collateral (e.g., fxSAVE) held **by the Minter**. As fxSAVE appreciates, the Minter accumulates excess wrapped collateral above what's needed to back the underlying collateral. This excess is the harvestable amount. - -The StabilityPoolManager distributes harvested fxSAVE to the two stability pools **proportional to their current pegged token balances**. Within each pool, harvest rewards are distributed to depositors proportional to their pegged token holdings. - -### What Happens During Rebalance - -**Collateral SP rebalance**: pegged tokens are redeemed for wrapped collateral. The wrapped collateral is **removed from the Minter** and transferred to the collateral SP. This reduces the Minter's collateral holdings, reducing future harvests for everyone. However, the transferred fxSAVE continues to generate yield independently (fxSAVE is inherently interest-bearing). This yield accrues to the collateral SP depositors who received it -- it was distributed immediately at rebalance time via `_accumulateReward` and belongs to them regardless of whether they claim or withdraw. - -**Leveraged SP rebalance**: pegged tokens are exchanged for leveraged tokens. The collateral backing those leveraged tokens **stays with the Minter**. This means leveraged SP rebalances do not reduce the Minter's collateral and do not directly reduce future harvest generation. The collateral remains, generating harvest that benefits both pools equally according to deposit size. - -### Worked Example - -All figures below are from `test/RebalanceFairness.t.sol`, which deploys the full system via the production deployment scripts (ETH::fxUSD market) and runs scenarios with real contract code. - -#### Setup - -Deployed via production scripts (ETH::fxUSD market) with a mock oracle. - -- Oracle price = 1.0 initially (so 1 fxSAVE collateral = 1 pegged token -- makes balance sheets readable) -- Oracle rate = 1.0 (1 fxSAVE = 1 fxUSD, no yield accrued yet) -- A market maker mints 600 pegged (from 600 fxSAVE) and 200 leveraged (from 200 fxSAVE) -- Minter holds 800 fxSAVE, 600 pegged outstanding, CR = 800/600 = 1.333 (healthy) -- Market maker distributes **100 pegged** to each of 6 actors (keeps leveraged tokens) -- Oracle price drops 10% (1.0 → 0.9): CR = 800 × 0.9 / 600 = **1.20** (below 1.30 threshold) -- Bounty/cut ratios set to 0 for clarity -- Harvest simulated by bumping oracle rate from 1.0 to 1.05 (5% yield accrual) - -**Cast:** - -| Actor | Initial position | Behaviour | -|-------|-----------------|-----------| -| Alice | 100 pegged in Collateral SP | Stays through rebalance | -| Bob | 100 pegged in Collateral SP | Withdraws before, re-deposits after | -| Charlie | 100 pegged in Leveraged SP | Stays through rebalance | -| Dave | 100 pegged in Leveraged SP | Withdraws before, re-deposits after | -| Fred | 100 pegged outside SPs | Deposits into Collateral SP after rebalance | -| George | 100 pegged outside SPs | Deposits into Leveraged SP after rebalance | - -**Pool totals (equal sizes):** -- Collateral SP: 200 pegged (Alice + Bob) -- Leveraged SP: 200 pegged (Charlie + Dave) - -#### Liquidation Split - -The `StabilityPoolManager` weights each pool's contribution to account for the different "CR restoration effectiveness" of collateral vs leveraged redemptions. The weighting formula ensures that the **percentage of pegged tokens liquidated is always equal from both pools**, regardless of pool sizes. - -From the test output with equal pools (Scenario A: 200 per pool): -- Total liquidated: **75 pegged** -- From Collateral SP: **37.5** (18.75% of 200) -- From Leveraged SP: **37.5** (18.75% of 200) - -With half-sized pools (Scenario B after withdrawals, 100 per pool): -- From Collateral SP: **37.5** (37.5% of 100) -- From Leveraged SP: **37.5** (37.5% of 100) - -The percentage is always equal. The absolute amount only differs when pool sizes differ, and even then each pool loses the same fraction of its holdings. - -#### Scenario A: Everyone Stays (Baseline) - -All four depositors stay through the rebalance. Each loses 18.75% of their deposit (100 → 81.25). Harvest: 36.11 fxSAVE total (5% rate increase). - -| Actor | Pool | Deposit after | Rebalance fxSAVE | Rebalance lev tokens | Harvest fxSAVE | -|-------|------|--------------|-----------------|---------------------|---------------| -| Alice | Coll | 81.25 | 20.83 | -- | **9.03** | -| Bob | Coll | 81.25 | 20.83 | -- | **9.03** | -| Charlie | Lev | 81.25 | -- | 31.25 | **9.03** | -| Dave | Lev | 81.25 | -- | 31.25 | **9.03** | -| Fred | -- | -- | -- | -- | 0 | -| George | -- | -- | -- | -- | 0 | -| | | | | **Total** | **36.12** | - -Harvest fxSAVE is **exactly equal** for all four depositors (9.03 each) -- strictly proportional to deposit size. The rebalance compensation differs by pool type (fxSAVE vs leveraged tokens) but that is a user choice, not a fairness issue. - -#### Scenario B: Bob and Dave Withdraw Before Rebalance - -**Step 1 -- Withdrawals:** -- Bob withdraws 100 from Collateral SP -- Dave withdraws 100 from Leveraged SP -- Collateral SP: 100 (Alice only) -- Leveraged SP: 100 (Charlie only) - -**Step 2 -- Rebalance** (75 total, 37.5 from each pool): -- Alice absorbs all Collateral SP loss: 100 → **62.5 pegged + 41.67 fxSAVE** -- Charlie absorbs all Leveraged SP loss: 100 → **62.5 pegged + 62.5 lev tokens** -- Minter fxSAVE: 800 → **758.3** (collateral removed for Alice's fxSAVE) - -**Step 3 -- Re-deposits + new entrants:** -- Bob: 100 pegged → Collateral SP -- Dave: 100 pegged → Leveraged SP -- Fred: 100 pegged → Collateral SP -- George: 100 pegged → Leveraged SP - -**After re-deposits + one harvest** (36.11 fxSAVE total, 5% rate increase): - -| Actor | Behaviour | Deposit | Rebalance fxSAVE | Rebalance lev tokens | Harvest fxSAVE | -|-------|-----------|---------|-----------------|---------------------|---------------| -| **Alice** | Coll, stayed | 62.5 | 41.67 | -- | **4.30** | -| **Bob** | Coll, returned | 100 | -- | -- | **6.88** | -| **Charlie** | Lev, stayed | 62.5 | -- | 62.50 | **4.30** | -| **Dave** | Lev, returned | 100 | -- | -- | **6.88** | -| **Fred** | new → Coll | 100 | -- | -- | **6.88** | -| **George** | new → Lev | 100 | -- | -- | **6.88** | -| | | | | **Total** | **36.12** | - -#### The Harvest Unfairness - -Separating rebalance rewards (static, one-off) from harvest rewards (streamed, ongoing) makes the problem clear: - -1. **Harvest rewards are strictly proportional to current deposit size.** Alice and Charlie each have 62.5 pegged and earn 4.30 fxSAVE harvest. Bob, Dave, Fred, and George each have 100 pegged and earn 6.88. This is mechanically correct -- harvest is per pegged token. But it means **stayers earn less harvest per person than leavers** because their deposit shrunk in the rebalance. - -2. **Bob = Dave = Fred = George** in harvest terms (all 6.88). The system cannot distinguish a leaver who dodged the loss from a new entrant. Both get the same harvest rate on their full deposit. - -3. **Alice vs Bob** -- Alice's total claimable fxSAVE (45.97) exceeds Bob's (6.88), but this is due to the one-off rebalance reward (41.67). Her ongoing harvest rate (4.30) is **less** than Bob's (6.88). Over time, this compounds: Bob earns more harvest per period, which if auto-compounded, grows his base faster. - -4. **Charlie is worst off.** His harvest (4.30) is less than Bob's and Dave's (6.88), despite being loyal. His rebalance compensation was 62.50 leveraged tokens -- not fxSAVE -- so it doesn't show up as fxSAVE claimable. The collateral backing those leveraged tokens stays with the Minter, generating harvest that benefits everyone including Dave who dodged the loss. - -5. **The harvest is identical for both pool types** at equal deposit sizes. Alice and Charlie both have 62.5 pegged and both earn 4.30 fxSAVE harvest. The choice of collateral vs leveraged pool affects the rebalance compensation token (fxSAVE vs leveraged tokens) but that is a user choice based on their risk appetite, not a fairness issue. What matters for this analysis is the harvest redistribution. - -6. **Harvest income flows from stayers to leavers and new entrants.** Alice and Charlie both subsidise Bob, Dave, Fred, and George. Both stayers earn 4.30 harvest vs 6.88 for every leaver/new entrant -- a 37% reduction in ongoing income for staying loyal through the rebalance. - -#### Auto-Compounding Consideration - -A depositor can manually auto-compound: claim fxSAVE reward → mint pegged tokens (via Minter) → deposit back into SP. This converts the fxSAVE reward back into pegged tokens, restoring harvest earning power. - -**Alice's auto-compound opportunity:** -- Claim 41.67 fxSAVE from rebalance reward -- Mint ~41.67 pegged tokens (minus Minter fees, depending on CR) -- Deposit back into Collateral SP -- New pegged balance: 62.5 + 41.67 ≈ **104** (exceeds original 100 -- rebalance reward slightly overcompensates at price=0.9) - -This would restore Alice's harvest share. But: -- Minting incurs fees (CR-dependent, could be significant right after rebalance) -- She gives up the independent fxSAVE yield in exchange for harvest income -- She re-enters the risk of future rebalances with the re-minted pegged tokens -- The compound cycle favours actors with more capital (gas costs are fixed) - -**Compounding rates differ by position.** If Alice and Bob both auto-compound weekly: -- Bob starts with 100 pegged, earns 6.88 fxSAVE/harvest → compounds from a larger base -- Alice starts with 62.5 pegged (before claiming), earns less per harvest → compounds from a smaller base -- Over time, Bob's absolute advantage grows because compounding amplifies the base difference - -If Alice first converts her fxSAVE to pegged (restoring to ~100), then both compound at the same rate. But this requires Alice to act, incur fees, and accept re-entry risk -- while Bob simply re-deposited for free. - -**Charlie cannot auto-compound in the same way.** His leveraged tokens are not fxSAVE -- he cannot mint pegged tokens from them. To restore his harvest share, he would need to sell his leveraged tokens for fxSAVE (or pegged tokens) on the market, which may have slippage and doesn't fully compensate. - ---- - -## 2. Current Mechanism: Withdrawal Window - -### How It Works - -Withdrawals outside a pre-requested time window pay a fixed early-withdrawal fee. The flow: - -1. User calls `requestWithdrawal()` -- opens a window starting at `now + WITHDRAWAL_START_DELAY` lasting `WITHDRAWAL_END_WINDOW` -2. Withdrawals during the window: no fee -3. Withdrawals outside the window: fixed `earlyWithdrawalFee` (configured at initialisation, up to 100%) -4. Depositing cancels any pending withdrawal request -5. `EXEMPT_WITHDRAWAL_FEE_ROLE` bypasses the fee entirely - -### What It Solves - -- **Patience incentive**: discourages impulsive withdrawals -- **Some mempool protection**: an attacker can't open a fee-free window reactively to a rebalance tx already in the mempool (the delay prevents it) -- **Simple**: easy to understand and audit - -### What It Doesn't Solve - -- **Pre-positioned windows**: a user can maintain near-continuous fee-free withdrawal coverage by calling `requestWithdrawal()` every `WITHDRAWAL_END_WINDOW` seconds. Each call resets the delay, but a patient attacker who plans one `WITHDRAWAL_START_DELAY` ahead always has a window open or about to open. Note: depositing cancels the request, so a full sandwich (withdraw + re-deposit) does lose the window -- but the withdrawal half is still fee-free if timed within an existing window -- **No link to system health**: the fee is flat regardless of whether the system is healthy or under stress -- a withdrawal at CR = 2.0 costs the same as at CR = 1.01 -- **No deposit-side protection**: re-depositing after rebalance is free, which is half the sandwich attack -- **Window UX burden**: legitimate users must plan withdrawals days in advance even when the system is perfectly healthy -- **Composability barrier**: the request/window state machine breaks the standard ERC4626 tokenized vault interface (EIP-4626), which defines `withdraw(assets, receiver, owner)` as a single atomic call that burns shares and transfers assets. Contracts built to the ERC4626 spec -- Yearn v3 vaults, ERC4626 autocompounders (e.g., Beefy, Sommelier cellars), yield aggregators (e.g., Yearn routers, DeFi Saver), and any composing vault that wraps another vault -- all expect `withdraw()` to complete in one transaction. The two-step request-then-withdraw flow requires bespoke integration for every wrapper or composing contract, limiting the SP's utility as a building block in DeFi. EIP-7540 (asynchronous vaults) exists specifically to standardise async redemption flows, but adoption is far lower than ERC4626 and most existing infrastructure does not support it - ---- - -## 3. Proposed Mechanism: CR-Based Dynamic Fees - -### How It Works - -Replace the withdrawal window with dynamic fees on both deposits and withdrawals that scale with systemic risk (collateral ratio). When CR is healthy, fees are zero. - -``` -FEE_ACTIVATION_RATIO (immutable, e.g., 1.4e18 if rebalance threshold is 1.3e18) - -if CR >= FEE_ACTIVATION_RATIO: - feeRate = 0 -elif CR >= 1e18: - feeRate = (FEE_ACTIVATION_RATIO - CR) / (FEE_ACTIVATION_RATIO - 1e18) -else: - feeRate = 1e18 (100% -- full depeg, operations effectively blocked) -``` - -At the rebalance threshold (1.3 with activation at 1.4): -`feeRate = (1.4 - 1.3) / (1.4 - 1.0) = 25%` - -The same formula applies to both `withdraw()` and `deposit()`. Both fees go to the protocol `feeAddress`. - -The withdrawal window, request mechanism, and fixed early withdrawal fee are all removed. - -### What It Solves - -- **Scales with risk**: no fee under healthy conditions; steep fee as rebalance approaches -- **Both sides of the sandwich**: withdrawal and deposit are both penalised during stress -- **Address-switching resistant**: attacker withdraws from address A (pays withdrawal fee), deposits from address B (pays deposit fee) -- both sides are captured -- **No UX burden**: no need to plan withdrawal requests in advance; just withdraw (it's free when the system is healthy) -- **Stateless**: computed from `IMinter.collateralRatio()` on each call, no new storage needed -- **Simpler contract**: removes withdrawal window state, request mapping, delay/window immutables - -### What It Doesn't Solve - -- **Post-rebalance gap**: after a rebalance, CR jumps back up to the threshold. The CR-based fee drops immediately -- exactly when an attacker wants to re-enter. An attacker who can deposit in the same block as or shortly after a rebalance faces a low fee. Mitigation: set `FEE_ACTIVATION_RATIO` well above the threshold, or use private mempool for rebalance txs. But this gap is not fully closed on-chain. -- **Withdrawal fee destination**: fees go to the protocol, not to remaining depositors. Redistributing to depositors was considered but rejected: if multiple deposits occur post-rebalance, later depositors' fees partially go to earlier post-rebalance depositors (who already paid their own fee), creating ordering-dependent unfairness. -- **Imprecise calibration**: the linear ramp is a heuristic. The actual rebalance loss fraction at a given CR depends on the rebalance threshold, oracle price, and how much the Minter redeems. The fee may overshoot or undershoot the actual loss. -- **Legitimate stress-period activity penalised**: a user who genuinely wants to deposit during low CR (e.g., to support the pool) pays a fee. This is the trade-off for address-switching resistance. - ---- - -## 4. Mechanism Comparison Under the Worked Example - -### A. Current Mechanism (Withdrawal Window) - -Assume `WITHDRAWAL_START_DELAY = 1 hour`, `WITHDRAWAL_END_WINDOW = 25 hours`, `earlyWithdrawalFee = 1%`. - -**Bob's attack:** -- Bob maintains a standing withdrawal request (re-requests periodically) -- When CR approaches 1.30 threshold, Bob withdraws 100 pegged fee-free during his window -- After rebalance, Bob deposits 100 pegged (depositing cancels his window, but he doesn't need it anymore) -- Net cost to Bob: **0** (fee-free withdrawal within window) - -**Result:** The withdrawal window does not prevent the attack for a patient, pre-positioned attacker. Alice and Charlie bear the same losses as Scenario B: Alice gets 45.97 total (41.67 rebal + 4.30 harvest), Charlie gets 4.30 harvest only, while Bob gets 6.88 for free. - -### B. CR-Based Dynamic Fees - -Assume `FEE_ACTIVATION_RATIO = 1.40`, rebalance threshold = 1.30. - -At the test CR of 1.20 before rebalance: -- `feeRate = (1.40 - 1.20) / (1.40 - 1.00) = 50%` - -**Bob's withdrawal:** -- Withdraws 100 pegged, pays 50% fee = 50 pegged in fees -- Receives 50 pegged -- Can only re-deposit 50 pegged after rebalance - -**But the post-rebalance gap:** After rebalance, CR jumps back to 1.30 → fee drops to `(1.40 - 1.30) / (1.40 - 1.00) = 25%`. Still significant. - -**Fred's deposit (post-rebalance, CR = 1.30):** -- Fred deposits 100 pegged, pays 25% = 25 pegged fee -- Fred credited with 75 pegged - -**Net effect:** Fees capture value on both sides but: -- The withdrawal fee reduces the attacker's capital (50 instead of 100) -- The deposit fee reduces the new entrant's advantage -- **Alice and Charlie still absorb concentrated losses** -- fees don't compensate them -- The gap: if `FEE_ACTIVATION_RATIO` were set at 1.30 (equal to threshold), post-rebalance deposits would face zero fee - -### C. Effective Share: Unclaimed Rebalance Reward Boost - -No fees on withdrawal or deposit. Instead, a depositor's effective harvest share includes the pegged-equivalent value of their unclaimed rebalance reward. - -**Mechanism:** - -``` -effectiveShare = peggedBalance + peggedValueOf(unclaimedRebalanceReward) -``` - -Where `peggedValueOf` converts the unclaimed fxSAVE (for collateral SP) or leveraged tokens (for leveraged SP) to pegged-equivalent using the oracle price. - -Note: SP-held fxSAVE does NOT generate harvest -- only Minter-held fxSAVE does. The unclaimed rebalance reward sitting in the SP appreciates on its own but does not feed into the harvest mechanism. There is no double-counting. - -**In the worked example:** - -Alice has 62.5 pegged + 41.67 fxSAVE unclaimed. At oracle price 0.9, the fxSAVE is worth ~46.3 pegged. Her effective share = 62.5 + 46.3 = **108.8**. Bob has 100 pegged + 0 unclaimed = **100**. Alice's effective share exceeds Bob's, compensating for her smaller pegged balance. - -Charlie has 62.5 pegged + 62.5 lev tokens unclaimed. The leveraged token value converts similarly. His effective share also exceeds Bob's. - -**Natural decay -- no governance parameter needed:** - -When a user claims their rebalance reward, `claimable()` drops to zero and the boost disappears. The mechanism decays automatically via user action rather than a time parameter. - -**Incentive to claim and compound:** - -A user who holds (never claims) has a static effective share. A user who claims, mints pegged, and re-deposits has a growing pegged balance that compounds. Over time, exponential growth always beats a static boost: - -| Period | Alice holds | Alice claims + compounds | -|--------|-----------|------------------------| -| 1 | 62.5 pegged + 46.3 boost = 108.8 effective | Claims 46.3, pays mint fee, deposits ~45. 107.5 pegged, 0 boost | -| 2 | Still 108.8 (static) | 107.5 + harvest reinvested (growing) | -| N | Still 108.8 (static) | 107.5 × (1+r)^N (exponential) | - -The compounding advantage is self-incentivising -- no discount or premium on the unclaimed amount is needed. - -**Interaction with minting fees:** - -The mint fee (CR-dependent) naturally regulates when compounding occurs: - -- **Low CR (high/disallow mint fee)**: minting pegged would push CR lower, risking another rebalance. The fee is prohibitive. User holds → the full-value boost maintains their harvest share → correct behaviour rewarded. -- **High CR (low/zero mint fee)**: minting is safe, system can absorb it. User claims and compounds → exponential growth beats static boost → SP grows with healthy activity. - -The mint fee gates the behaviour without any additional mechanism. No discount on the unclaimed amount is needed at any CR level. - -**What it solves:** -- Pre-rebalance depositors earn harvest proportional to their full position (pegged + compensation) -- Natural decay via claiming -- no governance parameter, no time decay calibration -- Compounding is incentivised when healthy, holding is incentivised when stressed -- mint fee handles both -- No penalty on new depositors -- they have no unclaimed reward, so no boost -- Simpler than the BOLD product approach: no second integral, no per-exponent math changes - -**What it doesn't solve:** -- Oracle dependency: converting fxSAVE/leveraged tokens to pegged-equivalent requires an oracle call in `_getUserPoolShare`. Gas increase + oracle manipulation risk (though oracle is already trusted for CR). -- Does not prevent the withdrawal/re-deposit attack itself -- only adjusts reward distribution -- Leveraged SP token pricing: no direct `mintPegged(leveragedToken)` path. Must use `leveragedTokenPrice()` from Minter for conversion, which is an approximation of market value. - -### D. Harvest Fairness Product (BOLD-Inspired) - -No fees on withdrawal or deposit. Instead, harvest distribution accounts for rebalance history via a second product in the accumulator. - -**Mechanism:** A second product (like Liquity's B sum) that incorporates the loss product P into harvest accumulation: - -``` -harvestGain = initialDeposit * (B_current - B_snapshot) / P_snapshot -``` - -When harvest rewards are accumulated, the integral includes the current loss product: - -``` -B[currentScale] += P * harvestAmount / totalDeposits -``` - -This means harvest is attributed proportional to **original deposit size** (before losses), not current compounded balance. A depositor who absorbed losses via the product still earns harvest as if their deposit were larger. - -**In the worked example:** - -Alice deposited 100 and absorbed losses (product decreased, deposit fell to 62.5). But her harvest is calculated from her initial 100, scaled by the product ratio at each harvest event. Bob deposited 100 after the rebalance with a fresh product snapshot. His harvest is calculated from his 100 at the current (post-loss) product. - -Because Alice's B_snapshot was taken at a higher P, her `(B_current - B_snapshot) / P_snapshot` captures harvest accumulated during the loss period at the pre-loss rate. Bob's snapshot is at the lower P, so he only captures harvest from his deposit time onward. - -**Result for Alice:** her harvest share would be boosted relative to Bob's 6.88, compensating for the product decrease. The boost decays naturally as new harvest events accumulate at the post-loss product -- eventually Alice and Bob converge to equal rates per pegged token. - -**Result for Charlie:** same boost mechanism. Currently Charlie gets 4.30 (less than Bob's 6.88 despite being loyal). With the fairness product, Charlie's harvest would be boosted toward the level implied by his original 100 deposit. - -**What it solves:** -- Pre-rebalance depositors are not permanently disadvantaged in harvest distribution -- No penalty on new depositors -- they simply don't get the boost -- Mathematically precise: uses the existing product/integral system -- Composable with CR fees - -**What it doesn't solve:** -- Complexity: second product, modified accumulator math, interaction with per-exponent tracking -- Does not prevent the withdrawal/re-deposit attack itself -- only adjusts reward distribution -- Decay calibration depends on harvest frequency and collateral type -- Different pool types may need different parameters - -### E. Comparison: Effective Share vs BOLD Product - -| Aspect | Effective Share (C) | BOLD Product (D) | -|--------|-------------------|-----------------| -| **Complexity** | Modifies `_getUserPoolShare` only | New integral, per-exponent tracking | -| **Decay** | Natural (claim to remove) | Time-based (needs calibration) | -| **Governance params** | None | Decay period per pool type | -| **Oracle dependency** | Yes (price conversion) | No | -| **Compounding incentive** | Built-in (exponential beats static) | Requires separate analysis | -| **Mint fee interaction** | Natural gating (hold when expensive, compound when cheap) | Not connected to mint fee | -| **Multiple rebalances** | Additive (each rebalance adds unclaimed) | Multiplicative (products compound) | - -### F. Combined: CR-Based Fees + Effective Share - -Fees deter the movement; the effective share corrects the reward distribution. - -**Bob's attack (combined):** -1. Withdrawal fee: loses 50% of 100 = 50 pegged. Receives 50. -2. Re-deposit fee (post-rebalance, CR = 1.30): loses 25% of 50 = 12.5. Credited 37.5 pegged. -3. Effective share: Bob has 37.5 pegged, 0 unclaimed = 37.5 effective. Alice has 62.5 pegged + 46.3 boost = 108.8 effective. Alice dominates. - -**Net result:** Bob entered with 100, now has 37.5 pegged with no harvest boost. Alice absorbed concentrated losses but earns harvest on 108.8 effective share. Attack is clearly unprofitable. - -**Fred (legitimate new entrant, combined):** -1. No withdrawal (wasn't in pool), no withdrawal fee. -2. Deposit fee: 25% of 100 = 25 fee. Credited 75 pegged. -3. No effective share boost (no unclaimed rewards). - -Fred pays a deposit fee that is arguably unfair to a legitimate new entrant. The effective share mechanism alone (without deposit fees) would handle Fred more fairly: no fee, but no boost either. - -### G. Impact of Auto-Compounding on Each Mechanism - -The claim → mint → deposit cycle amplifies differences over time with compound interest. - -Using weekly compounding over 1 year, with harvest rate `r` per pegged token per year: - -| Mechanism | Alice (1yr compound) | Bob (1yr compound) | Notes | -|-----------|---------------------|-------------------|-------| -| **No protection** | 62.5 × (1+r)^52 | 100 × (1+r)^52 | Bob compounds from 1.6× larger base | -| **CR fees only** | 62.5 × (1+r)^52 | 37.5 × (1+r)^52 | Gap reversed by fees; Bob's capital cut to 37.5% | -| **Effective share only** | 108.8 static then compounds | 100 × (1+r)^52 | Alice starts higher; once she claims + compounds, both grow exponentially | -| **Effective share + CR fees** | 108.8 static then compounds | 37.5 × (1+r)^52 | Strongest protection | - -Note: with the effective share mechanism, Alice is incentivised to claim and compound when CR is healthy (low mint fee). Her static 108.8 effective share is eventually overtaken by Bob's compounding 100 -- but Alice can switch to compounding at any time by claiming. The mint fee naturally gates this: hold when expensive, compound when cheap. - ---- - -## 5. Open Questions - -1. Should the effective share / fairness product affect SAIL/gauge rewards too, or only wrapped collateral harvests? -2. For the effective share mechanism: does `_getUserPoolShare` modification interact correctly with the existing per-exponent integral tracking? -3. For the BOLD product: does the existing reward integral in `MultipleRewardCompoundingAccumulator_v3` already weight by the loss product correctly, or is a separate integral needed? -4. Can the effective share and BOLD product approaches be combined, or are they alternatives? - ---- - -## 6. Summary: Defence Layers - -| Layer | Mechanism | Addresses | -|-------|-----------|-----------| -| **CR-based withdrawal fee** | Dynamic fee scaling with CR | Frontrun withdrawal, general timing | -| **CR-based deposit fee** | Same formula on deposits | Address-switching, post-withdrawal re-entry | -| **Effective share boost** | Unclaimed rebalance reward counts toward harvest share | Harvest unfairness, natural claim-to-decay, compounding incentive | -| **BOLD-inspired fairness product** | Second integral weighted by loss product | Harvest unfairness via accumulator math | -| **Private mempool** (off-chain) | Submit rebalance via Flashbots Protect or similar | Mempool frontrunning specifically | - -The effective share mechanism (C) is the simplest harvest fairness approach: no new integral, no governance parameters, natural decay via claiming, and the mint fee naturally gates when to compound. It can be combined with CR-based fees for belt-and-suspenders protection, or used standalone if the harvest fairness alone provides sufficient deterrence. diff --git a/lib/bao-base b/lib/bao-base index 075141a8..60f67990 160000 --- a/lib/bao-base +++ b/lib/bao-base @@ -1 +1 @@ -Subproject commit 075141a890ae66b606367ea25dc85456f2ffb7a6 +Subproject commit 60f67990894e4cbfb628e8f280cff5efc4b6cfe6 diff --git a/test/deployment/RebalanceFairness.t.sol b/test/deployment/RebalanceFairness.t.sol index 2feeb416..80fbc5c5 100644 --- a/test/deployment/RebalanceFairness.t.sol +++ b/test/deployment/RebalanceFairness.t.sol @@ -21,7 +21,7 @@ import {console2} from "forge-std/console2.sol"; import {FmtLib} from "src/util/FmtLib.sol"; /// @title RebalanceFairnessTest -/// @notice Worked example from doc/ideas/sp-dynamic-fees.md using real contract code +/// @notice Worked example from doc/ideas/rebalance-fairness.md using real contract code /// deployed via the production deployment scripts. Simulates all actors through /// rebalance scenarios to measure the exact income redistribution. contract RebalanceFairnessSetUp is BaoTest, Deploy_ETH_Minter { From 9c37bce1a0116c95f6f590765bc31f7c606bf858 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Wed, 15 Apr 2026 11:24:52 +0100 Subject: [PATCH 046/232] slither fixes; formatting --- regression/coverage.txt | 10 +-- regression/gas.txt | 117 ++++++++++++++----------- script/src/contracts/HarborYield.sol | 7 +- src/autocompounding/HarborYield_v1.sol | 44 ++++++---- src/interfaces/IHarborYield.sol | 4 +- src/minter/StabilityPool_v3.sol | 1 - test/autocompounding/HarborYield.t.sol | 14 +-- 7 files changed, 111 insertions(+), 86 deletions(-) diff --git a/regression/coverage.txt b/regression/coverage.txt index d87229f1..1e8a31cc 100644 --- a/regression/coverage.txt +++ b/regression/coverage.txt @@ -34,14 +34,14 @@ | script/src/HarborFactoryDeployer.sol | X 56% (9/16) | X 73% (8/11) | ✓ 100% (0/0) | X 20% (1/5) | | script/src/contracts/AutoCompounder.sol | ✓ 100% (23/23) | ✓ 100% (33/33) | ✓ 100% (0/0) | ✓ 100% (3/3) | | script/src/contracts/Genesis.sol | X 77% (10/13) | X 73% (11/15) | ✓ 100% (0/0) | X 67% (2/3) | -| script/src/contracts/HarborYield.sol | X 0% (0/21) | X 0% (0/27) | ✓ 100% (0/0) | X 0% (0/3) | +| script/src/contracts/HarborYield.sol | X 0% (0/27) | X 0% (0/37) | ✓ 100% (0/0) | X 0% (0/3) | | script/src/contracts/LeveragedToken.sol | ✓ 100% (18/18) | ✓ 100% (26/26) | ✓ 100% (0/0) | ✓ 100% (2/2) | | script/src/contracts/Minter.sol | X 62% (32/52) | X 63% (38/60) | ✓ 100% (0/0) | X 60% (6/10) | | script/src/contracts/PeggedToken.sol | X 85% (23/27) | X 94% (34/36) | X 50% (3/6) | ✓ 100% (2/2) | | script/src/contracts/StabilityPool.sol | ✓ 100% (31/31) | ✓ 100% (50/50) | ✓ 100% (0/0) | ✓ 100% (3/3) | | script/src/contracts/StabilityPoolManager.sol | X 53% (17/32) | X 50% (18/36) | ✓ 100% (0/0) | X 50% (3/6) | -| src/autocompounding/AutoCompounder_v1.sol | X 96% (75/78) | X 97% (75/77) | X 60% (3/5) | X 94% (15/16) | -| src/autocompounding/HarborYield_v1.sol | X 91% (140/154) | X 93% (161/174) | X 86% (18/21) | X 76% (16/21) | +| src/autocompounding/AutoCompounder_v1.sol | X 97% (64/66) | X 99% (66/67) | X 75% (3/4) | X 93% (13/14) | +| src/autocompounding/HarborYield_v1.sol | X 94% (197/209) | X 95% (231/242) | X 88% (22/25) | X 88% (30/34) | | src/math/DecrementalFloatingPoint.sol | ✓ 100% (31/31) | ✓ 100% (33/33) | ✓ 100% (9/9) | ✓ 100% (6/6) | | src/minter/Genesis_v1.sol | X 99% (88/89) | ✓ 100% (88/88) | ✓ 100% (14/14) | X 92% (11/12) | | src/minter/Minter_v1.sol | X 0% (0/601) | X 0% (0/649) | X 0% (0/102) | X 0% (0/68) | @@ -51,7 +51,7 @@ | src/minter/StabilityPoolManager_v1.sol | X 99% (165/166) | X 99% (176/177) | X 95% (20/21) | ✓ 100% (24/24) | | src/minter/StabilityPool_v1.sol | X 0% (0/203) | X 0% (0/223) | X 0% (0/33) | X 0% (0/22) | | src/minter/StabilityPool_v2.sol | X 61% (122/199) | X 58% (127/219) | X 19% (6/31) | X 73% (16/22) | -| src/minter/StabilityPool_v3.sol | ✓ 100% (251/251) | ✓ 100% (273/273) | ✓ 100% (35/35) | ✓ 100% (32/32) | +| src/minter/StabilityPool_v3.sol | ✓ 100% (234/234) | ✓ 100% (255/255) | ✓ 100% (33/33) | ✓ 100% (29/29) | | src/minter/TokenDistributor_v1.sol | X 96% (94/98) | X 97% (112/116) | X 77% (10/13) | X 93% (14/15) | | src/minter/library/ConfigIncentiveLib.sol | ✓ 100% (24/24) | ✓ 100% (17/17) | ✓ 100% (2/2) | ✓ 100% (9/9) | | src/minter/library/Config_v1.sol | X 96% (76/79) | X 97% (92/95) | X 90% (18/20) | ✓ 100% (6/6) | @@ -67,4 +67,4 @@ | src/util/ERC20MetadataLib_v1.sol | ✓ 100% (24/24) | ✓ 100% (22/22) | ✓ 100% (4/4) | ✓ 100% (4/4) | | src/util/FmtLib.sol | X 79% (15/19) | X 73% (19/26) | X 50% (2/4) | ✓ 100% (1/1) | | src/util/WordCodec.sol | ✓ 100% (15/15) | ✓ 100% (14/14) | ✓ 100% (0/0) | ✓ 100% (4/4) | -| Total | X 62% (5076/8204) | X 61% (5383/8845) | X 50% (456/915) | X 63% (764/1213) | +| Total | X 62% (5123/8244) | X 61% (5436/8898) | X 50% (461/916) | X 64% (780/1225) | diff --git a/regression/gas.txt b/regression/gas.txt index 5c358771..d0cf9157 100644 --- a/regression/gas.txt +++ b/regression/gas.txt @@ -1,52 +1,68 @@ src/autocompounding/AutoCompounder_v1.sol:AutoCompounder_v1 | function name | max | |-----------------------|-----------| +| DOMAIN_SEPARATOR | 6.520e+02 | | MINTER | 3.250e+02 | | PEGGED_TOKEN | 3.050e+02 | | STABILITY_POOL | 2.840e+02 | -| WRAPPED_COLLATERAL | 3.050e+02 | -| approveCompoundTokens | 7.004e+04 | -| asset | 2.421e+03 | -| balanceOf | 2.592e+03 | +| WRAPPED_COLLATERAL | 2.830e+02 | +| allowance | 2.732e+03 | +| approveCompoundTokens | 7.002e+04 | +| asset | 3.030e+02 | +| balanceOf | 2.577e+03 | | compound | 3.948e+05 | -| decimals | 2.880e+02 | -| deposit | 2.114e+05 | -| depositPeggedToken | 3.213e+05 | -| initialize | 1.073e+05 | +| decimals | 3.980e+02 | +| deposit | 2.098e+05 | +| depositPeggedToken | 3.209e+05 | +| initialize | 7.067e+04 | | maxFeeRatio | 2.391e+03 | | name | 5.050e+02 | -| owner | 2.403e+03 | -| previewRedeem | 3.630e+04 | -| redeem | 9.756e+04 | +| nonces | 2.599e+03 | +| owner | 2.380e+03 | +| permit | 5.061e+04 | +| previewRedeem | 3.572e+04 | +| redeem | 9.616e+04 | | setMaxFeeRatio | 2.562e+04 | -| sweep | 4.525e+04 | -| symbol | 5.770e+02 | +| sweep | 4.524e+04 | +| symbol | 5.990e+02 | | totalAssets | 7.818e+04 | | transferOwnership | 1.202e+04 | src/autocompounding/HarborYield_v1.sol:HarborYield_v1 -| function name | max | -|--------------------|-----------| -| COMPOUNDER_ROLE | 2.290e+02 | -| REDISTRIBUTOR_ROLE | 2.720e+02 | -| activateVault | 1.345e+04 | -| addVault | 1.694e+05 | -| allowance | 2.793e+03 | -| approve | 2.482e+04 | -| balanceOf | 2.607e+03 | -| compound | 1.700e+05 | -| deactivateVault | 1.347e+04 | -| deposit | 1.872e+05 | -| grantRoles | 2.637e+04 | -| initialize | 7.626e+04 | -| redeem | 1.307e+05 | -| redistribute | 2.496e+05 | -| setVaultWeight | 1.946e+04 | -| totalAssets | 3.736e+04 | -| totalSupply | 2.371e+03 | -| totalWeight | 2.347e+03 | -| vaultAt | 9.181e+03 | -| vaultCount | 2.420e+03 | +| function name | max | +|------------------------|-----------| +| COMPOUNDER_ROLE | 2.520e+02 | +| DOMAIN_SEPARATOR | 6.240e+02 | +| REDISTRIBUTOR_ROLE | 2.720e+02 | +| activateVault | 1.149e+04 | +| addAutoCompounderVault | 1.280e+05 | +| addEquivalentVault | 1.281e+05 | +| allowance | 2.703e+03 | +| approve | 2.445e+04 | +| asset | 3.130e+02 | +| balanceOf | 2.615e+03 | +| compound | 1.974e+05 | +| convertToAssets | 6.420e+04 | +| convertToShares | 4.041e+04 | +| deactivateVault | 1.146e+04 | +| deposit | 1.874e+05 | +| grantRoles | 2.633e+04 | +| initialize | 7.062e+04 | +| maxPegDriftBps | 2.399e+03 | +| name | 5.440e+02 | +| nonces | 2.570e+03 | +| permit | 5.059e+04 | +| previewDeposit | 4.042e+04 | +| previewRedeem | 4.046e+04 | +| redeem | 1.302e+05 | +| redistribute | 2.750e+05 | +| setMaxPegDriftBps | 2.572e+04 | +| setVaultWeight | 1.733e+04 | +| totalAssets | 6.166e+04 | +| totalSupply | 2.349e+03 | +| totalWeight | 2.392e+03 | +| vaultAt | 8.257e+03 | +| vaultCount | 2.420e+03 | src/minter/Genesis_v1.sol:Genesis_v1 | function name | max | @@ -95,7 +111,7 @@ src/minter/Minter_v3.sol:Minter_v3 | mintLeveragedTokenIncentiveRatio | 3.108e+04 | | mintPeggedToken(uint256,address,uint256) | 1.911e+05 | | mintPeggedToken(uint256,address,uint256,uint256) | 1.251e+05 | -| mintPeggedTokenDryRun(uint256) | 6.399e+04 | +| mintPeggedTokenDryRun(uint256) | 6.401e+04 | | mintPeggedTokenDryRun(uint256,uint256) | 4.556e+04 | | mintPeggedTokenIncentiveRatio | 3.012e+04 | | owner | 2.402e+03 | @@ -136,14 +152,14 @@ src/minter/StabilityPoolManager_v1.sol:StabilityPoolManager_v1 | function name | max | |----------------------------|-----------| | feeReceiver | 2.441e+03 | -| harvest | 4.443e+05 | +| harvest | 4.442e+05 | | harvestBountyRatio | 2.369e+03 | | harvestCutRatio | 2.371e+03 | | harvestable | 3.052e+04 | | hasStabilityPool | 5.370e+02 | | initialize | 7.069e+04 | | owner | 2.423e+03 | -| rebalance | 5.541e+05 | +| rebalance | 5.542e+05 | | rebalanceBountyRatio | 2.354e+03 | | rebalanceThreshold | 2.347e+03 | | rebalanceable | 2.926e+04 | @@ -184,41 +200,44 @@ src/minter/StabilityPool_v2.sol:StabilityPool_v2 src/minter/StabilityPool_v3.sol:StabilityPool_v3 | function name | max | |----------------------------------------|-----------| -| ASSET_TOKEN | 3.490e+02 | +| ASSET_TOKEN | 3.050e+02 | +| DOMAIN_SEPARATOR | 6.860e+02 | | EXEMPT_WITHDRAWAL_FEE_ROLE | 2.860e+02 | | LIQUIDATION_TOKEN | 3.500e+02 | | REBALANCER_ROLE | 2.840e+02 | -| REWARD_DEPOSITOR_ROLE | 2.840e+02 | +| REWARD_DEPOSITOR_ROLE | 3.060e+02 | | REWARD_MANAGER_ROLE | 3.270e+02 | -| allowance | 2.789e+03 | -| approve | 2.458e+04 | +| allowance | 2.699e+03 | +| approve | 2.442e+04 | | assetBalanceOf | 8.053e+03 | | balanceOf | 5.789e+03 | | checkpoint | 1.465e+05 | | claim(address) | 2.246e+05 | -| claim(address,address) | 1.536e+05 | +| claim(address,address) | 1.535e+05 | | claim(address,address,address,uint256) | 2.161e+05 | | claimable | 2.488e+04 | | claimed | 7.472e+03 | -| decimals | 2.950e+02 | +| decimals | 2.670e+02 | | deposit | 2.848e+05 | | depositReward | 6.726e+04 | -| getWithdrawalRequest | 2.745e+03 | +| getWithdrawalRequest | 2.767e+03 | | grantRoles | 2.638e+04 | | historicalRewardTokens | 5.180e+03 | | initialize | 2.042e+05 | | name | 5.720e+02 | +| nonces | 2.654e+03 | | notifyLiquidation | 1.235e+05 | -| owner | 2.424e+03 | +| owner | 2.446e+03 | +| permit | 5.063e+04 | | proxiableUUID | 3.640e+02 | | registerRewardToken | 8.857e+04 | | requestWithdrawal | 2.501e+04 | | sweep | 4.024e+04 | -| symbol | 5.550e+02 | -| totalAssetSupply | 2.489e+03 | +| symbol | 5.770e+02 | +| totalAssetSupply | 2.423e+03 | | totalSupply | 2.424e+03 | | transfer | 1.880e+05 | -| transferFrom | 1.316e+05 | +| transferFrom | 1.313e+05 | | transferOwnership | 1.204e+04 | | unregisterRewardToken | 9.144e+04 | | withdraw | 2.585e+05 | diff --git a/script/src/contracts/HarborYield.sol b/script/src/contracts/HarborYield.sol index 5cbcd94d..d9f1aed5 100644 --- a/script/src/contracts/HarborYield.sol +++ b/script/src/contracts/HarborYield.sol @@ -102,7 +102,12 @@ abstract contract HarborYield is HarborFactoryDeployer { for (uint256 i = 0; i < equivalents.length; i++) { address eqVault = equivalents[i].vault; address asset = IERC4626(eqVault).asset(); - console.log(" addEquivalentVault: %s (asset: %s, weight: %s)", eqVault, asset, equivalents[i].weight); + console.log( + " addEquivalentVault: %s (asset: %s, weight: %s)", + eqVault, + asset, + equivalents[i].weight + ); HarborYield_v1(hyProxy).addEquivalentVault(eqVault, equivalents[i].weight, equivalents[i].valuationOracle); } } diff --git a/src/autocompounding/HarborYield_v1.sol b/src/autocompounding/HarborYield_v1.sol index 771a1d70..b22a4515 100644 --- a/src/autocompounding/HarborYield_v1.sol +++ b/src/autocompounding/HarborYield_v1.sol @@ -316,6 +316,10 @@ contract HarborYield_v1 is address vault = $.vaults[i].vault; // slither-disable-next-line calls-loop uint256 vaultShares = IERC20(vault).balanceOf(address(this)); + // Zero-balance skip: `== 0` is an exact guard, not a comparison used to drive + // financial logic — we just avoid the follow-on convertToAssets/oracle calls when + // there's nothing to value. + // slither-disable-next-line incorrect-equality if (vaultShares == 0) { continue; } @@ -359,11 +363,7 @@ contract HarborYield_v1 is /// @inheritdoc IHarborYield // slither-disable-next-line reentrancy-no-eth - function deposit( - address asset_, - uint256 amount, - address receiver - ) external nonReentrant returns (uint256 shares) { + function deposit(address asset_, uint256 amount, address receiver) external nonReentrant returns (uint256 shares) { amount = Token.allOf(msg.sender, asset_, amount); HarborYieldStorage storage $ = _getHarborYieldStorage(); @@ -423,7 +423,12 @@ contract HarborYield_v1 is //////////////////////////////////////////////////////////////////////////*/ /// @inheritdoc IHarborYield - // slither-disable-next-line reentrancy-events + // Guarded by `nonReentrant` and role-gated to COMPOUNDER_ROLE / owner. The external redeem + // on line 431 is followed by a storage read (_effectiveMinOut reads maxPegDriftBps via the + // ERC7201 slot), which slither classifies as a "state write" because of the assembly slot + // binding — it's a pointer load, not a mutation. No attacker-controlled state transition + // spans the call. + // slither-disable-next-line reentrancy-events,reentrancy-no-eth,reentrancy-benign function compound( address fromVault, address toVault, @@ -465,7 +470,12 @@ contract HarborYield_v1 is } /// @inheritdoc IHarborYield - // slither-disable-next-line reentrancy-events + // Guarded by `nonReentrant` and role-gated to REDISTRIBUTOR_ROLE / owner. The external + // redeem on line 520 is followed by a storage read (_effectiveMinOut reads maxPegDriftBps + // via the ERC7201 slot), which slither classifies as a "state write" because of the + // assembly slot binding — it's a pointer load, not a mutation. No attacker-controlled + // state transition spans the call. + // slither-disable-next-line reentrancy-events,reentrancy-no-eth,reentrancy-benign function redistribute( uint256 maxVaultSharesPerVault, uint256 minAmountOut, @@ -557,7 +567,12 @@ contract HarborYield_v1 is function _fairRateInPegUnits(address vault) private view returns (uint256) { address oracle = _getHarborYieldStorage().vaultValuationOracle[vault]; if (oracle == address(0)) { + // Called from totalAssets()'s per-vault loop. Targets are owner-gated at registration + // (addAutoCompounderVault), vault count is small by design, and the Minter is a + // trusted Harbor contract — so the calls-loop DoS risk does not apply here. + // slither-disable-next-line calls-loop address minter = IAutoCompounder(vault).MINTER(); + // slither-disable-next-line calls-loop return IMinter(minter).peggedTokenPrice(); } return _oracleRatePegUnits(oracle); @@ -566,6 +581,11 @@ contract HarborYield_v1 is /// @dev Return the mid-rate reported by an IWrappedPriceOracle, expressed as /// "peg units per 1 asset unit" in 18 decimals: `mid(price) * mid(rate) / 1e18`. function _oracleRatePegUnits(address oracle) private view returns (uint256) { + // Called transitively from totalAssets()'s per-vault loop. The oracle is owner-vetted + // at addEquivalentVault (_requirePegDriftWithin is called on it), vault count is small, + // and the oracle is a trusted Harbor-registered contract — so the calls-loop DoS risk + // does not apply here. + // slither-disable-next-line calls-loop (uint256 minP, uint256 maxP, uint256 minR, uint256 maxR) = IWrappedPriceOracle(oracle).latestAnswer(); uint256 price = (minP + maxP) / 2; uint256 rate = (minR + maxR) / 2; @@ -594,11 +614,7 @@ contract HarborYield_v1 is uint256 fromRate = _fairRateInPegUnits(fromVault); uint256 toRate = _fairRateInPegUnits(toVault); uint256 expectedOut = Math.mulDiv(amountIn, fromRate, toRate); - uint256 oracleFloor = Math.mulDiv( - expectedOut, - 10_000 - _getHarborYieldStorage().maxPegDriftBps, - 10_000 - ); + uint256 oracleFloor = Math.mulDiv(expectedOut, 10_000 - _getHarborYieldStorage().maxPegDriftBps, 10_000); return keeperMinOut > oracleFloor ? keeperMinOut : oracleFloor; } @@ -632,9 +648,7 @@ contract HarborYield_v1 is } /// @inheritdoc IHarborYield - function vaultAt( - uint256 index - ) external view returns (address vault, address asset_, bool active, uint64 weight) { + function vaultAt(uint256 index) external view returns (address vault, address asset_, bool active, uint64 weight) { ManagedVault storage mv = _getHarborYieldStorage().vaults[index]; vault = mv.vault; // slither-disable-next-line calls-loop diff --git a/src/interfaces/IHarborYield.sol b/src/interfaces/IHarborYield.sol index 872ebf29..697068c3 100644 --- a/src/interfaces/IHarborYield.sol +++ b/src/interfaces/IHarborYield.sol @@ -100,9 +100,7 @@ interface IHarborYield { function vaultCount() external view returns (uint256); /// @notice Get the managed vault info at a given index. - function vaultAt( - uint256 index - ) external view returns (address vault, address asset_, bool active, uint64 weight); + function vaultAt(uint256 index) external view returns (address vault, address asset_, bool active, uint64 weight); /// @notice The cached total of all vault weights. function totalWeight() external view returns (uint256); diff --git a/src/minter/StabilityPool_v3.sol b/src/minter/StabilityPool_v3.sol index dcb9c410..78cbc192 100644 --- a/src/minter/StabilityPool_v3.sol +++ b/src/minter/StabilityPool_v3.sol @@ -104,7 +104,6 @@ contract StabilityPool_v3 is /// @custom:oz-upgrades-unsafe-allow state-variable-immutable bytes32 private immutable _ERC20_SYMBOL; - /*********** * Structs * ***********/ diff --git a/test/autocompounding/HarborYield.t.sol b/test/autocompounding/HarborYield.t.sol index e3e3dea6..a8cc785d 100644 --- a/test/autocompounding/HarborYield.t.sol +++ b/test/autocompounding/HarborYield.t.sol @@ -83,12 +83,7 @@ contract HarborYieldTest is PermitTestBase { // Deploy HarborYield_v1 impl + proxy. // address(this) is both deployer-owner and pending-owner: owner is address(this). - HarborYield_v1 impl = new HarborYield_v1( - "Harbor Yield Test", - "hyTEST", - address(swapper), - address(pegToken) - ); + HarborYield_v1 impl = new HarborYield_v1("Harbor Yield Test", "hyTEST", address(swapper), address(pegToken)); bytes memory initData = abi.encodeCall(HarborYield_v1.initialize, (address(this), address(this))); hy = HarborYield_v1(address(new ERC1967Proxy(address(impl), initData))); @@ -703,12 +698,7 @@ contract HarborYieldTest is PermitTestBase { bytes32 proxy1Domain = hy.DOMAIN_SEPARATOR(); // Deploy a second HY behind a fresh proxy (same impl logic, different address). - HarborYield_v1 impl = new HarborYield_v1( - "Harbor Yield Other", - "hyOTHER", - address(swapper), - address(pegToken) - ); + HarborYield_v1 impl = new HarborYield_v1("Harbor Yield Other", "hyOTHER", address(swapper), address(pegToken)); bytes memory initData = abi.encodeCall(HarborYield_v1.initialize, (address(this), address(this))); HarborYield_v1 other = HarborYield_v1(address(new ERC1967Proxy(address(impl), initData))); bytes32 proxy2Domain = other.DOMAIN_SEPARATOR(); From 240635e968ae6a78625d9e2c29014cfa756a5dad Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Wed, 15 Apr 2026 17:16:31 +0100 Subject: [PATCH 047/232] fixed code verification and deploy code immutability checks --- .github/workflows/CI-test-foundry-stable.yml | 2 +- .validate-ignore | 13 ++ .verify-audit-ignore | 15 ++ lib/bao-base | 2 +- package.json | 2 +- ...ebalanceRemediationForStabilityPool_v2.sol | 185 ++++++++++++++++++ 6 files changed, 216 insertions(+), 3 deletions(-) create mode 100644 .validate-ignore create mode 100644 .verify-audit-ignore create mode 100644 src/minter/PostRebalanceRemediationForStabilityPool_v2.sol diff --git a/.github/workflows/CI-test-foundry-stable.yml b/.github/workflows/CI-test-foundry-stable.yml index 0ad96662..8ae1a20d 100644 --- a/.github/workflows/CI-test-foundry-stable.yml +++ b/.github/workflows/CI-test-foundry-stable.yml @@ -39,7 +39,7 @@ jobs: - name: Verify audited sources unchanged shell: bash - run: lib/bao-base/bin/verify-audit audit-2025-07 + run: lib/bao-base/bin/verify-audit - name: Run Bao-base CI actions uses: ./lib/bao-base/.github/actions/test-foundry diff --git a/.validate-ignore b/.validate-ignore new file mode 100644 index 00000000..dcd810b5 --- /dev/null +++ b/.validate-ignore @@ -0,0 +1,13 @@ +# Syntax: check file +# Supported checks: naming, pragma, storage, upgrades + +# These contracts predate the filename=contract-name convention. The class +# names intentionally omit the _v2 suffix (they are abstract bases, not +# deployed contracts) while the files carry _v2 to distinguish from v1. +naming src/reward/accumulator/MultipleRewardCompoundingAccumulator_v2.sol +naming src/reward/distributor/LinearMultipleRewardDistributor_v2.sol + +# One-off remediation contract deployed during the 1.2 incident response. +# Uses a version range intentionally so it can be compiled against any +# compatible compiler; it was never intended to be a long-lived deployable. +pragma src/minter/PostRebalanceRemediationForStabilityPool_v2.sol diff --git a/.verify-audit-ignore b/.verify-audit-ignore new file mode 100644 index 00000000..fb83949f --- /dev/null +++ b/.verify-audit-ignore @@ -0,0 +1,15 @@ +# Syntax: tag [file1 file2 ...] +# tag alone — ignore the whole tag +# tag file1 file2 — ignore specific files within the tag only + +# deploy/harbor-1.1 and 1.2: abstract contract classes inside *_v2.sol files +# were renamed post-1.2 to match their filenames (e.g. LinearMultipleRewardDistributor +# -> LinearMultipleRewardDistributor_v2). This is a source-only rename; the +# deployed bytecode is unchanged. Suppressed in .validate-ignore for naming check. +# +# deploy/harbor-1.2: StabilityPool_v2.sol additionally had @custom:oz-upgrades-from +# added for the OZ upgrade validation of SP_v1->SP_v2. HEAD holds the 1.1 +# (true deployed) state without that annotation. Suppress via .validate (to be +# implemented) when running upgrade path checks. +deploy/harbor-1.1 src/minter/StabilityPool_v2.sol src/reward/accumulator/MultipleRewardCompoundingAccumulator_v2.sol src/reward/distributor/LinearMultipleRewardDistributor_v2.sol +deploy/harbor-1.2 src/minter/StabilityPool_v2.sol src/reward/accumulator/MultipleRewardCompoundingAccumulator_v2.sol src/reward/distributor/LinearMultipleRewardDistributor_v2.sol diff --git a/lib/bao-base b/lib/bao-base index 60f67990..5e35c3c4 160000 --- a/lib/bao-base +++ b/lib/bao-base @@ -1 +1 @@ -Subproject commit 60f67990894e4cbfb628e8f280cff5efc4b6cfe6 +Subproject commit 5e35c3c47a7186eeb9fdfd1067aefbac4e285dd9 diff --git a/package.json b/package.json index a58d858a..f7d26b4e 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "coverage": "./lib/bao-base/run regression-of coverage", "wake": "uv run wake detect all src", "slither": "./lib/bao-base/run slither --filter-paths 'script/verify'", - "verify-audit": "lib/bao-base/run verify-audit", + "verify-audit": "./lib/bao-base/run verify-audit", "validate": "./lib/bao-base/run validate", "script": "forge script --force --ffi", "disabled": "echo 'disabled tests:' && grep -n -e '^\\s*function\\s*test.*(.*).*private' test/**/*.t.sol", diff --git a/src/minter/PostRebalanceRemediationForStabilityPool_v2.sol b/src/minter/PostRebalanceRemediationForStabilityPool_v2.sol new file mode 100644 index 00000000..9c37ecf0 --- /dev/null +++ b/src/minter/PostRebalanceRemediationForStabilityPool_v2.sol @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.28 <0.9.0; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IBurnable} from "@bao/interfaces/IBurnable.sol"; +import {IBurnableFrom} from "@bao/interfaces/IBurnableFrom.sol"; +import {IMinter} from "../interfaces/IMinter.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {DecrementalFloatingPoint} from "../math/DecrementalFloatingPoint.sol"; + +/// @title Post-Rebalance Remediation for StabilityPool_v2 +/// @notice One-shot upgrade that corrects the reward integral inflated by the +/// Minter v1 over-minting bug, burns excess sailETH from the pool and from the +/// Claimer's wallet, restoring all holders to their v2-correct state. +/// +/// See doc/remediation-ETH-fxUSD-SPL.md for full context. +/// +/// Lifecycle: +/// 1. Pause: upgrade proxy to BaoPauser_v1 +/// 2. Remediate: upgrade proxy to this contract, calling remediate() +/// 3. Restore: upgrade proxy back to StabilityPool_v2 +// solhint-disable-next-line contract-name-capwords +contract PostRebalanceRemediationForStabilityPool_v2 is UUPSUpgradeable { + using DecrementalFloatingPoint for uint128; + + // ── Distribution totals from V2ReplaySimulation ───────────────────────── + // Total sailETH that entered the SPL via notifyLiquidation. + // V1: SPL balance after last rebalance + tokens claimed by Claimer between rebalances + // = 9,018,421,479,602,093,456 (steps[6].splLevBal) + // + 52,963,888,575,928,980 (steps[1].splLevBal - steps[3].splLevBal) + uint256 private constant V1_DISTRIBUTED = 9071385368178022436; + // V2: SPL final balance + all sailETH claimed from SPL (Claimer + Exiter) + // = 151,557,802,561,965,107 (v2_correct_state.csv: SPL levBalance) + // + 876,808,384,680,618 (v2_correct_state.csv: Claimer hs Claimed) + // + 3,932,007,809,370,405 (v2_correct_state.csv: Exiter hs Claimed) + uint256 private constant V2_DISTRIBUTED = 156366618756016130; + + // ── Claimer excess ────────────────────────────────────────────────────── + // Claimer: 0xb9ab9578a34a05c86124c399735fdE44dEc80E7F + // v1 held: 374,304,151,449,162,791 (v1_replay.csv: hs Held) + // v2 held: 322,217,071,257,914,429 (v2_correct_state.csv: hs Held) + // excess = v1 - v2 + address private constant CLAIMER = 0xb9ab9578a34a05c86124c399735fdE44dEc80E7F; + uint256 private constant CLAIMER_EXCESS = 0.052087080191248362 ether; + + // ── Bounty excess ───────────────────────────────────────────────────── + // Rebalance bounty tokens were sent to the bot that called rebalance(). + // Under v1, the total bounty was 0.09163 sailETH; under v2 it would be 0.00158. + // Bounty receiver 1 (0xf1674..., ours): holds 89,169,424,352,193,203 sailETH + // v2 correct share: 89169424352193203 * v2_bounty / v1_bounty = 1,537,044,323,051,756 + // excess = 89,169,424,352,193,203 - 1,537,044,323,051,756 = 87,632,380,029,141,447 + // Bounty receiver 2 (0xc0ffee..., not ours): holds 2,460,730,881,928,232 (~$5, accepted loss) + address private constant BOUNTY_RECEIVER = 0xf1674FE69b2920b4de51E909cbf060dd78724CD8; + uint256 private constant BOUNTY_EXCESS = 0.087632380029141447 ether; + + // ── Collateral gap ────────────────────────────────────────────────────── + // Two components: + // 1. Exiter extracted excess fxSAVE by redeeming at v1 (diluted) prices. + // v1 collateral: 14463.82 fxSAVE (results/v1_replay.csv: collateralTokenBalance) + // v2 collateral: 14528.15 fxSAVE (results/v2_correct_state.csv: collateralTokenBalance) + // gap = 64.33 fxSAVE + // 2. Bounty receiver 2 (0xc0ffee, not ours) holds 0.00246 excess sailETH + // that we can't burn. Compensate by depositing extra fxSAVE to increase + // equity: v2_price * excess / oraclePrice = 18.13 fxSAVE + // Total: 64.33 + 18.13 = 82.47 fxSAVE + uint256 private constant COLLATERAL_GAP = 82.466171119621162782 ether; + + // ── Immutables ────────────────────────────────────────────────────────── + + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + address public immutable LIQUIDATION_TOKEN; + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + address public immutable MINTER; + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + address private immutable _OWNER; + + // ── Storage layout (mirrors StabilityPool_v2 + Accumulator_v2) ────────── + + struct TokenBalance { + uint128 product; + uint104 amount; + uint40 updatedAt; + } + + struct StabilityPoolStorage { + TokenBalance totalAssetSupply; + } + + bytes32 private constant _STABILITYPOOL_STORAGE = + 0xcb62d703974340239a82baeadff6ad7af3673eb85d9779bde2587fc9e0e3e400; + + function _getStabilityPoolStorage() private pure returns (StabilityPoolStorage storage $) { + assembly { $.slot := _STABILITYPOOL_STORAGE } + } + + struct AccumulatorStorage { + mapping(address => address) rewardReceiver; + mapping(address => mapping(uint8 => uint256)) tokenToExponentToIntegral; + } + + bytes32 private constant _ACCUMULATOR_STORAGE = + 0x47ddc56aaabfe9761e2e64ce86720771c5fd1fd7ef0605da74e07d71de0e7900; + + function _getAccumulatorStorage() private pure returns (AccumulatorStorage storage $) { + assembly { $.slot := _ACCUMULATOR_STORAGE } + } + + // ── Errors ────────────────────────────────────────────────────────────── + + error NotOwner(); + error NothingToRemediate(); + + // ── Constructor ───────────────────────────────────────────────────────── + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor(address liquidationToken_, address minter_, address owner_) { + _disableInitializers(); + LIQUIDATION_TOKEN = liquidationToken_; + MINTER = minter_; + _OWNER = owner_; + } + + // ── UUPS ──────────────────────────────────────────────────────────────── + + function owner() public view returns (address) { return _OWNER; } + + function _authorizeUpgrade(address) internal view override { + if (msg.sender != _OWNER) revert NotOwner(); + } + + // ── Remediation ───────────────────────────────────────────────────────── + + /// @notice Execute the full remediation. Called via upgradeToAndCall. + /// Requires BURNER_ROLE on the sailETH token (granted in the Safe batch). + function remediate() external { + if (msg.sender != _OWNER) revert NotOwner(); + + // 1. Get current product exponent + StabilityPoolStorage storage sp = _getStabilityPoolStorage(); + uint8 exp = sp.totalAssetSupply.product.exponent(); + + // 2. Read and correct the integral + AccumulatorStorage storage acc = _getAccumulatorStorage(); + uint256 currentIntegral = acc.tokenToExponentToIntegral[LIQUIDATION_TOKEN][exp]; + if (currentIntegral == 0) revert NothingToRemediate(); + + uint256 correctedIntegral = currentIntegral * V2_DISTRIBUTED / V1_DISTRIBUTED; + acc.tokenToExponentToIntegral[LIQUIDATION_TOKEN][exp] = correctedIntegral; + + // 3. Burn excess from pool + uint256 poolBalance = IERC20(LIQUIDATION_TOKEN).balanceOf(address(this)); + uint256 tokensToKeep = poolBalance * correctedIntegral / currentIntegral; + uint256 poolExcess = poolBalance - tokensToKeep; + if (poolExcess > 0) { + IBurnable(LIQUIDATION_TOKEN).burn(poolExcess); + } + + // 4. Burn excess from Claimer's wallet + // Requires Claimer to have approved this contract for CLAIMER_EXCESS + if (CLAIMER_EXCESS > 0) { + IBurnableFrom(LIQUIDATION_TOKEN).burnFrom(CLAIMER, CLAIMER_EXCESS); + } + + // 5. Burn excess bounty from bounty receiver (ours) + // Requires bounty receiver to have approved this contract for BOUNTY_EXCESS + if (BOUNTY_EXCESS > 0) { + IBurnableFrom(LIQUIDATION_TOKEN).burnFrom(BOUNTY_RECEIVER, BOUNTY_EXCESS); + } + + // 6. Restore missing collateral + // Deposit fxSAVE into minter via freeMintLeveragedToken, then burn the minted sailETH. + // This increases collateral without increasing supply. + // Requires: ZERO_FEE_ROLE on minter, BURNER_ROLE on sailETH (already granted), + // and treasury to have transferred COLLATERAL_GAP fxSAVE to this contract. + if (COLLATERAL_GAP > 0) { + address wrappedCollateral = IMinter(MINTER).WRAPPED_COLLATERAL_TOKEN(); + uint256 fxSaveBal = IERC20(wrappedCollateral).balanceOf(address(this)); + require(fxSaveBal >= COLLATERAL_GAP, "insufficient fxSAVE for collateral restoration"); + + IERC20(wrappedCollateral).approve(MINTER, COLLATERAL_GAP); + uint256 minted = IMinter(MINTER).freeMintLeveragedToken(COLLATERAL_GAP, address(this)); + IBurnable(LIQUIDATION_TOKEN).burn(minted); + } + } +} From bf043bf00c48addc59bd67b6cb6e8fb1afdf1cf6 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Wed, 15 Apr 2026 17:43:26 +0100 Subject: [PATCH 048/232] fix formatting issues --- .verify-audit-ignore | 6 +++++- ...ebalanceRemediationForStabilityPool_v2.sol | 19 ++++++++++++------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/.verify-audit-ignore b/.verify-audit-ignore index fb83949f..41fb91f8 100644 --- a/.verify-audit-ignore +++ b/.verify-audit-ignore @@ -11,5 +11,9 @@ # added for the OZ upgrade validation of SP_v1->SP_v2. HEAD holds the 1.1 # (true deployed) state without that annotation. Suppress via .validate (to be # implemented) when running upgrade path checks. +# +# deploy/harbor-1.2: PostRebalanceRemediationForStabilityPool_v2.sol was deployed +# with unformatted source (inline assembly and single-line functions). HEAD has +# been reformatted by Prettier; bytecode is unchanged. deploy/harbor-1.1 src/minter/StabilityPool_v2.sol src/reward/accumulator/MultipleRewardCompoundingAccumulator_v2.sol src/reward/distributor/LinearMultipleRewardDistributor_v2.sol -deploy/harbor-1.2 src/minter/StabilityPool_v2.sol src/reward/accumulator/MultipleRewardCompoundingAccumulator_v2.sol src/reward/distributor/LinearMultipleRewardDistributor_v2.sol +deploy/harbor-1.2 src/minter/StabilityPool_v2.sol src/minter/PostRebalanceRemediationForStabilityPool_v2.sol src/reward/accumulator/MultipleRewardCompoundingAccumulator_v2.sol src/reward/distributor/LinearMultipleRewardDistributor_v2.sol diff --git a/src/minter/PostRebalanceRemediationForStabilityPool_v2.sol b/src/minter/PostRebalanceRemediationForStabilityPool_v2.sol index 9c37ecf0..cf6bfb90 100644 --- a/src/minter/PostRebalanceRemediationForStabilityPool_v2.sol +++ b/src/minter/PostRebalanceRemediationForStabilityPool_v2.sol @@ -90,7 +90,9 @@ contract PostRebalanceRemediationForStabilityPool_v2 is UUPSUpgradeable { 0xcb62d703974340239a82baeadff6ad7af3673eb85d9779bde2587fc9e0e3e400; function _getStabilityPoolStorage() private pure returns (StabilityPoolStorage storage $) { - assembly { $.slot := _STABILITYPOOL_STORAGE } + assembly { + $.slot := _STABILITYPOOL_STORAGE + } } struct AccumulatorStorage { @@ -98,11 +100,12 @@ contract PostRebalanceRemediationForStabilityPool_v2 is UUPSUpgradeable { mapping(address => mapping(uint8 => uint256)) tokenToExponentToIntegral; } - bytes32 private constant _ACCUMULATOR_STORAGE = - 0x47ddc56aaabfe9761e2e64ce86720771c5fd1fd7ef0605da74e07d71de0e7900; + bytes32 private constant _ACCUMULATOR_STORAGE = 0x47ddc56aaabfe9761e2e64ce86720771c5fd1fd7ef0605da74e07d71de0e7900; function _getAccumulatorStorage() private pure returns (AccumulatorStorage storage $) { - assembly { $.slot := _ACCUMULATOR_STORAGE } + assembly { + $.slot := _ACCUMULATOR_STORAGE + } } // ── Errors ────────────────────────────────────────────────────────────── @@ -122,7 +125,9 @@ contract PostRebalanceRemediationForStabilityPool_v2 is UUPSUpgradeable { // ── UUPS ──────────────────────────────────────────────────────────────── - function owner() public view returns (address) { return _OWNER; } + function owner() public view returns (address) { + return _OWNER; + } function _authorizeUpgrade(address) internal view override { if (msg.sender != _OWNER) revert NotOwner(); @@ -144,12 +149,12 @@ contract PostRebalanceRemediationForStabilityPool_v2 is UUPSUpgradeable { uint256 currentIntegral = acc.tokenToExponentToIntegral[LIQUIDATION_TOKEN][exp]; if (currentIntegral == 0) revert NothingToRemediate(); - uint256 correctedIntegral = currentIntegral * V2_DISTRIBUTED / V1_DISTRIBUTED; + uint256 correctedIntegral = (currentIntegral * V2_DISTRIBUTED) / V1_DISTRIBUTED; acc.tokenToExponentToIntegral[LIQUIDATION_TOKEN][exp] = correctedIntegral; // 3. Burn excess from pool uint256 poolBalance = IERC20(LIQUIDATION_TOKEN).balanceOf(address(this)); - uint256 tokensToKeep = poolBalance * correctedIntegral / currentIntegral; + uint256 tokensToKeep = (poolBalance * correctedIntegral) / currentIntegral; uint256 poolExcess = poolBalance - tokensToKeep; if (poolExcess > 0) { IBurnable(LIQUIDATION_TOKEN).burn(poolExcess); From c068c04ea483364637c85699c2531eb4f04929bb Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Wed, 15 Apr 2026 17:54:04 +0100 Subject: [PATCH 049/232] remove file duplicate --- .verify-audit-ignore | 7 +- ...ebalanceRemediationForStabilityPool_v2.sol | 190 ------------------ 2 files changed, 4 insertions(+), 193 deletions(-) delete mode 100644 src/minter/PostRebalanceRemediationForStabilityPool_v2.sol diff --git a/.verify-audit-ignore b/.verify-audit-ignore index 41fb91f8..c8dc23ad 100644 --- a/.verify-audit-ignore +++ b/.verify-audit-ignore @@ -12,8 +12,9 @@ # (true deployed) state without that annotation. Suppress via .validate (to be # implemented) when running upgrade path checks. # -# deploy/harbor-1.2: PostRebalanceRemediationForStabilityPool_v2.sol was deployed -# with unformatted source (inline assembly and single-line functions). HEAD has -# been reformatted by Prettier; bytecode is unchanged. +# deploy/harbor-1.2: PostRebalanceRemediationForStabilityPool_v2.sol was a one-shot +# upgrade implementation. After remediation, the proxy was upgraded back to +# StabilityPool_v2. The source now lives in script/verify/spl-remediation/ alongside +# the remediation tests and docs. deploy/harbor-1.1 src/minter/StabilityPool_v2.sol src/reward/accumulator/MultipleRewardCompoundingAccumulator_v2.sol src/reward/distributor/LinearMultipleRewardDistributor_v2.sol deploy/harbor-1.2 src/minter/StabilityPool_v2.sol src/minter/PostRebalanceRemediationForStabilityPool_v2.sol src/reward/accumulator/MultipleRewardCompoundingAccumulator_v2.sol src/reward/distributor/LinearMultipleRewardDistributor_v2.sol diff --git a/src/minter/PostRebalanceRemediationForStabilityPool_v2.sol b/src/minter/PostRebalanceRemediationForStabilityPool_v2.sol deleted file mode 100644 index cf6bfb90..00000000 --- a/src/minter/PostRebalanceRemediationForStabilityPool_v2.sol +++ /dev/null @@ -1,190 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.28 <0.9.0; - -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {IBurnable} from "@bao/interfaces/IBurnable.sol"; -import {IBurnableFrom} from "@bao/interfaces/IBurnableFrom.sol"; -import {IMinter} from "../interfaces/IMinter.sol"; -import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; -import {DecrementalFloatingPoint} from "../math/DecrementalFloatingPoint.sol"; - -/// @title Post-Rebalance Remediation for StabilityPool_v2 -/// @notice One-shot upgrade that corrects the reward integral inflated by the -/// Minter v1 over-minting bug, burns excess sailETH from the pool and from the -/// Claimer's wallet, restoring all holders to their v2-correct state. -/// -/// See doc/remediation-ETH-fxUSD-SPL.md for full context. -/// -/// Lifecycle: -/// 1. Pause: upgrade proxy to BaoPauser_v1 -/// 2. Remediate: upgrade proxy to this contract, calling remediate() -/// 3. Restore: upgrade proxy back to StabilityPool_v2 -// solhint-disable-next-line contract-name-capwords -contract PostRebalanceRemediationForStabilityPool_v2 is UUPSUpgradeable { - using DecrementalFloatingPoint for uint128; - - // ── Distribution totals from V2ReplaySimulation ───────────────────────── - // Total sailETH that entered the SPL via notifyLiquidation. - // V1: SPL balance after last rebalance + tokens claimed by Claimer between rebalances - // = 9,018,421,479,602,093,456 (steps[6].splLevBal) - // + 52,963,888,575,928,980 (steps[1].splLevBal - steps[3].splLevBal) - uint256 private constant V1_DISTRIBUTED = 9071385368178022436; - // V2: SPL final balance + all sailETH claimed from SPL (Claimer + Exiter) - // = 151,557,802,561,965,107 (v2_correct_state.csv: SPL levBalance) - // + 876,808,384,680,618 (v2_correct_state.csv: Claimer hs Claimed) - // + 3,932,007,809,370,405 (v2_correct_state.csv: Exiter hs Claimed) - uint256 private constant V2_DISTRIBUTED = 156366618756016130; - - // ── Claimer excess ────────────────────────────────────────────────────── - // Claimer: 0xb9ab9578a34a05c86124c399735fdE44dEc80E7F - // v1 held: 374,304,151,449,162,791 (v1_replay.csv: hs Held) - // v2 held: 322,217,071,257,914,429 (v2_correct_state.csv: hs Held) - // excess = v1 - v2 - address private constant CLAIMER = 0xb9ab9578a34a05c86124c399735fdE44dEc80E7F; - uint256 private constant CLAIMER_EXCESS = 0.052087080191248362 ether; - - // ── Bounty excess ───────────────────────────────────────────────────── - // Rebalance bounty tokens were sent to the bot that called rebalance(). - // Under v1, the total bounty was 0.09163 sailETH; under v2 it would be 0.00158. - // Bounty receiver 1 (0xf1674..., ours): holds 89,169,424,352,193,203 sailETH - // v2 correct share: 89169424352193203 * v2_bounty / v1_bounty = 1,537,044,323,051,756 - // excess = 89,169,424,352,193,203 - 1,537,044,323,051,756 = 87,632,380,029,141,447 - // Bounty receiver 2 (0xc0ffee..., not ours): holds 2,460,730,881,928,232 (~$5, accepted loss) - address private constant BOUNTY_RECEIVER = 0xf1674FE69b2920b4de51E909cbf060dd78724CD8; - uint256 private constant BOUNTY_EXCESS = 0.087632380029141447 ether; - - // ── Collateral gap ────────────────────────────────────────────────────── - // Two components: - // 1. Exiter extracted excess fxSAVE by redeeming at v1 (diluted) prices. - // v1 collateral: 14463.82 fxSAVE (results/v1_replay.csv: collateralTokenBalance) - // v2 collateral: 14528.15 fxSAVE (results/v2_correct_state.csv: collateralTokenBalance) - // gap = 64.33 fxSAVE - // 2. Bounty receiver 2 (0xc0ffee, not ours) holds 0.00246 excess sailETH - // that we can't burn. Compensate by depositing extra fxSAVE to increase - // equity: v2_price * excess / oraclePrice = 18.13 fxSAVE - // Total: 64.33 + 18.13 = 82.47 fxSAVE - uint256 private constant COLLATERAL_GAP = 82.466171119621162782 ether; - - // ── Immutables ────────────────────────────────────────────────────────── - - /// @custom:oz-upgrades-unsafe-allow state-variable-immutable - address public immutable LIQUIDATION_TOKEN; - /// @custom:oz-upgrades-unsafe-allow state-variable-immutable - address public immutable MINTER; - /// @custom:oz-upgrades-unsafe-allow state-variable-immutable - address private immutable _OWNER; - - // ── Storage layout (mirrors StabilityPool_v2 + Accumulator_v2) ────────── - - struct TokenBalance { - uint128 product; - uint104 amount; - uint40 updatedAt; - } - - struct StabilityPoolStorage { - TokenBalance totalAssetSupply; - } - - bytes32 private constant _STABILITYPOOL_STORAGE = - 0xcb62d703974340239a82baeadff6ad7af3673eb85d9779bde2587fc9e0e3e400; - - function _getStabilityPoolStorage() private pure returns (StabilityPoolStorage storage $) { - assembly { - $.slot := _STABILITYPOOL_STORAGE - } - } - - struct AccumulatorStorage { - mapping(address => address) rewardReceiver; - mapping(address => mapping(uint8 => uint256)) tokenToExponentToIntegral; - } - - bytes32 private constant _ACCUMULATOR_STORAGE = 0x47ddc56aaabfe9761e2e64ce86720771c5fd1fd7ef0605da74e07d71de0e7900; - - function _getAccumulatorStorage() private pure returns (AccumulatorStorage storage $) { - assembly { - $.slot := _ACCUMULATOR_STORAGE - } - } - - // ── Errors ────────────────────────────────────────────────────────────── - - error NotOwner(); - error NothingToRemediate(); - - // ── Constructor ───────────────────────────────────────────────────────── - - /// @custom:oz-upgrades-unsafe-allow constructor - constructor(address liquidationToken_, address minter_, address owner_) { - _disableInitializers(); - LIQUIDATION_TOKEN = liquidationToken_; - MINTER = minter_; - _OWNER = owner_; - } - - // ── UUPS ──────────────────────────────────────────────────────────────── - - function owner() public view returns (address) { - return _OWNER; - } - - function _authorizeUpgrade(address) internal view override { - if (msg.sender != _OWNER) revert NotOwner(); - } - - // ── Remediation ───────────────────────────────────────────────────────── - - /// @notice Execute the full remediation. Called via upgradeToAndCall. - /// Requires BURNER_ROLE on the sailETH token (granted in the Safe batch). - function remediate() external { - if (msg.sender != _OWNER) revert NotOwner(); - - // 1. Get current product exponent - StabilityPoolStorage storage sp = _getStabilityPoolStorage(); - uint8 exp = sp.totalAssetSupply.product.exponent(); - - // 2. Read and correct the integral - AccumulatorStorage storage acc = _getAccumulatorStorage(); - uint256 currentIntegral = acc.tokenToExponentToIntegral[LIQUIDATION_TOKEN][exp]; - if (currentIntegral == 0) revert NothingToRemediate(); - - uint256 correctedIntegral = (currentIntegral * V2_DISTRIBUTED) / V1_DISTRIBUTED; - acc.tokenToExponentToIntegral[LIQUIDATION_TOKEN][exp] = correctedIntegral; - - // 3. Burn excess from pool - uint256 poolBalance = IERC20(LIQUIDATION_TOKEN).balanceOf(address(this)); - uint256 tokensToKeep = (poolBalance * correctedIntegral) / currentIntegral; - uint256 poolExcess = poolBalance - tokensToKeep; - if (poolExcess > 0) { - IBurnable(LIQUIDATION_TOKEN).burn(poolExcess); - } - - // 4. Burn excess from Claimer's wallet - // Requires Claimer to have approved this contract for CLAIMER_EXCESS - if (CLAIMER_EXCESS > 0) { - IBurnableFrom(LIQUIDATION_TOKEN).burnFrom(CLAIMER, CLAIMER_EXCESS); - } - - // 5. Burn excess bounty from bounty receiver (ours) - // Requires bounty receiver to have approved this contract for BOUNTY_EXCESS - if (BOUNTY_EXCESS > 0) { - IBurnableFrom(LIQUIDATION_TOKEN).burnFrom(BOUNTY_RECEIVER, BOUNTY_EXCESS); - } - - // 6. Restore missing collateral - // Deposit fxSAVE into minter via freeMintLeveragedToken, then burn the minted sailETH. - // This increases collateral without increasing supply. - // Requires: ZERO_FEE_ROLE on minter, BURNER_ROLE on sailETH (already granted), - // and treasury to have transferred COLLATERAL_GAP fxSAVE to this contract. - if (COLLATERAL_GAP > 0) { - address wrappedCollateral = IMinter(MINTER).WRAPPED_COLLATERAL_TOKEN(); - uint256 fxSaveBal = IERC20(wrappedCollateral).balanceOf(address(this)); - require(fxSaveBal >= COLLATERAL_GAP, "insufficient fxSAVE for collateral restoration"); - - IERC20(wrappedCollateral).approve(MINTER, COLLATERAL_GAP); - uint256 minted = IMinter(MINTER).freeMintLeveragedToken(COLLATERAL_GAP, address(this)); - IBurnable(LIQUIDATION_TOKEN).burn(minted); - } - } -} From c47087eb0c01eeba95ef52d9b3ced0bcacb1e806 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Thu, 16 Apr 2026 11:26:20 +0100 Subject: [PATCH 050/232] fixed audit trail fixed harbor's import "src/..." issue on new files replaced v1 tests with v3 tests --- .verify-audit-ignore | 18 ++--- lib/bao-base | 2 +- src/autocompounding/AutoCompounder_v1.sol | 14 ++-- src/autocompounding/HarborYield_v1.sol | 12 +-- src/interfaces/IStabilityPool_v3.sol | 2 +- src/minter/Minter_v3.sol | 12 +-- src/minter/StabilityPool_v2.sol | 12 +-- src/minter/StabilityPool_v3.sol | 10 +-- ...ultipleRewardCompoundingAccumulator_v2.sol | 12 +-- ...ultipleRewardCompoundingAccumulator_v3.sol | 12 +-- .../LinearMultipleRewardDistributor_v2.sol | 3 +- .../LinearMultipleRewardDistributor_v3.sol | 2 +- test/ExplainFinishAtZero.t.sol | 8 +- test/TestDepositAfterFinishAtZero.t.sol | 8 +- .../IMockLinearMultipleRewardDistributor.sol | 8 +- ...ckMultipleRewardCompoundingAccumulator.sol | 2 +- ...ckMultipleRewardCompoundingAccumulator.sol | 69 ----------------- ...ultipleRewardCompoundingAccumulator_v2.sol | 8 +- ...ultipleRewardCompoundingAccumulator_v3.sol | 13 ++++ .../MockLinearMultipleRewardDistributor.sol | 32 -------- ...ockLinearMultipleRewardDistributor_v3.sol} | 8 +- ...MultipleRewardCompoundingAccumulator.t.sol | 74 +------------------ .../LinearMultipleRewardDistributor.t.sol | 27 ++----- 23 files changed, 95 insertions(+), 273 deletions(-) delete mode 100644 test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator.sol delete mode 100644 test/mocks/reward/distributor/MockLinearMultipleRewardDistributor.sol rename test/mocks/reward/distributor/{MockLinearMultipleRewardDistributor_v2.sol => MockLinearMultipleRewardDistributor_v3.sol} (75%) diff --git a/.verify-audit-ignore b/.verify-audit-ignore index c8dc23ad..c35a1d0e 100644 --- a/.verify-audit-ignore +++ b/.verify-audit-ignore @@ -2,19 +2,13 @@ # tag alone — ignore the whole tag # tag file1 file2 — ignore specific files within the tag only -# deploy/harbor-1.1 and 1.2: abstract contract classes inside *_v2.sol files -# were renamed post-1.2 to match their filenames (e.g. LinearMultipleRewardDistributor -# -> LinearMultipleRewardDistributor_v2). This is a source-only rename; the -# deployed bytecode is unchanged. Suppressed in .validate-ignore for naming check. -# -# deploy/harbor-1.2: StabilityPool_v2.sol additionally had @custom:oz-upgrades-from -# added for the OZ upgrade validation of SP_v1->SP_v2. HEAD holds the 1.1 -# (true deployed) state without that annotation. Suppress via .validate (to be -# implemented) when running upgrade path checks. -# +# deploy/harbor-1.1: StabilityPool_v2.sol additionally had @custom:oz-upgrades-from +# added for the OZ upgrade validation of SP_v1->SP_v2. HEAD holds the 1.2 +# with that annotation. +deploy/harbor-1.1 src/minter/StabilityPool_v2.sol + # deploy/harbor-1.2: PostRebalanceRemediationForStabilityPool_v2.sol was a one-shot # upgrade implementation. After remediation, the proxy was upgraded back to # StabilityPool_v2. The source now lives in script/verify/spl-remediation/ alongside # the remediation tests and docs. -deploy/harbor-1.1 src/minter/StabilityPool_v2.sol src/reward/accumulator/MultipleRewardCompoundingAccumulator_v2.sol src/reward/distributor/LinearMultipleRewardDistributor_v2.sol -deploy/harbor-1.2 src/minter/StabilityPool_v2.sol src/minter/PostRebalanceRemediationForStabilityPool_v2.sol src/reward/accumulator/MultipleRewardCompoundingAccumulator_v2.sol src/reward/distributor/LinearMultipleRewardDistributor_v2.sol +deploy/harbor-1.2 src/minter/PostRebalanceRemediationForStabilityPool_v2.sol diff --git a/lib/bao-base b/lib/bao-base index 5e35c3c4..65314643 160000 --- a/lib/bao-base +++ b/lib/bao-base @@ -1 +1 @@ -Subproject commit 5e35c3c47a7186eeb9fdfd1067aefbac4e285dd9 +Subproject commit 65314643224d6210bc2d74d999a75ea3955f5fe4 diff --git a/src/autocompounding/AutoCompounder_v1.sol b/src/autocompounding/AutoCompounder_v1.sol index 49d56436..b003e5f2 100644 --- a/src/autocompounding/AutoCompounder_v1.sol +++ b/src/autocompounding/AutoCompounder_v1.sol @@ -13,13 +13,13 @@ import {HarborOwnable} from "@bao/HarborOwnable.sol"; import {Token} from "@bao/Token.sol"; import {TokenHolder, ITokenHolder} from "@bao/TokenHolder.sol"; -import {IAutoCompounder} from "src/interfaces/IAutoCompounder.sol"; -import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; -import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; -import {IMultipleRewardAccumulator_v3} from "src/interfaces/IMultipleRewardAccumulator_v3.sol"; -import {IMinter} from "src/interfaces/IMinter.sol"; -import {IMinter_v3} from "src/interfaces/IMinter_v3.sol"; -import {ERC20MetadataLib_v1} from "src/util/ERC20MetadataLib_v1.sol"; +import {IAutoCompounder} from "@harbor/interfaces/IAutoCompounder.sol"; +import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; +import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; +import {IMultipleRewardAccumulator_v3} from "@harbor/interfaces/IMultipleRewardAccumulator_v3.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; +import {IMinter_v3} from "@harbor/interfaces/IMinter_v3.sol"; +import {ERC20MetadataLib_v1} from "@harbor/util/ERC20MetadataLib_v1.sol"; /// @title AutoCompounder_v1 /// @notice Level 1 auto-compounder: non-rebasing ERC4626 vault wrapping a rebasing stability pool position. diff --git a/src/autocompounding/HarborYield_v1.sol b/src/autocompounding/HarborYield_v1.sol index b22a4515..9824a6c4 100644 --- a/src/autocompounding/HarborYield_v1.sol +++ b/src/autocompounding/HarborYield_v1.sol @@ -14,12 +14,12 @@ import {HarborOwnableRoles} from "@bao/HarborOwnableRoles.sol"; import {Token} from "@bao/Token.sol"; import {TokenHolder, ITokenHolder} from "@bao/TokenHolder.sol"; -import {IHarborYield} from "src/interfaces/IHarborYield.sol"; -import {IAutoCompounder} from "src/interfaces/IAutoCompounder.sol"; -import {ISwapper} from "src/interfaces/ISwapper.sol"; -import {IWrappedPriceOracle} from "src/interfaces/IWrappedPriceOracle.sol"; -import {IMinter} from "src/interfaces/IMinter.sol"; -import {ERC20MetadataLib_v1} from "src/util/ERC20MetadataLib_v1.sol"; +import {IHarborYield} from "@harbor/interfaces/IHarborYield.sol"; +import {IAutoCompounder} from "@harbor/interfaces/IAutoCompounder.sol"; +import {ISwapper} from "@harbor/interfaces/ISwapper.sol"; +import {IWrappedPriceOracle} from "@harbor/interfaces/IWrappedPriceOracle.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; +import {ERC20MetadataLib_v1} from "@harbor/util/ERC20MetadataLib_v1.sol"; /// @title HarborYield_v1 /// @notice Level 2 yield vault: one per peg. Manages multiple ERC4626 vaults (AutoCompounders, diff --git a/src/interfaces/IStabilityPool_v3.sol b/src/interfaces/IStabilityPool_v3.sol index fe338c4e..2bf783ac 100644 --- a/src/interfaces/IStabilityPool_v3.sol +++ b/src/interfaces/IStabilityPool_v3.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; -import {IMultipleRewardAccumulator_v3} from "src/interfaces/IMultipleRewardAccumulator_v3.sol"; +import {IMultipleRewardAccumulator_v3} from "@harbor/interfaces/IMultipleRewardAccumulator_v3.sol"; /// @notice StabilityPool v3 additions: unified claim interface. /// @dev Does NOT inherit IStabilityPool — the SP_v3 contract inherits both separately. diff --git a/src/minter/Minter_v3.sol b/src/minter/Minter_v3.sol index ffc5a246..df8f1f0d 100644 --- a/src/minter/Minter_v3.sol +++ b/src/minter/Minter_v3.sol @@ -15,8 +15,8 @@ import {Token} from "@bao/Token.sol"; import {TokenHolder, ITokenHolder} from "@bao/TokenHolder.sol"; import {BaoOwnableRoles} from "@bao/BaoOwnableRoles.sol"; -import {IMinter} from "src/interfaces/IMinter.sol"; -import {IMinter_v3} from "src/interfaces/IMinter_v3.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; +import {IMinter_v3} from "@harbor/interfaces/IMinter_v3.sol"; // different ERC20 mint/burn interfaces import {IMintable} from "@bao/interfaces/IMintable.sol"; @@ -24,11 +24,11 @@ import {IBurnable} from "@bao/interfaces/IBurnable.sol"; import {IBurnableFrom} from "@bao/interfaces/IBurnableFrom.sol"; import {IBurnable2Arg} from "@bao/interfaces/IBurnable2Arg.sol"; -import {IWrappedPriceOracle} from "src/interfaces/IWrappedPriceOracle.sol"; -import {IReservePool} from "src/interfaces/IReservePool.sol"; +import {IWrappedPriceOracle} from "@harbor/interfaces/IWrappedPriceOracle.sol"; +import {IReservePool} from "@harbor/interfaces/IReservePool.sol"; -import {ConfigIncentiveLib} from "src/minter/library/ConfigIncentiveLib.sol"; -import {Config_v1} from "src/minter/library/Config_v1.sol"; +import {ConfigIncentiveLib} from "@harbor/minter/library/ConfigIncentiveLib.sol"; +import {Config_v1} from "@harbor/minter/library/Config_v1.sol"; /// @title Bao Minter /// @author rootminus0x1 based on (albeit significantly modified) Aladdin's FX system diff --git a/src/minter/StabilityPool_v2.sol b/src/minter/StabilityPool_v2.sol index 2916f674..15849e72 100644 --- a/src/minter/StabilityPool_v2.sol +++ b/src/minter/StabilityPool_v2.sol @@ -11,7 +11,7 @@ import {Token} from "@bao/Token.sol"; import {TokenHolder} from "@bao/TokenHolder.sol"; import {DecrementalFloatingPoint} from "src/math/DecrementalFloatingPoint.sol"; -import {MultipleRewardCompoundingAccumulator_v2} from "src/reward/accumulator/MultipleRewardCompoundingAccumulator_v2.sol"; +import {MultipleRewardCompoundingAccumulator} from "src/reward/accumulator/MultipleRewardCompoundingAccumulator_v2.sol"; import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; import {IMinter} from "src/interfaces/IMinter.sol"; @@ -37,7 +37,7 @@ import {IMinter} from "src/interfaces/IMinter.sol"; contract StabilityPool_v2 is Initializable, UUPSUpgradeable, - MultipleRewardCompoundingAccumulator_v2, + MultipleRewardCompoundingAccumulator, TokenHolder, IStabilityPool { @@ -194,7 +194,7 @@ contract StabilityPool_v2 is uint256 withdrawalStartDelay_, uint256 withdrawalEndWindow_, uint256 minTotalAssetSupply - ) MultipleRewardCompoundingAccumulator_v2(_REWARD_MANAGER_ROLE, _REWARD_DEPOSITOR_ROLE, 1 weeks) { + ) MultipleRewardCompoundingAccumulator(_REWARD_MANAGER_ROLE, _REWARD_DEPOSITOR_ROLE, 1 weeks) { _disableInitializers(); address asset = IMinter(minter_).PEGGED_TOKEN(); Token.sanityCheckERC20Token(asset); @@ -461,7 +461,7 @@ contract StabilityPool_v2 is * Internal Functions * **********************/ - /// @inheritdoc MultipleRewardCompoundingAccumulator_v2 + /// @inheritdoc MultipleRewardCompoundingAccumulator // slither-disable-next-line reentrancy-events,reentrancy-benign,reentrancy-no-eth // function is only called from nonReentrant external functions function _checkpoint(address account) internal virtual override { StabilityPoolStorage storage $ = _getStabilityPoolStorage(); @@ -481,7 +481,7 @@ contract StabilityPool_v2 is } } - /// @inheritdoc MultipleRewardCompoundingAccumulator_v2 + /// @inheritdoc MultipleRewardCompoundingAccumulator function _getTotalPoolShare() internal view virtual override returns (uint128 currentProd, uint256 totalShare) { StabilityPoolStorage storage $ = _getStabilityPoolStorage(); TokenBalance memory supply = $.totalAssetSupply; @@ -489,7 +489,7 @@ contract StabilityPool_v2 is totalShare = supply.amount; } - /// @inheritdoc MultipleRewardCompoundingAccumulator_v2 + /// @inheritdoc MultipleRewardCompoundingAccumulator function _getUserPoolShare( address account ) internal view virtual override returns (uint128 previousProd, uint256 share) { diff --git a/src/minter/StabilityPool_v3.sol b/src/minter/StabilityPool_v3.sol index 78cbc192..e238032d 100644 --- a/src/minter/StabilityPool_v3.sol +++ b/src/minter/StabilityPool_v3.sol @@ -11,12 +11,12 @@ import {ERC20} from "@solady/tokens/ERC20.sol"; import {Token} from "@bao/Token.sol"; import {TokenHolder} from "@bao/TokenHolder.sol"; -import {DecrementalFloatingPoint} from "src/math/DecrementalFloatingPoint.sol"; -import {MultipleRewardCompoundingAccumulator_v3} from "src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol"; +import {DecrementalFloatingPoint} from "@harbor/math/DecrementalFloatingPoint.sol"; +import {MultipleRewardCompoundingAccumulator_v3} from "@harbor/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol"; -import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; -import {IMinter} from "src/interfaces/IMinter.sol"; -import {ERC20MetadataLib_v1} from "src/util/ERC20MetadataLib_v1.sol"; +import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; +import {ERC20MetadataLib_v1} from "@harbor/util/ERC20MetadataLib_v1.sol"; // solhint-disable not-rely-on-time // slither-disable-start timestamp diff --git a/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v2.sol b/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v2.sol index a4249627..93dfb6bc 100644 --- a/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v2.sol +++ b/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v2.sol @@ -10,7 +10,7 @@ import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; import {DecrementalFloatingPoint} from "src/math/DecrementalFloatingPoint.sol"; -import {LinearMultipleRewardDistributor_v2} from "src/reward/distributor/LinearMultipleRewardDistributor_v2.sol"; +import {LinearMultipleRewardDistributor} from "src/reward/distributor/LinearMultipleRewardDistributor_v2.sol"; // solhint-disable not-rely-on-time @@ -111,10 +111,10 @@ import {LinearMultipleRewardDistributor_v2} from "src/reward/distributor/LinearM /// /// @dev The method comes from liquity's StabilityPool, the paper is in /// https://github.com/liquity/dev/blob/main/papers/Scalable_Reward_Distribution_with_Compounding_Stakes.pdf -// solhint-disable-next-line contract-name-capwords -abstract contract MultipleRewardCompoundingAccumulator_v2 is + +abstract contract MultipleRewardCompoundingAccumulator is ReentrancyGuardTransientUpgradeable, - LinearMultipleRewardDistributor_v2, + LinearMultipleRewardDistributor, IMultipleRewardAccumulator { using SafeERC20 for IERC20; @@ -271,7 +271,7 @@ abstract contract MultipleRewardCompoundingAccumulator_v2 is uint256 rewardManagerRole, uint256 rewardDepositorRole, uint40 periodLength - ) LinearMultipleRewardDistributor_v2(rewardManagerRole, rewardDepositorRole, periodLength) {} + ) LinearMultipleRewardDistributor(rewardManagerRole, rewardDepositorRole, periodLength) {} /************************* * Public View Functions * @@ -535,7 +535,7 @@ abstract contract MultipleRewardCompoundingAccumulator_v2 is return amount; } - /// @inheritdoc LinearMultipleRewardDistributor_v2 + /// @inheritdoc LinearMultipleRewardDistributor function _accumulateReward(address token, uint256 amount) internal virtual override { // slither-disable-next-line incorrect-equality if (amount == 0) { diff --git a/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol b/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol index 163bc547..dc8811e0 100644 --- a/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol +++ b/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol @@ -7,11 +7,11 @@ import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol import {ReentrancyGuardTransientUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardTransientUpgradeable.sol"; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; -import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; -import {IMultipleRewardAccumulator_v3} from "src/interfaces/IMultipleRewardAccumulator_v3.sol"; +import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; +import {IMultipleRewardAccumulator_v3} from "@harbor/interfaces/IMultipleRewardAccumulator_v3.sol"; -import {DecrementalFloatingPoint} from "src/math/DecrementalFloatingPoint.sol"; -import {LinearMultipleRewardDistributor_v3} from "src/reward/distributor/LinearMultipleRewardDistributor_v3.sol"; +import {DecrementalFloatingPoint} from "@harbor/math/DecrementalFloatingPoint.sol"; +import {LinearMultipleRewardDistributor_v3} from "@harbor/reward/distributor/LinearMultipleRewardDistributor_v3.sol"; // solhint-disable not-rely-on-time @@ -372,12 +372,12 @@ abstract contract MultipleRewardCompoundingAccumulator_v3 is } /// @inheritdoc IMultipleRewardAccumulator - function claimHistorical(address[] memory tokens) external { + function claimHistorical(address[] memory tokens) external nonReentrant { _claimTokenList(_msgSender(), tokens); } /// @inheritdoc IMultipleRewardAccumulator - function claimHistorical(address account, address[] memory tokens) external { + function claimHistorical(address account, address[] memory tokens) external nonReentrant { _claimTokenList(account, tokens); } diff --git a/src/reward/distributor/LinearMultipleRewardDistributor_v2.sol b/src/reward/distributor/LinearMultipleRewardDistributor_v2.sol index 0236623b..10cbe975 100644 --- a/src/reward/distributor/LinearMultipleRewardDistributor_v2.sol +++ b/src/reward/distributor/LinearMultipleRewardDistributor_v2.sol @@ -34,8 +34,7 @@ import {LinearReward} from "./LinearReward.sol"; /// and supports immediate or time-based reward distribution depending on the /// configured period length. -// solhint-disable-next-line contract-name-capwords -abstract contract LinearMultipleRewardDistributor_v2 is +abstract contract LinearMultipleRewardDistributor is Initializable, ContextUpgradeable, BaoOwnableRoles, diff --git a/src/reward/distributor/LinearMultipleRewardDistributor_v3.sol b/src/reward/distributor/LinearMultipleRewardDistributor_v3.sol index 39f7c88f..fec8e34e 100644 --- a/src/reward/distributor/LinearMultipleRewardDistributor_v3.sol +++ b/src/reward/distributor/LinearMultipleRewardDistributor_v3.sol @@ -10,7 +10,7 @@ import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Ini import {HarborOwnableRoles} from "@bao/HarborOwnableRoles.sol"; -import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; +import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; import {LinearReward} from "./LinearReward.sol"; // solhint-disable no-empty-blocks diff --git a/test/ExplainFinishAtZero.t.sol b/test/ExplainFinishAtZero.t.sol index 1dbb00a2..43f6f09f 100644 --- a/test/ExplainFinishAtZero.t.sol +++ b/test/ExplainFinishAtZero.t.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {MockERC20} from "@bao-test/mocks/MockERC20.sol"; -import {MockLinearMultipleRewardDistributor_v2} from "test/mocks/reward/distributor/MockLinearMultipleRewardDistributor_v2.sol"; +import {MockLinearMultipleRewardDistributor_v3} from "test/mocks/reward/distributor/MockLinearMultipleRewardDistributor_v3.sol"; import "forge-std/Test.sol"; /// @title Explain Why finishAt is Zero @@ -30,8 +30,8 @@ contract ExplainFinishAtZeroTest is Test { token1 = new MockERC20("R1", "R1", 18); } - function _setupDistributor(uint40 rewardPeriodLength) internal returns (MockLinearMultipleRewardDistributor_v2) { - MockLinearMultipleRewardDistributor_v2 distributor = new MockLinearMultipleRewardDistributor_v2( + function _setupDistributor(uint40 rewardPeriodLength) internal returns (MockLinearMultipleRewardDistributor_v3) { + MockLinearMultipleRewardDistributor_v3 distributor = new MockLinearMultipleRewardDistributor_v3( REWARD_MANAGER_ROLE, REWARD_DEPOSITOR_ROLE, rewardPeriodLength @@ -57,7 +57,7 @@ contract ExplainFinishAtZeroTest is Test { /// for ALL active tokens, even if they have no pending rewards! function test_SmokingGun_FinishAtZero_ExplainedCompletely() public { uint40 REWARD_PERIOD_LENGTH = 1 weeks; - MockLinearMultipleRewardDistributor_v2 distributor = _setupDistributor(REWARD_PERIOD_LENGTH); + MockLinearMultipleRewardDistributor_v3 distributor = _setupDistributor(REWARD_PERIOD_LENGTH); // Register two reward tokens vm.startPrank(manager); diff --git a/test/TestDepositAfterFinishAtZero.t.sol b/test/TestDepositAfterFinishAtZero.t.sol index f4a5aa0f..4a7068a3 100644 --- a/test/TestDepositAfterFinishAtZero.t.sol +++ b/test/TestDepositAfterFinishAtZero.t.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {MockERC20} from "@bao-test/mocks/MockERC20.sol"; -import {MockLinearMultipleRewardDistributor_v2} from "test/mocks/reward/distributor/MockLinearMultipleRewardDistributor_v2.sol"; +import {MockLinearMultipleRewardDistributor_v3} from "test/mocks/reward/distributor/MockLinearMultipleRewardDistributor_v3.sol"; import "forge-std/Test.sol"; /// @title Test Deposit After finishAt is Zero @@ -29,8 +29,8 @@ contract TestDepositAfterFinishAtZeroTest is Test { token1 = new MockERC20("R1", "R1", 18); } - function _setupDistributor(uint40 rewardPeriodLength) internal returns (MockLinearMultipleRewardDistributor_v2) { - MockLinearMultipleRewardDistributor_v2 distributor = new MockLinearMultipleRewardDistributor_v2( + function _setupDistributor(uint40 rewardPeriodLength) internal returns (MockLinearMultipleRewardDistributor_v3) { + MockLinearMultipleRewardDistributor_v3 distributor = new MockLinearMultipleRewardDistributor_v3( REWARD_MANAGER_ROLE, REWARD_DEPOSITOR_ROLE, rewardPeriodLength @@ -46,7 +46,7 @@ contract TestDepositAfterFinishAtZeroTest is Test { function test_DepositAfterCreatingProblematicState() public { uint40 REWARD_PERIOD_LENGTH = 1 weeks; - MockLinearMultipleRewardDistributor_v2 distributor = _setupDistributor(REWARD_PERIOD_LENGTH); + MockLinearMultipleRewardDistributor_v3 distributor = _setupDistributor(REWARD_PERIOD_LENGTH); // Register two reward tokens vm.startPrank(manager); diff --git a/test/mocks/IMockLinearMultipleRewardDistributor.sol b/test/mocks/IMockLinearMultipleRewardDistributor.sol index 6188a0ba..25dd079a 100644 --- a/test/mocks/IMockLinearMultipleRewardDistributor.sol +++ b/test/mocks/IMockLinearMultipleRewardDistributor.sol @@ -2,11 +2,11 @@ pragma solidity >=0.8.28 <0.9.0; -import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; -import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; -import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; +import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; +import {IHarborOwnable} from "@bao/interfaces/IHarborOwnable.sol"; +import {IHarborRoles} from "@bao/interfaces/IHarborRoles.sol"; -interface IMockLinearMultipleRewardDistributor is IMultipleRewardDistributor, IBaoOwnable, IBaoRoles { +interface IMockLinearMultipleRewardDistributor is IMultipleRewardDistributor, IHarborOwnable, IHarborRoles { event _accumulateReward_called(address token, uint256 amount); function initialize(address owner_) external; diff --git a/test/mocks/IMockMultipleRewardCompoundingAccumulator.sol b/test/mocks/IMockMultipleRewardCompoundingAccumulator.sol index 257dba03..21c18502 100644 --- a/test/mocks/IMockMultipleRewardCompoundingAccumulator.sol +++ b/test/mocks/IMockMultipleRewardCompoundingAccumulator.sol @@ -15,7 +15,7 @@ interface IMockMultipleRewardCompoundingAccumulator is { event AccumulateReward(address token, uint256 amount); - function initialize(address owner_) external; + function initialize(address deployerOwner_, address pendingOwner_) external; function setTotalPoolShare(uint256 _totalPoolShare, uint128 _product) external; diff --git a/test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator.sol b/test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator.sol deleted file mode 100644 index 8a4817d5..00000000 --- a/test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator.sol +++ /dev/null @@ -1,69 +0,0 @@ -// SPDX-License-Identifier: MIT - -pragma solidity >=0.8.28 <0.9.0; - -import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; - -import {MultipleRewardCompoundingAccumulator} from "src/reward/accumulator/MultipleRewardCompoundingAccumulator.sol"; - -contract MockMultipleRewardCompoundingAccumulator is Initializable, MultipleRewardCompoundingAccumulator { - event AccumulateReward(address token, uint256 amount); - - uint256 public totalPoolShare; - uint128 public product; - uint256 public userPoolShare; - uint128 public userProduct; - - constructor(uint40 period) MultipleRewardCompoundingAccumulator(_ROLE_0, _ROLE_1, period) {} - - function initialize(address owner_) external initializer { - _initializeOwner(owner_); - __ReentrancyGuardTransient_init(); - } - - function setTotalPoolShare(uint256 _totalPoolShare, uint128 _product) external { - totalPoolShare = _totalPoolShare; - product = _product; - } - - function setUserPoolShare(uint256 _userPoolShare, uint128 _userProduct) external { - userPoolShare = _userPoolShare; - userProduct = _userProduct; - } - - function reentrantCall(bytes calldata _data) external nonReentrant { - (bool _success, ) = address(this).call(_data); - if (!_success) { - // solhint-disable-next-line no-inline-assembly - assembly { - let ptr := mload(0x40) - let size := returndatasize() - returndatacopy(ptr, 0, size) - revert(ptr, size) - } - } - } - - function _getTotalPoolShare() internal view virtual override returns (uint128, uint256) { - return (product, totalPoolShare); - } - - function _getUserPoolShare(address) internal view virtual override returns (uint128, uint256) { - return (userProduct, userPoolShare); - } - - function tokenToExponentToIntegral(address token, uint8 exponent) public view returns (uint256 globalIntegral) { - globalIntegral = uint256(_tokenToExponentToIntegral(token, exponent)); - } - - function userRewardSnapshot( - address account, - address token - ) public view returns (uint64 timestamp, uint256 integral, uint128 pending, uint128 claimed_) { - UserRewardSnapshot memory snapshot = _userRewardSnapshot(account, token); - timestamp = snapshot.checkpoint.timestamp; - integral = uint256(snapshot.checkpoint.integral); - pending = snapshot.rewards.pending; - claimed_ = snapshot.rewards.claimed; - } -} diff --git a/test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v2.sol b/test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v2.sol index 7cdb833f..91aed314 100644 --- a/test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v2.sol +++ b/test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v2.sol @@ -5,9 +5,9 @@ pragma solidity >=0.8.28 <0.9.0; // import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; -import {MultipleRewardCompoundingAccumulator_v2} from "src/reward/accumulator/MultipleRewardCompoundingAccumulator_v2.sol"; +import {MultipleRewardCompoundingAccumulator_v3} from "src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol"; -contract MockMultipleRewardCompoundingAccumulator_v2 is Initializable, MultipleRewardCompoundingAccumulator_v2 { +contract MockMultipleRewardCompoundingAccumulator_v2 is Initializable, MultipleRewardCompoundingAccumulator_v3 { event AccumulateReward(address token, uint256 amount); uint256 public totalPoolShare; @@ -15,10 +15,10 @@ contract MockMultipleRewardCompoundingAccumulator_v2 is Initializable, MultipleR uint256 public userPoolShare; uint128 public userProduct; - constructor(uint40 period) MultipleRewardCompoundingAccumulator_v2(_ROLE_0, _ROLE_1, period) {} + constructor(uint40 period) MultipleRewardCompoundingAccumulator_v3(_ROLE_0, _ROLE_1, period) {} function initialize(address owner_) external initializer { - _initializeOwner(owner_); + _initializeOwner(msg.sender, owner_); __ReentrancyGuardTransient_init(); // __MultipleRewardCompoundingAccumulator_init(); } diff --git a/test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v3.sol b/test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v3.sol index e16f4349..62809f65 100644 --- a/test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v3.sol +++ b/test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v3.sol @@ -31,6 +31,19 @@ contract MockMultipleRewardCompoundingAccumulator_v3 is Initializable, MultipleR userProduct = _userProduct; } + function reentrantCall(bytes calldata _data) external nonReentrant { + (bool _success, ) = address(this).call(_data); + if (!_success) { + // solhint-disable-next-line no-inline-assembly + assembly { + let ptr := mload(0x40) + let size := returndatasize() + returndatacopy(ptr, 0, size) + revert(ptr, size) + } + } + } + function _getTotalPoolShare() internal view virtual override returns (uint128, uint256) { return (product, totalPoolShare); } diff --git a/test/mocks/reward/distributor/MockLinearMultipleRewardDistributor.sol b/test/mocks/reward/distributor/MockLinearMultipleRewardDistributor.sol deleted file mode 100644 index 72d1fcd5..00000000 --- a/test/mocks/reward/distributor/MockLinearMultipleRewardDistributor.sol +++ /dev/null @@ -1,32 +0,0 @@ -// SPDX-License-Identifier: MIT - -pragma solidity >=0.8.28 <0.9.0; - -import {LinearMultipleRewardDistributor} from "src/reward/distributor/LinearMultipleRewardDistributor.sol"; -import {LinearReward} from "src/reward/distributor/LinearReward.sol"; - -contract MockLinearMultipleRewardDistributor is LinearMultipleRewardDistributor { - // used to discover if the _accumulateReward virtual function has been called - event _accumulateReward_called(address token, uint256 amount); - - constructor( - uint256 rewardManagerRole, - uint256 rewardDepositorRole, - uint40 period - ) LinearMultipleRewardDistributor(rewardManagerRole, rewardDepositorRole, period) {} - - function initialize(address owner_) external initializer { - _initializeOwner(owner_); - } - - function _accumulateReward(address _token, uint256 _amount) internal virtual override { - emit _accumulateReward_called(_token, _amount); - } - - function getRewardDataStorage( - address token - ) external view returns (uint256 lastUpdate, uint256 finishAt, uint256 rate, uint256 queued) { - LinearReward.RewardData storage data = _getRewardData(token); - return (data.lastUpdate, data.finishAt, data.rate, data.queued); - } -} diff --git a/test/mocks/reward/distributor/MockLinearMultipleRewardDistributor_v2.sol b/test/mocks/reward/distributor/MockLinearMultipleRewardDistributor_v3.sol similarity index 75% rename from test/mocks/reward/distributor/MockLinearMultipleRewardDistributor_v2.sol rename to test/mocks/reward/distributor/MockLinearMultipleRewardDistributor_v3.sol index c2ad498d..a237f48f 100644 --- a/test/mocks/reward/distributor/MockLinearMultipleRewardDistributor_v2.sol +++ b/test/mocks/reward/distributor/MockLinearMultipleRewardDistributor_v3.sol @@ -2,10 +2,10 @@ pragma solidity >=0.8.28 <0.9.0; -import {LinearMultipleRewardDistributor_v2} from "src/reward/distributor/LinearMultipleRewardDistributor_v2.sol"; +import {LinearMultipleRewardDistributor_v3} from "src/reward/distributor/LinearMultipleRewardDistributor_v3.sol"; import {LinearReward} from "src/reward/distributor/LinearReward.sol"; -contract MockLinearMultipleRewardDistributor_v2 is LinearMultipleRewardDistributor_v2 { +contract MockLinearMultipleRewardDistributor_v3 is LinearMultipleRewardDistributor_v3 { // used to discover if the _accumulateReward virtual function has been called event _accumulateReward_called(address token, uint256 amount); @@ -13,10 +13,10 @@ contract MockLinearMultipleRewardDistributor_v2 is LinearMultipleRewardDistribut uint256 rewardManagerRole, uint256 rewardDepositorRole, uint40 period - ) LinearMultipleRewardDistributor_v2(rewardManagerRole, rewardDepositorRole, period) {} + ) LinearMultipleRewardDistributor_v3(rewardManagerRole, rewardDepositorRole, period) {} function initialize(address owner_) external initializer { - _initializeOwner(owner_); + _initializeOwner(msg.sender, owner_); } function _accumulateReward(address _token, uint256 _amount) internal virtual override { diff --git a/test/reward/accumulator/MultipleRewardCompoundingAccumulator.t.sol b/test/reward/accumulator/MultipleRewardCompoundingAccumulator.t.sol index 3aaf4f6c..8ec7a845 100644 --- a/test/reward/accumulator/MultipleRewardCompoundingAccumulator.t.sol +++ b/test/reward/accumulator/MultipleRewardCompoundingAccumulator.t.sol @@ -9,7 +9,7 @@ import {IMockMultipleRewardCompoundingAccumulator} from "test/mocks/IMockMultipl import {Test, Vm} from "forge-std/Test.sol"; import {MockERC20} from "@bao-test/mocks/MockERC20.sol"; -import {MockMultipleRewardCompoundingAccumulator_v2} from "test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v2.sol"; +import {MockMultipleRewardCompoundingAccumulator_v3} from "test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v3.sol"; contract MultipleRewardCompoundingAccumulatorTest is Test { // Addresses @@ -43,7 +43,7 @@ contract MultipleRewardCompoundingAccumulatorTest is Test { ) internal virtual returns (IMockMultipleRewardCompoundingAccumulator) { return IMockMultipleRewardCompoundingAccumulator( - address(new MockMultipleRewardCompoundingAccumulator_v2(periodLength)) + address(new MockMultipleRewardCompoundingAccumulator_v3(periodLength)) ); } @@ -53,7 +53,7 @@ contract MultipleRewardCompoundingAccumulatorTest is Test { ) internal returns (IMockMultipleRewardCompoundingAccumulator accumulator, address[] memory tokenAddresses) { // Deploy accumulator accumulator = createMultipleRewardCompoundingAccumulator(periodLength); - accumulator.initialize(address(this)); + accumulator.initialize(address(this), address(0)); // Grant manager role accumulator.grantRoles(manager, accumulator.REWARD_MANAGER_ROLE()); @@ -1184,74 +1184,6 @@ contract MultipleRewardCompoundingAccumulatorTest is Test { } } -import {MockMultipleRewardCompoundingAccumulator} from "test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator.sol"; - -contract MultipleRewardCompoundingAccumulatorTest_v1 is MultipleRewardCompoundingAccumulatorTest { - function createMultipleRewardCompoundingAccumulator( - uint40 periodLength - ) internal override returns (IMockMultipleRewardCompoundingAccumulator) { - return - IMockMultipleRewardCompoundingAccumulator( - address(new MockMultipleRewardCompoundingAccumulator(periodLength)) - ); - } - - /// @notice v1 override: realistic params (BTC MIN_DEPOSIT, magnitude=1e36), - /// $600/wk reward overflows uint192 integral on the 7th week. - function test_integralBounds_MinPool_RealisticOverflow() public override { - uint40 periodLength = 1 weeks; - - (IMockMultipleRewardCompoundingAccumulator accumulator, address[] memory tokens) = _setupAccumulator( - 1, - periodLength - ); - - accumulator.setTotalPoolShare(1e13, uint128(1e36)); - - uint256 weeklyReward = 1e16; - - accumulator.depositReward(tokens[0], weeklyReward); - - for (uint256 i = 0; i < 6; i++) { - vm.warp(block.timestamp + periodLength); - accumulator.depositReward(tokens[0], weeklyReward); - } - - // 7th week: uint192 overflow - vm.warp(block.timestamp + periodLength); - vm.expectRevert(abi.encodeWithSignature("Panic(uint256)", 0x11)); - accumulator.depositReward(tokens[0], weeklyReward); - } - - /// @notice v1 override: the 7th cycle reverts because uint192 integral overflows. - function test_accumulateReward_Uint192Overflow() public override { - uint40 periodLength = 1 weeks; - - (IMockMultipleRewardCompoundingAccumulator accumulator, address[] memory tokens) = _setupAccumulator( - 2, - periodLength - ); - - accumulator.setTotalPoolShare(1, 1 ether); - - uint256 depositAmount = 1000 ether; - - // First deposit: sets rate - accumulator.depositReward(tokens[0], depositAmount); - - // 6 weekly cycles: integral grows to ~6e57 < uint192.max - for (uint256 i = 0; i < 6; i++) { - vm.warp(block.timestamp + periodLength); - accumulator.depositReward(tokens[0], depositAmount); - } - - // 7th cycle: uint192 overflow — Panic(0x11) - vm.warp(block.timestamp + periodLength); - vm.expectRevert(abi.encodeWithSignature("Panic(uint256)", 0x11)); - accumulator.depositReward(tokens[0], depositAmount); - } -} - /* import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; import { MockERC20, MockMultipleRewardCompoundingAccumulator } from "@/types/index"; diff --git a/test/reward/distributor/LinearMultipleRewardDistributor.t.sol b/test/reward/distributor/LinearMultipleRewardDistributor.t.sol index 81c70b5d..bf97ef38 100644 --- a/test/reward/distributor/LinearMultipleRewardDistributor.t.sol +++ b/test/reward/distributor/LinearMultipleRewardDistributor.t.sol @@ -3,12 +3,12 @@ pragma solidity >=0.8.28 <0.9.0; import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; import {IMockLinearMultipleRewardDistributor} from "test/mocks/IMockLinearMultipleRewardDistributor.sol"; -import {MockLinearMultipleRewardDistributor_v2} from "test/mocks/reward/distributor/MockLinearMultipleRewardDistributor_v2.sol"; +import {MockLinearMultipleRewardDistributor_v3} from "test/mocks/reward/distributor/MockLinearMultipleRewardDistributor_v3.sol"; import {MockERC20} from "@bao-test/mocks/MockERC20.sol"; import "forge-std/Test.sol"; -import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; +import {IHarborOwnable} from "@bao/interfaces/IHarborOwnable.sol"; contract LinearMultipleRewardDistributorTest is Test { address owner; @@ -35,7 +35,7 @@ contract LinearMultipleRewardDistributorTest is Test { ) internal virtual returns (IMockLinearMultipleRewardDistributor) { return IMockLinearMultipleRewardDistributor( - address(new MockLinearMultipleRewardDistributor_v2(rewardManagerRole, rewardDepositorRole, period)) + address(new MockLinearMultipleRewardDistributor_v3(rewardManagerRole, rewardDepositorRole, period)) ); } @@ -171,7 +171,7 @@ contract LinearMultipleRewardDistributorTest is Test { function test_registerRewardToken_RevertWhenNonManagerCall() public { IMockLinearMultipleRewardDistributor distributor = _setupDistributor(1 days); - vm.expectRevert(IBaoOwnable.Unauthorized.selector); + vm.expectRevert(IHarborOwnable.Unauthorized.selector); distributor.registerRewardToken(address(token0)); } @@ -295,7 +295,7 @@ contract LinearMultipleRewardDistributorTest is Test { vm.prank(manager); distributor.registerRewardToken(address(token0)); - vm.expectRevert(IBaoOwnable.Unauthorized.selector); + vm.expectRevert(IHarborOwnable.Unauthorized.selector); distributor.unregisterRewardToken(address(token0)); } @@ -354,7 +354,7 @@ contract LinearMultipleRewardDistributorTest is Test { vm.prank(manager); distributor.registerRewardToken(address(token0)); - vm.expectRevert(IBaoOwnable.Unauthorized.selector); + vm.expectRevert(IHarborOwnable.Unauthorized.selector); distributor.depositReward(address(token0), 0); } @@ -1275,18 +1275,3 @@ contract LinearMultipleRewardDistributorTest is Test { assertTrue(rd.rate > 0); } } - -import {MockLinearMultipleRewardDistributor} from "test/mocks/reward/distributor/MockLinearMultipleRewardDistributor.sol"; - -contract LinearMultipleRewardDistributorTest_v1 is LinearMultipleRewardDistributorTest { - function createLinearMultipleRewardDistributor( - uint256 rewardManagerRole, - uint256 rewardDepositorRole, - uint40 period - ) internal override returns (IMockLinearMultipleRewardDistributor) { - return - IMockLinearMultipleRewardDistributor( - address(new MockLinearMultipleRewardDistributor(rewardManagerRole, rewardDepositorRole, period)) - ); - } -} From 666b64124120754f7efedbe2af6b63e431fdc79d Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Thu, 16 Apr 2026 16:23:02 +0100 Subject: [PATCH 051/232] remove HarborYield as it's now in the harbor-yield repo --- script/src/contracts/HarborYield.sol | 114 ---- src/autocompounding/HarborYield_v1.sol | 673 ----------------------- test/autocompounding/HarborYield.t.sol | 708 ------------------------- 3 files changed, 1495 deletions(-) delete mode 100644 script/src/contracts/HarborYield.sol delete mode 100644 src/autocompounding/HarborYield_v1.sol delete mode 100644 test/autocompounding/HarborYield.t.sol diff --git a/script/src/contracts/HarborYield.sol b/script/src/contracts/HarborYield.sol deleted file mode 100644 index d9f1aed5..00000000 --- a/script/src/contracts/HarborYield.sol +++ /dev/null @@ -1,114 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.28 <0.9.0; - -import {console2 as console} from "forge-std/console2.sol"; -import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; -import {HarborFactoryDeployer} from "script/src/HarborFactoryDeployer.sol"; -import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; - -import {HarborYield_v1} from "@harbor/autocompounding/HarborYield_v1.sol"; -import {ConfigTokenNames} from "script/config/ConfigTokenNames.sol"; -import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; - -/// @notice Harbor HarborYield deployment logic. -/// @dev One HarborYield per peg. Manages multiple ERC4626 vaults (AutoCompounders, wrapped -/// collateral, equivalents) that share the same peg. Standalone -- minimal dependencies -/// on the minter deployment infrastructure. -abstract contract HarborYield is HarborFactoryDeployer { - // Salt-type constant for the collateral AC — mirrors the value in - // script/src/contracts/AutoCompounder.sol. Kept local to avoid cross-abstract inheritance. - string private constant _AUTOCOMPOUNDER_COLLATERAL = "autoCompounderCollateral"; - - // ========== HARBOR YIELD DEPLOYMENT ========== - - /// @notice Deploy HarborYield_v1 impl only, record in state. - function deployHarborYieldImplementation( - DeploymentTypes.State memory stateData, - string memory yieldKey, - ConfigPeg pegConfig - ) internal virtual returns (address impl) { - console.log(" > %s", yieldKey); - - ConfigTokenNames names = ConfigTokenNames(address(pegConfig)); - string memory tokenName = names.harborYieldName(); - string memory tokenSymbol = names.harborYieldSymbol(); - address swapper = _predictAddressFromFullSalt("harbor_v1::swapper"); - address pegToken = _predictAddress(_key(pegConfig.key(), "pegged")); - - impl = address(new HarborYield_v1(tokenName, tokenSymbol, swapper, pegToken)); - console.log(" Impl: %s", impl); - console.log(" Name: %s", tokenName); - console.log(" Symbol: %s", tokenSymbol); - console.log(" Asset: %s", pegToken); - - _recordImplementation( - stateData, - yieldKey, - "@harbor/autocompounding/HarborYield_v1.sol", - "HarborYield_v1", - impl - ); - } - - /// @notice Deploy HarborYield_v1 impl+proxy, record in state. - function deployHarborYield( - DeploymentTypes.State memory stateData, - string memory yieldKey, - ConfigPeg pegConfig - ) internal returns (address proxy) { - address impl = deployHarborYieldImplementation(stateData, yieldKey, pegConfig); - - bytes memory initData = abi.encodeCall(HarborYield_v1.initialize, (address(this), owner())); - - proxy = _deployProxyAndRecord(stateData, yieldKey, impl, initData); - } - - /// @notice Register an AutoCompounder for a market as a managed vault in HY. - /// @param marketKey Salt key of the market whose collateral AutoCompounder is being registered - /// (e.g., "EUR::fxUSD"). The collateral AC address is derived from this. - /// @param weight Target distribution weight for this AC in the HY basket. - struct AutoCompounderVaultConfig { - string marketKey; - uint64 weight; - } - - /// @notice Register an equivalent-yield ERC4626 as a managed vault in HY. - /// @param vault The ERC4626 vault address (e.g., an fxSAVE wrapper). - /// @param weight Target distribution weight. - /// @param valuationOracle IWrappedPriceOracle pricing the vault's asset against the peg token. - struct EquivalentVaultConfig { - address vault; - uint64 weight; - address valuationOracle; - } - - /// @notice Register a set of collateral AutoCompounders and equivalents with a deployed HY. - /// @dev Two distinct config arrays — no flag. ACs are identified by market key (the script - /// predicts their address from the existing salt namespace); equivalents carry their - /// own vault address and oracle because they're external to the market infrastructure. - /// @param hyProxy The HarborYield proxy address. - /// @param autoCompounders ACs to register — one per collateral market in this peg. - /// @param equivalents Equivalent-yield vaults to register alongside the ACs. - function configureHarborYield( - address hyProxy, - AutoCompounderVaultConfig[] memory autoCompounders, - EquivalentVaultConfig[] memory equivalents - ) internal { - for (uint256 i = 0; i < autoCompounders.length; i++) { - address acVault = _predictAddress(_key(autoCompounders[i].marketKey, _AUTOCOMPOUNDER_COLLATERAL)); - console.log(" addAutoCompounderVault: %s (weight: %s)", acVault, autoCompounders[i].weight); - HarborYield_v1(hyProxy).addAutoCompounderVault(acVault, autoCompounders[i].weight); - } - for (uint256 i = 0; i < equivalents.length; i++) { - address eqVault = equivalents[i].vault; - address asset = IERC4626(eqVault).asset(); - console.log( - " addEquivalentVault: %s (asset: %s, weight: %s)", - eqVault, - asset, - equivalents[i].weight - ); - HarborYield_v1(hyProxy).addEquivalentVault(eqVault, equivalents[i].weight, equivalents[i].valuationOracle); - } - } -} diff --git a/src/autocompounding/HarborYield_v1.sol b/src/autocompounding/HarborYield_v1.sol deleted file mode 100644 index 9824a6c4..00000000 --- a/src/autocompounding/HarborYield_v1.sol +++ /dev/null @@ -1,673 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.30; - -import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; -import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; -import {ERC20} from "@solady/tokens/ERC20.sol"; -import {ReentrancyGuardTransientUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardTransientUpgradeable.sol"; -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; -import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; - -import {HarborOwnableRoles} from "@bao/HarborOwnableRoles.sol"; -import {Token} from "@bao/Token.sol"; -import {TokenHolder, ITokenHolder} from "@bao/TokenHolder.sol"; - -import {IHarborYield} from "@harbor/interfaces/IHarborYield.sol"; -import {IAutoCompounder} from "@harbor/interfaces/IAutoCompounder.sol"; -import {ISwapper} from "@harbor/interfaces/ISwapper.sol"; -import {IWrappedPriceOracle} from "@harbor/interfaces/IWrappedPriceOracle.sol"; -import {IMinter} from "@harbor/interfaces/IMinter.sol"; -import {ERC20MetadataLib_v1} from "@harbor/util/ERC20MetadataLib_v1.sol"; - -/// @title HarborYield_v1 -/// @notice Level 2 yield vault: one per peg. Manages multiple ERC4626 vaults (AutoCompounders, -/// wrapped collateral, equivalents) that share the same peg. -/// @dev Replaces both hyToken_v1 (compound/swap) and HarborAnchoredVault_v1 (weighted distribution). -/// -/// Each managed vault has a weight. Deposits are routed to the vault the user specifies. -/// `redistribute()` moves holdings toward the target weight distribution. Permissionless. -/// `compound()` converts equivalent vault holdings into AC vault holdings via the swapper. -/// -/// All assets are assumed pegged 1:1. totalAssets() = SUM(IERC4626(v).convertToAssets(balance)). -// solhint-disable-next-line contract-name-capwords -contract HarborYield_v1 is - Initializable, - UUPSUpgradeable, - ERC20, - ReentrancyGuardTransientUpgradeable, - HarborOwnableRoles, - TokenHolder, - IHarborYield -{ - using SafeERC20 for IERC20; - - /*////////////////////////////////////////////////////////////////////////// - ERRORS - //////////////////////////////////////////////////////////////////////////*/ - - error VaultNotRegistered(address token); - error VaultNotActive(address vault); - error VaultAlreadyRegistered(address vault); - error ZeroShares(); - error ZeroWeight(); - error NothingToRedistribute(); - - /// @notice An AutoCompounder vault's `PEGGED_TOKEN` does not match this HarborYield's peg - /// token. The caller is trying to register an AC from the wrong market. - error WrongPegToken(address expected, address actual); - - /// @notice A vault's asset does not value 1:1 against the peg token within the allowed - /// drift. Either a config error (wrong asset) or a market depeg in progress. - error ExcessivePegDrift(uint256 expected, uint256 actual); - - /*////////////////////////////////////////////////////////////////////////// - CONSTANTS - //////////////////////////////////////////////////////////////////////////*/ - - /// @notice Role for triggering compound (equivalent → AC conversion via swapper). - uint256 public constant COMPOUNDER_ROLE = _ROLE_0; - - /// @notice Role for triggering redistribution toward target weights. - uint256 public constant REDISTRIBUTOR_ROLE = _ROLE_1; - - /*////////////////////////////////////////////////////////////////////////// - IMMUTABLES - //////////////////////////////////////////////////////////////////////////*/ - - /// @custom:oz-upgrades-unsafe-allow state-variable-immutable - bytes32 private immutable _ERC20_NAME_0; - /// @custom:oz-upgrades-unsafe-allow state-variable-immutable - bytes32 private immutable _ERC20_NAME_1; - /// @custom:oz-upgrades-unsafe-allow state-variable-immutable - bytes32 private immutable _ERC20_SYMBOL; - - /// @notice The swapper contract for token conversions (at a predictable proxy address). - /// @custom:oz-upgrades-unsafe-allow state-variable-immutable - address public immutable SWAPPER; // solhint-disable-line immutable-vars-naming - - /// @notice The peg token (e.g. haEUR) that values the HarborYield share in peg units. - /// HY is not ERC-4626 — it holds multiple assets — but `asset()` returns this token - /// for interop with aggregators and price feeds. Also serves as the peg-identity - /// reference for `addVault` — AC vaults are checked against this via - /// `IAutoCompounder.PEGGED_TOKEN()`, and equivalent vaults are checked via the - /// swapper's value preview against this token. - /// @custom:oz-upgrades-unsafe-allow state-variable-immutable - address private immutable _PEG_TOKEN; - - /*////////////////////////////////////////////////////////////////////////// - STORAGE (ERC7201) - //////////////////////////////////////////////////////////////////////////*/ - - /// @custom:storage-location erc7201:harbor.storage.HarborYield_v1 - // chisel eval 'keccak256(abi.encode(uint256(keccak256("harbor.storage.HarborYield_v1")) - 1)) & ~bytes32(uint256(0xff))' - bytes32 private constant _HARBOR_YIELD_STORAGE = 0xb05ebd6dfc4d62a678d881c33089de39bf9f2de81bf0c8c698a99ab10ff31300; - - struct ManagedVault { - address vault; // ERC4626 vault (AutoCompounder, wstETH wrapper, fxSAVE wrapper, etc.) - uint64 weight; // target distribution weight (arbitrary units) — packs into slot with vault + bools - bool active; // accepts new deposits - bool isAutoCompounder; // true if vault implements IAutoCompounder - } - - struct HarborYieldStorage { - ManagedVault[] vaults; - mapping(address => uint256) assetToVaultIndex; // asset => index+1 (0 = not registered) - uint256 totalWeight; // sum of all vault weights (cached for gas) - uint64 maxPegDriftBps; // max deviation from 1:1 for equivalent vaults, in bps (10000 = 100%) - mapping(address => address) vaultValuationOracle; // sparse: equivalents only; AC vaults use default address(0) - } - - function _getHarborYieldStorage() private pure returns (HarborYieldStorage storage $) { - // solhint-disable-next-line no-inline-assembly - assembly { - $.slot := _HARBOR_YIELD_STORAGE - } - } - - /*////////////////////////////////////////////////////////////////////////// - CONSTRUCTOR / INITIALIZER - //////////////////////////////////////////////////////////////////////////*/ - - /// @custom:oz-upgrades-unsafe-allow constructor - constructor(string memory name_, string memory symbol_, address swapper_, address pegToken_) { - _disableInitializers(); - (_ERC20_NAME_0, _ERC20_NAME_1) = ERC20MetadataLib_v1.packName(name_); - _ERC20_SYMBOL = ERC20MetadataLib_v1.packSymbol(symbol_); - Token.ensureNonZeroAddress(swapper_); - Token.ensureNonZeroAddress(pegToken_); - // slither-disable-next-line missing-zero-check - SWAPPER = swapper_; - // slither-disable-next-line missing-zero-check - _PEG_TOKEN = pegToken_; - } - - function initialize(address deployerOwner_, address pendingOwner_) external initializer { - _initializeOwner(deployerOwner_, pendingOwner_); - __UUPSUpgradeable_init(); - __ReentrancyGuardTransient_init(); - // Solady ERC20 has no init hook — name/symbol are resolved via virtual overrides - // backed by ERC20MetadataLib_v1 immutables in the constructor. Permit is built in. - } - - /*////////////////////////////////////////////////////////////////////////// - UUPS - //////////////////////////////////////////////////////////////////////////*/ - - function _authorizeUpgrade(address) internal override onlyOwner {} // solhint-disable-line no-empty-blocks - - /*////////////////////////////////////////////////////////////////////////// - ADMIN: VAULT MANAGEMENT - //////////////////////////////////////////////////////////////////////////*/ - - /// @notice Register an AutoCompounder vault. Verifies the AC's underlying pegged token - /// matches this HarborYield's peg token by introspecting `IAutoCompounder.PEGGED_TOKEN()`. - /// No oracle required — ACs hold the peg token directly via their underlying SP. - /// @param vault The AutoCompounder vault address. - /// @param weight Target distribution weight (arbitrary units, must be > 0). - // slither-disable-next-line reentrancy-no-eth,reentrancy-events - function addAutoCompounderVault(address vault, uint64 weight) external onlyOwner { - Token.ensureContract(vault); - address acPegged = IAutoCompounder(vault).PEGGED_TOKEN(); - if (acPegged != _PEG_TOKEN) { - revert WrongPegToken(_PEG_TOKEN, acPegged); - } - _addVault(vault, weight, true, address(0)); - } - - /// @notice Register an equivalent-yield ERC4626 vault with a required peg-value oracle. - /// The oracle's current mid-rate must be within `maxPegDriftBps` of 1:1 with the - /// peg token, or registration reverts. The oracle is stored per-vault and used by - /// `totalAssets` for valuation and by `compound`/`redistribute` for the runtime - /// oracle-bounded swap floor. - /// @param vault The equivalent-yield ERC4626 vault. - /// @param weight Target distribution weight. - /// @param valuationOracle IWrappedPriceOracle providing (price, rate) for the vault's - /// asset vs the peg token. - // slither-disable-next-line reentrancy-no-eth,reentrancy-events - function addEquivalentVault(address vault, uint64 weight, address valuationOracle) external onlyOwner { - Token.ensureContract(vault); - Token.ensureContract(valuationOracle); - - // Check that the oracle currently reports a rate close to 1:1. This catches wrong-class - // assets (oracle rate obviously not ~1e18) and currently-depegged assets (oracle rate - // > maxPegDriftBps away from 1e18). - uint256 rate = _oracleRatePegUnits(valuationOracle); - _requirePegDriftWithin(1 ether, rate); - - _addVault(vault, weight, false, valuationOracle); - } - - /// @dev Shared bookkeeping for both addVault variants. Both callers have already verified - /// the vault-class-specific peg check before reaching here. - function _addVault(address vault, uint64 weight, bool isAutoCompounder, address valuationOracle) private { - if (weight == 0) { - revert ZeroWeight(); - } - address vaultAsset = IERC4626(vault).asset(); - - HarborYieldStorage storage $ = _getHarborYieldStorage(); - if ($.assetToVaultIndex[vaultAsset] != 0) { - revert VaultAlreadyRegistered(vault); - } - - $.vaults.push(ManagedVault({vault: vault, weight: weight, active: true, isAutoCompounder: isAutoCompounder})); - $.assetToVaultIndex[vaultAsset] = $.vaults.length; // 1-indexed - $.totalWeight += weight; - if (valuationOracle != address(0)) { - $.vaultValuationOracle[vault] = valuationOracle; - } - - IERC20(vaultAsset).forceApprove(vault, type(uint256).max); - - emit VaultAdded(vault, vaultAsset, weight); - } - - /// @notice Update the maximum peg drift allowed for equivalent-vault registration and - /// rebalance swaps, in basis points (e.g., 200 = 2%). - /// @dev Setting to 0 forces exact 1:1 parity, which will break for any real equivalent; - /// intended for deactivation / emergency freeze only. - function setMaxPegDriftBps(uint64 newMaxPegDriftBps) external onlyOwner { - _getHarborYieldStorage().maxPegDriftBps = newMaxPegDriftBps; - emit MaxPegDriftBpsUpdated(newMaxPegDriftBps); - } - - /// @notice The current maximum peg drift in basis points. - function maxPegDriftBps() external view returns (uint64) { - return _getHarborYieldStorage().maxPegDriftBps; - } - - /// @notice Update a vault's target weight. Set to 0 to drain via redistribution. - function setVaultWeight(address vault, uint64 weight) external onlyOwner { - HarborYieldStorage storage $ = _getHarborYieldStorage(); - for (uint256 i = 0; i < $.vaults.length; i++) { - if ($.vaults[i].vault == vault) { - $.totalWeight = $.totalWeight - $.vaults[i].weight + weight; - $.vaults[i].weight = weight; - emit VaultWeightUpdated(vault, weight); - return; - } - } - revert VaultNotRegistered(vault); - } - - /// @notice Deactivate a vault (stop accepting deposits, keep existing holdings). - function deactivateVault(address vault) external onlyOwner { - HarborYieldStorage storage $ = _getHarborYieldStorage(); - for (uint256 i = 0; i < $.vaults.length; i++) { - if ($.vaults[i].vault == vault) { - $.vaults[i].active = false; - emit VaultDeactivated(vault); - return; - } - } - revert VaultNotRegistered(vault); - } - - /// @notice Reactivate a previously deactivated vault. - function activateVault(address vault) external onlyOwner { - HarborYieldStorage storage $ = _getHarborYieldStorage(); - for (uint256 i = 0; i < $.vaults.length; i++) { - if ($.vaults[i].vault == vault) { - $.vaults[i].active = true; - emit VaultActivated(vault); - return; - } - } - revert VaultNotRegistered(vault); - } - - /*////////////////////////////////////////////////////////////////////////// - ERC20 METADATA (IMMUTABLE) - //////////////////////////////////////////////////////////////////////////*/ - - function name() public view override returns (string memory) { - return ERC20MetadataLib_v1.unpackName(_ERC20_NAME_0, _ERC20_NAME_1); - } - - function symbol() public view override returns (string memory) { - return ERC20MetadataLib_v1.unpackSymbol(_ERC20_SYMBOL); - } - - function decimals() public pure override returns (uint8) { - return 18; - } - - /*////////////////////////////////////////////////////////////////////////// - ERC-4626 VIEW SHIM - //////////////////////////////////////////////////////////////////////////*/ - - /// @notice The peg token that values HarborYield shares. - /// @dev HarborYield is not a standard ERC-4626 vault (it holds multiple assets with - /// proportional redemption). This view exists for interop with aggregators, portfolio - /// trackers, and price feeds that expect an ERC-4626-style `asset()` getter. The - /// mutation surface (`deposit(asset, amount, receiver)`, `redeem`) is intentionally - /// non-standard. - function asset() public view returns (address) { - return _PEG_TOKEN; - } - - /// @inheritdoc IHarborYield - function totalAssets() public view returns (uint256 total) { - HarborYieldStorage storage $ = _getHarborYieldStorage(); - uint256 length = $.vaults.length; - for (uint256 i = 0; i < length; i++) { - address vault = $.vaults[i].vault; - // slither-disable-next-line calls-loop - uint256 vaultShares = IERC20(vault).balanceOf(address(this)); - // Zero-balance skip: `== 0` is an exact guard, not a comparison used to drive - // financial logic — we just avoid the follow-on convertToAssets/oracle calls when - // there's nothing to value. - // slither-disable-next-line incorrect-equality - if (vaultShares == 0) { - continue; - } - // slither-disable-next-line calls-loop - uint256 vaultAssets = IERC4626(vault).convertToAssets(vaultShares); - // slither-disable-next-line calls-loop - total += Math.mulDiv(vaultAssets, _fairRateInPegUnits(vault), 1 ether); - } - } - - /// @notice Convert an assets amount (in peg units) to HarborYield share units, rounded down. - /// @dev Matches the internal formula used in `deposit`: `shares * (supply + 1) / (assets + 1)`. - /// For interop only; the actual `deposit(asset, amount, receiver)` path uses the - /// vault-specific asset, not the peg token. - function convertToShares(uint256 assets) public view returns (uint256) { - return Math.mulDiv(assets, totalSupply() + 1, totalAssets() + 1); - } - - /// @notice Convert a HarborYield share amount to assets in peg units, rounded down. - function convertToAssets(uint256 shares) public view returns (uint256) { - return Math.mulDiv(shares, totalAssets() + 1, totalSupply() + 1); - } - - /// @notice Preview the shares that would be minted by depositing `assets` peg units. - /// @dev HY has no deposit entrypoint that takes the peg token directly; this preview - /// reflects the economic conversion rate, not a concrete deposit path. - function previewDeposit(uint256 assets) public view returns (uint256) { - return convertToShares(assets); - } - - /// @notice Preview the assets (in peg units) that `shares` would redeem for at the current rate. - /// @dev HY's actual `redeem` pays out a proportional mix of every managed vault's holdings, - /// not peg tokens. This preview reflects the share price in peg units for valuation only. - function previewRedeem(uint256 shares) public view returns (uint256) { - return convertToAssets(shares); - } - - /*////////////////////////////////////////////////////////////////////////// - CORE: DEPOSIT - //////////////////////////////////////////////////////////////////////////*/ - - /// @inheritdoc IHarborYield - // slither-disable-next-line reentrancy-no-eth - function deposit(address asset_, uint256 amount, address receiver) external nonReentrant returns (uint256 shares) { - amount = Token.allOf(msg.sender, asset_, amount); - - HarborYieldStorage storage $ = _getHarborYieldStorage(); - uint256 idx = $.assetToVaultIndex[asset_]; - if (idx == 0) { - revert VaultNotRegistered(asset_); - } - ManagedVault storage mv = $.vaults[idx - 1]; - if (!mv.active) { - revert VaultNotActive(mv.vault); - } - - uint256 assetsBefore = totalAssets(); - uint256 supplyBefore = totalSupply(); - - IERC20(asset_).safeTransferFrom(msg.sender, address(this), amount); - // slither-disable-next-line unused-return - IERC4626(mv.vault).deposit(amount, address(this)); - - shares = Math.mulDiv(amount, supplyBefore + 1, assetsBefore + 1); - if (shares == 0) { - revert ZeroShares(); - } - _mint(receiver, shares); - } - - /*////////////////////////////////////////////////////////////////////////// - CORE: REDEEM - //////////////////////////////////////////////////////////////////////////*/ - - /// @inheritdoc IHarborYield - function redeem(uint256 shares, address receiver, address tokenOwner) external nonReentrant { - if (msg.sender != tokenOwner) { - _spendAllowance(tokenOwner, msg.sender, shares); - } - - uint256 supply = totalSupply(); - _burn(tokenOwner, shares); - - HarborYieldStorage storage $ = _getHarborYieldStorage(); - uint256 length = $.vaults.length; - for (uint256 i = 0; i < length; i++) { - // slither-disable-next-line calls-loop - uint256 vaultShares = IERC20($.vaults[i].vault).balanceOf(address(this)); - if (vaultShares > 0) { - uint256 redeemAmount = Math.mulDiv(vaultShares, shares, supply); - if (redeemAmount > 0) { - // slither-disable-next-line calls-loop,unused-return - IERC4626($.vaults[i].vault).redeem(redeemAmount, receiver, address(this)); - } - } - } - } - - /*////////////////////////////////////////////////////////////////////////// - CORE: COMPOUND - //////////////////////////////////////////////////////////////////////////*/ - - /// @inheritdoc IHarborYield - // Guarded by `nonReentrant` and role-gated to COMPOUNDER_ROLE / owner. The external redeem - // on line 431 is followed by a storage read (_effectiveMinOut reads maxPegDriftBps via the - // ERC7201 slot), which slither classifies as a "state write" because of the assembly slot - // binding — it's a pointer load, not a mutation. No attacker-controlled state transition - // spans the call. - // slither-disable-next-line reentrancy-events,reentrancy-no-eth,reentrancy-benign - function compound( - address fromVault, - address toVault, - uint256 vaultShareAmount, - uint256 minAmountOut, - bytes calldata swapData - ) external nonReentrant onlyOwnerOrRoles(COMPOUNDER_ROLE) { - // Redeem from the source vault to get its underlying asset - uint256 assetAmount = IERC4626(fromVault).redeem(vaultShareAmount, address(this), address(this)); - - // Apply HY's oracle-bounded floor on top of the keeper's minAmountOut. If the keeper - // is lazy or compromised and passes a low minAmountOut, HY's own floor kicks in. - uint256 effectiveMin = _effectiveMinOut(fromVault, toVault, assetAmount, minAmountOut); - - // Swap the asset to the target vault's asset - uint256 swappedAmount = _swapIfNeeded( - IERC4626(fromVault).asset(), - IERC4626(toVault).asset(), - assetAmount, - effectiveMin, - swapData - ); - - // Deposit into the target vault (typically an AC) - // slither-disable-next-line unused-return - IERC4626(toVault).deposit(swappedAmount, address(this)); - - emit Compounded(msg.sender, fromVault, toVault, assetAmount, swappedAmount); - } - - /*////////////////////////////////////////////////////////////////////////// - CORE: REDISTRIBUTE - //////////////////////////////////////////////////////////////////////////*/ - - struct RedistributeWork { - uint256 sourceIdx; - uint256 targetIdx; - uint256 moveValue; - } - - /// @inheritdoc IHarborYield - // Guarded by `nonReentrant` and role-gated to REDISTRIBUTOR_ROLE / owner. The external - // redeem on line 520 is followed by a storage read (_effectiveMinOut reads maxPegDriftBps - // via the ERC7201 slot), which slither classifies as a "state write" because of the - // assembly slot binding — it's a pointer load, not a mutation. No attacker-controlled - // state transition spans the call. - // slither-disable-next-line reentrancy-events,reentrancy-no-eth,reentrancy-benign - function redistribute( - uint256 maxVaultSharesPerVault, - uint256 minAmountOut, - bytes calldata swapData - ) external nonReentrant onlyOwnerOrRoles(REDISTRIBUTOR_ROLE) { - HarborYieldStorage storage $ = _getHarborYieldStorage(); - uint256 tw = $.totalWeight; - if (tw == 0) { - revert NothingToRedistribute(); - } - uint256 total = totalAssets(); - if (total == 0) { - revert NothingToRedistribute(); - } - - // Find the most over/under-weight vaults - RedistributeWork memory w; - { - uint256 maxExcess; - uint256 maxDeficit; - uint256 length = $.vaults.length; - for (uint256 i = 0; i < length; i++) { - // slither-disable-next-line calls-loop - uint256 bal = IERC20($.vaults[i].vault).balanceOf(address(this)); - // slither-disable-next-line calls-loop - uint256 cur = bal > 0 ? IERC4626($.vaults[i].vault).convertToAssets(bal) : 0; - uint256 tgt = Math.mulDiv(total, $.vaults[i].weight, tw); - if (cur > tgt) { - uint256 excess = cur - tgt; - if (excess > maxExcess) { - maxExcess = excess; - w.sourceIdx = i; - } - } else { - uint256 deficit = tgt - cur; - if (deficit > maxDeficit) { - maxDeficit = deficit; - w.targetIdx = i; - } - } - } - if (maxExcess == 0 || maxDeficit == 0) { - revert NothingToRedistribute(); - } - w.moveValue = maxExcess < maxDeficit ? maxExcess : maxDeficit; - } - - // Redeem from source, cap shares - address srcVault = $.vaults[w.sourceIdx].vault; - address dstVault = $.vaults[w.targetIdx].vault; - { - uint256 srcShares = IERC4626(srcVault).convertToShares(w.moveValue); - if (srcShares > maxVaultSharesPerVault) { - srcShares = maxVaultSharesPerVault; - } - w.moveValue = IERC4626(srcVault).redeem(srcShares, address(this), address(this)); - } - - // Apply HY's oracle-bounded floor on top of the keeper's minAmountOut. - uint256 effectiveMin = _effectiveMinOut(srcVault, dstVault, w.moveValue, minAmountOut); - - // Swap if needed, deposit to target - uint256 deposited = _swapIfNeeded( - IERC4626(srcVault).asset(), - IERC4626(dstVault).asset(), - w.moveValue, - effectiveMin, - swapData - ); - // slither-disable-next-line unused-return - IERC4626(dstVault).deposit(deposited, address(this)); - emit Redistributed(msg.sender, srcVault, dstVault, w.moveValue, deposited); - } - - /*////////////////////////////////////////////////////////////////////////// - INTERNAL: PEG VALUATION - //////////////////////////////////////////////////////////////////////////*/ - - /// @dev Return the current fair rate (peg units per 1 asset unit, 18 decimals) for a - /// registered vault. - /// - /// The sparse `vaultValuationOracle` mapping is the branch discriminator: - /// - `address(0)` → AC vault. Read `peggedTokenPrice()` from the vault's Minter - /// (normally 1e18, lower during a peg-token depeg). No oracle lookup for the - /// asset side because an AC's asset is the SP token, which is 1:1 with the peg - /// token (haXXX) via the pool. - /// - non-zero → equivalent vault. Read the registered `IWrappedPriceOracle` and - /// combine min/max price and rate into a single mid value. - function _fairRateInPegUnits(address vault) private view returns (uint256) { - address oracle = _getHarborYieldStorage().vaultValuationOracle[vault]; - if (oracle == address(0)) { - // Called from totalAssets()'s per-vault loop. Targets are owner-gated at registration - // (addAutoCompounderVault), vault count is small by design, and the Minter is a - // trusted Harbor contract — so the calls-loop DoS risk does not apply here. - // slither-disable-next-line calls-loop - address minter = IAutoCompounder(vault).MINTER(); - // slither-disable-next-line calls-loop - return IMinter(minter).peggedTokenPrice(); - } - return _oracleRatePegUnits(oracle); - } - - /// @dev Return the mid-rate reported by an IWrappedPriceOracle, expressed as - /// "peg units per 1 asset unit" in 18 decimals: `mid(price) * mid(rate) / 1e18`. - function _oracleRatePegUnits(address oracle) private view returns (uint256) { - // Called transitively from totalAssets()'s per-vault loop. The oracle is owner-vetted - // at addEquivalentVault (_requirePegDriftWithin is called on it), vault count is small, - // and the oracle is a trusted Harbor-registered contract — so the calls-loop DoS risk - // does not apply here. - // slither-disable-next-line calls-loop - (uint256 minP, uint256 maxP, uint256 minR, uint256 maxR) = IWrappedPriceOracle(oracle).latestAnswer(); - uint256 price = (minP + maxP) / 2; - uint256 rate = (minR + maxR) / 2; - return Math.mulDiv(price, rate, 1 ether); - } - - /// @dev Revert if `actual` is not within `maxPegDriftBps` of `expected`. Symmetric - /// range check used by `addEquivalentVault` to assert the oracle currently reports - /// a rate close to 1:1 with the peg token. - function _requirePegDriftWithin(uint256 expected, uint256 actual) private view { - uint256 tolerance = Math.mulDiv(expected, _getHarborYieldStorage().maxPegDriftBps, 10_000); - if (actual < expected - tolerance || actual > expected + tolerance) { - revert ExcessivePegDrift(expected, actual); - } - } - - /// @dev Compute the oracle-bounded minimum acceptable output for a swap from one managed - /// vault's asset into another's. The returned value is `max(keeperMinOut, oracleFloor)`, - /// so a compromised keeper passing `keeperMinOut = 0` still gets HY's own floor. - function _effectiveMinOut( - address fromVault, - address toVault, - uint256 amountIn, - uint256 keeperMinOut - ) private view returns (uint256) { - uint256 fromRate = _fairRateInPegUnits(fromVault); - uint256 toRate = _fairRateInPegUnits(toVault); - uint256 expectedOut = Math.mulDiv(amountIn, fromRate, toRate); - uint256 oracleFloor = Math.mulDiv(expectedOut, 10_000 - _getHarborYieldStorage().maxPegDriftBps, 10_000); - return keeperMinOut > oracleFloor ? keeperMinOut : oracleFloor; - } - - /*////////////////////////////////////////////////////////////////////////// - INTERNAL: SWAPPER - //////////////////////////////////////////////////////////////////////////*/ - - /// @dev Swap fromAsset -> toAsset via SWAPPER, or pass through if same asset. - /// Called from both compound() and redistribute(). - function _swapIfNeeded( - address fromAsset, - address toAsset, - uint256 amountIn, - uint256 minAmountOut, - bytes calldata swapData - ) private returns (uint256 amountOut) { - if (fromAsset == toAsset) { - return amountIn; - } - IERC20(fromAsset).forceApprove(SWAPPER, amountIn); - amountOut = ISwapper(SWAPPER).swap(fromAsset, toAsset, amountIn, minAmountOut, swapData); - } - - /*////////////////////////////////////////////////////////////////////////// - VIEW: VAULT INFO - //////////////////////////////////////////////////////////////////////////*/ - - /// @inheritdoc IHarborYield - function vaultCount() external view returns (uint256) { - return _getHarborYieldStorage().vaults.length; - } - - /// @inheritdoc IHarborYield - function vaultAt(uint256 index) external view returns (address vault, address asset_, bool active, uint64 weight) { - ManagedVault storage mv = _getHarborYieldStorage().vaults[index]; - vault = mv.vault; - // slither-disable-next-line calls-loop - asset_ = IERC4626(mv.vault).asset(); - active = mv.active; - weight = mv.weight; - } - - /// @notice The cached total of all vault weights. - function totalWeight() external view returns (uint256) { - return _getHarborYieldStorage().totalWeight; - } - - /*////////////////////////////////////////////////////////////////////////// - SWEEP - //////////////////////////////////////////////////////////////////////////*/ - - /// @inheritdoc TokenHolder - function _checkSweeper() internal view override(TokenHolder) { - _checkOwner(); - } -} diff --git a/test/autocompounding/HarborYield.t.sol b/test/autocompounding/HarborYield.t.sol deleted file mode 100644 index a8cc785d..00000000 --- a/test/autocompounding/HarborYield.t.sol +++ /dev/null @@ -1,708 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.28 <0.9.0; - -import "forge-std/Test.sol"; - -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; -import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; - -import {MockERC20} from "@bao-test/mocks/MockERC20.sol"; - -import {HarborYield_v1} from "src/autocompounding/HarborYield_v1.sol"; -import {IHarborYield} from "src/interfaces/IHarborYield.sol"; -import {MockSwapper} from "test/mocks/MockSwapper.sol"; -import {MockERC4626Vault} from "test/mocks/MockERC4626Vault.sol"; -import {MockMinter} from "test/mocks/MockMinter.sol"; -import {MockWrappedPriceOracle} from "test/mocks/MockWrappedPriceOracle.sol"; -import {PermitTestBase} from "@bao-test/helpers/PermitTestBase.t.sol"; - -/// @title HarborYield_v1 unit tests -/// @notice Tests HarborYield in isolation using MockERC20 assets, MockERC4626Vault, and MockSwapper. -/// Avoids the full Minter+SP+AC deployment to keep tests fast and focused on HY behaviour. -/// -/// Run: forge test --mc HarborYieldTest -vv -contract HarborYieldTest is PermitTestBase { - function _permitTarget() internal view override returns (address) { - return address(hy); - } - - // ── Actors ───────────────────────────────────────────────────────── - address alice = makeAddr("alice"); - address bob = makeAddr("bob"); - address keeper = makeAddr("keeper"); - - // ── Tokens ───────────────────────────────────────────────────────── - MockERC20 pegToken; // e.g. haEUR (the HarborYield share's peg-unit asset) - MockERC20 asset0; // e.g. stETH - MockERC20 asset1; // e.g. fxSAVE - - // ── Managed ERC4626 vaults ───────────────────────────────────────── - MockERC4626Vault vault0; - MockERC4626Vault vault1; - - // ── Infrastructure ───────────────────────────────────────────────── - MockSwapper swapper; - HarborYield_v1 hy; - - // ── AC vault (vault0) — requires a MockMinter so it can introspect PEGGED_TOKEN/MINTER ── - MockMinter minter0; - - // ── Equivalent vault (vault1) — requires an IWrappedPriceOracle ── - MockWrappedPriceOracle oracle1; - - // ── Constants ────────────────────────────────────────────────────── - uint64 constant DEFAULT_DRIFT_BPS = 200; // 2% - - uint64 constant WEIGHT_0 = 60; // 60% of target - uint64 constant WEIGHT_1 = 40; // 40% of target - - function setUp() public virtual { - pegToken = new MockERC20("Peg Token", "PEG", 18); - asset0 = new MockERC20("Asset 0", "A0", 18); - asset1 = new MockERC20("Asset 1", "A1", 18); - - // vault0 stands in as an AutoCompounder. Its "asset" is asset0 (playing the role of an - // SP token 1:1 with the peg). Configure PEGGED_TOKEN and MINTER so HY can introspect it - // during `addAutoCompounderVault`. The MockMinter returns peggedTokenPrice = 1e18. - vault0 = new MockERC4626Vault(IERC20(address(asset0)), "Vault 0", "V0"); - minter0 = new MockMinter(address(asset0), address(pegToken), makeAddr("lev0")); - vault0.configureAsAutoCompounder(address(pegToken), address(minter0)); - - // vault1 is an equivalent-yield vault. Its asset is asset1 (e.g. fxSAVE-analog). HY - // registers it via `addEquivalentVault` with a price oracle; the oracle reports 1:1 - // to satisfy the drift check. - vault1 = new MockERC4626Vault(IERC20(address(asset1)), "Vault 1", "V1"); - oracle1 = new MockWrappedPriceOracle(); - oracle1.setLatestAnswer(1 ether, 1 ether); // price = 1, rate = 1 → 1:1 with peg - - // Swapper at 1:1 rate — pre-fund with enough of each token for tests. - swapper = new MockSwapper(1 ether); - asset0.mint(address(swapper), 1_000_000 ether); - asset1.mint(address(swapper), 1_000_000 ether); - - // Deploy HarborYield_v1 impl + proxy. - // address(this) is both deployer-owner and pending-owner: owner is address(this). - HarborYield_v1 impl = new HarborYield_v1("Harbor Yield Test", "hyTEST", address(swapper), address(pegToken)); - bytes memory initData = abi.encodeCall(HarborYield_v1.initialize, (address(this), address(this))); - hy = HarborYield_v1(address(new ERC1967Proxy(address(impl), initData))); - - hy.setMaxPegDriftBps(DEFAULT_DRIFT_BPS); - - hy.addAutoCompounderVault(address(vault0), WEIGHT_0); - hy.addEquivalentVault(address(vault1), WEIGHT_1, address(oracle1)); - } - - // ── Helpers ──────────────────────────────────────────────────────── - - /// @dev Deposit `amount` of `asset` into HY on behalf of `user`. - function _deposit(address user, MockERC20 asset_, uint256 amount) internal returns (uint256 shares) { - asset_.mint(user, amount); - vm.startPrank(user); - IERC20(address(asset_)).approve(address(hy), amount); - shares = hy.deposit(address(asset_), amount, user); - vm.stopPrank(); - } - - /*////////////////////////////////////////////////////////////////////////// - VAULT MANAGEMENT - //////////////////////////////////////////////////////////////////////////*/ - - /// @notice addVault registers a vault, updates totalWeight, and sets the asset index. - function test_addVault_registersAndTracksWeight() public view { - assertEq(hy.vaultCount(), 2); - assertEq(hy.totalWeight(), uint256(WEIGHT_0) + WEIGHT_1); - - (address v0, address a0, bool active0, uint64 w0) = hy.vaultAt(0); - assertEq(v0, address(vault0)); - assertEq(a0, address(asset0)); - assertTrue(active0); - assertEq(w0, WEIGHT_0); - - (address v1, , bool active1, uint64 w1) = hy.vaultAt(1); - assertEq(v1, address(vault1)); - assertTrue(active1); - assertEq(w1, WEIGHT_1); - } - - /// @notice addEquivalentVault reverts when the weight is zero. - function test_addVault_zeroWeight_reverts() public { - MockERC20 asset2 = new MockERC20("Asset 2", "A2", 18); - MockERC4626Vault vault2 = new MockERC4626Vault(IERC20(address(asset2)), "Vault 2", "V2"); - MockWrappedPriceOracle oracle2 = new MockWrappedPriceOracle(); - oracle2.setLatestAnswer(1 ether, 1 ether); - - vm.expectRevert(HarborYield_v1.ZeroWeight.selector); - hy.addEquivalentVault(address(vault2), 0, address(oracle2)); - } - - /// @notice addEquivalentVault reverts when the asset is already registered by another vault. - function test_addVault_duplicateAsset_reverts() public { - MockERC4626Vault dup = new MockERC4626Vault(IERC20(address(asset0)), "Dup", "DUP"); - MockWrappedPriceOracle oracleDup = new MockWrappedPriceOracle(); - oracleDup.setLatestAnswer(1 ether, 1 ether); - - vm.expectRevert(abi.encodeWithSelector(HarborYield_v1.VaultAlreadyRegistered.selector, address(dup))); - hy.addEquivalentVault(address(dup), 10, address(oracleDup)); - } - - /// @notice addEquivalentVault is owner-only; non-owners revert with Unauthorized. - function test_addVault_nonOwner_reverts() public { - MockERC20 asset2 = new MockERC20("Asset 2", "A2", 18); - MockERC4626Vault vault2 = new MockERC4626Vault(IERC20(address(asset2)), "Vault 2", "V2"); - MockWrappedPriceOracle oracle2 = new MockWrappedPriceOracle(); - oracle2.setLatestAnswer(1 ether, 1 ether); - - vm.prank(alice); - vm.expectRevert(); // HarborOwnable Unauthorized - hy.addEquivalentVault(address(vault2), 10, address(oracle2)); - } - - /// @notice setVaultWeight adjusts the cached totalWeight correctly. - function test_setVaultWeight_updatesTotalWeight() public { - hy.setVaultWeight(address(vault0), 80); - assertEq(hy.totalWeight(), 80 + WEIGHT_1); - - (, , , uint64 w0) = hy.vaultAt(0); - assertEq(w0, 80); - } - - /// @notice setVaultWeight on an unregistered vault reverts. - function test_setVaultWeight_unknownVault_reverts() public { - address ghost = makeAddr("ghost"); - vm.expectRevert(abi.encodeWithSelector(HarborYield_v1.VaultNotRegistered.selector, ghost)); - hy.setVaultWeight(ghost, 100); - } - - /// @notice deactivateVault and activateVault toggle the active flag. - function test_deactivateAndActivateVault() public { - hy.deactivateVault(address(vault0)); - (, , bool active, ) = hy.vaultAt(0); - assertFalse(active); - - hy.activateVault(address(vault0)); - (, , active, ) = hy.vaultAt(0); - assertTrue(active); - } - - /*////////////////////////////////////////////////////////////////////////// - DEPOSIT / REDEEM - //////////////////////////////////////////////////////////////////////////*/ - - /// @notice First deposit mints shares 1:1 with assets (at the empty-vault rate). - function test_deposit_firstDepositOneToOne() public { - uint256 shares = _deposit(alice, asset0, 100 ether); - assertEq(shares, 100 ether, "first deposit 1:1"); - assertEq(hy.balanceOf(alice), shares); - assertEq(hy.totalAssets(), 100 ether); - // Vault holds the deposited assets, HY holds the vault shares. - assertEq(asset0.balanceOf(address(vault0)), 100 ether); - assertEq(IERC20(address(vault0)).balanceOf(address(hy)), 100 ether); - } - - /// @notice Deposit to an unregistered asset reverts. - function test_deposit_unregisteredAsset_reverts() public { - MockERC20 other = new MockERC20("Other", "OTH", 18); - other.mint(alice, 1 ether); - vm.startPrank(alice); - IERC20(address(other)).approve(address(hy), 1 ether); - vm.expectRevert(abi.encodeWithSelector(HarborYield_v1.VaultNotRegistered.selector, address(other))); - hy.deposit(address(other), 1 ether, alice); - vm.stopPrank(); - } - - /// @notice Deposit to a deactivated vault reverts with VaultNotActive. - function test_deposit_deactivatedVault_reverts() public { - hy.deactivateVault(address(vault0)); - asset0.mint(alice, 1 ether); - vm.startPrank(alice); - IERC20(address(asset0)).approve(address(hy), 1 ether); - vm.expectRevert(abi.encodeWithSelector(HarborYield_v1.VaultNotActive.selector, address(vault0))); - hy.deposit(address(asset0), 1 ether, alice); - vm.stopPrank(); - } - - /// @notice Deposit routes each asset to its mapped vault; shares are minted at the current exchange rate. - function test_deposit_routingTwoAssets() public { - _deposit(alice, asset0, 60 ether); - _deposit(bob, asset1, 40 ether); - - // Alice and Bob both deposited into empty vaults at 1:1 — each gets their deposit size in HY shares. - assertEq(hy.balanceOf(alice), 60 ether, "alice shares"); - assertEq(hy.balanceOf(bob), 40 ether, "bob shares"); - assertEq(hy.totalAssets(), 100 ether, "total assets sum"); - assertEq(asset0.balanceOf(address(vault0)), 60 ether, "vault0 holds asset0"); - assertEq(asset1.balanceOf(address(vault1)), 40 ether, "vault1 holds asset1"); - } - - /// @notice Existing user share value is not diluted by a subsequent deposit into another vault. - function test_deposit_doesNotDiluteExistingUsers() public { - _deposit(alice, asset0, 100 ether); - uint256 aliceShares = hy.balanceOf(alice); - - // Alice's share is worth the full 100 ether pool. - uint256 aliceAssetsBefore = (hy.totalAssets() * aliceShares) / hy.totalSupply(); - assertEq(aliceAssetsBefore, 100 ether); - - // Bob deposits into the other vault. - _deposit(bob, asset1, 50 ether); - - // Alice's implied assets should be unchanged. - uint256 aliceAssetsAfter = (hy.totalAssets() * aliceShares) / hy.totalSupply(); - assertEq(aliceAssetsAfter, aliceAssetsBefore, "alice not diluted"); - } - - /// @notice deposit(type(uint256).max, ...) consumes the caller's full balance. - function test_deposit_maxAmount_usesFullBalance() public { - asset0.mint(alice, 77 ether); - vm.startPrank(alice); - IERC20(address(asset0)).approve(address(hy), type(uint256).max); - uint256 shares = hy.deposit(address(asset0), type(uint256).max, alice); - vm.stopPrank(); - - assertEq(shares, 77 ether); - assertEq(asset0.balanceOf(alice), 0); - } - - /// @notice Yield accruing inside a managed vault increases HY.totalAssets and share price. - function test_totalAssets_reflectsVaultYield() public { - _deposit(alice, asset0, 100 ether); - uint256 assetsBefore = hy.totalAssets(); - - // Drop 10% yield into vault0. - vault0.addYield(10 ether); - - // OZ ERC4626 uses a virtual-share offset that introduces 1-wei rounding on convertToAssets. - assertApproxEqAbs(hy.totalAssets(), assetsBefore + 10 ether, 1, "totalAssets reflects yield"); - } - - /// @notice Redeem burns shares and pays out a proportional slice of every managed vault's holdings. - function test_redeem_proportionalAcrossVaults() public { - _deposit(alice, asset0, 60 ether); - _deposit(alice, asset1, 40 ether); - - uint256 shares = hy.balanceOf(alice); - // Redeem half of alice's shares. - vm.prank(alice); - hy.redeem(shares / 2, alice, alice); - - // Alice should have received half of each asset. - assertEq(asset0.balanceOf(alice), 30 ether, "got half of asset0"); - assertEq(asset1.balanceOf(alice), 20 ether, "got half of asset1"); - assertEq(hy.balanceOf(alice), shares - shares / 2); - } - - /// @notice Redeeming all shares drains both managed vaults. - function test_redeem_fullRedemptionDrainsVaults() public { - _deposit(alice, asset0, 60 ether); - _deposit(alice, asset1, 40 ether); - - uint256 shares = hy.balanceOf(alice); - vm.prank(alice); - hy.redeem(shares, alice, alice); - - assertEq(hy.balanceOf(alice), 0); - assertEq(IERC20(address(vault0)).balanceOf(address(hy)), 0, "vault0 shares drained"); - assertEq(IERC20(address(vault1)).balanceOf(address(hy)), 0, "vault1 shares drained"); - } - - /// @notice Redeeming on behalf of another account requires and consumes allowance. - function test_redeem_withAllowance() public { - _deposit(alice, asset0, 100 ether); - uint256 shares = hy.balanceOf(alice); - - vm.prank(alice); - hy.approve(bob, shares); - - vm.prank(bob); - hy.redeem(shares, bob, alice); - - assertEq(hy.balanceOf(alice), 0); - assertEq(asset0.balanceOf(bob), 100 ether, "bob received the underlying"); - assertEq(hy.allowance(alice, bob), 0, "allowance consumed"); - } - - /// @notice Redeem without allowance reverts. - function test_redeem_withoutAllowance_reverts() public { - _deposit(alice, asset0, 100 ether); - uint256 shares = hy.balanceOf(alice); - - vm.prank(bob); - vm.expectRevert(); // ERC20 allowance error - hy.redeem(shares, bob, alice); - } - - /*////////////////////////////////////////////////////////////////////////// - COMPOUND (swap path) - //////////////////////////////////////////////////////////////////////////*/ - - /// @notice Owner can compound: redeem from one vault, swap asset, deposit into another vault. - function test_compound_ownerCanConvertAcrossVaults() public { - _deposit(alice, asset0, 60 ether); - _deposit(bob, asset1, 40 ether); - - uint256 v0SharesBefore = IERC20(address(vault0)).balanceOf(address(hy)); - uint256 v1SharesBefore = IERC20(address(vault1)).balanceOf(address(hy)); - uint256 totalAssetsBefore = hy.totalAssets(); - - // Move 10 vault0 shares -> asset0 -> swap to asset1 -> vault1. - hy.compound(address(vault0), address(vault1), 10 ether, 10 ether, ""); - - assertEq(IERC20(address(vault0)).balanceOf(address(hy)), v0SharesBefore - 10 ether, "vault0 shares down"); - assertGt(IERC20(address(vault1)).balanceOf(address(hy)), v1SharesBefore, "vault1 shares up"); - // At 1:1 rate and 1:1 vault exchange rate, total assets are preserved. - assertEq(hy.totalAssets(), totalAssetsBefore, "totalAssets preserved across 1:1 swap"); - } - - /// @notice A compound caller holding COMPOUNDER_ROLE succeeds. - function test_compound_compounderRoleCanCompound() public { - _deposit(alice, asset0, 60 ether); - _deposit(bob, asset1, 40 ether); - - hy.grantRoles(keeper, hy.COMPOUNDER_ROLE()); - - vm.prank(keeper); - hy.compound(address(vault0), address(vault1), 5 ether, 5 ether, ""); - } - - /// @notice A caller without COMPOUNDER_ROLE or ownership cannot compound. - function test_compound_unauthorized_reverts() public { - _deposit(alice, asset0, 60 ether); - _deposit(bob, asset1, 40 ether); - - vm.prank(alice); - vm.expectRevert(); // Unauthorized - hy.compound(address(vault0), address(vault1), 5 ether, 5 ether, ""); - } - - /// @notice Compound honours the minAmountOut slippage check via the swapper. - function test_compound_slippageRevertsFromSwapper() public { - _deposit(alice, asset0, 60 ether); - _deposit(bob, asset1, 40 ether); - - // minOut exceeds the fixed 1:1 swap output. - vm.expectRevert(bytes("MockSwapper: slippage")); - hy.compound(address(vault0), address(vault1), 5 ether, 6 ether, ""); - } - - /*////////////////////////////////////////////////////////////////////////// - REDISTRIBUTE - //////////////////////////////////////////////////////////////////////////*/ - - /// @notice Redistribute moves value from the over-weight vault to the under-weight vault. - /// @dev Targets (60, 40) but we only deposit into asset0 (100, 0) — asset0 is 40 over, asset1 is 40 under. - function test_redistribute_rebalancesTowardTargetWeights() public { - _deposit(alice, asset0, 100 ether); - - uint256 v0Before = IERC4626(address(vault0)).convertToAssets(IERC20(address(vault0)).balanceOf(address(hy))); - uint256 v1Before = IERC4626(address(vault1)).convertToAssets(IERC20(address(vault1)).balanceOf(address(hy))); - assertEq(v0Before, 100 ether); - assertEq(v1Before, 0); - - // Permit up to the full source position; require 1:1 swap output. - hy.redistribute(type(uint256).max, 1, ""); - - uint256 v0After = IERC4626(address(vault0)).convertToAssets(IERC20(address(vault0)).balanceOf(address(hy))); - uint256 v1After = IERC4626(address(vault1)).convertToAssets(IERC20(address(vault1)).balanceOf(address(hy))); - - // Targets: (60, 40). A single rebalance moves exactly min(excess, deficit) = 40. - assertEq(v0After, 60 ether, "vault0 at target"); - assertEq(v1After, 40 ether, "vault1 at target"); - // Total preserved at 1:1 rates. - assertEq(hy.totalAssets(), 100 ether); - } - - /// @notice REDISTRIBUTOR_ROLE holder (not owner) can redistribute. - function test_redistribute_redistributorRoleCanCall() public { - _deposit(alice, asset0, 100 ether); - hy.grantRoles(keeper, hy.REDISTRIBUTOR_ROLE()); - - vm.prank(keeper); - hy.redistribute(type(uint256).max, 1, ""); - } - - /// @notice Non-owner without REDISTRIBUTOR_ROLE cannot redistribute. - function test_redistribute_unauthorized_reverts() public { - _deposit(alice, asset0, 100 ether); - vm.prank(alice); - vm.expectRevert(); - hy.redistribute(type(uint256).max, 1, ""); - } - - /// @notice When all vaults are already at their target weights, redistribute reverts. - function test_redistribute_alreadyBalanced_reverts() public { - // Deposit in exact 60/40 ratio -> already at target. - _deposit(alice, asset0, 60 ether); - _deposit(bob, asset1, 40 ether); - - vm.expectRevert(HarborYield_v1.NothingToRedistribute.selector); - hy.redistribute(type(uint256).max, 1, ""); - } - - /// @notice Empty vault (nothing deposited yet) reverts NothingToRedistribute. - function test_redistribute_emptyVault_reverts() public { - vm.expectRevert(HarborYield_v1.NothingToRedistribute.selector); - hy.redistribute(type(uint256).max, 1, ""); - } - - /// @notice The maxVaultSharesPerVault argument caps the amount moved in one call. - function test_redistribute_maxSharesCapsMovement() public { - _deposit(alice, asset0, 100 ether); - - // Cap source vault shares at 5 — less than the 40 needed to fully rebalance. - hy.redistribute(5 ether, 1, ""); - - uint256 v0After = IERC4626(address(vault0)).convertToAssets(IERC20(address(vault0)).balanceOf(address(hy))); - uint256 v1After = IERC4626(address(vault1)).convertToAssets(IERC20(address(vault1)).balanceOf(address(hy))); - - // Only 5 moved from v0 to v1, not the full 40. - assertEq(v0After, 95 ether); - assertEq(v1After, 5 ether); - } - - /*////////////////////////////////////////////////////////////////////////// - ERC-4626 VIEW SHIM - //////////////////////////////////////////////////////////////////////////*/ - - /// @notice `asset()` returns the peg token supplied at construction time. - function test_asset_returnsPegToken() public view { - assertEq(hy.asset(), address(pegToken)); - } - - /// @notice On an empty vault, convertToShares and convertToAssets return the input (1:1 rate - /// at supply = 0, totalAssets = 0 due to the virtual-share `+1` floor). - function test_convert_onEmptyVault_isOneToOne() public view { - assertEq(hy.totalSupply(), 0); - assertEq(hy.totalAssets(), 0); - assertEq(hy.convertToShares(100 ether), 100 ether); - assertEq(hy.convertToAssets(100 ether), 100 ether); - } - - /// @notice After a real deposit, convertToShares/Assets round-trip (within 1 wei). - function test_convert_roundTripAfterDeposit() public { - _deposit(alice, asset0, 100 ether); - - uint256 shares = hy.convertToShares(50 ether); - uint256 assetsBack = hy.convertToAssets(shares); - // Integer division in both directions can lose 1 wei. - assertApproxEqAbs(assetsBack, 50 ether, 1, "round-trip within 1 wei"); - } - - /// @notice `convertToAssets(shares)` tracks the internal share-price formula used in `deposit`. - /// If the formula were `(supply+1)/(assets+1)`, convertToAssets(totalSupply) should - /// equal totalAssets within the virtual-share floor. - function test_convert_matchesInternalFormula() public { - _deposit(alice, asset0, 100 ether); - _deposit(bob, asset1, 40 ether); - - uint256 supply = hy.totalSupply(); - uint256 assets = hy.totalAssets(); - - // convertToAssets(supply) = supply * (assets + 1) / (supply + 1) - // which differs from `assets` by at most 1 wei due to the virtual floor. - uint256 fromShim = hy.convertToAssets(supply); - assertApproxEqAbs(fromShim, assets, 1, "convertToAssets(supply) ~= totalAssets"); - } - - /// @notice previewDeposit matches convertToShares (both round down). - function test_previewDeposit_matchesConvertToShares() public { - _deposit(alice, asset0, 100 ether); - - uint256 preview = hy.previewDeposit(25 ether); - uint256 converted = hy.convertToShares(25 ether); - assertEq(preview, converted); - } - - /// @notice previewRedeem matches convertToAssets (both round down). - function test_previewRedeem_matchesConvertToAssets() public { - _deposit(alice, asset0, 100 ether); - - uint256 preview = hy.previewRedeem(10 ether); - uint256 converted = hy.convertToAssets(10 ether); - assertEq(preview, converted); - } - - /// @notice Share price (convertToAssets(1 ether)) grows as vault yield accrues. - function test_convertToAssets_reflectsVaultYield() public { - _deposit(alice, asset0, 100 ether); - uint256 priceBefore = hy.convertToAssets(1 ether); - - // Simulate 10% yield in vault0. - vault0.addYield(10 ether); - - uint256 priceAfter = hy.convertToAssets(1 ether); - assertGt(priceAfter, priceBefore, "share price increased with yield"); - } - - /// @notice The view shim is exposed via the IHarborYield interface. - function test_viewShim_reachableViaInterface() public view { - // Compile-time check: these calls compile if IHarborYield declares them. - assertEq(IHarborYield(address(hy)).asset(), address(pegToken)); - assertEq(IHarborYield(address(hy)).convertToShares(1 ether), hy.convertToShares(1 ether)); - assertEq(IHarborYield(address(hy)).convertToAssets(1 ether), hy.convertToAssets(1 ether)); - assertEq(IHarborYield(address(hy)).previewDeposit(1 ether), hy.previewDeposit(1 ether)); - assertEq(IHarborYield(address(hy)).previewRedeem(1 ether), hy.previewRedeem(1 ether)); - } - - /*////////////////////////////////////////////////////////////////////////// - B.4.3: PEG VERIFICATION AT addVault - //////////////////////////////////////////////////////////////////////////*/ - - /// @notice `addAutoCompounderVault` reverts if the AC's PEGGED_TOKEN doesn't match HY's peg. - function test_addAutoCompounder_wrongPeg_reverts() public { - MockERC20 asset2 = new MockERC20("Asset 2", "A2", 18); - MockERC4626Vault acBad = new MockERC4626Vault(IERC20(address(asset2)), "Bad AC", "BAD"); - MockERC20 otherPeg = new MockERC20("Other Peg", "OPE", 18); - MockMinter minterBad = new MockMinter(address(asset2), address(otherPeg), makeAddr("levBad")); - acBad.configureAsAutoCompounder(address(otherPeg), address(minterBad)); - - vm.expectRevert( - abi.encodeWithSelector(HarborYield_v1.WrongPegToken.selector, address(pegToken), address(otherPeg)) - ); - hy.addAutoCompounderVault(address(acBad), 10); - } - - /// @notice `addEquivalentVault` reverts if the oracle reports a rate outside maxPegDriftBps. - function test_addEquivalent_excessiveDrift_reverts() public { - MockERC20 asset2 = new MockERC20("Asset 2", "A2", 18); - MockERC4626Vault equiv = new MockERC4626Vault(IERC20(address(asset2)), "Drift", "DFT"); - MockWrappedPriceOracle oracleDrift = new MockWrappedPriceOracle(); - // 5% depeg — way outside the default 2% drift tolerance - oracleDrift.setLatestAnswer(0.95 ether, 1 ether); - - vm.expectRevert( - abi.encodeWithSelector(HarborYield_v1.ExcessivePegDrift.selector, uint256(1 ether), uint256(0.95 ether)) - ); - hy.addEquivalentVault(address(equiv), 10, address(oracleDrift)); - } - - /// @notice A near-peg equivalent (within tolerance) registers successfully. - function test_addEquivalent_withinDrift_succeeds() public { - MockERC20 asset2 = new MockERC20("Asset 2", "A2", 18); - MockERC4626Vault equiv = new MockERC4626Vault(IERC20(address(asset2)), "OK", "OK"); - MockWrappedPriceOracle oracleOk = new MockWrappedPriceOracle(); - // 1% "depeg" — inside the 2% tolerance - oracleOk.setLatestAnswer(0.99 ether, 1 ether); - - hy.addEquivalentVault(address(equiv), 10, address(oracleOk)); - assertEq(hy.vaultCount(), 3); - } - - /*////////////////////////////////////////////////////////////////////////// - B.4.3: ORACLE-VALUED totalAssets - //////////////////////////////////////////////////////////////////////////*/ - - /// @notice totalAssets for an AC-only holder equals convertToAssets at healthy peggedTokenPrice (1e18). - function test_totalAssets_ac_healthyPeg() public { - _deposit(alice, asset0, 100 ether); - uint256 total = hy.totalAssets(); - // At peggedTokenPrice = 1e18, AC contribution = convertToAssets unchanged. - assertApproxEqAbs(total, 100 ether, 1); - } - - /// @notice totalAssets drops when the Minter reports a haXXX depeg via peggedTokenPrice(). - function test_totalAssets_ac_depegReducesValue() public { - _deposit(alice, asset0, 100 ether); - - // Simulate haEUR depegging to 0.80 EUR — AC contribution to totalAssets drops 20%. - minter0.setPeggedTokenPrice(0.8 ether); - - uint256 total = hy.totalAssets(); - assertApproxEqAbs(total, 80 ether, 1); - } - - /// @notice totalAssets for an equivalent uses the oracle's mid rate. - function test_totalAssets_equivalent_usesOracle() public { - _deposit(alice, asset1, 100 ether); - uint256 total = hy.totalAssets(); - // Oracle reports 1:1, so totalAssets ≈ 100 ether. - assertApproxEqAbs(total, 100 ether, 1); - - // Move the oracle to 0.99 (1% depeg); totalAssets drops ~1%. - oracle1.setLatestAnswer(0.99 ether, 1 ether); - uint256 totalAfter = hy.totalAssets(); - assertApproxEqAbs(totalAfter, 99 ether, 1); - } - - /*////////////////////////////////////////////////////////////////////////// - B.4.3: maxPegDriftBps ADMIN - //////////////////////////////////////////////////////////////////////////*/ - - /// @notice Setting a new drift value updates state and emits the event. - function test_setMaxPegDriftBps_updatesAndEmits() public { - vm.expectEmit(false, false, false, true); - emit IHarborYield.MaxPegDriftBpsUpdated(500); - hy.setMaxPegDriftBps(500); - assertEq(hy.maxPegDriftBps(), 500); - } - - /// @notice setMaxPegDriftBps is owner-only. - function test_setMaxPegDriftBps_nonOwner_reverts() public { - vm.prank(alice); - vm.expectRevert(); - hy.setMaxPegDriftBps(500); - } - - /*////////////////////////////////////////////////////////////////////////// - B.4.3: RUNTIME ORACLE-BOUNDED MIN-OUT - //////////////////////////////////////////////////////////////////////////*/ - - /// @notice compound with keeperMinOut = 0 still enforces HY's oracle-bounded floor. - /// Here the swapper rate is 1:1, oracle rates are 1:1, so effectiveMin ≈ 0.98x amountIn. - /// The swap yields 1x which is above 0.98x → success. - function test_compound_oracleFloorAcceptsFairSwap() public { - _deposit(alice, asset0, 60 ether); - _deposit(bob, asset1, 40 ether); - - // keeperMinOut = 0 — HY overrides with its own floor based on oracle rates. - hy.compound(address(vault0), address(vault1), 10 ether, 0, ""); - } - - /// @notice When the swapper yields less than HY's oracle floor, compound reverts via the - /// swapper's slippage check (because HY passed the floor as effectiveMinOut). - function test_compound_oracleFloorRejectsBadSwap() public { - _deposit(alice, asset0, 60 ether); - _deposit(bob, asset1, 40 ether); - - // Swapper yields only 95% of input — below the 98% oracle floor. - swapper.setRate(0.95 ether); - - vm.expectRevert(bytes("MockSwapper: slippage")); - hy.compound(address(vault0), address(vault1), 10 ether, 0, ""); - } - - /// @notice If the oracle itself reflects a depeg, the floor follows the oracle down — - /// swaps at the depegged rate continue to succeed. - function test_compound_floorFollowsOracleDuringDepeg() public { - _deposit(alice, asset0, 60 ether); - _deposit(bob, asset1, 40 ether); - - // Oracle reflects a 3% depeg on asset1; swapper also yields 97% (matching). - oracle1.setLatestAnswer(0.97 ether, 1 ether); - swapper.setRate(0.97 ether); - - // Oracle floor: expected ≈ from/to = 1/0.97 ≈ 1.031 ether per 1 ether - // with 2% drift → floor ≈ 1.0106. Swap yields amountIn * 0.97 = 0.97 — BELOW floor. - // So this SHOULD revert. - // - // The point is: the oracle-floor tracks the oracle BUT asset1's rate being 0.97 - // increases the expected out for from→to swaps, not decreases it. (You need more of a - // "cheaper" asset to match a "full" output.) So the swap still has to beat the floor. - vm.expectRevert(bytes("MockSwapper: slippage")); - hy.compound(address(vault0), address(vault1), 10 ether, 0, ""); - } - - /// @notice HY-specific: `DOMAIN_SEPARATOR` encodes the proxy's own address at runtime — - /// two HY proxies behind the same implementation produce distinct domain separators. - function test_permit_domainSeparatorIsProxySpecific() public { - bytes32 proxy1Domain = hy.DOMAIN_SEPARATOR(); - - // Deploy a second HY behind a fresh proxy (same impl logic, different address). - HarborYield_v1 impl = new HarborYield_v1("Harbor Yield Other", "hyOTHER", address(swapper), address(pegToken)); - bytes memory initData = abi.encodeCall(HarborYield_v1.initialize, (address(this), address(this))); - HarborYield_v1 other = HarborYield_v1(address(new ERC1967Proxy(address(impl), initData))); - bytes32 proxy2Domain = other.DOMAIN_SEPARATOR(); - - assertTrue(proxy1Domain != proxy2Domain, "two proxies produce distinct domain separators"); - } -} From ff108017f3c55924e380a3200c5fe71a44a7f408 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Tue, 21 Apr 2026 18:47:45 +0100 Subject: [PATCH 052/232] autocompounder config back out claim changes CLAUDE.md moved to bao-base --- CLAUDE.md | 31 +-- diffvers | 23 ++ lib/bao-base | 2 +- .../autocompounder/ConfigAutoCompounder.sol | 11 - .../ConfigMarket_BTC_fxUSD_mainnet.sol | 4 +- .../ConfigMarket_BTC_stETH_mainnet.sol | 4 +- .../ConfigMarket_ETH_fxUSD_mainnet.sol | 4 +- .../ConfigMarket_EUR_fxUSD_mainnet.sol | 4 +- .../ConfigMarket_EUR_stETH_mainnet.sol | 4 +- .../ConfigMarket_GOLD_fxUSD_mainnet.sol | 4 +- .../ConfigMarket_GOLD_stETH_mainnet.sol | 4 +- .../ConfigMarket_MCAP_fxUSD_mainnet.sol | 4 +- .../ConfigMarket_MCAP_stETH_mainnet.sol | 4 +- .../ConfigMarket_SILVER_fxUSD_mainnet.sol | 4 +- .../ConfigMarket_SILVER_stETH_mainnet.sol | 4 +- .../volatility/ConfigPriceVolatility_105.sol | 4 + .../ConfigPriceVolatility_105_stable.sol | 4 + .../volatility/ConfigPriceVolatility_115.sol | 4 + .../ConfigPriceVolatility_115_stable.sol | 4 + .../volatility/ConfigPriceVolatility_125.sol | 4 + .../ConfigPriceVolatility_125_stable.sol | 4 + .../volatility/ConfigPriceVolatility_130.sol | 4 + .../ConfigPriceVolatility_130_stable.sol | 4 + .../IMultipleRewardAccumulator_v3.sol | 21 -- src/interfaces/IStabilityPool_v3.sol | 10 - ...ultipleRewardCompoundingAccumulator_v3.sol | 76 ++---- .../LinearMultipleRewardDistributor_v3.sol | 12 +- test/StabilityPoolClaimable.t.sol | 249 ++--------------- test/deployment/RebalanceFairnessScan.t.sol | 5 +- test/deployment/RewardSystem.t.sol | 10 +- .../reward/accumulator/ClaimEquivalence.t.sol | 253 ++++-------------- 31 files changed, 169 insertions(+), 610 deletions(-) create mode 100755 diffvers delete mode 100644 script/config/autocompounder/ConfigAutoCompounder.sol delete mode 100644 src/interfaces/IMultipleRewardAccumulator_v3.sol delete mode 100644 src/interfaces/IStabilityPool_v3.sol diff --git a/CLAUDE.md b/CLAUDE.md index 307a9336..af953e6d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,32 +1,3 @@ # CLAUDE.md -- When discussing design decisions, do not present disconnected multiple-choice questions. Instead, write out the full picture first — user flows, accounting, consequences — so the decision context is clear. Present a recommendation with reasoning, not a menu of options without enough background. Use the plan document or design docs for detailed analysis, not the question dialog. -- Do not create functions that are only called once. Inline the logic instead. -- When diagnosing an issue, do not use words like "likely", "probably", or "may" to describe a root cause. Either verify the hypothesis with data (dry run, log, trace) or state explicitly that it is unverified. Never proceed with a fix based on an unverified hypothesis. -- When the user reports a problem, fix it — do not unilaterally decide the problem is out of scope, pre-existing, already resolved by another fix, or someone else's concern. If you believe any of those things, say so and ask whether the user still wants it addressed. Never declare a judgement like "this is pre-existing" or "the root cause is X" and then act on it without confirmation. Present your reasoning, then ask. -- When fixing bugs, follow this process: (1) write a test that fails because of the bug, (2) write the fix, (3) run the test again to confirm it passes. This applies to both Solidity and script/tooling bugs. -- Plan files are under git at `~/.claude/plans/`. After modifying a plan file, commit it with a short message describing the change (e.g. `git -C ~/.claude/plans commit -am "added D.1 versioned directories"`). This allows reverting mistakes. -- When fixing error handling, do not silently skip or suppress errors. If something fails, the failure should be visible and the process should fail clearly. Do not work around errors by hiding them unless explicitly asked to. -- In bash, `set -e` does NOT catch failures in `[[ ]]` conditionals, variable assignments (e.g. `x=$(failing_cmd)`), commands in pipelines (use `set -o pipefail` AND check `${PIPESTATUS[@]}`), or sourced scripts. Always check exit status explicitly with `${PIPESTATUS[0]}` or `$?` after critical commands rather than relying on `set -e` alone. -- use forge install/remove for managing submodule dependencies -- In tests and scripts, use interface types (e.g. `IStabilityPool_v3(address)`) not concrete contract types (e.g. `StabilityPool_v3(address)`) when calling functions. This verifies the interface matches the implementation. Concrete types are only for initialisation (constructor, deploy). - - **Declarations:** use `address`, not typed contract variables. E.g. `address rewardToken = address(new MockERC20(...))`, not `MockERC20 rewardToken = new MockERC20(...)`. - - **Calls:** cast to the interface at the call site. E.g. `IERC20(rewardToken).balanceOf(user)`. - - **Setup/mock operations:** casting to concrete types is acceptable for mock-specific functions like `MockERC20(token).mint()`. -- Every branch must have each path on a separate line so coverage tools can distinguish them. Use curly brackets on all if/for/while statements (no single-line bodies). Ternary expressions are fine — formatters already split the branches across lines. -- In deployment scripts, use salt keys and `_predictAddress(key)` to reference contracts — not deployed addresses. BaoFactory CREATE3 gives deterministic addresses from salts, so contracts can reference each other before deployment. For example, `registerRewardToken(_predictAddress(aliasKey))` works even if the alias hasn't been deployed yet. This decouples deployment order from contract dependencies. -- In deployment scripts, build salt strings using `_saltString()` / `_predictAddress()` library functions from FactoryDeployer — never manually concat salt strings with `string.concat`. -- Three ownership patterns for UUPS contracts: - - **BaoOwnable** (legacy): `_initializeOwner(finalOwner)` uses `msg.sender` as temp owner. Deploy via `_deployProxyViaStubAndRecord` (needs UUPSProxyDeployStub so msg.sender = FactoryDeployer, not BaoFactory). Used by: Minter_v2, StabilityPool_v3, SPM, Genesis, LeveragedToken, PeggedToken. - - **HarborOwnable** (modern): `_initializeOwner(deployerOwner, pendingOwner)` takes explicit deployer. Deploy via `_deployProxyAndRecord` (direct, no stub). Used by: RewardAlias, all new contracts. - - **HarborFixedOwnable** (hardcoded): Owner is immutable constructor param (Harbor multisig). Deploy via `_deployProxyAndRecord` with empty initData. Used by: HarborPauser_v1. -- Each UUPS contract composes Initializable + UUPSUpgradeable + ownership mixin directly — don't create "Upgradeable" base contracts that bundle these, as each contract has different init needs (roles, reentrancy, custom state). The "Upgradeable" suffix means something different in OZ (storage-safe proxy variant) and combining meanings causes confusion. -- When adding functions to interfaces in an inheritance hierarchy, avoid creating diamond inheritance. If a function is defined on both an interface and a concrete base, the derived contract must override to resolve the ambiguity. Instead, put the function on only one path — either a new versioned interface (e.g. `IMultipleRewardDistributor_v3`) or directly on the implementation. Prefer eliminating the diamond over resolving it with overrides. -- In tests, never create and then remove files or directories — forge runs tests in parallel so you can create a race condition. Write test output to `./results` and leave it there. -- Tests verify *what code is supposed to do*, not merely that lines execute. When asked to improve testing, think: "what is the intended behaviour?" — then construct scenarios that demonstrate the code fulfils that intent. If unsure what a function is supposed to do, ask — the specification is not in the code. Avoid writing tests that only exercise code paths to increase coverage metrics; such tests reinforce any misunderstanding in the implementation and give false confidence. Always add a comment at the top of a test to say what functionality it is testing: keep it concise. -- In tests, prefer `console2.log` over `emit` for debug logging — it shows in `forge test -vvv` output without cluttering the event log. Use the `Fmt` library with `string.concat` for readable formatted messages. -- Prefer immutable constructor arguments over configurable storage for addresses of related contracts deployed at predictable proxy addresses. The related contract can be upgraded via its own proxy without the consuming contract needing a setter. This saves bytecode (no setter function, no zero-address checks, no storage reads) and gas. Only use storage for addresses that genuinely need to change independently of contract upgrades. -- Always use HarborOwnable/HarborOwnableRoles over BaoOwnable/BaoOwnableRoles. They are near-drop-in replacements that take explicit `(deployerOwner, pendingOwner)` instead of relying on `msg.sender`. They don't need the UUPSProxyDeployStub — deploy via `_deployProxyAndRecord` (direct), not `_deployProxyViaStubAndRecord`. When upgrading a contract from BaoOwnable to a new version, switch to HarborOwnable. -- Never use module-level or contract-level flags/booleans to communicate state between functions within a single call. If a function needs to behave differently based on context, pass the context explicitly via parameters or use separate functions. Hidden state makes code harder to reason about and introduces coupling that isn't visible in function signatures. Use explicit parameters or dedicated function variants instead. -- Never modify a deployed contract's source file. Check `deployments/*.state.json` for deployed contracts. To add functionality: inherit from the deployed version (e.g. `Minter_v3 is Minter_v2`) if the changes are additive, or clone and modify if the inheritance chain doesn't work. The proxy upgrade mechanism allows swapping implementations, but the old source must remain unchanged for audit traceability. -- Never read files that are likely to contain secrets — `.env`, `.env.*`, `*.pem`, `*.key`, `credentials*`, `*.secret`, `id_rsa*`, etc. — unless the user explicitly asks for it. This applies even when investigating something unrelated (e.g. resolving a symlink, looking for shell hooks): skip the file. Once a secret is read by a tool call, the contents are in the conversation transcript and must be treated as compromised. If you need information *about* such a file (existence, size, ownership), use `ls -la`, not `cat`. Match the scope of investigation to the actual question being asked. \ No newline at end of file +@./lib/bao-base/CLAUDE.md diff --git a/diffvers b/diffvers new file mode 100755 index 00000000..786b5610 --- /dev/null +++ b/diffvers @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +find src -name "*_v1.sol" | while read f; do + base="${f%_v1.sol}" + files="$f" + for v in 2 3; do + vf="${base}_v${v}.sol" + [ -f "$vf" ] && files="$files $vf" + done + [ "$files" != "$f" ] && meld $files & +done + +# Also handle prefix.sol (unversioned) as the v1 when _v2 exists but _v1 doesn't +find src -name "*_v2.sol" | while read f; do + base="${f%_v2.sol}" + unversioned="${base}.sol" + v1="${base}_v1.sol" + [ -f "$v1" ] && continue # already handled above + [ ! -f "$unversioned" ] && continue + files="$unversioned $f" + v3="${base}_v3.sol" + [ -f "$v3" ] && files="$files $v3" + meld $files & +done diff --git a/lib/bao-base b/lib/bao-base index 65314643..3e4f346c 160000 --- a/lib/bao-base +++ b/lib/bao-base @@ -1 +1 @@ -Subproject commit 65314643224d6210bc2d74d999a75ea3955f5fe4 +Subproject commit 3e4f346cd5ce08fae0bc4c559f724c9e5c979399 diff --git a/script/config/autocompounder/ConfigAutoCompounder.sol b/script/config/autocompounder/ConfigAutoCompounder.sol deleted file mode 100644 index d6007836..00000000 --- a/script/config/autocompounder/ConfigAutoCompounder.sol +++ /dev/null @@ -1,11 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.28 <0.9.0; - -/// @notice Auto-compounder configuration defaults. -abstract contract ConfigAutoCompounder { - /// @notice Maximum fee ratio for compound minting (18 decimals). - /// @dev 5% default - compound will skip if cumulative fee exceeds this. - function autoCompounderMaxFeeRatio() public pure virtual returns (uint256) { - return 0.05 ether; - } -} diff --git a/script/config/markets/ConfigMarket_BTC_fxUSD_mainnet.sol b/script/config/markets/ConfigMarket_BTC_fxUSD_mainnet.sol index 64ff8059..b102c55e 100644 --- a/script/config/markets/ConfigMarket_BTC_fxUSD_mainnet.sol +++ b/script/config/markets/ConfigMarket_BTC_fxUSD_mainnet.sol @@ -9,7 +9,6 @@ import {ConfigStabilityPool} from "../stabilitypool/ConfigStabilityPool.sol"; import {ConfigStabilityPoolManager} from "../stabilitypool/ConfigStabilityPoolManager.sol"; import {Config_MinterMarket} from "../ConfigBase.sol"; import {ConfigTokenNames} from "../ConfigTokenNames.sol"; -import {ConfigAutoCompounder} from "../autocompounder/ConfigAutoCompounder.sol"; /// @notice Market configuration for BTC::fxUSD. contract ConfigMarket_BTC_fxUSD_mainnet is @@ -20,6 +19,5 @@ contract ConfigMarket_BTC_fxUSD_mainnet is ConfigPriceVolatility_130_stable, ConfigStabilityPool, ConfigStabilityPoolManager, - ConfigTokenNames, - ConfigAutoCompounder + ConfigTokenNames {} diff --git a/script/config/markets/ConfigMarket_BTC_stETH_mainnet.sol b/script/config/markets/ConfigMarket_BTC_stETH_mainnet.sol index c9314310..b75285d7 100644 --- a/script/config/markets/ConfigMarket_BTC_stETH_mainnet.sol +++ b/script/config/markets/ConfigMarket_BTC_stETH_mainnet.sol @@ -9,7 +9,6 @@ import {ConfigStabilityPool} from "../stabilitypool/ConfigStabilityPool.sol"; import {ConfigStabilityPoolManager} from "../stabilitypool/ConfigStabilityPoolManager.sol"; import {Config_MinterMarket} from "../ConfigBase.sol"; import {ConfigTokenNames} from "../ConfigTokenNames.sol"; -import {ConfigAutoCompounder} from "../autocompounder/ConfigAutoCompounder.sol"; /// @notice Market configuration for BTC::stETH. contract ConfigMarket_BTC_stETH_mainnet is @@ -20,6 +19,5 @@ contract ConfigMarket_BTC_stETH_mainnet is ConfigPriceVolatility_125_stable, ConfigStabilityPool, ConfigStabilityPoolManager, - ConfigTokenNames, - ConfigAutoCompounder + ConfigTokenNames {} diff --git a/script/config/markets/ConfigMarket_ETH_fxUSD_mainnet.sol b/script/config/markets/ConfigMarket_ETH_fxUSD_mainnet.sol index 1f128c9f..dd64f4e3 100644 --- a/script/config/markets/ConfigMarket_ETH_fxUSD_mainnet.sol +++ b/script/config/markets/ConfigMarket_ETH_fxUSD_mainnet.sol @@ -8,7 +8,6 @@ import {ConfigPriceVolatility_130_stable} from "../volatility/ConfigPriceVolatil import {ConfigStabilityPool} from "../stabilitypool/ConfigStabilityPool.sol"; import {ConfigStabilityPoolManager} from "../stabilitypool/ConfigStabilityPoolManager.sol"; import {ConfigTokenNames} from "../ConfigTokenNames.sol"; -import {ConfigAutoCompounder} from "../autocompounder/ConfigAutoCompounder.sol"; import {Config_MinterMarket} from "../ConfigBase.sol"; /// @notice Market configuration for ETH::fxUSD. @@ -20,6 +19,5 @@ contract ConfigMarket_ETH_fxUSD_mainnet is ConfigPriceVolatility_130_stable, ConfigStabilityPool, ConfigStabilityPoolManager, - ConfigTokenNames, - ConfigAutoCompounder + ConfigTokenNames {} diff --git a/script/config/markets/ConfigMarket_EUR_fxUSD_mainnet.sol b/script/config/markets/ConfigMarket_EUR_fxUSD_mainnet.sol index 2da9ddec..6dfb657e 100644 --- a/script/config/markets/ConfigMarket_EUR_fxUSD_mainnet.sol +++ b/script/config/markets/ConfigMarket_EUR_fxUSD_mainnet.sol @@ -9,7 +9,6 @@ import {ConfigStabilityPool} from "../stabilitypool/ConfigStabilityPool.sol"; import {ConfigStabilityPoolManager} from "../stabilitypool/ConfigStabilityPoolManager.sol"; import {Config_MinterMarket} from "../ConfigBase.sol"; import {ConfigTokenNames} from "../ConfigTokenNames.sol"; -import {ConfigAutoCompounder} from "../autocompounder/ConfigAutoCompounder.sol"; /// @notice Market configuration for EUR::fxUSD. contract ConfigMarket_EUR_fxUSD_mainnet is @@ -20,6 +19,5 @@ contract ConfigMarket_EUR_fxUSD_mainnet is ConfigPriceVolatility_105, ConfigStabilityPool, ConfigStabilityPoolManager, - ConfigTokenNames, - ConfigAutoCompounder + ConfigTokenNames {} diff --git a/script/config/markets/ConfigMarket_EUR_stETH_mainnet.sol b/script/config/markets/ConfigMarket_EUR_stETH_mainnet.sol index 3fa5c72a..deafde1c 100644 --- a/script/config/markets/ConfigMarket_EUR_stETH_mainnet.sol +++ b/script/config/markets/ConfigMarket_EUR_stETH_mainnet.sol @@ -9,7 +9,6 @@ import {ConfigStabilityPool} from "../stabilitypool/ConfigStabilityPool.sol"; import {ConfigStabilityPoolManager} from "../stabilitypool/ConfigStabilityPoolManager.sol"; import {Config_MinterMarket} from "../ConfigBase.sol"; import {ConfigTokenNames} from "../ConfigTokenNames.sol"; -import {ConfigAutoCompounder} from "../autocompounder/ConfigAutoCompounder.sol"; /// @notice Market configuration for EUR::stETH. contract ConfigMarket_EUR_stETH_mainnet is @@ -20,6 +19,5 @@ contract ConfigMarket_EUR_stETH_mainnet is ConfigPriceVolatility_130, ConfigStabilityPool, ConfigStabilityPoolManager, - ConfigTokenNames, - ConfigAutoCompounder + ConfigTokenNames {} diff --git a/script/config/markets/ConfigMarket_GOLD_fxUSD_mainnet.sol b/script/config/markets/ConfigMarket_GOLD_fxUSD_mainnet.sol index f7802dc0..3fb856af 100644 --- a/script/config/markets/ConfigMarket_GOLD_fxUSD_mainnet.sol +++ b/script/config/markets/ConfigMarket_GOLD_fxUSD_mainnet.sol @@ -9,7 +9,6 @@ import {ConfigStabilityPool} from "../stabilitypool/ConfigStabilityPool.sol"; import {ConfigStabilityPoolManager} from "../stabilitypool/ConfigStabilityPoolManager.sol"; import {Config_MinterMarket} from "../ConfigBase.sol"; import {ConfigTokenNames} from "../ConfigTokenNames.sol"; -import {ConfigAutoCompounder} from "../autocompounder/ConfigAutoCompounder.sol"; /// @notice Market configuration for GOLD::fxUSD. contract ConfigMarket_GOLD_fxUSD_mainnet is @@ -20,6 +19,5 @@ contract ConfigMarket_GOLD_fxUSD_mainnet is ConfigPriceVolatility_115, ConfigStabilityPool, ConfigStabilityPoolManager, - ConfigTokenNames, - ConfigAutoCompounder + ConfigTokenNames {} diff --git a/script/config/markets/ConfigMarket_GOLD_stETH_mainnet.sol b/script/config/markets/ConfigMarket_GOLD_stETH_mainnet.sol index 674268e0..c22187c1 100644 --- a/script/config/markets/ConfigMarket_GOLD_stETH_mainnet.sol +++ b/script/config/markets/ConfigMarket_GOLD_stETH_mainnet.sol @@ -9,7 +9,6 @@ import {ConfigStabilityPool} from "../stabilitypool/ConfigStabilityPool.sol"; import {ConfigStabilityPoolManager} from "../stabilitypool/ConfigStabilityPoolManager.sol"; import {Config_MinterMarket} from "../ConfigBase.sol"; import {ConfigTokenNames} from "../ConfigTokenNames.sol"; -import {ConfigAutoCompounder} from "../autocompounder/ConfigAutoCompounder.sol"; /// @notice Market configuration for GOLD::stETH. contract ConfigMarket_GOLD_stETH_mainnet is @@ -20,6 +19,5 @@ contract ConfigMarket_GOLD_stETH_mainnet is ConfigPriceVolatility_130, ConfigStabilityPool, ConfigStabilityPoolManager, - ConfigTokenNames, - ConfigAutoCompounder + ConfigTokenNames {} diff --git a/script/config/markets/ConfigMarket_MCAP_fxUSD_mainnet.sol b/script/config/markets/ConfigMarket_MCAP_fxUSD_mainnet.sol index cf9bc6b9..4f6877b0 100644 --- a/script/config/markets/ConfigMarket_MCAP_fxUSD_mainnet.sol +++ b/script/config/markets/ConfigMarket_MCAP_fxUSD_mainnet.sol @@ -9,7 +9,6 @@ import {ConfigStabilityPool} from "../stabilitypool/ConfigStabilityPool.sol"; import {ConfigStabilityPoolManager} from "../stabilitypool/ConfigStabilityPoolManager.sol"; import {Config_MinterMarket} from "../ConfigBase.sol"; import {ConfigTokenNames} from "../ConfigTokenNames.sol"; -import {ConfigAutoCompounder} from "../autocompounder/ConfigAutoCompounder.sol"; /// @notice Market configuration for MCAP::fxUSD. contract ConfigMarket_MCAP_fxUSD_mainnet is @@ -20,6 +19,5 @@ contract ConfigMarket_MCAP_fxUSD_mainnet is ConfigPriceVolatility_130, ConfigStabilityPool, ConfigStabilityPoolManager, - ConfigTokenNames, - ConfigAutoCompounder + ConfigTokenNames {} diff --git a/script/config/markets/ConfigMarket_MCAP_stETH_mainnet.sol b/script/config/markets/ConfigMarket_MCAP_stETH_mainnet.sol index 94b3a4e9..67ce8d2d 100644 --- a/script/config/markets/ConfigMarket_MCAP_stETH_mainnet.sol +++ b/script/config/markets/ConfigMarket_MCAP_stETH_mainnet.sol @@ -9,7 +9,6 @@ import {ConfigStabilityPool} from "../stabilitypool/ConfigStabilityPool.sol"; import {ConfigStabilityPoolManager} from "../stabilitypool/ConfigStabilityPoolManager.sol"; import {Config_MinterMarket} from "../ConfigBase.sol"; import {ConfigTokenNames} from "../ConfigTokenNames.sol"; -import {ConfigAutoCompounder} from "../autocompounder/ConfigAutoCompounder.sol"; /// @notice Market configuration for MCAP::stETH. contract ConfigMarket_MCAP_stETH_mainnet is @@ -20,6 +19,5 @@ contract ConfigMarket_MCAP_stETH_mainnet is ConfigPriceVolatility_130, ConfigStabilityPool, ConfigStabilityPoolManager, - ConfigTokenNames, - ConfigAutoCompounder + ConfigTokenNames {} diff --git a/script/config/markets/ConfigMarket_SILVER_fxUSD_mainnet.sol b/script/config/markets/ConfigMarket_SILVER_fxUSD_mainnet.sol index 78db7a3f..0bd45d78 100644 --- a/script/config/markets/ConfigMarket_SILVER_fxUSD_mainnet.sol +++ b/script/config/markets/ConfigMarket_SILVER_fxUSD_mainnet.sol @@ -9,7 +9,6 @@ import {ConfigStabilityPool} from "../stabilitypool/ConfigStabilityPool.sol"; import {ConfigStabilityPoolManager} from "../stabilitypool/ConfigStabilityPoolManager.sol"; import {Config_MinterMarket} from "../ConfigBase.sol"; import {ConfigTokenNames} from "../ConfigTokenNames.sol"; -import {ConfigAutoCompounder} from "../autocompounder/ConfigAutoCompounder.sol"; /// @notice Market configuration for SILVER::fxUSD. contract ConfigMarket_SILVER_fxUSD_mainnet is @@ -20,6 +19,5 @@ contract ConfigMarket_SILVER_fxUSD_mainnet is ConfigPriceVolatility_125, ConfigStabilityPool, ConfigStabilityPoolManager, - ConfigTokenNames, - ConfigAutoCompounder + ConfigTokenNames {} diff --git a/script/config/markets/ConfigMarket_SILVER_stETH_mainnet.sol b/script/config/markets/ConfigMarket_SILVER_stETH_mainnet.sol index f854d43c..b7657f73 100644 --- a/script/config/markets/ConfigMarket_SILVER_stETH_mainnet.sol +++ b/script/config/markets/ConfigMarket_SILVER_stETH_mainnet.sol @@ -9,7 +9,6 @@ import {ConfigStabilityPool} from "../stabilitypool/ConfigStabilityPool.sol"; import {ConfigStabilityPoolManager} from "../stabilitypool/ConfigStabilityPoolManager.sol"; import {Config_MinterMarket} from "../ConfigBase.sol"; import {ConfigTokenNames} from "../ConfigTokenNames.sol"; -import {ConfigAutoCompounder} from "../autocompounder/ConfigAutoCompounder.sol"; /// @notice Market configuration for SILVER::stETH. contract ConfigMarket_SILVER_stETH_mainnet is @@ -20,6 +19,5 @@ contract ConfigMarket_SILVER_stETH_mainnet is ConfigPriceVolatility_130, ConfigStabilityPool, ConfigStabilityPoolManager, - ConfigTokenNames, - ConfigAutoCompounder + ConfigTokenNames {} diff --git a/script/config/volatility/ConfigPriceVolatility_105.sol b/script/config/volatility/ConfigPriceVolatility_105.sol index 43c788e1..60334513 100644 --- a/script/config/volatility/ConfigPriceVolatility_105.sol +++ b/script/config/volatility/ConfigPriceVolatility_105.sol @@ -99,4 +99,8 @@ contract ConfigPriceVolatility_105 { }) }); } + + function autoCompounderMintMaxFeeRatio() public pure virtual returns (uint256) { + return 0.05 ether; + } } diff --git a/script/config/volatility/ConfigPriceVolatility_105_stable.sol b/script/config/volatility/ConfigPriceVolatility_105_stable.sol index d51a1fb6..d4c9a0fc 100644 --- a/script/config/volatility/ConfigPriceVolatility_105_stable.sol +++ b/script/config/volatility/ConfigPriceVolatility_105_stable.sol @@ -98,4 +98,8 @@ contract ConfigPriceVolatility_105_stable { }) }); } + + function autoCompounderMintMaxFeeRatio() public pure virtual returns (uint256) { + return 0.05 ether; + } } diff --git a/script/config/volatility/ConfigPriceVolatility_115.sol b/script/config/volatility/ConfigPriceVolatility_115.sol index 3357394e..6fc76cf7 100644 --- a/script/config/volatility/ConfigPriceVolatility_115.sol +++ b/script/config/volatility/ConfigPriceVolatility_115.sol @@ -99,4 +99,8 @@ contract ConfigPriceVolatility_115 { }) }); } + + function autoCompounderMintMaxFeeRatio() public pure virtual returns (uint256) { + return 0.05 ether; + } } diff --git a/script/config/volatility/ConfigPriceVolatility_115_stable.sol b/script/config/volatility/ConfigPriceVolatility_115_stable.sol index 455e5350..831d8c16 100644 --- a/script/config/volatility/ConfigPriceVolatility_115_stable.sol +++ b/script/config/volatility/ConfigPriceVolatility_115_stable.sol @@ -98,4 +98,8 @@ contract ConfigPriceVolatility_115_stable { }) }); } + + function autoCompounderMintMaxFeeRatio() public pure virtual returns (uint256) { + return 0.05 ether; + } } diff --git a/script/config/volatility/ConfigPriceVolatility_125.sol b/script/config/volatility/ConfigPriceVolatility_125.sol index 4c69635d..67c9d9e4 100644 --- a/script/config/volatility/ConfigPriceVolatility_125.sol +++ b/script/config/volatility/ConfigPriceVolatility_125.sol @@ -99,4 +99,8 @@ contract ConfigPriceVolatility_125 { }) }); } + + function autoCompounderMintMaxFeeRatio() public pure virtual returns (uint256) { + return 0.05 ether; + } } diff --git a/script/config/volatility/ConfigPriceVolatility_125_stable.sol b/script/config/volatility/ConfigPriceVolatility_125_stable.sol index 2b23860f..dc77350d 100644 --- a/script/config/volatility/ConfigPriceVolatility_125_stable.sol +++ b/script/config/volatility/ConfigPriceVolatility_125_stable.sol @@ -98,4 +98,8 @@ contract ConfigPriceVolatility_125_stable { }) }); } + + function autoCompounderMintMaxFeeRatio() public pure virtual returns (uint256) { + return 0.05 ether; + } } diff --git a/script/config/volatility/ConfigPriceVolatility_130.sol b/script/config/volatility/ConfigPriceVolatility_130.sol index 46f603c3..537b662f 100644 --- a/script/config/volatility/ConfigPriceVolatility_130.sol +++ b/script/config/volatility/ConfigPriceVolatility_130.sol @@ -99,4 +99,8 @@ contract ConfigPriceVolatility_130 { }) }); } + + function autoCompounderMintMaxFeeRatio() public pure virtual returns (uint256) { + return 0.05 ether; + } } diff --git a/script/config/volatility/ConfigPriceVolatility_130_stable.sol b/script/config/volatility/ConfigPriceVolatility_130_stable.sol index 42917eee..f8a60c60 100644 --- a/script/config/volatility/ConfigPriceVolatility_130_stable.sol +++ b/script/config/volatility/ConfigPriceVolatility_130_stable.sol @@ -98,4 +98,8 @@ contract ConfigPriceVolatility_130_stable { }) }); } + + function autoCompounderMintMaxFeeRatio() public pure virtual returns (uint256) { + return 0.05 ether; + } } diff --git a/src/interfaces/IMultipleRewardAccumulator_v3.sol b/src/interfaces/IMultipleRewardAccumulator_v3.sol deleted file mode 100644 index 4fb808c7..00000000 --- a/src/interfaces/IMultipleRewardAccumulator_v3.sol +++ /dev/null @@ -1,21 +0,0 @@ -// SPDX-License-Identifier: MIT - -pragma solidity >=0.8.28 <0.9.0; - -/// @notice Accumulator v3: unified claim interface replacing claim/claimSingle/claimHistorical. -// solhint-disable-next-line contract-name-capwords -interface IMultipleRewardAccumulator_v3 { - /// @notice Claim rewards for a single token (or all active tokens if token == address(0)). - /// @param account The address of the user to claim for. - /// @param receiver The address to receive the tokens. address(0) = use stored receiver or account. - /// @param token The reward token to claim. address(0) = all active tokens. - /// @param maxAmount Maximum amount to claim. type(uint256).max = all available. - function claim(address account, address receiver, address token, uint256 maxAmount) external; - - /// @notice Claim rewards for multiple tokens (active or historical). - /// @param account The address of the user to claim for. - /// @param receiver The address to receive the tokens. address(0) = use stored receiver or account. - /// @param tokens Array of reward token addresses to claim from. - /// @param maxAmount Maximum amount to claim per token. type(uint256).max = all available. - function claim(address account, address receiver, address[] calldata tokens, uint256 maxAmount) external; -} diff --git a/src/interfaces/IStabilityPool_v3.sol b/src/interfaces/IStabilityPool_v3.sol deleted file mode 100644 index 2bf783ac..00000000 --- a/src/interfaces/IStabilityPool_v3.sol +++ /dev/null @@ -1,10 +0,0 @@ -// SPDX-License-Identifier: MIT - -pragma solidity >=0.8.28 <0.9.0; - -import {IMultipleRewardAccumulator_v3} from "@harbor/interfaces/IMultipleRewardAccumulator_v3.sol"; - -/// @notice StabilityPool v3 additions: unified claim interface. -/// @dev Does NOT inherit IStabilityPool — the SP_v3 contract inherits both separately. -// solhint-disable-next-line contract-name-capwords,no-empty-blocks -interface IStabilityPool_v3 is IMultipleRewardAccumulator_v3 {} diff --git a/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol b/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol index dc8811e0..427b1ad2 100644 --- a/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol +++ b/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol @@ -8,7 +8,6 @@ import {ReentrancyGuardTransientUpgradeable} from "@openzeppelin/contracts-upgra import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; -import {IMultipleRewardAccumulator_v3} from "@harbor/interfaces/IMultipleRewardAccumulator_v3.sol"; import {DecrementalFloatingPoint} from "@harbor/math/DecrementalFloatingPoint.sol"; import {LinearMultipleRewardDistributor_v3} from "@harbor/reward/distributor/LinearMultipleRewardDistributor_v3.sol"; @@ -116,8 +115,7 @@ import {LinearMultipleRewardDistributor_v3} from "@harbor/reward/distributor/Lin abstract contract MultipleRewardCompoundingAccumulator_v3 is ReentrancyGuardTransientUpgradeable, LinearMultipleRewardDistributor_v3, - IMultipleRewardAccumulator, - IMultipleRewardAccumulator_v3 + IMultipleRewardAccumulator { using SafeERC20 for IERC20; using DecrementalFloatingPoint for uint128; @@ -315,73 +313,55 @@ abstract contract MultipleRewardCompoundingAccumulator_v3 is } // ═══════════════════════════════════════════════════════════════════════ - // v3 unified claim + // Claim // ═══════════════════════════════════════════════════════════════════════ - /// @inheritdoc IMultipleRewardAccumulator_v3 - function claim(address account, address receiver, address token, uint256 maxAmount) public nonReentrant { - if (account != _msgSender() && receiver != address(0)) { - revert ClaimOthersRewardToAnother(); + /// @inheritdoc IMultipleRewardAccumulator + function claim() external override nonReentrant { + address account = _msgSender(); + _checkpoint(account); + address receiver = _resolveReceiver(account, address(0)); + address[] memory tokens = activeRewardTokens(); + for (uint256 i = 0; i < tokens.length; i++) { + _claimSingle(account, tokens[i], receiver, type(uint256).max); } + } + + /// @inheritdoc IMultipleRewardAccumulator + function claim(address account) external override nonReentrant { _checkpoint(account); - receiver = _resolveReceiver(account, receiver); - if (token == address(0)) { - address[] memory tokens = activeRewardTokens(); - for (uint256 i = 0; i < tokens.length; i++) { - _claimSingle(account, tokens[i], receiver, maxAmount); - } - } else { - _claimSingle(account, token, receiver, maxAmount); + address receiver = _resolveReceiver(account, address(0)); + address[] memory tokens = activeRewardTokens(); + for (uint256 i = 0; i < tokens.length; i++) { + _claimSingle(account, tokens[i], receiver, type(uint256).max); } } - /// @inheritdoc IMultipleRewardAccumulator_v3 - function claim( - address account, - address receiver, - address[] calldata tokens, - uint256 maxAmount - ) external nonReentrant { + /// @inheritdoc IMultipleRewardAccumulator + function claim(address account, address receiver) public override nonReentrant { if (account != _msgSender() && receiver != address(0)) { revert ClaimOthersRewardToAnother(); } _checkpoint(account); receiver = _resolveReceiver(account, receiver); + address[] memory tokens = activeRewardTokens(); for (uint256 i = 0; i < tokens.length; i++) { - _claimSingle(account, tokens[i], receiver, maxAmount); + _claimSingle(account, tokens[i], receiver, type(uint256).max); } } - // ═══════════════════════════════════════════════════════════════════════ - // Legacy claim — thin wrappers with defaults - // ═══════════════════════════════════════════════════════════════════════ - - /// @inheritdoc IMultipleRewardAccumulator - function claim() external override { - claim(_msgSender(), address(0), address(0), type(uint256).max); - } - - /// @inheritdoc IMultipleRewardAccumulator - function claim(address account) external override { - claim(account, address(0), address(0), type(uint256).max); - } - - /// @inheritdoc IMultipleRewardAccumulator - function claim(address account, address receiver) public override { - claim(account, receiver, address(0), type(uint256).max); - } - /// @inheritdoc IMultipleRewardAccumulator function claimHistorical(address[] memory tokens) external nonReentrant { - _claimTokenList(_msgSender(), tokens); + address account = _msgSender(); + _checkpoint(account); + address receiver = _resolveReceiver(account, address(0)); + for (uint256 i = 0; i < tokens.length; i++) { + _claimSingle(account, tokens[i], receiver, type(uint256).max); + } } /// @inheritdoc IMultipleRewardAccumulator function claimHistorical(address account, address[] memory tokens) external nonReentrant { - _claimTokenList(account, tokens); - } - - function _claimTokenList(address account, address[] memory tokens) private { _checkpoint(account); address receiver = _resolveReceiver(account, address(0)); for (uint256 i = 0; i < tokens.length; i++) { diff --git a/src/reward/distributor/LinearMultipleRewardDistributor_v3.sol b/src/reward/distributor/LinearMultipleRewardDistributor_v3.sol index fec8e34e..5ef2dc02 100644 --- a/src/reward/distributor/LinearMultipleRewardDistributor_v3.sol +++ b/src/reward/distributor/LinearMultipleRewardDistributor_v3.sol @@ -174,17 +174,13 @@ abstract contract LinearMultipleRewardDistributor_v3 is /// @inheritdoc IMultipleRewardDistributor function registerRewardToken(address token) external onlyOwnerOrRoles(REWARD_MANAGER_ROLE) { - _registerRewardToken(token); - } - - function _registerRewardToken(address token) internal { if (token == address(0)) { revert RewardTokenIsZero(); } LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); if (!$.activeRewardTokens.add(token)) { - revert DuplicatedRewardToken(); + revert DuplicatedRewardToken(); // if value was not added then it already exists } // slither-disable-next-line unused-return we don't care if the the token was already in the set $.historicalRewardTokens.remove(token); // wake-disable-line unchecked-return-value @@ -194,10 +190,6 @@ abstract contract LinearMultipleRewardDistributor_v3 is /// @inheritdoc IMultipleRewardDistributor function unregisterRewardToken(address token) external onlyOwnerOrRoles(REWARD_MANAGER_ROLE) { - _unregisterRewardToken(token); - } - - function _unregisterRewardToken(address token) internal { LinearMultipleRewardDistributorStorage storage $ = _getLinearMultipleRewardDistributorStorage(); if (!$.activeRewardTokens.remove(token)) { @@ -214,7 +206,7 @@ abstract contract LinearMultipleRewardDistributor_v3 is } } - // slither-disable-next-line unused-return + // slither-disable-next-line unused-return we don't care if the the token was already in the set $.historicalRewardTokens.add(token); // wake-disable-line unchecked-return-value emit UnregisterRewardToken(token); } diff --git a/test/StabilityPoolClaimable.t.sol b/test/StabilityPoolClaimable.t.sol index ee91dda9..b816f3e7 100644 --- a/test/StabilityPoolClaimable.t.sol +++ b/test/StabilityPoolClaimable.t.sol @@ -9,7 +9,6 @@ import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistribu import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; import {MockERC20} from "@bao-test/mocks/MockERC20.sol"; -import {IMultipleRewardAccumulator_v3} from "src/interfaces/IMultipleRewardAccumulator_v3.sol"; import {TestStabilityPoolRebalanceSetUp} from "test/StabilityPoolRebalance.t.sol"; contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { @@ -604,10 +603,11 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { } // ═══════════════════════════════════════════════════════════════════════ - // claimSingle tests + // claim() routing tests // ═══════════════════════════════════════════════════════════════════════ - function testClaimSingle_claimsOnlySpecifiedToken() public { + function testClaim_claimsAllTokens() public { + // claim() claims all active reward tokens at once. _depositForUsers(); _depositRewardAndWait(rewardToken1, 100 ether); _depositRewardAndWait(rewardToken2, 200 ether); @@ -617,31 +617,15 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { assertGt(claimable1, 0, "should have claimable rewardToken1"); assertGt(claimable2, 0, "should have claimable rewardToken2"); - // Claim only rewardToken1 - uint256 bal1Before = IERC20(rewardToken1).balanceOf(user1); - uint256 bal2Before = IERC20(rewardToken2).balanceOf(user1); vm.prank(user1); - IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim( - user1, - address(0), - rewardToken1, - type(uint256).max - ); + IMultipleRewardAccumulator(stabilityPoolCollateral).claim(); - // rewardToken1 claimed - assertEq(IERC20(rewardToken1).balanceOf(user1) - bal1Before, claimable1, "rewardToken1 claimed"); - // rewardToken2 NOT claimed - assertEq(IERC20(rewardToken2).balanceOf(user1), bal2Before, "rewardToken2 untouched"); - - // rewardToken2 still claimable - assertGt( - IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken2), - 0, - "rewardToken2 still claimable" - ); + assertEq(IERC20(rewardToken1).balanceOf(user1), claimable1, "rewardToken1 claimed"); + assertEq(IERC20(rewardToken2).balanceOf(user1), claimable2, "rewardToken2 claimed"); } - function testClaimSingle_withReceiver() public { + function testClaim_withReceiver() public { + // claim(account, receiver) routes rewards to an explicit receiver. _depositForUsers(); _depositRewardAndWait(rewardToken1, 100 ether); @@ -649,239 +633,42 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { address receiver = makeAddr("receiver"); vm.prank(user1); - IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(user1, receiver, rewardToken1, type(uint256).max); + IMultipleRewardAccumulator(stabilityPoolCollateral).claim(user1, receiver); assertEq(IERC20(rewardToken1).balanceOf(receiver), claimable1, "receiver got tokens"); assertEq(IERC20(rewardToken1).balanceOf(user1), 0, "user1 got nothing"); } - function testClaimSingle_forOtherUser() public { + function testClaim_forOtherUser() public { + // Anyone can trigger claim(account) for another user — tokens go to that user. _depositForUsers(); _depositRewardAndWait(rewardToken1, 100 ether); uint256 claimable1 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1); - // Anyone can trigger claim for user1 — tokens go to user1 vm.prank(user2); - IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim( - user1, - address(0), - rewardToken1, - type(uint256).max - ); + IMultipleRewardAccumulator(stabilityPoolCollateral).claim(user1); assertEq(IERC20(rewardToken1).balanceOf(user1), claimable1, "user1 received tokens"); } - function testClaimSingle_cannotRedirectOthersReward() public { + function testClaim_cannotRedirectOthersReward() public { + // Third party cannot redirect another user's rewards to an explicit receiver. _depositForUsers(); _depositRewardAndWait(rewardToken1, 100 ether); address receiver = makeAddr("receiver"); - // user2 cannot redirect user1's rewards to receiver vm.prank(user2); vm.expectRevert(IMultipleRewardAccumulator.ClaimOthersRewardToAnother.selector); - IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(user1, receiver, rewardToken1, type(uint256).max); + IMultipleRewardAccumulator(stabilityPoolCollateral).claim(user1, receiver); } - function testClaimSingle_zeroClaimable() public { + function testClaim_zeroClaimable() public { + // claim() does not revert when there is nothing to claim. _depositForUsers(); - // No rewards deposited — claimSingle should not revert vm.prank(user1); - IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim( - user1, - address(0), - rewardToken1, - type(uint256).max - ); + IMultipleRewardAccumulator(stabilityPoolCollateral).claim(); assertEq(IERC20(rewardToken1).balanceOf(user1), 0, "nothing claimed"); } - - // ═══════════════════════════════════════════════════════════════════════ - // Fractional claimSingle tests - // ═══════════════════════════════════════════════════════════════════════ - - function testClaimSingle_fractional_claimsPartial() public { - _depositForUsers(); - _depositRewardAndWait(rewardToken1, 100 ether); - - uint256 claimable = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1); - assertGt(claimable, 0, "should have claimable"); - - // Claim half - uint256 halfAmount = claimable / 2; - uint256 balBefore = IERC20(rewardToken1).balanceOf(user1); - vm.prank(user1); - IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(user1, address(0), rewardToken1, halfAmount); - - // Received exactly halfAmount - assertEq(IERC20(rewardToken1).balanceOf(user1) - balBefore, halfAmount, "received half"); - - // Remainder still claimable - uint256 remaining = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1); - assertApproxEqAbs(remaining, claimable - halfAmount, 1, "remainder still claimable"); - } - - function testClaimSingle_fractional_claimAll() public { - _depositForUsers(); - _depositRewardAndWait(rewardToken1, 100 ether); - - uint256 claimable = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1); - - // Claim with maxAmount > claimable — should claim all - uint256 balBefore = IERC20(rewardToken1).balanceOf(user1); - vm.prank(user1); - IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim( - user1, - address(0), - rewardToken1, - type(uint256).max - ); - - assertEq(IERC20(rewardToken1).balanceOf(user1) - balBefore, claimable, "claimed all"); - - // Nothing remaining - uint256 remaining = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1); - assertEq(remaining, 0, "nothing remaining"); - } - - function testClaimSingle_fractional_claimZero() public { - _depositForUsers(); - _depositRewardAndWait(rewardToken1, 100 ether); - - uint256 claimable = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1); - - // Claim zero — should be a no-op - uint256 balBefore = IERC20(rewardToken1).balanceOf(user1); - vm.prank(user1); - IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(user1, address(0), rewardToken1, 0); - - assertEq(IERC20(rewardToken1).balanceOf(user1), balBefore, "nothing transferred"); - assertEq( - IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1), - claimable, - "claimable unchanged" - ); - } - - function testClaimSingle_fractional_withReceiver() public { - _depositForUsers(); - _depositRewardAndWait(rewardToken1, 100 ether); - - uint256 claimable = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1); - - address receiver = makeAddr("receiver"); - uint256 partialAmount = claimable / 3; - vm.prank(user1); - IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(user1, receiver, rewardToken1, partialAmount); - - assertEq(IERC20(rewardToken1).balanceOf(receiver), partialAmount, "receiver got partial"); - assertEq(IERC20(rewardToken1).balanceOf(user1), 0, "user got nothing"); - - // Remainder still claimable - uint256 remaining = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1); - assertApproxEqAbs(remaining, claimable - partialAmount, 1, "remainder still claimable"); - } - - function testClaimSingle_fractional_multipleClaims() public { - _depositForUsers(); - _depositRewardAndWait(rewardToken1, 100 ether); - - uint256 claimable = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1); - - // Claim in three tranches - uint256 tranche = claimable / 3; - vm.startPrank(user1); - IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(user1, address(0), rewardToken1, tranche); - IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(user1, address(0), rewardToken1, tranche); - IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim( - user1, - address(0), - rewardToken1, - type(uint256).max - ); - vm.stopPrank(); - - // Should have claimed everything - assertApproxEqAbs(IERC20(rewardToken1).balanceOf(user1), claimable, 1, "claimed everything in 3 tranches"); - assertEq( - IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1), - 0, - "nothing remaining after 3 tranches" - ); - } - - function testClaimSingle_fractional_linearAccrual_midPeriod() public { - _depositForUsers(); - - // Deposit reward but only wait half the distribution period (604800s = 7 days) - vm.prank(rewardDepositor); - IMultipleRewardDistributor(stabilityPoolCollateral).depositReward(rewardToken1, 300 ether); - skip(3.5 days); - - // ~half should be claimable (distributed linearly) - uint256 claimable = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1); - assertGt(claimable, 0, "mid-period claimable"); - // ~50 ether per user (300/3 users * 50% of period) - assertApproxEqRel(claimable, 50 ether, 0.02 ether, "~50 per user at midpoint"); - - // Fractional claim: take 20 ether - vm.prank(user1); - IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(user1, address(0), rewardToken1, 20 ether); - assertEq(IERC20(rewardToken1).balanceOf(user1), 20 ether, "received 20"); - - // Wait for rest of period - skip(3.5 days); - - // Full amount now available (minus what was already claimed) - uint256 finalClaimable = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1); - // ~100 per user total, minus 20 already claimed = ~80 - assertApproxEqRel(finalClaimable, 80 ether, 0.02 ether, "~80 remaining after full period"); - - // Claim the rest - vm.prank(user1); - IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim( - user1, - address(0), - rewardToken1, - type(uint256).max - ); - assertApproxEqRel(IERC20(rewardToken1).balanceOf(user1), 100 ether, 0.02 ether, "~100 total"); - } - - function testClaimSingle_fractional_twoTokens_independent() public { - _depositForUsers(); - _depositRewardAndWait(rewardToken1, 90 ether); - _depositRewardAndWait(rewardToken2, 180 ether); - - uint256 claimable1 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1); - uint256 claimable2 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken2); - - // Partial claim from token1 only - vm.prank(user1); - IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(user1, address(0), rewardToken1, claimable1 / 4); - - // token2 claimable unchanged - assertEq( - IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken2), - claimable2, - "token2 unaffected by token1 partial claim" - ); - - // token1 reduced - uint256 remaining1 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1); - assertApproxEqAbs(remaining1, claimable1 - claimable1 / 4, 1, "token1 reduced by claimed amount"); - } - - function testClaimSingle_fractional_withReceiver_revertsForOthers() public { - _depositForUsers(); - _depositRewardAndWait(rewardToken1, 100 ether); - - // user2 tries to claim user1's reward to a custom receiver — should revert - address receiver = makeAddr("receiver"); - vm.prank(user2); - vm.expectRevert(); - IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim(user1, receiver, rewardToken1, 50 ether); - } } diff --git a/test/deployment/RebalanceFairnessScan.t.sol b/test/deployment/RebalanceFairnessScan.t.sol index 438f16b5..bd6a59a6 100644 --- a/test/deployment/RebalanceFairnessScan.t.sol +++ b/test/deployment/RebalanceFairnessScan.t.sol @@ -8,7 +8,6 @@ import {IMinter} from "src/interfaces/IMinter.sol"; import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; import {IStabilityPoolManager} from "src/interfaces/IStabilityPoolManager.sol"; import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; -import {IMultipleRewardAccumulator_v3} from "src/interfaces/IMultipleRewardAccumulator_v3.sol"; import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; @@ -629,7 +628,7 @@ contract RebalanceFairnessScan is RebalanceFairnessSetUp { uint256 levClaimable = IMultipleRewardAccumulator(pool).claimable(who, leveraged); if (levClaimable > 0) { vm.startPrank(who); - IMultipleRewardAccumulator_v3(pool).claim(who, who, leveraged, levClaimable); + IMultipleRewardAccumulator(pool).claim(who); uint256 levBal = IERC20(leveraged).balanceOf(who); IERC20(leveraged).approve(minter, levBal); IMinter(minter).freeRedeemLeveragedToken(levBal, who); // → wCOL to who @@ -640,7 +639,7 @@ contract RebalanceFairnessScan is RebalanceFairnessSetUp { uint256 wcolClaimable = IMultipleRewardAccumulator(pool).claimable(who, wrappedCollateral); if (wcolClaimable > 0) { vm.prank(who); - IMultipleRewardAccumulator_v3(pool).claim(who, who, wrappedCollateral, wcolClaimable); + IMultipleRewardAccumulator(pool).claim(who); } // Step 3: Convert all wCOL in wallet → haXXX → deposit diff --git a/test/deployment/RewardSystem.t.sol b/test/deployment/RewardSystem.t.sol index b1c00b4a..69a93561 100644 --- a/test/deployment/RewardSystem.t.sol +++ b/test/deployment/RewardSystem.t.sol @@ -12,9 +12,10 @@ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IMinter} from "src/interfaces/IMinter.sol"; import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; -import {IMultipleRewardAccumulator_v3} from "src/interfaces/IMultipleRewardAccumulator_v3.sol"; import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; import {MockWrappedPriceOracle} from "test/mocks/MockWrappedPriceOracle.sol"; +import {AutoCompounder_v1} from "src/autocompounding/AutoCompounder_v1.sol"; +import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; /// @title Reward system tests — accumulator, distributor — using deployment framework contract RewardSystemSetUp is BaoTest, Deploy_ETH_Minter { @@ -114,12 +115,7 @@ contract AccumulatorTest is RewardSystemSetUp { // Claim vm.prank(alice); - IMultipleRewardAccumulator_v3(stabilityPoolCollateral).claim( - alice, - address(0), - wrappedCollateral, - type(uint256).max - ); + IMultipleRewardAccumulator(stabilityPoolCollateral).claim(alice); // claimed() should return the claimed amount uint256 claimedAmount = IMultipleRewardAccumulator(stabilityPoolCollateral).claimed(alice, wrappedCollateral); diff --git a/test/reward/accumulator/ClaimEquivalence.t.sol b/test/reward/accumulator/ClaimEquivalence.t.sol index 9a5deed4..041bfb17 100644 --- a/test/reward/accumulator/ClaimEquivalence.t.sol +++ b/test/reward/accumulator/ClaimEquivalence.t.sol @@ -6,28 +6,26 @@ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {MockERC20} from "@bao-test/mocks/MockERC20.sol"; import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; -import {IMultipleRewardAccumulator_v3} from "src/interfaces/IMultipleRewardAccumulator_v3.sol"; import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; import {MockMultipleRewardCompoundingAccumulator_v3} from "test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v3.sol"; -/// @title ClaimEquivalenceTest -/// @notice Verifies that the v3 unified claim interface produces identical outcomes -/// to the legacy claim/claimHistorical wrappers it replaced. +/// @title ClaimTest +/// @notice Verifies claim() and claimHistorical() routing across all supported call signatures. /// -/// Authorization matrix tested: -/// | Scenario | Legacy path | V3 equivalent | Expected | -/// |---------------------------|--------------------------------------|--------------------------------------------|-----------------| -/// | Self claim all | claim() | claim(self, 0, 0, max) | tokens → self | -/// | 3rd party claim all | claim(other) | claim(other, 0, 0, max) | tokens → other | -/// | Self claim to receiver | claim(self, recv) | claim(self, recv, 0, max) | tokens → recv | -/// | 3rd party to receiver | claim(other, recv) | claim(other, recv, 0, max) | REVERT | -/// | Self historical | claimHistorical(tokens) | claim(self, 0, tokens, max) | tokens → self | -/// | 3rd party historical | claimHistorical(other, tokens) | claim(other, 0, tokens, max) | tokens → other | -/// | All above + stored recv | same paths | same paths | → stored recv | +/// Authorization matrix: +/// | Scenario | Call | Expected | +/// |---------------------------|--------------------------------------|-----------------| +/// | Self claim all | claim() | tokens → self | +/// | 3rd party claim all | claim(other) | tokens → other | +/// | Self claim to receiver | claim(self, recv) | tokens → recv | +/// | 3rd party to receiver | claim(other, recv) | REVERT | +/// | Self historical | claimHistorical(tokens) | tokens → self | +/// | 3rd party historical | claimHistorical(other, tokens) | tokens → other | +/// | All above + stored recv | same paths | → stored recv | /// -/// Run: forge test --mc ClaimEquivalenceTest -vv -contract ClaimEquivalenceTest is Test { +/// Run: forge test --mc ClaimTest -vv +contract ClaimTest is Test { address deployer; address alice; address bob; @@ -77,154 +75,75 @@ contract ClaimEquivalenceTest is Test { vm.warp(block.timestamp + 2 weeks); } - function _claimableTotal(address account) internal view returns (uint256) { - return - IMultipleRewardAccumulator(accumulator).claimable(account, rewardToken1) + - IMultipleRewardAccumulator(accumulator).claimable(account, rewardToken2); - } - // ═══════════════════════════════════════════════════════════════════════ // Without stored receiver // ═══════════════════════════════════════════════════════════════════════ // ── Self claim all ────────────────────────────────────────────────── - function test_selfClaimAll_legacy() public { - _depositRewards(); - vm.prank(alice); - IMultipleRewardAccumulator(accumulator).claim(); - assertGt(IERC20(rewardToken1).balanceOf(alice), 0, "legacy: alice got token1"); - assertGt(IERC20(rewardToken2).balanceOf(alice), 0, "legacy: alice got token2"); - } - - function test_selfClaimAll_v3() public { - _depositRewards(); - vm.prank(alice); - IMultipleRewardAccumulator_v3(accumulator).claim(alice, address(0), address(0), type(uint256).max); - assertGt(IERC20(rewardToken1).balanceOf(alice), 0, "v3: alice got token1"); - assertGt(IERC20(rewardToken2).balanceOf(alice), 0, "v3: alice got token2"); - } - - function test_selfClaimAll_equivalent() public { - // Run legacy path, snapshot balances + function test_selfClaimAll() public { + // claim() with no args claims all active tokens for msg.sender. _depositRewards(); vm.prank(alice); IMultipleRewardAccumulator(accumulator).claim(); - uint256 legacyBal1 = IERC20(rewardToken1).balanceOf(alice); - uint256 legacyBal2 = IERC20(rewardToken2).balanceOf(alice); - - // Reset: redeploy - setUp(); - _depositRewards(); - vm.prank(alice); - IMultipleRewardAccumulator_v3(accumulator).claim(alice, address(0), address(0), type(uint256).max); - assertEq(IERC20(rewardToken1).balanceOf(alice), legacyBal1, "token1 equivalent"); - assertEq(IERC20(rewardToken2).balanceOf(alice), legacyBal2, "token2 equivalent"); + assertGt(IERC20(rewardToken1).balanceOf(alice), 0, "alice got token1"); + assertGt(IERC20(rewardToken2).balanceOf(alice), 0, "alice got token2"); } // ── Third party claim all (no receiver) ───────────────────────────── - function test_thirdPartyClaimAll_legacy() public { + function test_thirdPartyClaimAll() public { + // claim(account) lets a third party trigger claims — tokens go to the account. _depositRewards(); vm.prank(bob); IMultipleRewardAccumulator(accumulator).claim(alice); - assertGt(IERC20(rewardToken1).balanceOf(alice), 0, "legacy: alice got token1 (claimed by bob)"); - } - - function test_thirdPartyClaimAll_v3() public { - _depositRewards(); - vm.prank(bob); - IMultipleRewardAccumulator_v3(accumulator).claim(alice, address(0), address(0), type(uint256).max); - assertGt(IERC20(rewardToken1).balanceOf(alice), 0, "v3: alice got token1 (claimed by bob)"); - } - - function test_thirdPartyClaimAll_equivalent() public { - _depositRewards(); - vm.prank(bob); - IMultipleRewardAccumulator(accumulator).claim(alice); - uint256 legacyBal1 = IERC20(rewardToken1).balanceOf(alice); - - setUp(); - _depositRewards(); - vm.prank(bob); - IMultipleRewardAccumulator_v3(accumulator).claim(alice, address(0), address(0), type(uint256).max); - assertEq(IERC20(rewardToken1).balanceOf(alice), legacyBal1, "equivalent"); + assertGt(IERC20(rewardToken1).balanceOf(alice), 0, "alice got token1 (claimed by bob)"); } // ── Self claim to explicit receiver ───────────────────────────────── - function test_selfClaimToReceiver_legacy() public { + function test_selfClaimToReceiver() public { + // claim(account, receiver) routes tokens to an explicit receiver. _depositRewards(); vm.prank(alice); IMultipleRewardAccumulator(accumulator).claim(alice, explicitReceiver); - assertGt(IERC20(rewardToken1).balanceOf(explicitReceiver), 0, "legacy: receiver got token1"); - assertEq(IERC20(rewardToken1).balanceOf(alice), 0, "legacy: alice got nothing"); - } - - function test_selfClaimToReceiver_v3() public { - _depositRewards(); - vm.prank(alice); - IMultipleRewardAccumulator_v3(accumulator).claim(alice, explicitReceiver, address(0), type(uint256).max); - assertGt(IERC20(rewardToken1).balanceOf(explicitReceiver), 0, "v3: receiver got token1"); - assertEq(IERC20(rewardToken1).balanceOf(alice), 0, "v3: alice got nothing"); + assertGt(IERC20(rewardToken1).balanceOf(explicitReceiver), 0, "receiver got token1"); + assertEq(IERC20(rewardToken1).balanceOf(alice), 0, "alice got nothing"); } // ── Third party claim to receiver → REVERT ────────────────────────── - function test_thirdPartyClaimToReceiver_legacy_reverts() public { + function test_thirdPartyClaimToReceiver_reverts() public { + // Third party cannot redirect another account's rewards. _depositRewards(); vm.prank(bob); vm.expectRevert(IMultipleRewardAccumulator.ClaimOthersRewardToAnother.selector); IMultipleRewardAccumulator(accumulator).claim(alice, explicitReceiver); } - function test_thirdPartyClaimToReceiver_v3_reverts() public { - _depositRewards(); - vm.prank(bob); - vm.expectRevert(IMultipleRewardAccumulator.ClaimOthersRewardToAnother.selector); - IMultipleRewardAccumulator_v3(accumulator).claim(alice, explicitReceiver, address(0), type(uint256).max); - } - // ── Self historical ───────────────────────────────────────────────── - function test_selfHistorical_legacy() public { + function test_selfHistorical() public { + // claimHistorical(tokens) claims a specific list of tokens for msg.sender. _depositRewards(); address[] memory tokens = new address[](1); tokens[0] = rewardToken1; vm.prank(alice); IMultipleRewardAccumulator(accumulator).claimHistorical(tokens); - assertGt(IERC20(rewardToken1).balanceOf(alice), 0, "legacy: alice got token1"); - assertEq(IERC20(rewardToken2).balanceOf(alice), 0, "legacy: token2 unclaimed"); - } - - function test_selfHistorical_v3() public { - _depositRewards(); - address[] memory tokens = new address[](1); - tokens[0] = rewardToken1; - vm.prank(alice); - IMultipleRewardAccumulator_v3(accumulator).claim(alice, address(0), tokens, type(uint256).max); - assertGt(IERC20(rewardToken1).balanceOf(alice), 0, "v3: alice got token1"); - assertEq(IERC20(rewardToken2).balanceOf(alice), 0, "v3: token2 unclaimed"); + assertGt(IERC20(rewardToken1).balanceOf(alice), 0, "alice got token1"); + assertEq(IERC20(rewardToken2).balanceOf(alice), 0, "token2 unclaimed"); } // ── Third party historical ────────────────────────────────────────── - function test_thirdPartyHistorical_legacy() public { + function test_thirdPartyHistorical() public { + // claimHistorical(account, tokens) lets a third party trigger historical claims. _depositRewards(); address[] memory tokens = new address[](1); tokens[0] = rewardToken1; vm.prank(bob); IMultipleRewardAccumulator(accumulator).claimHistorical(alice, tokens); - assertGt(IERC20(rewardToken1).balanceOf(alice), 0, "legacy: alice got token1 (claimed by bob)"); - } - - function test_thirdPartyHistorical_v3() public { - _depositRewards(); - address[] memory tokens = new address[](1); - tokens[0] = rewardToken1; - vm.prank(bob); - IMultipleRewardAccumulator_v3(accumulator).claim(alice, address(0), tokens, type(uint256).max); - assertGt(IERC20(rewardToken1).balanceOf(alice), 0, "v3: alice got token1 (claimed by bob)"); + assertGt(IERC20(rewardToken1).balanceOf(alice), 0, "alice got token1 (claimed by bob)"); } // ═══════════════════════════════════════════════════════════════════════ @@ -238,124 +157,48 @@ contract ClaimEquivalenceTest is Test { // ── Self claim all → stored receiver ──────────────────────────────── - function test_selfClaimAll_storedReceiver_legacy() public { + function test_selfClaimAll_storedReceiver() public { + // When a stored receiver is set, claim() sends tokens there. _setStoredReceiver(); _depositRewards(); vm.prank(alice); IMultipleRewardAccumulator(accumulator).claim(); - assertGt(IERC20(rewardToken1).balanceOf(storedReceiver), 0, "legacy: stored receiver got token1"); - assertEq(IERC20(rewardToken1).balanceOf(alice), 0, "legacy: alice got nothing"); - } - - function test_selfClaimAll_storedReceiver_v3() public { - _setStoredReceiver(); - _depositRewards(); - vm.prank(alice); - IMultipleRewardAccumulator_v3(accumulator).claim(alice, address(0), address(0), type(uint256).max); - assertGt(IERC20(rewardToken1).balanceOf(storedReceiver), 0, "v3: stored receiver got token1"); - assertEq(IERC20(rewardToken1).balanceOf(alice), 0, "v3: alice got nothing"); + assertGt(IERC20(rewardToken1).balanceOf(storedReceiver), 0, "stored receiver got token1"); + assertEq(IERC20(rewardToken1).balanceOf(alice), 0, "alice got nothing"); } // ── Third party claim all → stored receiver ───────────────────────── - function test_thirdPartyClaimAll_storedReceiver_legacy() public { + function test_thirdPartyClaimAll_storedReceiver() public { + // Third party claim(account) respects the account's stored receiver. _setStoredReceiver(); _depositRewards(); vm.prank(bob); IMultipleRewardAccumulator(accumulator).claim(alice); - assertGt(IERC20(rewardToken1).balanceOf(storedReceiver), 0, "legacy: stored receiver got token1"); - assertEq(IERC20(rewardToken1).balanceOf(alice), 0, "legacy: alice got nothing"); - } - - function test_thirdPartyClaimAll_storedReceiver_v3() public { - _setStoredReceiver(); - _depositRewards(); - vm.prank(bob); - IMultipleRewardAccumulator_v3(accumulator).claim(alice, address(0), address(0), type(uint256).max); - assertGt(IERC20(rewardToken1).balanceOf(storedReceiver), 0, "v3: stored receiver got token1"); - assertEq(IERC20(rewardToken1).balanceOf(alice), 0, "v3: alice got nothing"); + assertGt(IERC20(rewardToken1).balanceOf(storedReceiver), 0, "stored receiver got token1"); + assertEq(IERC20(rewardToken1).balanceOf(alice), 0, "alice got nothing"); } // ── Self claim to explicit receiver overrides stored ───────────────── - function test_selfClaimToExplicit_overridesStored_legacy() public { + function test_selfClaimToExplicit_overridesStored() public { + // An explicit receiver passed to claim(account, receiver) overrides the stored one. _setStoredReceiver(); _depositRewards(); vm.prank(alice); IMultipleRewardAccumulator(accumulator).claim(alice, explicitReceiver); - assertGt(IERC20(rewardToken1).balanceOf(explicitReceiver), 0, "legacy: explicit receiver got token1"); - assertEq(IERC20(rewardToken1).balanceOf(storedReceiver), 0, "legacy: stored receiver got nothing"); - } - - function test_selfClaimToExplicit_overridesStored_v3() public { - _setStoredReceiver(); - _depositRewards(); - vm.prank(alice); - IMultipleRewardAccumulator_v3(accumulator).claim(alice, explicitReceiver, address(0), type(uint256).max); - assertGt(IERC20(rewardToken1).balanceOf(explicitReceiver), 0, "v3: explicit receiver got token1"); - assertEq(IERC20(rewardToken1).balanceOf(storedReceiver), 0, "v3: stored receiver got nothing"); + assertGt(IERC20(rewardToken1).balanceOf(explicitReceiver), 0, "explicit receiver got token1"); + assertEq(IERC20(rewardToken1).balanceOf(storedReceiver), 0, "stored receiver got nothing"); } // ── Third party + stored receiver + explicit → REVERT ─────────────── - function test_thirdPartyToExplicit_storedReceiver_legacy_reverts() public { + function test_thirdPartyToExplicit_storedReceiver_reverts() public { + // Third party cannot override the stored receiver by passing an explicit one. _setStoredReceiver(); _depositRewards(); vm.prank(bob); vm.expectRevert(IMultipleRewardAccumulator.ClaimOthersRewardToAnother.selector); IMultipleRewardAccumulator(accumulator).claim(alice, explicitReceiver); } - - function test_thirdPartyToExplicit_storedReceiver_v3_reverts() public { - _setStoredReceiver(); - _depositRewards(); - vm.prank(bob); - vm.expectRevert(IMultipleRewardAccumulator.ClaimOthersRewardToAnother.selector); - IMultipleRewardAccumulator_v3(accumulator).claim(alice, explicitReceiver, address(0), type(uint256).max); - } - - // ═══════════════════════════════════════════════════════════════════════ - // V3-specific: fractional claim (no legacy equivalent) - // ═══════════════════════════════════════════════════════════════════════ - - function test_fractionalClaim_leavesRemainder() public { - _depositRewards(); - uint256 total = IMultipleRewardAccumulator(accumulator).claimable(alice, rewardToken1); - uint256 half = total / 2; - - vm.prank(alice); - IMultipleRewardAccumulator_v3(accumulator).claim(alice, address(0), rewardToken1, half); - - assertEq(IERC20(rewardToken1).balanceOf(alice), half, "got half"); - assertGt( - IMultipleRewardAccumulator(accumulator).claimable(alice, rewardToken1), - 0, - "remainder still claimable" - ); - } - - function test_fractionalClaim_thirdParty_leavesRemainder() public { - _depositRewards(); - uint256 total = IMultipleRewardAccumulator(accumulator).claimable(alice, rewardToken1); - uint256 half = total / 2; - - // Bob claims half of alice's rewards (receiver=0 → goes to alice) - vm.prank(bob); - IMultipleRewardAccumulator_v3(accumulator).claim(alice, address(0), rewardToken1, half); - - assertEq(IERC20(rewardToken1).balanceOf(alice), half, "alice got half"); - assertEq(IERC20(rewardToken1).balanceOf(bob), 0, "bob got nothing"); - assertGt( - IMultipleRewardAccumulator(accumulator).claimable(alice, rewardToken1), - 0, - "remainder still claimable" - ); - } - - function test_fractionalClaim_thirdParty_toExplicitReceiver_reverts() public { - _depositRewards(); - vm.prank(bob); - vm.expectRevert(IMultipleRewardAccumulator.ClaimOthersRewardToAnother.selector); - IMultipleRewardAccumulator_v3(accumulator).claim(alice, explicitReceiver, rewardToken1, 1 ether); - } } From f7766d3d7eb72629f25d26c7f20a6bce83d790b1 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Tue, 21 Apr 2026 19:08:28 +0100 Subject: [PATCH 053/232] autocompounder deploy --- script/src/DeployMintersShared.sol | 22 ++- script/src/contracts/AutoCompounder.sol | 44 +++-- src/autocompounding/AutoCompounder_v1.sol | 205 ++++++++++++++-------- src/interfaces/IYieldManager.sol | 27 +++ test/deployment/AutoCompounderTest.t.sol | 49 ++++-- test/deployment/DeployEURSetUp.t.sol | 13 ++ 6 files changed, 251 insertions(+), 109 deletions(-) create mode 100644 src/interfaces/IYieldManager.sol diff --git a/script/src/DeployMintersShared.sol b/script/src/DeployMintersShared.sol index 8f072236..57b5423c 100644 --- a/script/src/DeployMintersShared.sol +++ b/script/src/DeployMintersShared.sol @@ -230,12 +230,21 @@ abstract contract DeployMintersShared is address spCollateral = _predictAddress(_key(marketKey, StabilityPoolCollateral)); address spLeveraged = _predictAddress(_key(marketKey, StabilityPoolLeveraged)); + // ETH price oracle: peg-scoped (same oracle for all markets with the same peg). + // Deployed by harbor-price-aggregators deploy scripts; address derived from peg name. + address pegOracle = predictEthPriceOracleAddress(IMarketConfig(address(cfg)).peg()); + + IAutoCompounderMarketConfig acCfg = IAutoCompounderMarketConfig(address(cfg)); + + // Standalone ACs (no HarborYield) — pass address(0) as yieldManager. deployAutoCompounder( AutoCompounderCollateral, stateData, Config_MinterMarket(address(cfg)), spCollateral, - minter + minter, + address(0), + pegOracle ); deployAutoCompounder( @@ -243,7 +252,9 @@ abstract contract DeployMintersShared is stateData, Config_MinterMarket(address(cfg)), spLeveraged, - minter + minter, + address(0), + pegOracle ); } @@ -300,10 +311,9 @@ abstract contract DeployMintersShared is grantStabilityPoolRoles(marketKey, StabilityPoolCollateral, AutoCompounderCollateral); grantStabilityPoolRoles(marketKey, StabilityPoolLeveraged, AutoCompounderLeveraged); - // Configure Auto-Compounders (maxFeeRatio, approvals) - uint256 maxFeeRatio = IAutoCompounderMarketConfig(address(market)).autoCompounderMaxFeeRatio(); - configureAutoCompounder(marketKey, AutoCompounderCollateral, maxFeeRatio); - configureAutoCompounder(marketKey, AutoCompounderLeveraged, maxFeeRatio); + // Configure Auto-Compounders (approve compound tokens — maxFeeRatio is now an immutable set at deploy) + configureAutoCompounder(marketKey, AutoCompounderCollateral); + configureAutoCompounder(marketKey, AutoCompounderLeveraged); // Configure StabilityPoolManager configureStabilityPoolManager( diff --git a/script/src/contracts/AutoCompounder.sol b/script/src/contracts/AutoCompounder.sol index c26ce1b4..5fb8bd22 100644 --- a/script/src/contracts/AutoCompounder.sol +++ b/script/src/contracts/AutoCompounder.sol @@ -6,31 +6,47 @@ import {HarborFactoryDeployer} from "script/src/HarborFactoryDeployer.sol"; import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; import {AutoCompounder_v1} from "@harbor/autocompounding/AutoCompounder_v1.sol"; -import {Config_MinterMarket, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; +import {Config_MinterMarket, MinterMarketConfigLib, IMarketConfig} from "script/config/ConfigBase.sol"; import {ConfigTokenNames} from "script/config/ConfigTokenNames.sol"; /// @notice Config interface for auto-compounder deployment parameters. interface IAutoCompounderMarketConfig { - function autoCompounderMaxFeeRatio() external pure returns (uint256); + function autoCompounderMintMaxFeeRatio() external pure returns (uint256); } /// @notice Harbor AutoCompounder deployment logic. /// @dev Each market has TWO auto-compounders: Collateral and Leveraged (one per stability pool). -/// Post-deployment: setMaxFeeRatio, approveCompoundTokens. +/// Post-deployment: approveCompoundTokens. /// EXEMPT_WITHDRAWAL_FEE_ROLE is granted via grantStabilityPoolAutoCompounderRole (using predicted address). +/// +/// yieldManager: pass address(0) for standalone ACs (MAX_FEE_RATIO is used instead). +/// For HY-connected ACs (harbor-yield repo), pass the HY predicted address and +/// maxFeeRatio = 0 (exactly one of the two must be non-zero). +/// pegOracle: IWrappedPriceOracle for the wrapped collateral. Required; provides the +/// gas floor for compound() via maxUnderlyingPrice. abstract contract AutoCompounder is HarborFactoryDeployer { string AutoCompounderCollateral = "autoCompounderCollateral"; string AutoCompounderLeveraged = "autoCompounderLeveraged"; // ========== AUTO-COMPOUNDER DEPLOYMENT ========== + /// @notice Predict the address of the ETH price oracle for a peg. + /// @dev Salt: {saltPrefix}::{peg}::ethPriceAggregator. Deployed by harbor-price-aggregators scripts. + function predictEthPriceOracleAddress(string memory peg) internal returns (address) { + return _predictAddress(string.concat(peg, "::ethPriceAggregator")); + } + /// @notice Deploy AutoCompounder impl only, record in state. + /// @param yieldManager HarborYield address, or address(0) for standalone AC. + /// @param pegOracle IWrappedPriceOracle for the peg/ETH price (gas floor). Required. function deployAutoCompounderImplementation( string memory acType, DeploymentTypes.State memory stateData, Config_MinterMarket marketConfig, address stabilityPool, - address minter + address minter, + address yieldManager, + address pegOracle ) internal virtual returns (address impl) { string memory marketKey = MinterMarketConfigLib.salt(marketConfig); string memory acKey = _key(marketKey, acType); @@ -41,7 +57,11 @@ abstract contract AutoCompounder is HarborFactoryDeployer { string memory tokenName = isCollateral ? names.acCollateralName() : names.acLeveragedName(); string memory tokenSymbol = isCollateral ? names.acCollateralSymbol() : names.acLeveragedSymbol(); - impl = address(new AutoCompounder_v1(stabilityPool, minter, tokenName, tokenSymbol)); + uint256 maxFeeRatio = yieldManager == address(0) + ? IAutoCompounderMarketConfig(address(marketConfig)).autoCompounderMintMaxFeeRatio() + : 0; + + impl = address(new AutoCompounder_v1(stabilityPool, minter, yieldManager, maxFeeRatio, pegOracle, tokenName, tokenSymbol)); console.log(" Impl: %s", impl); console.log(" Name: %s", tokenName); console.log(" Symbol: %s", tokenSymbol); @@ -56,30 +76,32 @@ abstract contract AutoCompounder is HarborFactoryDeployer { } /// @notice Deploy AutoCompounder impl+proxy, record in state. + /// @param yieldManager HarborYield address, or address(0) for standalone AC. + /// @param pegOracle IWrappedPriceOracle for the wrapped collateral (required). function deployAutoCompounder( string memory acType, DeploymentTypes.State memory stateData, Config_MinterMarket marketConfig, address stabilityPool, - address minter + address minter, + address yieldManager, + address pegOracle ) internal returns (address proxy) { string memory marketKey = MinterMarketConfigLib.salt(marketConfig); string memory acKey = _key(marketKey, acType); - address impl = deployAutoCompounderImplementation(acType, stateData, marketConfig, stabilityPool, minter); + address impl = deployAutoCompounderImplementation(acType, stateData, marketConfig, stabilityPool, minter, yieldManager, pegOracle); bytes memory initData = abi.encodeCall(AutoCompounder_v1.initialize, (address(this), owner())); proxy = _deployProxyAndRecord(stateData, acKey, impl, initData); } - /// @notice Post-deployment configuration: set maxFeeRatio and approve tokens. + /// @notice Post-deployment configuration: approve compound tokens. /// @param marketKey The market salt key (e.g., "ETH::fxUSD"). /// @param acType "autoCompounderCollateral" or "autoCompounderLeveraged". - /// @param maxFeeRatio The max fee ratio for compound minting (18 decimals). - function configureAutoCompounder(string memory marketKey, string memory acType, uint256 maxFeeRatio) internal { + function configureAutoCompounder(string memory marketKey, string memory acType) internal { address acProxy = _predictAddress(_key(marketKey, acType)); - AutoCompounder_v1(acProxy).setMaxFeeRatio(maxFeeRatio); AutoCompounder_v1(acProxy).approveCompoundTokens(); } } diff --git a/src/autocompounding/AutoCompounder_v1.sol b/src/autocompounding/AutoCompounder_v1.sol index b003e5f2..d37daabc 100644 --- a/src/autocompounding/AutoCompounder_v1.sol +++ b/src/autocompounding/AutoCompounder_v1.sol @@ -16,17 +16,19 @@ import {TokenHolder, ITokenHolder} from "@bao/TokenHolder.sol"; import {IAutoCompounder} from "@harbor/interfaces/IAutoCompounder.sol"; import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; -import {IMultipleRewardAccumulator_v3} from "@harbor/interfaces/IMultipleRewardAccumulator_v3.sol"; import {IMinter} from "@harbor/interfaces/IMinter.sol"; import {IMinter_v3} from "@harbor/interfaces/IMinter_v3.sol"; +import {IWrappedPriceOracle} from "@harbor/interfaces/IWrappedPriceOracle.sol"; +import {IYieldManager} from "@harbor/interfaces/IYieldManager.sol"; import {ERC20MetadataLib_v1} from "@harbor/util/ERC20MetadataLib_v1.sol"; /// @title AutoCompounder_v1 /// @notice Level 1 auto-compounder: non-rebasing ERC4626 vault wrapping a rebasing stability pool position. /// @dev The ERC4626 asset is the SP token (rebasing ERC20). Share count is fixed on deposit; share price /// moves as totalAssets changes from harvest rewards, compounding, and rebalance losses. -/// compound() claims wrapped collateral rewards, mints pegged tokens via the Minter (fee-capped), -/// and redeposits to the SP. +/// compound() claims all wrapped collateral rewards, mints pegged tokens via the Minter (fee-capped), +/// and redeposits to the SP. Any residual wCOLn that cannot be profitably minted (fee too high or +/// minPegged not met) is routed to YIELD_MANAGER.distribute() if a yield manager is registered. /// totalAssets() includes the SP position plus unclaimed wrapped collateral valued via Minter dry run. /// Works for both collateral and leveraged stability pools. // solhint-disable-next-line contract-name-capwords @@ -51,25 +53,34 @@ contract AutoCompounder_v1 is /// @dev Thrown when depositPeggedToken receives zero shares. error DepositPeggedTokenZeroShares(); + /// @dev Thrown when neither a yield manager nor a local maxFeeRatio is provided. + /// Exactly one must be set: a yield manager that supplies mintMaxFeeRatio() dynamically, + /// or a non-zero maxFeeRatio constant for standalone use. + error MaxFeeRatioSourceRequired(); + + /// @dev Thrown when both a yield manager and a non-zero maxFeeRatio are provided. + /// Exactly one must be set: yield manager (dynamic) xor maxFeeRatio constant (standalone). + error MaxFeeRatioSourceConflict(); + /*////////////////////////////////////////////////////////////////////////// EVENTS //////////////////////////////////////////////////////////////////////////*/ /// @notice Emitted on every compound() call. /// @param caller The address that triggered the compound. - /// @param claimableCollateral The total amount of wrapped collateral available before compound. - /// @param collateralClaimed The amount of wrapped collateral claimed (0 if skipped due to fees). - /// @param peggedMinted The amount of pegged tokens minted (0 if skipped due to fees). - event Compounded( - address indexed caller, - uint256 claimableCollateral, - uint256 collateralClaimed, - uint256 peggedMinted - ); - - /// @notice Emitted when the max fee ratio is updated. - /// @param newMaxFeeRatio The new max fee ratio (18 decimals). - event MaxFeeRatioUpdated(uint256 newMaxFeeRatio); + /// @param collateralTotal Total wrapped collateral processed (claimed from SP + any pre-existing balance). + /// @param peggedMinted Amount of pegged tokens minted and redeposited to the SP (0 if minting failed/skipped). + /// @param residual Amount of wrapped collateral routed to YIELD_MANAGER.distribute() (0 if none). + event Compounded(address indexed caller, uint256 collateralTotal, uint256 peggedMinted, uint256 residual); + + /*////////////////////////////////////////////////////////////////////////// + CONSTANTS + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice Upper-bound gas estimate for a compound() execution, used to compute the + /// Yearn-style minimum pegged output floor. Sized conservatively at 500k to + /// cover Minter mintPeggedToken (~125k), SP claim and deposit, and overhead. + uint256 private constant MAX_COMPOUND_GAS = 500_000; /*////////////////////////////////////////////////////////////////////////// IMMUTABLES @@ -91,6 +102,28 @@ contract AutoCompounder_v1 is /// @custom:oz-upgrades-unsafe-allow state-variable-immutable address public immutable PEGGED_TOKEN; // solhint-disable-line immutable-vars-naming + /// @notice The yield manager (HarborYield) this AC is registered in. + /// Mutually exclusive with MAX_FEE_RATIO: exactly one of YIELD_MANAGER or MAX_FEE_RATIO + /// must be non-zero (enforced in constructor). + /// When set: compound() reads mintMaxFeeRatio() from here (portfolio-wide policy), routes + /// residual wCOLn via distribute(), and calls snapshotPerformance() after each compound. + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + address public immutable YIELD_MANAGER; // solhint-disable-line immutable-vars-naming + + /// @notice Maximum Minter fee ratio for standalone ACs (18 decimals, e.g. 0.05 ether = 5%). + /// Mutually exclusive with YIELD_MANAGER: exactly one must be non-zero. + /// Set at construction; linked to the Minter's fee tier configuration. + /// Zero when YIELD_MANAGER is set — mintMaxFeeRatio() is read from there instead. + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + uint256 public immutable MAX_FEE_RATIO; // solhint-disable-line immutable-vars-naming + + /// @notice Oracle providing the peg reference asset price in ETH (IWrappedPriceOracle). + /// Required: used every compound() to compute the Yearn-style gas floor: + /// minPeggedOut = block.basefee × MAX_COMPOUND_GAS × maxUnderlyingPrice / 1e18 + /// For the haETH peg a trivial constant oracle returning 1e18 is sufficient. + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + address public immutable PEG_ORACLE; // solhint-disable-line immutable-vars-naming + /// @dev ERC20 name stored as two bytes32 (up to 64 characters) /// @custom:oz-upgrades-unsafe-allow state-variable-immutable bytes32 private immutable _ERC20_NAME_0; @@ -101,36 +134,34 @@ contract AutoCompounder_v1 is /// @custom:oz-upgrades-unsafe-allow state-variable-immutable bytes32 private immutable _ERC20_SYMBOL; - /*////////////////////////////////////////////////////////////////////////// - STORAGE (ERC7201) - //////////////////////////////////////////////////////////////////////////*/ - - /// @custom:storage-location erc7201:harbor.storage.AutoCompounder_v1 - // chisel eval 'keccak256(abi.encode(uint256(keccak256("harbor.storage.AutoCompounder_v1")) - 1)) & ~bytes32(uint256(0xff))' - bytes32 private constant _AUTOCOMPOUNDER_STORAGE = - 0xaf31db2275af9d19e1d0340c8cbd037595d3c8505dede7df4950b3c168f87300; - - struct AutoCompounderStorage { - /// @dev Maximum fee ratio for compound minting (18 decimals). e.g. 0.05 ether = 5%. - uint256 maxFeeRatio; - } - - function _getAutoCompounderStorage() private pure returns (AutoCompounderStorage storage $) { - // solhint-disable-next-line no-inline-assembly - assembly { - $.slot := _AUTOCOMPOUNDER_STORAGE - } - } - /*////////////////////////////////////////////////////////////////////////// CONSTRUCTOR / INITIALIZER //////////////////////////////////////////////////////////////////////////*/ /// @custom:oz-upgrades-unsafe-allow constructor - constructor(address stabilityPool_, address minter_, string memory name_, string memory symbol_) { + /// @param yieldManager_ HarborYield address, or address(0) for standalone. Mutually exclusive with maxFeeRatio_. + /// @param maxFeeRatio_ Local fee cap (18 dec), or 0 when yieldManager_ is set. Mutually exclusive with yieldManager_. + /// @param pegOracle_ Required IWrappedPriceOracle for the gas floor calculation. + constructor( + address stabilityPool_, + address minter_, + address yieldManager_, + uint256 maxFeeRatio_, + address pegOracle_, + string memory name_, + string memory symbol_ + ) { _disableInitializers(); Token.ensureNonZeroAddress(stabilityPool_); Token.ensureNonZeroAddress(minter_); + Token.ensureNonZeroAddress(pegOracle_); + // Exactly one of {yieldManager, maxFeeRatio} must be set. + if (yieldManager_ == address(0) && maxFeeRatio_ == 0) { + revert MaxFeeRatioSourceRequired(); + } + if (yieldManager_ != address(0) && maxFeeRatio_ != 0) { + revert MaxFeeRatioSourceConflict(); + } // slither-disable-next-line missing-zero-check STABILITY_POOL = stabilityPool_; // slither-disable-next-line missing-zero-check @@ -138,6 +169,11 @@ contract AutoCompounder_v1 is WRAPPED_COLLATERAL = IMinter(minter_).WRAPPED_COLLATERAL_TOKEN(); PEGGED_TOKEN = IMinter(minter_).PEGGED_TOKEN(); assert(IStabilityPool(stabilityPool_).ASSET_TOKEN() == PEGGED_TOKEN); + // slither-disable-next-line missing-zero-check + YIELD_MANAGER = yieldManager_; + MAX_FEE_RATIO = maxFeeRatio_; + // slither-disable-next-line missing-zero-check + PEG_ORACLE = pegOracle_; (_ERC20_NAME_0, _ERC20_NAME_1) = ERC20MetadataLib_v1.packName(name_); _ERC20_SYMBOL = ERC20MetadataLib_v1.packSymbol(symbol_); } @@ -161,18 +197,6 @@ contract AutoCompounder_v1 is ADMIN //////////////////////////////////////////////////////////////////////////*/ - /// @notice Update the maximum fee ratio for compound minting. - /// @param maxFeeRatio_ New max fee ratio (18 decimals). e.g. 0.05 ether = 5%. - function setMaxFeeRatio(uint256 maxFeeRatio_) external onlyOwner { - _getAutoCompounderStorage().maxFeeRatio = maxFeeRatio_; - emit MaxFeeRatioUpdated(maxFeeRatio_); - } - - /// @notice The current maximum fee ratio for compound minting. - function maxFeeRatio() external view returns (uint256) { - return _getAutoCompounderStorage().maxFeeRatio; - } - /// @notice Set permanent token approvals for the compound flow. /// @dev Called by the deployer after proxy creation. Approves the SP to spend pegged tokens /// and the Minter to spend wrapped collateral. @@ -181,6 +205,16 @@ contract AutoCompounder_v1 is IERC20(WRAPPED_COLLATERAL).forceApprove(MINTER, type(uint256).max); } + /// @notice The effective maximum fee ratio for compound minting. + /// For yield-manager ACs reads dynamically from YIELD_MANAGER (portfolio-wide policy). + /// For standalone ACs returns the immutable MAX_FEE_RATIO set at construction. + function mintMaxFeeRatio() external view returns (uint256) { + if (YIELD_MANAGER != address(0)) { + return IYieldManager(YIELD_MANAGER).mintMaxFeeRatio(); + } + return MAX_FEE_RATIO; + } + /*////////////////////////////////////////////////////////////////////////// ERC20 / ERC4626 METADATA //////////////////////////////////////////////////////////////////////////*/ @@ -233,41 +267,58 @@ contract AutoCompounder_v1 is /// @inheritdoc IAutoCompounder function compound() external nonReentrant { - uint256 claimable = IMultipleRewardAccumulator(STABILITY_POOL).claimable(address(this), WRAPPED_COLLATERAL); - if (claimable == 0) { + // Claim all active reward tokens from SP to this contract (includes WRAPPED_COLLATERAL). + IMultipleRewardAccumulator(STABILITY_POOL).claim(); + + // Total available: just claimed + any pre-existing balance (e.g. residual from a prior standalone compound). + uint256 total = IERC20(WRAPPED_COLLATERAL).balanceOf(address(this)); + if (total == 0) { revert NothingToCompound(); } - uint256 maxFee = _getAutoCompounderStorage().maxFeeRatio; - - // Dry run to see how much can be profitably minted within the fee cap + // Yearn-style gas floor: skip minting when gas cost exceeds the pegged output value. + // minPeggedOut = block.basefee × MAX_COMPOUND_GAS × (peg units per ETH) / 1e18 + // Use maxUnderlyingPrice (conservative): higher price → higher floor → fewer unprofitable calls. // slither-disable-next-line unused-return - (, , uint256 collateralTaken, , , ) = IMinter_v3(MINTER).mintPeggedTokenDryRun(claimable, maxFee); - - if (collateralTaken == 0) { - // Fee too high - skip. Wrapped collateral stays as unclaimed in SP, - // included in totalAssets via claimable(). - emit Compounded(msg.sender, claimable, 0, 0); - return; + (, uint256 maxPegPerEth, ,) = IWrappedPriceOracle(PEG_ORACLE).latestAnswer(); + uint256 minPegged = Math.mulDiv(block.basefee, MAX_COMPOUND_GAS * maxPegPerEth, 1e18); + + // Fee cap: yield manager knows the opportunity cost of alternative DEX paths; + // standalone ACs use the immutable set at construction. + uint256 maxFee = YIELD_MANAGER != address(0) + ? IYieldManager(YIELD_MANAGER).mintMaxFeeRatio() + : MAX_FEE_RATIO; + + // Mint pegged tokens from claimed collateral. mintPeggedToken reverts if minPegged cannot + // be met, or returns (0, 0) if the fee exceeds maxFee (when minPegged == 0, but here + // minPegged > 0 so any failure reverts). Catch all failures and route wCOLn instead. + uint256 peggedMinted; + uint256 residual; + try IMinter_v3(MINTER).mintPeggedToken(total, address(this), minPegged, maxFee) + returns (uint256 peggedOut, uint256 collateralUsed) { + if (peggedOut > 0) { + // Deposit minted pegged tokens back into the SP. + // slither-disable-next-line unused-return + IStabilityPool(STABILITY_POOL).deposit(peggedOut, address(this), 0); + peggedMinted = peggedOut; + } + residual = total - collateralUsed; + } catch { + residual = total; } - // Fractional claim: only take what can be profitably minted - IMultipleRewardAccumulator_v3(STABILITY_POOL).claim( - address(this), - address(this), - WRAPPED_COLLATERAL, - collateralTaken - ); - - // Mint pegged tokens from the claimed collateral - // slither-disable-next-line unused-return - (uint256 minted, ) = IMinter_v3(MINTER).mintPeggedToken(collateralTaken, address(this), 0, maxFee); + // Route residual wCOLn to the yield manager for alternative conversion. + if (residual > 0 && YIELD_MANAGER != address(0)) { + IERC20(WRAPPED_COLLATERAL).safeTransfer(YIELD_MANAGER, residual); + IYieldManager(YIELD_MANAGER).distribute(WRAPPED_COLLATERAL, residual); + } - // Deposit minted pegged tokens back into the SP - // slither-disable-next-line unused-return - IStabilityPool(STABILITY_POOL).deposit(minted, address(this), 0); + // Ask the yield manager to snapshot all vault rates now that state has changed. + if (YIELD_MANAGER != address(0)) { + IYieldManager(YIELD_MANAGER).snapshotPerformance(); + } - emit Compounded(msg.sender, claimable, collateralTaken, minted); + emit Compounded(msg.sender, total, peggedMinted, residual); } /*////////////////////////////////////////////////////////////////////////// diff --git a/src/interfaces/IYieldManager.sol b/src/interfaces/IYieldManager.sol new file mode 100644 index 00000000..337996c2 --- /dev/null +++ b/src/interfaces/IYieldManager.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.28 <0.9.0; + +/// @title IYieldManager +/// @notice Interface an AutoCompounder calls on its registered yield manager (HarborYield). +/// @dev AutoCompounder reads mintMaxFeeRatio() to cap minting fees, routes residual wCOLn via +/// distribute(), and triggers a performance snapshot after each compound. +interface IYieldManager { + /// @notice Maximum Minter fee ratio the AC should accept for haXXX minting (18 decimals). + /// @dev Computed by HarborYield from its knowledge of alternative conversion paths. + /// When the Minter fee exceeds this threshold it is cheaper to route wCOLn to + /// distribute() for a DEX swap into an equivalent vault. + /// Standalone ACs (YIELD_MANAGER == address(0)) use their own storage value instead. + function mintMaxFeeRatio() external view returns (uint256); + + /// @notice Receive residual wrapped collateral from an AC and route it to the most under-weight vault. + /// @dev The AC must transfer `amount` of `token` to this contract before calling. + /// Only callable by a registered AutoCompounder vault. + /// @param token The wrapped collateral token transferred. + /// @param amount The amount transferred. + function distribute(address token, uint256 amount) external; + + /// @notice Snapshot the current `convertToAssets(1e18)` rate for every registered vault. + /// @dev Permissionless. Stores `(timestamp, rate)` in a per-vault ring buffer. + /// Called by AC.compound() after state has changed — the natural trigger. + function snapshotPerformance() external; +} diff --git a/test/deployment/AutoCompounderTest.t.sol b/test/deployment/AutoCompounderTest.t.sol index 63cce969..3a4faccb 100644 --- a/test/deployment/AutoCompounderTest.t.sol +++ b/test/deployment/AutoCompounderTest.t.sol @@ -6,6 +6,7 @@ import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; import {IAutoCompounder} from "src/interfaces/IAutoCompounder.sol"; +import {IMinter} from "src/interfaces/IMinter.sol"; import {AutoCompounder_v1} from "src/autocompounding/AutoCompounder_v1.sol"; import {DeployEURSetUp} from "test/deployment/DeployEURSetUp.t.sol"; import {PermitTestBase} from "@bao-test/helpers/PermitTestBase.t.sol"; @@ -44,9 +45,9 @@ contract AutoCompounderTest is DeployEURSetUp, PermitTestBase { assertEq(IERC4626(acCollStETH).decimals(), 18); } - function test_deployment_maxFeeRatio() public view { - assertEq(AutoCompounder_v1(acCollFxUSD).maxFeeRatio(), 0.05 ether, "fxUSD AC maxFeeRatio"); - assertEq(AutoCompounder_v1(acCollStETH).maxFeeRatio(), 0.05 ether, "stETH AC maxFeeRatio"); + function test_deployment_mintMaxFeeRatio() public view { + assertEq(AutoCompounder_v1(acCollFxUSD).mintMaxFeeRatio(), 0.05 ether, "fxUSD AC mintMaxFeeRatio"); + assertEq(AutoCompounder_v1(acCollStETH).mintMaxFeeRatio(), 0.05 ether, "stETH AC mintMaxFeeRatio"); } function test_deployment_asset() public view { @@ -212,9 +213,9 @@ contract AutoCompounderTest is DeployEURSetUp, PermitTestBase { assertApproxEqRel(totalAssetsAfter, totalAssetsWithRewards, 0.05 ether, "totalAssets preserved"); } - // ── Compound: fee too high -> skip ────────────────────────────────── + // ── Compound: fee too high -> claims but does not mint ────────────── - function test_compound_feeTooHigh_skips() public { + function test_compound_feeTooHigh_claimsButDoesNotMint() public { // Setup: healthy CR _setupHealthyMarket(minterFxUSD, spCollFxUSD, alice, 100 ether, 100 ether); uint256 spBal = IERC20(spCollFxUSD).balanceOf(alice); @@ -227,22 +228,40 @@ contract AutoCompounderTest is DeployEURSetUp, PermitTestBase { _depositReward(spCollFxUSD, wrappedCollateralFxUSD, wrappedCollateralFxUSD, 5 ether); skip(2 weeks); - // Set maxFeeRatio to 0 - nothing should be profitable + // Push Minter fees above MAX_FEE_RATIO. + // Minter config requires a disallow sentinel (1e18) at index 0 (depeg band). The test market's CR (~200%) + // is below the band upper bound (1000%), so incentiveRatios[0] = 1e18 (disallow) applies. + // 1e18 fee >> MAX_FEE_RATIO (0.05e18) → mintPeggedToken returns (0, 0) → compound() routes as residual. + IMinter.IncentiveConfig memory highFeeConfig = IMinter.IncentiveConfig({ + collateralRatioBandUpperBounds: new uint256[](1), + incentiveRatios: new int256[](2) + }); + highFeeConfig.collateralRatioBandUpperBounds[0] = 10e18; // band upper bound at 1000% CR + highFeeConfig.incentiveRatios[0] = 1e18; // disallow below band (depeg sentinel, valid at index 0) + highFeeConfig.incentiveRatios[1] = 0.1e18; // 10% fee above band (unreachable given test CR) + + IMinter.Config memory highFeeFullConfig = IMinter.Config({ + mintPeggedIncentiveConfig: highFeeConfig, + redeemPeggedIncentiveConfig: IMinter(minterFxUSD).config().redeemPeggedIncentiveConfig, + mintLeveragedIncentiveConfig: IMinter(minterFxUSD).config().mintLeveragedIncentiveConfig, + redeemLeveragedIncentiveConfig: IMinter(minterFxUSD).config().redeemLeveragedIncentiveConfig + }); vm.prank(HARBOR_MULTISIG); - AutoCompounder_v1(acCollFxUSD).setMaxFeeRatio(0); + IMinter(minterFxUSD).updateConfig(highFeeFullConfig); - uint256 claimableBefore = IMultipleRewardAccumulator(spCollFxUSD).claimable( - acCollFxUSD, - wrappedCollateralFxUSD - ); - assertGt(claimableBefore, 0, "rewards exist"); + // Track the raw SP token balance (not totalAssets — that includes claimable which drops to 0 after claim()) + uint256 spBalanceBefore = IERC20(spCollFxUSD).balanceOf(acCollFxUSD); - // Compound should skip (not revert) + // Compound should not revert, but should not mint (fee too high → try/catch skips minting) IAutoCompounder(acCollFxUSD).compound(); - // Claimable unchanged - nothing was claimed + // SP balance unchanged — no new haXXX deposited to the SP + uint256 spBalanceAfter = IERC20(spCollFxUSD).balanceOf(acCollFxUSD); + assertEq(spBalanceAfter, spBalanceBefore, "SP balance unchanged: minting skipped"); + + // Claimable is now 0 — claim() always runs in compound() uint256 claimableAfter = IMultipleRewardAccumulator(spCollFxUSD).claimable(acCollFxUSD, wrappedCollateralFxUSD); - assertEq(claimableAfter, claimableBefore, "claimable unchanged - compound skipped"); + assertEq(claimableAfter, 0, "all rewards claimed from SP"); } // ── Compound: nothing to compound -> revert ───────────────────────── diff --git a/test/deployment/DeployEURSetUp.t.sol b/test/deployment/DeployEURSetUp.t.sol index a541db08..310c27f4 100644 --- a/test/deployment/DeployEURSetUp.t.sol +++ b/test/deployment/DeployEURSetUp.t.sol @@ -13,6 +13,7 @@ import {IMinter} from "src/interfaces/IMinter.sol"; import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; import {MockWrappedPriceOracle} from "test/mocks/MockWrappedPriceOracle.sol"; +import {IWrappedPriceOracle} from "src/interfaces/IWrappedPriceOracle.sol"; /// @title Common deployment setup for EUR market tests. /// @dev Deploys EUR peg with two collaterals (fxUSD, stETH), each with collateral + leveraged SPs and ACs. @@ -55,6 +56,18 @@ abstract contract DeployEURSetUp is BaoTest, Deploy_EUR_Minter { IBaoFactory(factory).setOperator(address(this), 365 days); (ConfigPeg peg_, Config_MinterMarket[] memory mktConfigs) = createEURMintersConfig(); + + // Mock the peg/ETH oracle at its predicted address — deployed by harbor-price-aggregators + // in production, but not available as a harbor dependency. The address is stable (BaoFactory + // CREATE3 from salt "test_eur::EUR::ethPriceAggregator") so we mock it here before deployment. + _setSaltPrefix("test_eur"); + address pegOracle = _predictAddress(_key("EUR", "ethPriceAggregator")); + vm.mockCall( + pegOracle, + abi.encodeCall(IWrappedPriceOracle.latestAnswer, ()), + abi.encode(uint256(1e18), uint256(1e18), uint256(1e18), uint256(1e18)) + ); + deployForPeg("test_eur", peg_, mktConfigs, "mainnet", true, mktConfigs); _setSaltPrefix("test_eur"); From 327efe1ac810dfe4630ed3028c315237cc720393 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Wed, 22 Apr 2026 03:45:59 +0100 Subject: [PATCH 054/232] use @harbor* over src/, test/ and script/ in imports create Config_v2.sol to fix type confilcts due to the above --- lib/bao-base | 2 +- script/Deploy_BTC_mainnet.s.sol | 6 +- script/Deploy_ETH_mainnet.s.sol | 6 +- script/Deploy_EUR_mainnet.s.sol | 6 +- script/Deploy_GOLD_mainnet.s.sol | 6 +- script/Deploy_MCAP_mainnet.s.sol | 6 +- script/Deploy_Minter_v2_mainnet.s.sol | 16 +- script/Deploy_SILVER_mainnet.s.sol | 6 +- script/Deploy_StabilityPool_v3_mainnet.s.sol | 18 +- .../Grant_Minter_ZeroFeeRoles_mainnet.s.sol | 18 +- script/Pause_SPL_ETH_fxUSD.s.sol | 2 +- script/Remediate_Accumulators.s.sol | 20 +- script/Remediate_SPL_ETH_fxUSD.s.sol | 6 +- script/UpdateVolatility_OGPlus.s.sol | 22 +- script/UpdateVolatility_test3_SILVER.s.sol | 10 +- .../ConfigStabilityPoolManagerCommon.sol | 2 +- .../volatility/ConfigPriceVolatility_105.sol | 2 +- .../ConfigPriceVolatility_105_stable.sol | 2 +- .../volatility/ConfigPriceVolatility_115.sol | 2 +- .../ConfigPriceVolatility_115_stable.sol | 2 +- .../volatility/ConfigPriceVolatility_125.sol | 2 +- .../ConfigPriceVolatility_125_stable.sol | 2 +- .../volatility/ConfigPriceVolatility_130.sol | 2 +- .../ConfigPriceVolatility_130_stable.sol | 2 +- script/safe/SafeBatch.s.sol | 2 +- script/src/DeployMintersShared.sol | 12 +- script/src/Deploy_BTC_Minter.sol | 10 +- script/src/Deploy_ETH_Minter.sol | 8 +- script/src/Deploy_EUR_Minter.sol | 10 +- script/src/Deploy_GOLD_Minter.sol | 10 +- script/src/Deploy_MCAP_Minter.sol | 10 +- script/src/Deploy_SILVER_Minter.sol | 10 +- script/src/contracts/AutoCompounder.sol | 6 +- script/src/contracts/Genesis.sol | 2 +- script/src/contracts/LeveragedToken.sol | 6 +- script/src/contracts/Minter.sol | 4 +- script/src/contracts/PeggedToken.sol | 6 +- script/src/contracts/StabilityPool.sol | 8 +- script/src/contracts/StabilityPoolManager.sol | 2 +- .../minter-v2-upgrade/DeployMinters.t.sol | 16 +- .../MainnetForkUpgradeTest.t.sol | 6 +- .../MinterUpgradeMigration.t.sol | 8 +- .../minter-v2-upgrade/MinterUpgradeTest.t.sol | 12 +- .../minter-v2-upgrade/RebalanceCheck.t.sol | 14 +- script/verify/roles/MainnetRoles.t.sol | 4 +- .../sp-v2-upgrade/MainnetUpgradeTest.t.sol | 6 +- .../sp-v3-migration/SPv3MigrationTest.t.sol | 14 +- ...ebalanceRemediationForStabilityPool_v2.sol | 4 +- .../spl-remediation/SPLRemediationTest.t.sol | 16 +- .../spl-remediation/V2ReplaySimulation.t.sol | 16 +- src/minter/Minter_v3.sol | 40 ++-- src/minter/library/Config_v2.sol | 212 ++++++++++++++++++ test/CollateralRatio.t.sol | 10 +- test/Config.sol | 2 +- test/ERC20MetadataLib_v1.t.sol | 2 +- test/ExplainFinishAtZero.t.sol | 2 +- test/Genesis.t.sol | 8 +- test/Graph.t.sol | 2 +- test/GraphMinter.t.sol | 6 +- test/GraphReward.t.sol | 18 +- test/Graphs.t.sol | 2 +- test/GraphsBasicCalculations.t.sol | 12 +- test/GraphsFees.t.sol | 8 +- test/GraphsInvariant.t.sol | 8 +- test/GraphsLiquidate.t.sol | 16 +- test/Minter_base.t.sol | 18 +- test/Minter_feeRange.t.sol | 10 +- test/Minter_fees.t.sol | 10 +- test/Minter_harvest.t.sol | 10 +- test/Minter_liquidate.t.sol | 8 +- test/Minter_mint.t.sol | 8 +- test/Minter_mintLeveraged.t.sol | 10 +- test/Minter_mintPegged.t.sol | 8 +- test/Minter_redeemLeveraged.t.sol | 10 +- test/Minter_redeemPegged.t.sol | 8 +- test/Minter_slash.t.sol | 10 +- test/Rebalance.t.sol | 20 +- test/ReservePool.t.sol | 4 +- test/StabilityPool.t.sol | 14 +- test/StabilityPoolBaseSetUp.t.sol | 4 +- test/StabilityPoolClaimable.t.sol | 8 +- test/StabilityPoolExtras.t.sol | 4 +- test/StabilityPoolExtras2.t.sol | 10 +- test/StabilityPoolFeatures.t.sol | 8 +- test/StabilityPoolLoss.t.sol | 8 +- test/StabilityPoolManager_v1.t.sol | 20 +- test/StabilityPoolRebalance.t.sol | 10 +- test/StabilityPoolSpec.t.sol | 8 +- test/StabilityPoolUpgradeMigration.t.sol | 14 +- test/StabilityPool_v3_ERC20.t.sol | 10 +- test/TestDepositAfterFinishAtZero.t.sol | 2 +- test/TestLinearRewardFix.t.sol | 2 +- test/TestStabilityPool2SetUp.sol | 2 +- test/TokenDistributor.t.sol | 6 +- test/Useful.t.sol | 2 +- test/depeg.t.sol | 8 +- test/deployment/AutoCompounderTest.t.sol | 12 +- test/deployment/ConfigTest.t.sol | 16 +- test/deployment/DeployETHfxUSD.t.sol | 14 +- test/deployment/DeployEURSetUp.t.sol | 16 +- test/deployment/MinterCappedMint.t.sol | 12 +- test/deployment/RebalanceFairness.t.sol | 20 +- test/deployment/RebalanceFairnessScan.t.sol | 10 +- test/deployment/RewardSystem.t.sol | 18 +- test/math/DecrementalFloatingPoint.t.sol | 2 +- ...ckMultipleRewardCompoundingAccumulator.sol | 4 +- test/mocks/MockPriceOracle.sol | 2 +- test/mocks/MockSwapper.sol | 2 +- ...ultipleRewardCompoundingAccumulator_v2.sol | 2 +- ...ultipleRewardCompoundingAccumulator_v3.sol | 2 +- ...MockLinearMultipleRewardDistributor_v3.sol | 4 +- test/price/PriceOracle.t.sol | 6 +- .../reward/accumulator/ClaimEquivalence.t.sol | 6 +- ...MultipleRewardCompoundingAccumulator.t.sol | 6 +- .../LinearMultipleRewardDistributor.t.sol | 6 +- 115 files changed, 691 insertions(+), 481 deletions(-) create mode 100644 src/minter/library/Config_v2.sol diff --git a/lib/bao-base b/lib/bao-base index 3e4f346c..d9ab54f4 160000 --- a/lib/bao-base +++ b/lib/bao-base @@ -1 +1 @@ -Subproject commit 3e4f346cd5ce08fae0bc4c559f724c9e5c979399 +Subproject commit d9ab54f451dbfe4717aae2ee135aad809f96ddec diff --git a/script/Deploy_BTC_mainnet.s.sol b/script/Deploy_BTC_mainnet.s.sol index cf4f5108..ae2f690f 100644 --- a/script/Deploy_BTC_mainnet.s.sol +++ b/script/Deploy_BTC_mainnet.s.sol @@ -2,9 +2,9 @@ pragma solidity >=0.8.28 <0.9.0; import {Script} from "forge-std/Script.sol"; -import {Deploy_BTC_Minter} from "script/src/Deploy_BTC_Minter.sol"; -import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; -import {Config_MinterMarket} from "script/config/ConfigBase.sol"; +import {Deploy_BTC_Minter} from "@harbor-script/src/Deploy_BTC_Minter.sol"; +import {ConfigPeg} from "@harbor-script/config/pegs/ConfigPeg.sol"; +import {Config_MinterMarket} from "@harbor-script/config/ConfigBase.sol"; /// @notice Deploy Harbor BTC pegged token and BTC markets. contract Deploy_BTC_mainnet is Deploy_BTC_Minter, Script { diff --git a/script/Deploy_ETH_mainnet.s.sol b/script/Deploy_ETH_mainnet.s.sol index 06c5678d..5c38f86d 100644 --- a/script/Deploy_ETH_mainnet.s.sol +++ b/script/Deploy_ETH_mainnet.s.sol @@ -2,9 +2,9 @@ pragma solidity >=0.8.28 <0.9.0; import {Script} from "forge-std/Script.sol"; -import {Deploy_ETH_Minter} from "script/src/Deploy_ETH_Minter.sol"; -import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; -import {Config_MinterMarket} from "script/config/ConfigBase.sol"; +import {Deploy_ETH_Minter} from "@harbor-script/src/Deploy_ETH_Minter.sol"; +import {ConfigPeg} from "@harbor-script/config/pegs/ConfigPeg.sol"; +import {Config_MinterMarket} from "@harbor-script/config/ConfigBase.sol"; /// @notice Deploy Harbor ETH pegged token and ETH markets. contract Deploy_ETH_mainnet is Deploy_ETH_Minter, Script { diff --git a/script/Deploy_EUR_mainnet.s.sol b/script/Deploy_EUR_mainnet.s.sol index a6d708ec..b8874f72 100644 --- a/script/Deploy_EUR_mainnet.s.sol +++ b/script/Deploy_EUR_mainnet.s.sol @@ -2,9 +2,9 @@ pragma solidity >=0.8.28 <0.9.0; import {Script} from "forge-std/Script.sol"; -import {Deploy_EUR_Minter} from "script/src/Deploy_EUR_Minter.sol"; -import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; -import {Config_MinterMarket} from "script/config/ConfigBase.sol"; +import {Deploy_EUR_Minter} from "@harbor-script/src/Deploy_EUR_Minter.sol"; +import {ConfigPeg} from "@harbor-script/config/pegs/ConfigPeg.sol"; +import {Config_MinterMarket} from "@harbor-script/config/ConfigBase.sol"; /// @notice Deploy Harbor EUR pegged token and EUR markets. contract Deploy_EUR_mainnet is Deploy_EUR_Minter, Script { diff --git a/script/Deploy_GOLD_mainnet.s.sol b/script/Deploy_GOLD_mainnet.s.sol index 2c86b64c..538dd2c0 100644 --- a/script/Deploy_GOLD_mainnet.s.sol +++ b/script/Deploy_GOLD_mainnet.s.sol @@ -2,9 +2,9 @@ pragma solidity >=0.8.28 <0.9.0; import {Script} from "forge-std/Script.sol"; -import {Deploy_GOLD_Minter} from "script/src/Deploy_GOLD_Minter.sol"; -import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; -import {Config_MinterMarket} from "script/config/ConfigBase.sol"; +import {Deploy_GOLD_Minter} from "@harbor-script/src/Deploy_GOLD_Minter.sol"; +import {ConfigPeg} from "@harbor-script/config/pegs/ConfigPeg.sol"; +import {Config_MinterMarket} from "@harbor-script/config/ConfigBase.sol"; /// @notice Deploy Harbor GOLD pegged token and GOLD markets. contract Deploy_GOLD_mainnet is Deploy_GOLD_Minter, Script { diff --git a/script/Deploy_MCAP_mainnet.s.sol b/script/Deploy_MCAP_mainnet.s.sol index 661431cd..771b2a93 100644 --- a/script/Deploy_MCAP_mainnet.s.sol +++ b/script/Deploy_MCAP_mainnet.s.sol @@ -2,9 +2,9 @@ pragma solidity >=0.8.28 <0.9.0; import {Script} from "forge-std/Script.sol"; -import {Deploy_MCAP_Minter} from "script/src/Deploy_MCAP_Minter.sol"; -import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; -import {Config_MinterMarket} from "script/config/ConfigBase.sol"; +import {Deploy_MCAP_Minter} from "@harbor-script/src/Deploy_MCAP_Minter.sol"; +import {ConfigPeg} from "@harbor-script/config/pegs/ConfigPeg.sol"; +import {Config_MinterMarket} from "@harbor-script/config/ConfigBase.sol"; /// @notice Deploy Harbor MCAP pegged token and MCAP markets. contract Deploy_MCAP_mainnet is Deploy_MCAP_Minter, Script { diff --git a/script/Deploy_Minter_v2_mainnet.s.sol b/script/Deploy_Minter_v2_mainnet.s.sol index ce963535..5772dcdc 100644 --- a/script/Deploy_Minter_v2_mainnet.s.sol +++ b/script/Deploy_Minter_v2_mainnet.s.sol @@ -6,17 +6,17 @@ import {console2 as console} from "forge-std/console2.sol"; import {LibString} from "@solady/utils/LibString.sol"; import {DeploymentState} from "@bao-script/deployment/DeploymentState.sol"; import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; -import {Config_MinterMarket, IMarketConfig, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; +import {Config_MinterMarket, IMarketConfig, MinterMarketConfigLib} from "@harbor-script/config/ConfigBase.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; -import {Deploy_BTC_Minter} from "script/src/Deploy_BTC_Minter.sol"; -import {Deploy_ETH_Minter} from "script/src/Deploy_ETH_Minter.sol"; -import {Deploy_EUR_Minter} from "script/src/Deploy_EUR_Minter.sol"; -import {Deploy_GOLD_Minter} from "script/src/Deploy_GOLD_Minter.sol"; -import {Deploy_MCAP_Minter} from "script/src/Deploy_MCAP_Minter.sol"; -import {Deploy_SILVER_Minter} from "script/src/Deploy_SILVER_Minter.sol"; +import {Deploy_BTC_Minter} from "@harbor-script/src/Deploy_BTC_Minter.sol"; +import {Deploy_ETH_Minter} from "@harbor-script/src/Deploy_ETH_Minter.sol"; +import {Deploy_EUR_Minter} from "@harbor-script/src/Deploy_EUR_Minter.sol"; +import {Deploy_GOLD_Minter} from "@harbor-script/src/Deploy_GOLD_Minter.sol"; +import {Deploy_MCAP_Minter} from "@harbor-script/src/Deploy_MCAP_Minter.sol"; +import {Deploy_SILVER_Minter} from "@harbor-script/src/Deploy_SILVER_Minter.sol"; -import {SafeBatch} from "script/safe/SafeBatch.s.sol"; +import {SafeBatch} from "@harbor-script/safe/SafeBatch.s.sol"; import {console2} from "forge-std/console2.sol"; diff --git a/script/Deploy_SILVER_mainnet.s.sol b/script/Deploy_SILVER_mainnet.s.sol index f92291ad..915aedf3 100644 --- a/script/Deploy_SILVER_mainnet.s.sol +++ b/script/Deploy_SILVER_mainnet.s.sol @@ -2,9 +2,9 @@ pragma solidity >=0.8.28 <0.9.0; import {Script} from "forge-std/Script.sol"; -import {Deploy_SILVER_Minter} from "script/src/Deploy_SILVER_Minter.sol"; -import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; -import {Config_MinterMarket} from "script/config/ConfigBase.sol"; +import {Deploy_SILVER_Minter} from "@harbor-script/src/Deploy_SILVER_Minter.sol"; +import {ConfigPeg} from "@harbor-script/config/pegs/ConfigPeg.sol"; +import {Config_MinterMarket} from "@harbor-script/config/ConfigBase.sol"; /// @notice Deploy Harbor SILVER pegged token and SILVER markets. contract Deploy_SILVER_mainnet is Deploy_SILVER_Minter, Script { diff --git a/script/Deploy_StabilityPool_v3_mainnet.s.sol b/script/Deploy_StabilityPool_v3_mainnet.s.sol index 8f1d7cf9..16849de6 100644 --- a/script/Deploy_StabilityPool_v3_mainnet.s.sol +++ b/script/Deploy_StabilityPool_v3_mainnet.s.sol @@ -6,18 +6,18 @@ import {console2 as console} from "forge-std/console2.sol"; import {LibString} from "@solady/utils/LibString.sol"; import {DeploymentState} from "@bao-script/deployment/DeploymentState.sol"; import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; -import {Config_MinterMarket, IMarketConfig, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; +import {Config_MinterMarket, IMarketConfig, MinterMarketConfigLib} from "@harbor-script/config/ConfigBase.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; -import {StabilityPool} from "script/src/contracts/StabilityPool.sol"; +import {StabilityPool} from "@harbor-script/src/contracts/StabilityPool.sol"; -import {Deploy_BTC_Minter} from "script/src/Deploy_BTC_Minter.sol"; -import {Deploy_ETH_Minter} from "script/src/Deploy_ETH_Minter.sol"; -import {Deploy_EUR_Minter} from "script/src/Deploy_EUR_Minter.sol"; -import {Deploy_GOLD_Minter} from "script/src/Deploy_GOLD_Minter.sol"; -import {Deploy_MCAP_Minter} from "script/src/Deploy_MCAP_Minter.sol"; -import {Deploy_SILVER_Minter} from "script/src/Deploy_SILVER_Minter.sol"; +import {Deploy_BTC_Minter} from "@harbor-script/src/Deploy_BTC_Minter.sol"; +import {Deploy_ETH_Minter} from "@harbor-script/src/Deploy_ETH_Minter.sol"; +import {Deploy_EUR_Minter} from "@harbor-script/src/Deploy_EUR_Minter.sol"; +import {Deploy_GOLD_Minter} from "@harbor-script/src/Deploy_GOLD_Minter.sol"; +import {Deploy_MCAP_Minter} from "@harbor-script/src/Deploy_MCAP_Minter.sol"; +import {Deploy_SILVER_Minter} from "@harbor-script/src/Deploy_SILVER_Minter.sol"; -import {SafeBatch} from "script/safe/SafeBatch.s.sol"; +import {SafeBatch} from "@harbor-script/safe/SafeBatch.s.sol"; interface IFullMinterConfig { function wrappedCollateralToken() external view returns (address); diff --git a/script/Grant_Minter_ZeroFeeRoles_mainnet.s.sol b/script/Grant_Minter_ZeroFeeRoles_mainnet.s.sol index 288a8780..1b9df89e 100644 --- a/script/Grant_Minter_ZeroFeeRoles_mainnet.s.sol +++ b/script/Grant_Minter_ZeroFeeRoles_mainnet.s.sol @@ -4,18 +4,18 @@ pragma solidity >=0.8.28 <0.9.0; import {console2 as console} from "forge-std/console2.sol"; import {LibString} from "@solady/utils/LibString.sol"; -import {Config_MinterMarket, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; +import {Config_MinterMarket, MinterMarketConfigLib} from "@harbor-script/config/ConfigBase.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; -import {IMinter} from "src/interfaces/IMinter.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; -import {Deploy_BTC_Minter} from "script/src/Deploy_BTC_Minter.sol"; -import {Deploy_ETH_Minter} from "script/src/Deploy_ETH_Minter.sol"; -import {Deploy_EUR_Minter} from "script/src/Deploy_EUR_Minter.sol"; -import {Deploy_GOLD_Minter} from "script/src/Deploy_GOLD_Minter.sol"; -import {Deploy_MCAP_Minter} from "script/src/Deploy_MCAP_Minter.sol"; -import {Deploy_SILVER_Minter} from "script/src/Deploy_SILVER_Minter.sol"; +import {Deploy_BTC_Minter} from "@harbor-script/src/Deploy_BTC_Minter.sol"; +import {Deploy_ETH_Minter} from "@harbor-script/src/Deploy_ETH_Minter.sol"; +import {Deploy_EUR_Minter} from "@harbor-script/src/Deploy_EUR_Minter.sol"; +import {Deploy_GOLD_Minter} from "@harbor-script/src/Deploy_GOLD_Minter.sol"; +import {Deploy_MCAP_Minter} from "@harbor-script/src/Deploy_MCAP_Minter.sol"; +import {Deploy_SILVER_Minter} from "@harbor-script/src/Deploy_SILVER_Minter.sol"; -import {SafeBatch} from "script/safe/SafeBatch.s.sol"; +import {SafeBatch} from "@harbor-script/safe/SafeBatch.s.sol"; /// @notice Grant ZERO_FEE_ROLE to all StabilityPoolManagers on their minters. /// @dev This role was missing from the initial deployment. Queue as a separate Safe batch. diff --git a/script/Pause_SPL_ETH_fxUSD.s.sol b/script/Pause_SPL_ETH_fxUSD.s.sol index 393f9068..fd7b1303 100644 --- a/script/Pause_SPL_ETH_fxUSD.s.sol +++ b/script/Pause_SPL_ETH_fxUSD.s.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; -import {SafeBatch} from "script/safe/SafeBatch.s.sol"; +import {SafeBatch} from "@harbor-script/safe/SafeBatch.s.sol"; /// @notice Queue a Safe transaction to pause the ETH::fxUSD stabilityPoolLeveraged /// by upgrading its proxy to BaoPauser_v1. diff --git a/script/Remediate_Accumulators.s.sol b/script/Remediate_Accumulators.s.sol index 0f659702..01d1af62 100644 --- a/script/Remediate_Accumulators.s.sol +++ b/script/Remediate_Accumulators.s.sol @@ -4,19 +4,19 @@ pragma solidity >=0.8.28 <0.9.0; import {console2 as console} from "forge-std/console2.sol"; import {LibString} from "@solady/utils/LibString.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; -import {Config_MinterMarket, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; +import {Config_MinterMarket, MinterMarketConfigLib} from "@harbor-script/config/ConfigBase.sol"; -import {ForceMigrateAccumulator_v1} from "script/verify/sp-v3-migration/ForceMigrateAccumulator_v1.sol"; -import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; +import {ForceMigrateAccumulator_v1} from "@harbor-script/verify/sp-v3-migration/ForceMigrateAccumulator_v1.sol"; +import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; -import {Deploy_BTC_Minter} from "script/src/Deploy_BTC_Minter.sol"; -import {Deploy_ETH_Minter} from "script/src/Deploy_ETH_Minter.sol"; -import {Deploy_EUR_Minter} from "script/src/Deploy_EUR_Minter.sol"; -import {Deploy_GOLD_Minter} from "script/src/Deploy_GOLD_Minter.sol"; -import {Deploy_MCAP_Minter} from "script/src/Deploy_MCAP_Minter.sol"; -import {Deploy_SILVER_Minter} from "script/src/Deploy_SILVER_Minter.sol"; +import {Deploy_BTC_Minter} from "@harbor-script/src/Deploy_BTC_Minter.sol"; +import {Deploy_ETH_Minter} from "@harbor-script/src/Deploy_ETH_Minter.sol"; +import {Deploy_EUR_Minter} from "@harbor-script/src/Deploy_EUR_Minter.sol"; +import {Deploy_GOLD_Minter} from "@harbor-script/src/Deploy_GOLD_Minter.sol"; +import {Deploy_MCAP_Minter} from "@harbor-script/src/Deploy_MCAP_Minter.sol"; +import {Deploy_SILVER_Minter} from "@harbor-script/src/Deploy_SILVER_Minter.sol"; -import {SafeBatch} from "script/safe/SafeBatch.s.sol"; +import {SafeBatch} from "@harbor-script/safe/SafeBatch.s.sol"; /// @notice Force-migrate accumulator storage from V1 (uint192) to V2 (uint256) format /// for all stability pools across all markets. diff --git a/script/Remediate_SPL_ETH_fxUSD.s.sol b/script/Remediate_SPL_ETH_fxUSD.s.sol index de7366f3..27b0c977 100644 --- a/script/Remediate_SPL_ETH_fxUSD.s.sol +++ b/script/Remediate_SPL_ETH_fxUSD.s.sol @@ -3,12 +3,12 @@ pragma solidity >=0.8.28 <0.9.0; import {LibString} from "@solady/utils/LibString.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; -import {PostRebalanceRemediationForStabilityPool_v2} from "script/verify/spl-remediation/PostRebalanceRemediationForStabilityPool_v2.sol"; -import {SafeBatch} from "script/safe/SafeBatch.s.sol"; +import {PostRebalanceRemediationForStabilityPool_v2} from "@harbor-script/verify/spl-remediation/PostRebalanceRemediationForStabilityPool_v2.sol"; +import {SafeBatch} from "@harbor-script/safe/SafeBatch.s.sol"; import {WellKnownAddress} from "@bao-script/deployment/FactoryDeployer.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {IMinter} from "src/interfaces/IMinter.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; import {IBurnableRole} from "@bao/interfaces/IBurnableRole.sol"; interface Ownable { diff --git a/script/UpdateVolatility_OGPlus.s.sol b/script/UpdateVolatility_OGPlus.s.sol index e177bc70..2de91f20 100644 --- a/script/UpdateVolatility_OGPlus.s.sol +++ b/script/UpdateVolatility_OGPlus.s.sol @@ -3,17 +3,17 @@ pragma solidity >=0.8.28 <0.9.0; import {console2 as console} from "forge-std/console2.sol"; -import {SafeBatch} from "script/safe/SafeBatch.s.sol"; -import {IMinter} from "src/interfaces/IMinter.sol"; -import {IStabilityPoolManager} from "src/interfaces/IStabilityPoolManager.sol"; -import {ConfigPriceVolatility_130_stable} from "script/config/volatility/ConfigPriceVolatility_130_stable.sol"; -import {ConfigPriceVolatility_130} from "script/config/volatility/ConfigPriceVolatility_130.sol"; -import {ConfigPriceVolatility_125_stable} from "script/config/volatility/ConfigPriceVolatility_125_stable.sol"; -import {ConfigPriceVolatility_125} from "script/config/volatility/ConfigPriceVolatility_125.sol"; -// import {ConfigPriceVolatility_115_stable} from "script/config/volatility/ConfigPriceVolatility_115_stable.sol"; -import {ConfigPriceVolatility_115} from "script/config/volatility/ConfigPriceVolatility_115.sol"; -// import {ConfigPriceVolatility_105_stable} from "script/config/volatility/ConfigPriceVolatility_105_stable.sol"; -import {ConfigPriceVolatility_105} from "script/config/volatility/ConfigPriceVolatility_105.sol"; +import {SafeBatch} from "@harbor-script/safe/SafeBatch.s.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; +import {IStabilityPoolManager} from "@harbor/interfaces/IStabilityPoolManager.sol"; +import {ConfigPriceVolatility_130_stable} from "@harbor-script/config/volatility/ConfigPriceVolatility_130_stable.sol"; +import {ConfigPriceVolatility_130} from "@harbor-script/config/volatility/ConfigPriceVolatility_130.sol"; +import {ConfigPriceVolatility_125_stable} from "@harbor-script/config/volatility/ConfigPriceVolatility_125_stable.sol"; +import {ConfigPriceVolatility_125} from "@harbor-script/config/volatility/ConfigPriceVolatility_125.sol"; +// import {ConfigPriceVolatility_115_stable} from "@harbor-script/config/volatility/ConfigPriceVolatility_115_stable.sol"; +import {ConfigPriceVolatility_115} from "@harbor-script/config/volatility/ConfigPriceVolatility_115.sol"; +// import {ConfigPriceVolatility_105_stable} from "@harbor-script/config/volatility/ConfigPriceVolatility_105_stable.sol"; +import {ConfigPriceVolatility_105} from "@harbor-script/config/volatility/ConfigPriceVolatility_105.sol"; /// @notice Update volatility config for SILVER::fxUSD to 125. /// @dev Run with: ./script/safe-batch UpdateVolatility_OGPlus --salt harbor_v1 diff --git a/script/UpdateVolatility_test3_SILVER.s.sol b/script/UpdateVolatility_test3_SILVER.s.sol index 1c631432..2ff78afa 100644 --- a/script/UpdateVolatility_test3_SILVER.s.sol +++ b/script/UpdateVolatility_test3_SILVER.s.sol @@ -3,11 +3,11 @@ pragma solidity >=0.8.28 <0.9.0; import {console2 as console} from "forge-std/console2.sol"; -import {SafeBatch} from "script/safe/SafeBatch.s.sol"; -import {IMinter} from "src/interfaces/IMinter.sol"; -import {IStabilityPoolManager} from "src/interfaces/IStabilityPoolManager.sol"; -import {ConfigPriceVolatility_125} from "script/config/volatility/ConfigPriceVolatility_125.sol"; -import {ConfigPriceVolatility_130} from "script/config/volatility/ConfigPriceVolatility_130.sol"; +import {SafeBatch} from "@harbor-script/safe/SafeBatch.s.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; +import {IStabilityPoolManager} from "@harbor/interfaces/IStabilityPoolManager.sol"; +import {ConfigPriceVolatility_125} from "@harbor-script/config/volatility/ConfigPriceVolatility_125.sol"; +import {ConfigPriceVolatility_130} from "@harbor-script/config/volatility/ConfigPriceVolatility_130.sol"; /// @notice Update volatility config for SILVER::fxUSD to 125. /// @dev Run with: ./script/generate-safe-batch UpdateVolatility_test3_SILVER --salt test3 diff --git a/script/config/stabilitypool/ConfigStabilityPoolManagerCommon.sol b/script/config/stabilitypool/ConfigStabilityPoolManagerCommon.sol index 26b976c9..39b6da5a 100644 --- a/script/config/stabilitypool/ConfigStabilityPoolManagerCommon.sol +++ b/script/config/stabilitypool/ConfigStabilityPoolManagerCommon.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {ConfigStabilityPoolManager} from "./ConfigStabilityPoolManager.sol"; -import {HarborFactoryDeployer} from "script/src/HarborFactoryDeployer.sol"; +import {HarborFactoryDeployer} from "@harbor-script/src/HarborFactoryDeployer.sol"; /// @notice Shared stability pool manager fee receiver and parameter defaults. /// @dev Keeps stability pool manager concerns separate from minter config. diff --git a/script/config/volatility/ConfigPriceVolatility_105.sol b/script/config/volatility/ConfigPriceVolatility_105.sol index 60334513..015ad0d5 100644 --- a/script/config/volatility/ConfigPriceVolatility_105.sol +++ b/script/config/volatility/ConfigPriceVolatility_105.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.28 <0.9.0; -import {IMinter} from "src/interfaces/IMinter.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; /// @notice Volatility configuration for 105% rebalance threshold markets (Month 1 fees). /// @dev Higher redeem leveraged fees for initial month after launch. diff --git a/script/config/volatility/ConfigPriceVolatility_105_stable.sol b/script/config/volatility/ConfigPriceVolatility_105_stable.sol index d4c9a0fc..6086a09c 100644 --- a/script/config/volatility/ConfigPriceVolatility_105_stable.sol +++ b/script/config/volatility/ConfigPriceVolatility_105_stable.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.28 <0.9.0; -import {IMinter} from "src/interfaces/IMinter.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; /// @notice Volatility configuration for 105% rebalance threshold markets. contract ConfigPriceVolatility_105_stable { diff --git a/script/config/volatility/ConfigPriceVolatility_115.sol b/script/config/volatility/ConfigPriceVolatility_115.sol index 6fc76cf7..1af276d0 100644 --- a/script/config/volatility/ConfigPriceVolatility_115.sol +++ b/script/config/volatility/ConfigPriceVolatility_115.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.28 <0.9.0; -import {IMinter} from "src/interfaces/IMinter.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; /// @notice Volatility configuration for 115% rebalance threshold markets (Month 1 fees). /// @dev Higher redeem leveraged fees for initial month after launch. diff --git a/script/config/volatility/ConfigPriceVolatility_115_stable.sol b/script/config/volatility/ConfigPriceVolatility_115_stable.sol index 831d8c16..35e61019 100644 --- a/script/config/volatility/ConfigPriceVolatility_115_stable.sol +++ b/script/config/volatility/ConfigPriceVolatility_115_stable.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.28 <0.9.0; -import {IMinter} from "src/interfaces/IMinter.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; /// @notice Volatility configuration for 115% rebalance threshold markets. contract ConfigPriceVolatility_115_stable { diff --git a/script/config/volatility/ConfigPriceVolatility_125.sol b/script/config/volatility/ConfigPriceVolatility_125.sol index 67c9d9e4..6405be3e 100644 --- a/script/config/volatility/ConfigPriceVolatility_125.sol +++ b/script/config/volatility/ConfigPriceVolatility_125.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.28 <0.9.0; -import {IMinter} from "src/interfaces/IMinter.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; /// @notice Volatility configuration for 125% rebalance threshold markets (Month 1 fees). /// @dev Higher redeem leveraged fees for initial month after launch. diff --git a/script/config/volatility/ConfigPriceVolatility_125_stable.sol b/script/config/volatility/ConfigPriceVolatility_125_stable.sol index dc77350d..96293d76 100644 --- a/script/config/volatility/ConfigPriceVolatility_125_stable.sol +++ b/script/config/volatility/ConfigPriceVolatility_125_stable.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.28 <0.9.0; -import {IMinter} from "src/interfaces/IMinter.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; /// @notice Volatility configuration for 125% rebalance threshold markets. contract ConfigPriceVolatility_125_stable { diff --git a/script/config/volatility/ConfigPriceVolatility_130.sol b/script/config/volatility/ConfigPriceVolatility_130.sol index 537b662f..6066ce44 100644 --- a/script/config/volatility/ConfigPriceVolatility_130.sol +++ b/script/config/volatility/ConfigPriceVolatility_130.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.28 <0.9.0; -import {IMinter} from "src/interfaces/IMinter.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; /// @notice Volatility configuration for 130% rebalance threshold markets (Month 1 fees). /// @dev Higher redeem leveraged fees for initial month after launch. diff --git a/script/config/volatility/ConfigPriceVolatility_130_stable.sol b/script/config/volatility/ConfigPriceVolatility_130_stable.sol index f8a60c60..fcb41a1b 100644 --- a/script/config/volatility/ConfigPriceVolatility_130_stable.sol +++ b/script/config/volatility/ConfigPriceVolatility_130_stable.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.28 <0.9.0; -import {IMinter} from "src/interfaces/IMinter.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; /// @notice Volatility configuration for 130% rebalance threshold markets. contract ConfigPriceVolatility_130_stable { diff --git a/script/safe/SafeBatch.s.sol b/script/safe/SafeBatch.s.sol index ec7aa2dc..5e8a005e 100644 --- a/script/safe/SafeBatch.s.sol +++ b/script/safe/SafeBatch.s.sol @@ -4,7 +4,7 @@ pragma solidity >=0.8.28 <0.9.0; import {Script} from "forge-std/Script.sol"; import {console2 as console} from "forge-std/console2.sol"; import {LibString} from "@solady/utils/LibString.sol"; -import {HarborFactoryDeployer} from "script/src/HarborFactoryDeployer.sol"; +import {HarborFactoryDeployer} from "@harbor-script/src/HarborFactoryDeployer.sol"; import {DeploymentState} from "@bao-script/deployment/DeploymentState.sol"; /// @notice Base contract for generating Safe Transaction Builder JSON batches. diff --git a/script/src/DeployMintersShared.sol b/script/src/DeployMintersShared.sol index 57b5423c..0628ed3c 100644 --- a/script/src/DeployMintersShared.sol +++ b/script/src/DeployMintersShared.sol @@ -10,13 +10,13 @@ import {StabilityPool} from "./contracts/StabilityPool.sol"; import {StabilityPoolManager} from "./contracts/StabilityPoolManager.sol"; import {Genesis} from "./contracts/Genesis.sol"; import {AutoCompounder, IAutoCompounderMarketConfig} from "./contracts/AutoCompounder.sol"; -import {HarborFactoryDeployer} from "script/src/HarborFactoryDeployer.sol"; +import {HarborFactoryDeployer} from "@harbor-script/src/HarborFactoryDeployer.sol"; import {DeploymentState} from "@bao-script/deployment/DeploymentState.sol"; import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; -import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; -import {Config_MinterMarket, IMarketConfig, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; -import {IMinter} from "src/interfaces/IMinter.sol"; -import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; +import {ConfigPeg} from "@harbor-script/config/pegs/ConfigPeg.sol"; +import {Config_MinterMarket, IMarketConfig, MinterMarketConfigLib} from "@harbor-script/config/ConfigBase.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; +import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; /// @notice Extended market config interface with methods from collateral and chain configs. interface IFullMinterConfig { @@ -234,8 +234,6 @@ abstract contract DeployMintersShared is // Deployed by harbor-price-aggregators deploy scripts; address derived from peg name. address pegOracle = predictEthPriceOracleAddress(IMarketConfig(address(cfg)).peg()); - IAutoCompounderMarketConfig acCfg = IAutoCompounderMarketConfig(address(cfg)); - // Standalone ACs (no HarborYield) — pass address(0) as yieldManager. deployAutoCompounder( AutoCompounderCollateral, diff --git a/script/src/Deploy_BTC_Minter.sol b/script/src/Deploy_BTC_Minter.sol index 9e0d2a5d..0948a5e2 100644 --- a/script/src/Deploy_BTC_Minter.sol +++ b/script/src/Deploy_BTC_Minter.sol @@ -4,11 +4,11 @@ pragma solidity >=0.8.28 <0.9.0; import {console2 as console} from "forge-std/console2.sol"; import {DeployMintersShared} from "./DeployMintersShared.sol"; import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; -import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; -import {ConfigPeg_BTC} from "script/config/pegs/ConfigPeg_BTC.sol"; -import {ConfigMarket_BTC_fxUSD_mainnet} from "script/config/markets/ConfigMarket_BTC_fxUSD_mainnet.sol"; -import {ConfigMarket_BTC_stETH_mainnet} from "script/config/markets/ConfigMarket_BTC_stETH_mainnet.sol"; -import {Config_MinterMarket} from "script/config/ConfigBase.sol"; +import {ConfigPeg} from "@harbor-script/config/pegs/ConfigPeg.sol"; +import {ConfigPeg_BTC} from "@harbor-script/config/pegs/ConfigPeg_BTC.sol"; +import {ConfigMarket_BTC_fxUSD_mainnet} from "@harbor-script/config/markets/ConfigMarket_BTC_fxUSD_mainnet.sol"; +import {ConfigMarket_BTC_stETH_mainnet} from "@harbor-script/config/markets/ConfigMarket_BTC_stETH_mainnet.sol"; +import {Config_MinterMarket} from "@harbor-script/config/ConfigBase.sol"; /// @notice BTC-specific minter deployment functionality. abstract contract Deploy_BTC_Minter is DeployMintersShared { diff --git a/script/src/Deploy_ETH_Minter.sol b/script/src/Deploy_ETH_Minter.sol index aa92c58a..63c72dc8 100644 --- a/script/src/Deploy_ETH_Minter.sol +++ b/script/src/Deploy_ETH_Minter.sol @@ -4,10 +4,10 @@ pragma solidity >=0.8.28 <0.9.0; import {console2 as console} from "forge-std/console2.sol"; import {DeployMintersShared} from "./DeployMintersShared.sol"; import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; -import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; -import {ConfigPeg_ETH} from "script/config/pegs/ConfigPeg_ETH.sol"; -import {ConfigMarket_ETH_fxUSD_mainnet} from "script/config/markets/ConfigMarket_ETH_fxUSD_mainnet.sol"; -import {Config_MinterMarket} from "script/config/ConfigBase.sol"; +import {ConfigPeg} from "@harbor-script/config/pegs/ConfigPeg.sol"; +import {ConfigPeg_ETH} from "@harbor-script/config/pegs/ConfigPeg_ETH.sol"; +import {ConfigMarket_ETH_fxUSD_mainnet} from "@harbor-script/config/markets/ConfigMarket_ETH_fxUSD_mainnet.sol"; +import {Config_MinterMarket} from "@harbor-script/config/ConfigBase.sol"; /// @notice ETH-specific minter deployment functionality. abstract contract Deploy_ETH_Minter is DeployMintersShared { diff --git a/script/src/Deploy_EUR_Minter.sol b/script/src/Deploy_EUR_Minter.sol index 38aacfc9..3ccc3b9d 100644 --- a/script/src/Deploy_EUR_Minter.sol +++ b/script/src/Deploy_EUR_Minter.sol @@ -4,11 +4,11 @@ pragma solidity >=0.8.28 <0.9.0; import {console2 as console} from "forge-std/console2.sol"; import {DeployMintersShared} from "./DeployMintersShared.sol"; import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; -import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; -import {ConfigPeg_EUR} from "script/config/pegs/ConfigPeg_EUR.sol"; -import {ConfigMarket_EUR_fxUSD_mainnet} from "script/config/markets/ConfigMarket_EUR_fxUSD_mainnet.sol"; -import {ConfigMarket_EUR_stETH_mainnet} from "script/config/markets/ConfigMarket_EUR_stETH_mainnet.sol"; -import {Config_MinterMarket} from "script/config/ConfigBase.sol"; +import {ConfigPeg} from "@harbor-script/config/pegs/ConfigPeg.sol"; +import {ConfigPeg_EUR} from "@harbor-script/config/pegs/ConfigPeg_EUR.sol"; +import {ConfigMarket_EUR_fxUSD_mainnet} from "@harbor-script/config/markets/ConfigMarket_EUR_fxUSD_mainnet.sol"; +import {ConfigMarket_EUR_stETH_mainnet} from "@harbor-script/config/markets/ConfigMarket_EUR_stETH_mainnet.sol"; +import {Config_MinterMarket} from "@harbor-script/config/ConfigBase.sol"; /// @notice EUR-specific minter deployment functionality. abstract contract Deploy_EUR_Minter is DeployMintersShared { diff --git a/script/src/Deploy_GOLD_Minter.sol b/script/src/Deploy_GOLD_Minter.sol index 73365b94..ca2c8b45 100644 --- a/script/src/Deploy_GOLD_Minter.sol +++ b/script/src/Deploy_GOLD_Minter.sol @@ -4,11 +4,11 @@ pragma solidity >=0.8.28 <0.9.0; import {console2 as console} from "forge-std/console2.sol"; import {DeployMintersShared} from "./DeployMintersShared.sol"; import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; -import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; -import {ConfigPeg_GOLD} from "script/config/pegs/ConfigPeg_GOLD.sol"; -import {ConfigMarket_GOLD_fxUSD_mainnet} from "script/config/markets/ConfigMarket_GOLD_fxUSD_mainnet.sol"; -import {ConfigMarket_GOLD_stETH_mainnet} from "script/config/markets/ConfigMarket_GOLD_stETH_mainnet.sol"; -import {Config_MinterMarket} from "script/config/ConfigBase.sol"; +import {ConfigPeg} from "@harbor-script/config/pegs/ConfigPeg.sol"; +import {ConfigPeg_GOLD} from "@harbor-script/config/pegs/ConfigPeg_GOLD.sol"; +import {ConfigMarket_GOLD_fxUSD_mainnet} from "@harbor-script/config/markets/ConfigMarket_GOLD_fxUSD_mainnet.sol"; +import {ConfigMarket_GOLD_stETH_mainnet} from "@harbor-script/config/markets/ConfigMarket_GOLD_stETH_mainnet.sol"; +import {Config_MinterMarket} from "@harbor-script/config/ConfigBase.sol"; /// @notice GOLD-specific minter deployment functionality. abstract contract Deploy_GOLD_Minter is DeployMintersShared { diff --git a/script/src/Deploy_MCAP_Minter.sol b/script/src/Deploy_MCAP_Minter.sol index 436bc463..30ca0eb7 100644 --- a/script/src/Deploy_MCAP_Minter.sol +++ b/script/src/Deploy_MCAP_Minter.sol @@ -4,11 +4,11 @@ pragma solidity >=0.8.28 <0.9.0; import {console2 as console} from "forge-std/console2.sol"; import {DeployMintersShared} from "./DeployMintersShared.sol"; import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; -import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; -import {ConfigPeg_MCAP} from "script/config/pegs/ConfigPeg_MCAP.sol"; -import {ConfigMarket_MCAP_fxUSD_mainnet} from "script/config/markets/ConfigMarket_MCAP_fxUSD_mainnet.sol"; -import {ConfigMarket_MCAP_stETH_mainnet} from "script/config/markets/ConfigMarket_MCAP_stETH_mainnet.sol"; -import {Config_MinterMarket} from "script/config/ConfigBase.sol"; +import {ConfigPeg} from "@harbor-script/config/pegs/ConfigPeg.sol"; +import {ConfigPeg_MCAP} from "@harbor-script/config/pegs/ConfigPeg_MCAP.sol"; +import {ConfigMarket_MCAP_fxUSD_mainnet} from "@harbor-script/config/markets/ConfigMarket_MCAP_fxUSD_mainnet.sol"; +import {ConfigMarket_MCAP_stETH_mainnet} from "@harbor-script/config/markets/ConfigMarket_MCAP_stETH_mainnet.sol"; +import {Config_MinterMarket} from "@harbor-script/config/ConfigBase.sol"; /// @notice MCAP-specific minter deployment functionality. abstract contract Deploy_MCAP_Minter is DeployMintersShared { diff --git a/script/src/Deploy_SILVER_Minter.sol b/script/src/Deploy_SILVER_Minter.sol index d6cb1a7f..897527ec 100644 --- a/script/src/Deploy_SILVER_Minter.sol +++ b/script/src/Deploy_SILVER_Minter.sol @@ -4,11 +4,11 @@ pragma solidity >=0.8.28 <0.9.0; import {console2 as console} from "forge-std/console2.sol"; import {DeployMintersShared} from "./DeployMintersShared.sol"; import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; -import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; -import {ConfigPeg_SILVER} from "script/config/pegs/ConfigPeg_SILVER.sol"; -import {ConfigMarket_SILVER_fxUSD_mainnet} from "script/config/markets/ConfigMarket_SILVER_fxUSD_mainnet.sol"; -import {ConfigMarket_SILVER_stETH_mainnet} from "script/config/markets/ConfigMarket_SILVER_stETH_mainnet.sol"; -import {Config_MinterMarket} from "script/config/ConfigBase.sol"; +import {ConfigPeg} from "@harbor-script/config/pegs/ConfigPeg.sol"; +import {ConfigPeg_SILVER} from "@harbor-script/config/pegs/ConfigPeg_SILVER.sol"; +import {ConfigMarket_SILVER_fxUSD_mainnet} from "@harbor-script/config/markets/ConfigMarket_SILVER_fxUSD_mainnet.sol"; +import {ConfigMarket_SILVER_stETH_mainnet} from "@harbor-script/config/markets/ConfigMarket_SILVER_stETH_mainnet.sol"; +import {Config_MinterMarket} from "@harbor-script/config/ConfigBase.sol"; /// @notice SILVER-specific minter deployment functionality. abstract contract Deploy_SILVER_Minter is DeployMintersShared { diff --git a/script/src/contracts/AutoCompounder.sol b/script/src/contracts/AutoCompounder.sol index 5fb8bd22..c131937a 100644 --- a/script/src/contracts/AutoCompounder.sol +++ b/script/src/contracts/AutoCompounder.sol @@ -2,12 +2,12 @@ pragma solidity >=0.8.28 <0.9.0; import {console2 as console} from "forge-std/console2.sol"; -import {HarborFactoryDeployer} from "script/src/HarborFactoryDeployer.sol"; +import {HarborFactoryDeployer} from "@harbor-script/src/HarborFactoryDeployer.sol"; import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; import {AutoCompounder_v1} from "@harbor/autocompounding/AutoCompounder_v1.sol"; -import {Config_MinterMarket, MinterMarketConfigLib, IMarketConfig} from "script/config/ConfigBase.sol"; -import {ConfigTokenNames} from "script/config/ConfigTokenNames.sol"; +import {Config_MinterMarket, MinterMarketConfigLib, IMarketConfig} from "@harbor-script/config/ConfigBase.sol"; +import {ConfigTokenNames} from "@harbor-script/config/ConfigTokenNames.sol"; /// @notice Config interface for auto-compounder deployment parameters. interface IAutoCompounderMarketConfig { diff --git a/script/src/contracts/Genesis.sol b/script/src/contracts/Genesis.sol index 901049b9..f944da5b 100644 --- a/script/src/contracts/Genesis.sol +++ b/script/src/contracts/Genesis.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {console2 as console} from "forge-std/console2.sol"; -import {HarborFactoryDeployer} from "script/src/HarborFactoryDeployer.sol"; +import {HarborFactoryDeployer} from "@harbor-script/src/HarborFactoryDeployer.sol"; import {DeploymentState} from "@bao-script/deployment/DeploymentState.sol"; import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; diff --git a/script/src/contracts/LeveragedToken.sol b/script/src/contracts/LeveragedToken.sol index 53a70655..7d3d9dd7 100644 --- a/script/src/contracts/LeveragedToken.sol +++ b/script/src/contracts/LeveragedToken.sol @@ -2,13 +2,13 @@ pragma solidity >=0.8.28 <0.9.0; import {console2 as console} from "forge-std/console2.sol"; -import {HarborFactoryDeployer} from "script/src/HarborFactoryDeployer.sol"; +import {HarborFactoryDeployer} from "@harbor-script/src/HarborFactoryDeployer.sol"; import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; import {MintableBurnableERC20_v1} from "@bao/MintableBurnableERC20_v1.sol"; import {IMintableRole} from "@bao/interfaces/IMintableRole.sol"; import {IBurnableRole} from "@bao/interfaces/IBurnableRole.sol"; -import {Config_MinterMarket, IMarketConfig, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; -import {ConfigTokenNames} from "script/config/ConfigTokenNames.sol"; +import {Config_MinterMarket, IMarketConfig, MinterMarketConfigLib} from "@harbor-script/config/ConfigBase.sol"; +import {ConfigTokenNames} from "@harbor-script/config/ConfigTokenNames.sol"; /// @notice Harbor leveraged token deployment logic. /// @dev Leveraged tokens are unique per market (e.g., hsFXUSD-BTC for BTC::fxUSD market). abstract contract LeveragedToken is HarborFactoryDeployer { diff --git a/script/src/contracts/Minter.sol b/script/src/contracts/Minter.sol index 0d706d6b..d49c072a 100644 --- a/script/src/contracts/Minter.sol +++ b/script/src/contracts/Minter.sol @@ -2,9 +2,9 @@ pragma solidity >=0.8.28 <0.9.0; import {console2 as console} from "forge-std/console2.sol"; -import {HarborFactoryDeployer} from "script/src/HarborFactoryDeployer.sol"; +import {HarborFactoryDeployer} from "@harbor-script/src/HarborFactoryDeployer.sol"; import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; -import {Config_MinterMarket, IMarketConfig, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; +import {Config_MinterMarket, IMarketConfig, MinterMarketConfigLib} from "@harbor-script/config/ConfigBase.sol"; import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; import {Minter_v3} from "@harbor/minter/Minter_v3.sol"; diff --git a/script/src/contracts/PeggedToken.sol b/script/src/contracts/PeggedToken.sol index 0ec39a5e..20be2c1a 100644 --- a/script/src/contracts/PeggedToken.sol +++ b/script/src/contracts/PeggedToken.sol @@ -2,13 +2,13 @@ pragma solidity >=0.8.28 <0.9.0; import {console2 as console} from "forge-std/console2.sol"; -import {HarborFactoryDeployer} from "script/src/HarborFactoryDeployer.sol"; +import {HarborFactoryDeployer} from "@harbor-script/src/HarborFactoryDeployer.sol"; import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; import {MintableBurnableERC20_v1} from "@bao/MintableBurnableERC20_v1.sol"; import {IMintableRole} from "@bao/interfaces/IMintableRole.sol"; import {IBurnableRole} from "@bao/interfaces/IBurnableRole.sol"; -import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; -import {Config_MinterMarket, IMarketConfig, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; +import {ConfigPeg} from "@harbor-script/config/pegs/ConfigPeg.sol"; +import {Config_MinterMarket, IMarketConfig, MinterMarketConfigLib} from "@harbor-script/config/ConfigBase.sol"; import {LibString} from "@solady/utils/LibString.sol"; /// @notice Harbor pegged token deployment logic. diff --git a/script/src/contracts/StabilityPool.sol b/script/src/contracts/StabilityPool.sol index 0272014e..b01c86d8 100644 --- a/script/src/contracts/StabilityPool.sol +++ b/script/src/contracts/StabilityPool.sol @@ -2,14 +2,14 @@ pragma solidity >=0.8.28 <0.9.0; import {console2 as console} from "forge-std/console2.sol"; -import {HarborFactoryDeployer} from "script/src/HarborFactoryDeployer.sol"; +import {HarborFactoryDeployer} from "@harbor-script/src/HarborFactoryDeployer.sol"; import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; import {StabilityPool_v3} from "@harbor/minter/StabilityPool_v3.sol"; -import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; -import {Config_MinterMarket, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; -import {ConfigTokenNames} from "script/config/ConfigTokenNames.sol"; +import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; +import {Config_MinterMarket, MinterMarketConfigLib} from "@harbor-script/config/ConfigBase.sol"; +import {ConfigTokenNames} from "@harbor-script/config/ConfigTokenNames.sol"; /// @notice Config interface for stability pool deployment parameters. interface IStabilityPoolMarketConfig { diff --git a/script/src/contracts/StabilityPoolManager.sol b/script/src/contracts/StabilityPoolManager.sol index ec233bd2..b9431ce0 100644 --- a/script/src/contracts/StabilityPoolManager.sol +++ b/script/src/contracts/StabilityPoolManager.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {console2 as console} from "forge-std/console2.sol"; -import {HarborFactoryDeployer} from "script/src/HarborFactoryDeployer.sol"; +import {HarborFactoryDeployer} from "@harbor-script/src/HarborFactoryDeployer.sol"; import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; diff --git a/script/verify/minter-v2-upgrade/DeployMinters.t.sol b/script/verify/minter-v2-upgrade/DeployMinters.t.sol index 8cbac123..4f17a8d5 100644 --- a/script/verify/minter-v2-upgrade/DeployMinters.t.sol +++ b/script/verify/minter-v2-upgrade/DeployMinters.t.sol @@ -3,17 +3,17 @@ pragma solidity >=0.8.28 <0.9.0; import {BaoTest} from "@bao-test/BaoTest.sol"; import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; -import {Deploy_BTC_Minter} from "script/src/Deploy_BTC_Minter.sol"; -import {Deploy_ETH_Minter} from "script/src/Deploy_ETH_Minter.sol"; -import {Deploy_EUR_Minter} from "script/src/Deploy_EUR_Minter.sol"; -import {Deploy_GOLD_Minter} from "script/src/Deploy_GOLD_Minter.sol"; -import {Deploy_SILVER_Minter} from "script/src/Deploy_SILVER_Minter.sol"; -import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; -import {Config_MinterMarket, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; +import {Deploy_BTC_Minter} from "@harbor-script/src/Deploy_BTC_Minter.sol"; +import {Deploy_ETH_Minter} from "@harbor-script/src/Deploy_ETH_Minter.sol"; +import {Deploy_EUR_Minter} from "@harbor-script/src/Deploy_EUR_Minter.sol"; +import {Deploy_GOLD_Minter} from "@harbor-script/src/Deploy_GOLD_Minter.sol"; +import {Deploy_SILVER_Minter} from "@harbor-script/src/Deploy_SILVER_Minter.sol"; +import {ConfigPeg} from "@harbor-script/config/pegs/ConfigPeg.sol"; +import {Config_MinterMarket, MinterMarketConfigLib} from "@harbor-script/config/ConfigBase.sol"; import {MintableBurnableERC20_v1} from "@bao/MintableBurnableERC20_v1.sol"; import {WellKnownAddress} from "@bao-script/deployment/FactoryDeployer.sol"; import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; -import {IMinter} from "src/interfaces/IMinter.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; import {console2 as console} from "forge-std/console2.sol"; import {stdJson} from "forge-std/StdJson.sol"; diff --git a/script/verify/minter-v2-upgrade/MainnetForkUpgradeTest.t.sol b/script/verify/minter-v2-upgrade/MainnetForkUpgradeTest.t.sol index 76a78928..c5b03bc3 100644 --- a/script/verify/minter-v2-upgrade/MainnetForkUpgradeTest.t.sol +++ b/script/verify/minter-v2-upgrade/MainnetForkUpgradeTest.t.sol @@ -2,9 +2,9 @@ pragma solidity >=0.8.28 <0.9.0; import "forge-std/Test.sol"; -import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; -import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; -import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; +import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; +import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; +import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; import {LibString} from "@solady/utils/LibString.sol"; diff --git a/script/verify/minter-v2-upgrade/MinterUpgradeMigration.t.sol b/script/verify/minter-v2-upgrade/MinterUpgradeMigration.t.sol index 8baef205..3d968508 100644 --- a/script/verify/minter-v2-upgrade/MinterUpgradeMigration.t.sol +++ b/script/verify/minter-v2-upgrade/MinterUpgradeMigration.t.sol @@ -7,11 +7,11 @@ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; -import {Minter_v1} from "src/minter/Minter_v1.sol"; -import {Minter_v2} from "src/minter/Minter_v2.sol"; -import {IMinter} from "src/interfaces/IMinter.sol"; +import {Minter_v1} from "@harbor/minter/Minter_v1.sol"; +import {Minter_v2} from "@harbor/minter/Minter_v2.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; -import {TestMinterSetUp} from "test/Minter_base.t.sol"; +import {TestMinterSetUp} from "@harbor-test/Minter_base.t.sol"; /// @title TestMinterUpgradeMigration /// @notice Tests that upgrading Minter_v1 → Minter_v2 via UUPS proxy preserves diff --git a/script/verify/minter-v2-upgrade/MinterUpgradeTest.t.sol b/script/verify/minter-v2-upgrade/MinterUpgradeTest.t.sol index 2cc3af70..56daf6e5 100644 --- a/script/verify/minter-v2-upgrade/MinterUpgradeTest.t.sol +++ b/script/verify/minter-v2-upgrade/MinterUpgradeTest.t.sol @@ -2,13 +2,13 @@ pragma solidity >=0.8.28 <0.9.0; import {BaoTest} from "@bao-test/BaoTest.sol"; -import {HarborFactoryDeployer} from "script/src/HarborFactoryDeployer.sol"; -import {IMinter} from "src/interfaces/IMinter.sol"; -import {IStabilityPoolManager} from "src/interfaces/IStabilityPoolManager.sol"; -import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; -import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; +import {HarborFactoryDeployer} from "@harbor-script/src/HarborFactoryDeployer.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; +import {IStabilityPoolManager} from "@harbor/interfaces/IStabilityPoolManager.sol"; +import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; +import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {StabilityPoolManager_v1} from "src/minter/StabilityPoolManager_v1.sol"; +import {StabilityPoolManager_v1} from "@harbor/minter/StabilityPoolManager_v1.sol"; /// @title Minter v2 Post-Deploy Verification /// @notice Asserts that the deploy script correctly upgraded all minters and diff --git a/script/verify/minter-v2-upgrade/RebalanceCheck.t.sol b/script/verify/minter-v2-upgrade/RebalanceCheck.t.sol index 3ed17da5..785d2e93 100644 --- a/script/verify/minter-v2-upgrade/RebalanceCheck.t.sol +++ b/script/verify/minter-v2-upgrade/RebalanceCheck.t.sol @@ -2,16 +2,16 @@ pragma solidity >=0.8.28 <0.9.0; import {BaoTest} from "@bao-test/BaoTest.sol"; -import {HarborFactoryDeployer} from "script/src/HarborFactoryDeployer.sol"; -import {StabilityPoolManager_v1} from "src/minter/StabilityPoolManager_v1.sol"; +import {HarborFactoryDeployer} from "@harbor-script/src/HarborFactoryDeployer.sol"; +import {StabilityPoolManager_v1} from "@harbor/minter/StabilityPoolManager_v1.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; -import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; -import {IStabilityPoolManager} from "src/interfaces/IStabilityPoolManager.sol"; -import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; +import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; +import {IStabilityPoolManager} from "@harbor/interfaces/IStabilityPoolManager.sol"; +import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {IMinter} from "src/interfaces/IMinter.sol"; -import {Minter_v2} from "src/minter/Minter_v2.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; +import {Minter_v2} from "@harbor/minter/Minter_v2.sol"; abstract contract RebalanceCheckBase is BaoTest, HarborFactoryDeployer { uint256 constant FORK_BLOCK = 24687073; diff --git a/script/verify/roles/MainnetRoles.t.sol b/script/verify/roles/MainnetRoles.t.sol index 8d5d9a8a..effa8046 100644 --- a/script/verify/roles/MainnetRoles.t.sol +++ b/script/verify/roles/MainnetRoles.t.sol @@ -2,9 +2,9 @@ pragma solidity >=0.8.28 <0.9.0; import {Test} from "forge-std/Test.sol"; -import {HarborFactoryDeployer} from "script/src/HarborFactoryDeployer.sol"; +import {HarborFactoryDeployer} from "@harbor-script/src/HarborFactoryDeployer.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; -import {IMinter} from "src/interfaces/IMinter.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; /// @notice Verify that all deployed StabilityPoolManagers have the expected /// roles on their minters. This catches missing role grants in deploy scripts. diff --git a/script/verify/sp-v2-upgrade/MainnetUpgradeTest.t.sol b/script/verify/sp-v2-upgrade/MainnetUpgradeTest.t.sol index 0c9d64ec..fa3e82e0 100644 --- a/script/verify/sp-v2-upgrade/MainnetUpgradeTest.t.sol +++ b/script/verify/sp-v2-upgrade/MainnetUpgradeTest.t.sol @@ -2,10 +2,10 @@ pragma solidity >=0.8.28 <0.9.0; import "forge-std/Test.sol"; -import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; -import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; +import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; +import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {StabilityPool_v2} from "src/minter/StabilityPool_v2.sol"; +import {StabilityPool_v2} from "@harbor/minter/StabilityPool_v2.sol"; import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; diff --git a/script/verify/sp-v3-migration/SPv3MigrationTest.t.sol b/script/verify/sp-v3-migration/SPv3MigrationTest.t.sol index 88c1e84a..ac32c414 100644 --- a/script/verify/sp-v3-migration/SPv3MigrationTest.t.sol +++ b/script/verify/sp-v3-migration/SPv3MigrationTest.t.sol @@ -2,16 +2,16 @@ pragma solidity >=0.8.28 <0.9.0; import {BaoTest} from "@bao-test/BaoTest.sol"; -import {HarborFactoryDeployer} from "script/src/HarborFactoryDeployer.sol"; +import {HarborFactoryDeployer} from "@harbor-script/src/HarborFactoryDeployer.sol"; import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; -import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; -import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; -import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; +import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; +import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; +import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {IMinter} from "src/interfaces/IMinter.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; -import {ForceMigrateAccumulator_v1} from "script/verify/sp-v3-migration/ForceMigrateAccumulator_v1.sol"; -import {StabilityPool_v3} from "src/minter/StabilityPool_v3.sol"; +import {ForceMigrateAccumulator_v1} from "@harbor-script/verify/sp-v3-migration/ForceMigrateAccumulator_v1.sol"; +import {StabilityPool_v3} from "@harbor/minter/StabilityPool_v3.sol"; import {console2 as console} from "forge-std/console2.sol"; /// @title SPv3MigrationTest diff --git a/script/verify/spl-remediation/PostRebalanceRemediationForStabilityPool_v2.sol b/script/verify/spl-remediation/PostRebalanceRemediationForStabilityPool_v2.sol index 6807d507..dc827281 100644 --- a/script/verify/spl-remediation/PostRebalanceRemediationForStabilityPool_v2.sol +++ b/script/verify/spl-remediation/PostRebalanceRemediationForStabilityPool_v2.sol @@ -4,9 +4,9 @@ pragma solidity >=0.8.28 <0.9.0; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IBurnable} from "@bao/interfaces/IBurnable.sol"; import {IBurnableFrom} from "@bao/interfaces/IBurnableFrom.sol"; -import {IMinter} from "src/interfaces/IMinter.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; -import {DecrementalFloatingPoint} from "src/math/DecrementalFloatingPoint.sol"; +import {DecrementalFloatingPoint} from "@harbor/math/DecrementalFloatingPoint.sol"; /// @title Post-Rebalance Remediation for StabilityPool_v2 /// @notice One-shot upgrade that corrects the reward integral inflated by the diff --git a/script/verify/spl-remediation/SPLRemediationTest.t.sol b/script/verify/spl-remediation/SPLRemediationTest.t.sol index f42ea7e2..6619a541 100644 --- a/script/verify/spl-remediation/SPLRemediationTest.t.sol +++ b/script/verify/spl-remediation/SPLRemediationTest.t.sol @@ -2,18 +2,18 @@ pragma solidity >=0.8.28 <0.9.0; import {BaoTest} from "@bao-test/BaoTest.sol"; -import {HarborFactoryDeployer} from "script/src/HarborFactoryDeployer.sol"; +import {HarborFactoryDeployer} from "@harbor-script/src/HarborFactoryDeployer.sol"; import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; -import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; -import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; +import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; +import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {IMinter} from "src/interfaces/IMinter.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; -import {PostRebalanceRemediationForStabilityPool_v2} from "script/verify/spl-remediation/PostRebalanceRemediationForStabilityPool_v2.sol"; -import {MockWrappedPriceOracle} from "test/mocks/MockWrappedPriceOracle.sol"; -import {IWrappedPriceOracle} from "src/interfaces/IWrappedPriceOracle.sol"; -import {IStabilityPoolManager} from "src/interfaces/IStabilityPoolManager.sol"; +import {PostRebalanceRemediationForStabilityPool_v2} from "@harbor-script/verify/spl-remediation/PostRebalanceRemediationForStabilityPool_v2.sol"; +import {MockWrappedPriceOracle} from "@harbor-test/mocks/MockWrappedPriceOracle.sol"; +import {IWrappedPriceOracle} from "@harbor/interfaces/IWrappedPriceOracle.sol"; +import {IStabilityPoolManager} from "@harbor/interfaces/IStabilityPoolManager.sol"; import {console2 as console} from "forge-std/console2.sol"; interface IStabilityPoolImmutables { diff --git a/script/verify/spl-remediation/V2ReplaySimulation.t.sol b/script/verify/spl-remediation/V2ReplaySimulation.t.sol index 969d9599..843f0bb8 100644 --- a/script/verify/spl-remediation/V2ReplaySimulation.t.sol +++ b/script/verify/spl-remediation/V2ReplaySimulation.t.sol @@ -2,16 +2,16 @@ pragma solidity >=0.8.28 <0.9.0; import {BaoTest} from "@bao-test/BaoTest.sol"; -import {HarborFactoryDeployer} from "script/src/HarborFactoryDeployer.sol"; -import {StabilityPoolManager_v1} from "src/minter/StabilityPoolManager_v1.sol"; +import {HarborFactoryDeployer} from "@harbor-script/src/HarborFactoryDeployer.sol"; +import {StabilityPoolManager_v1} from "@harbor/minter/StabilityPoolManager_v1.sol"; import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; -import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; -import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; +import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; +import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {IMinter} from "src/interfaces/IMinter.sol"; -import {IWrappedPriceOracle} from "src/interfaces/IWrappedPriceOracle.sol"; -import {Minter_v2} from "src/minter/Minter_v2.sol"; -import {MockWrappedPriceOracle} from "test/mocks/MockWrappedPriceOracle.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; +import {IWrappedPriceOracle} from "@harbor/interfaces/IWrappedPriceOracle.sol"; +import {Minter_v2} from "@harbor/minter/Minter_v2.sol"; +import {MockWrappedPriceOracle} from "@harbor-test/mocks/MockWrappedPriceOracle.sol"; import {console2 as console} from "forge-std/console2.sol"; /// @title V1/V2 Replay Simulation diff --git a/src/minter/Minter_v3.sol b/src/minter/Minter_v3.sol index df8f1f0d..f0f4f3d2 100644 --- a/src/minter/Minter_v3.sol +++ b/src/minter/Minter_v3.sol @@ -28,7 +28,7 @@ import {IWrappedPriceOracle} from "@harbor/interfaces/IWrappedPriceOracle.sol"; import {IReservePool} from "@harbor/interfaces/IReservePool.sol"; import {ConfigIncentiveLib} from "@harbor/minter/library/ConfigIncentiveLib.sol"; -import {Config_v1} from "@harbor/minter/library/Config_v1.sol"; +import {Config_v2} from "@harbor/minter/library/Config_v2.sol"; /// @title Bao Minter /// @author rootminus0x1 based on (albeit significantly modified) Aladdin's FX system @@ -208,7 +208,7 @@ contract Minter_v3 is $.underlyingCollateral = 0; // initialise the config to something that works - Config_v1.defaultIncentive($.incentiveConfig); + Config_v2.defaultIncentive($.incentiveConfig); } /// @notice In UUPS proxies the constructor is used only to stop the implementation being initialized to any version /// https://forum.openzeppelin.com/t/what-does-disableinitializers-function-mean/28730 @@ -301,7 +301,7 @@ contract Minter_v3 is /// @inheritdoc IMinter function config() external view returns (Config memory config_) { MinterStorage storage $ = _getMinterStorage(); - config_ = Config_v1.copyIncentivesBack($.incentiveConfig); + config_ = Config_v2.copyIncentivesBack($.incentiveConfig); } /// @inheritdoc IMinter @@ -406,22 +406,22 @@ contract Minter_v3 is /// @inheritdoc IMinter function mintPeggedTokenIncentiveRatio() external view override returns (int256 incentiveRatio) { - incentiveRatio = _lookupIncentiveRatio(Config_v1.MINT_PEGGED); + incentiveRatio = _lookupIncentiveRatio(Config_v2.MINT_PEGGED); } /// @inheritdoc IMinter function redeemPeggedTokenIncentiveRatio() external view override returns (int256 incentiveRatio) { - incentiveRatio = _lookupIncentiveRatio(Config_v1.REDEEM_PEGGED); + incentiveRatio = _lookupIncentiveRatio(Config_v2.REDEEM_PEGGED); } /// @inheritdoc IMinter function mintLeveragedTokenIncentiveRatio() external view override returns (int256 incentiveRatio) { - incentiveRatio = _lookupIncentiveRatio(Config_v1.MINT_LEVERAGED); + incentiveRatio = _lookupIncentiveRatio(Config_v2.MINT_LEVERAGED); } /// @inheritdoc IMinter function redeemLeveragedTokenIncentiveRatio() external view override returns (int256 incentiveRatio) { - incentiveRatio = _lookupIncentiveRatio(Config_v1.REDEEM_LEVERAGED); + incentiveRatio = _lookupIncentiveRatio(Config_v2.REDEEM_LEVERAGED); } // dry run functions @@ -478,14 +478,14 @@ contract Minter_v3 is : Math.mulDiv(wrappedCollateralIn, maxFeeRatio, 1 ether) * oracle.rate; uint256 underlyingCollateralAdded; (wrappedFee, peggedMinted, wrappedCollateralUsed, underlyingCollateralAdded) = _mintPeggedAdjustments( - $.incentiveConfig[Config_v1.MINT_PEGGED], + $.incentiveConfig[Config_v2.MINT_PEGGED], wrappedCollateralIn, CollateralRatioData($.underlyingCollateral, oracle.price, oracle.rate, $.peggedTokenBalance), maxFeeE36 ); // slither-disable-next-line incorrect-equality incentiveRatio = wrappedCollateralUsed == 0 - ? _lookupIncentiveRatio(Config_v1.MINT_PEGGED) + ? _lookupIncentiveRatio(Config_v2.MINT_PEGGED) : int256(Math.mulDiv(wrappedFee, 1 ether, wrappedCollateralUsed)); } @@ -515,14 +515,14 @@ contract Minter_v3 is peggedRedeemed = peggedIn; uint256 peggedPriceE36; (wrappedFee, wrappedDiscount, wrappedCollateralReturned, , peggedPriceE36) = _redeemPeggedAdjustments( - $.incentiveConfig[Config_v1.REDEEM_PEGGED], + $.incentiveConfig[Config_v2.REDEEM_PEGGED], peggedIn, CollateralRatioData($.underlyingCollateral, price, rate, peggedTokenBalance_), IERC20(WRAPPED_COLLATERAL_TOKEN).balanceOf($.reservePool) ); // slither-disable-next-line incorrect-equality if (peggedRedeemed == 0) { - incentiveRatio = _lookupIncentiveRatio(Config_v1.REDEEM_PEGGED); + incentiveRatio = _lookupIncentiveRatio(Config_v2.REDEEM_PEGGED); } else { uint256 incentive; int256 sign; @@ -560,14 +560,14 @@ contract Minter_v3 is price = oracle.price; rate = oracle.rate; (wrappedFee, wrappedDiscount, leveragedMinted, wrappedCollateralUsed, ) = _mintLeveragedAdjustments( - $.incentiveConfig[Config_v1.MINT_LEVERAGED], + $.incentiveConfig[Config_v2.MINT_LEVERAGED], wrappedCollateralIn, CollateralRatioData($.underlyingCollateral, oracle.price, oracle.rate, $.peggedTokenBalance), IERC20(WRAPPED_COLLATERAL_TOKEN).balanceOf($.reservePool) ); // slither-disable-next-line incorrect-equality if (wrappedCollateralUsed == 0) { - incentiveRatio = _lookupIncentiveRatio(Config_v1.MINT_LEVERAGED); + incentiveRatio = _lookupIncentiveRatio(Config_v2.MINT_LEVERAGED); } else { uint256 incentive; int256 sign; @@ -605,14 +605,14 @@ contract Minter_v3 is price = oracle.price; rate = oracle.rate; (wrappedFee, leveragedRedeemed, wrappedCollateralReturned, ) = _redeemLeveragedAdjustments( - $.incentiveConfig[Config_v1.REDEEM_LEVERAGED], + $.incentiveConfig[Config_v2.REDEEM_LEVERAGED], leveragedIn, CollateralRatioData($.underlyingCollateral, price, rate, $.peggedTokenBalance), leveragedTokenBalance_ ); // slither-disable-next-line incorrect-equality incentiveRatio = wrappedCollateralReturned == 0 - ? _lookupIncentiveRatio(Config_v1.REDEEM_LEVERAGED) + ? _lookupIncentiveRatio(Config_v2.REDEEM_LEVERAGED) : int256(Math.mulDiv(wrappedFee, 1 ether, wrappedCollateralReturned + wrappedFee)); } @@ -652,7 +652,7 @@ contract Minter_v3 is // incentive config - Config_v1.checkAndCopyIncentives(config_, $.incentiveConfig); + Config_v2.checkAndCopyIncentives(config_, $.incentiveConfig); } /// @inheritdoc IMinter @@ -724,7 +724,7 @@ contract Minter_v3 is uint256 wrappedFee; uint256 underlyingCollateralAdded; (wrappedFee, peggedOut, wrappedCollateralUsed, underlyingCollateralAdded) = _mintPeggedAdjustments( - $.incentiveConfig[Config_v1.MINT_PEGGED], + $.incentiveConfig[Config_v2.MINT_PEGGED], wrappedCollateralIn, CollateralRatioData(underlyingCollateral_, oracle.price, oracle.rate, peggedTokenBalance_), maxFeeE36 @@ -782,7 +782,7 @@ contract Minter_v3 is uint256 wrappedDiscount; uint256 underlyingCollateralRemoved; (wrappedFee, wrappedDiscount, wrappedCollateralOut, underlyingCollateralRemoved, ) = _redeemPeggedAdjustments( - $.incentiveConfig[Config_v1.REDEEM_PEGGED], + $.incentiveConfig[Config_v2.REDEEM_PEGGED], peggedIn, CollateralRatioData(underlyingCollateral_, oracle.price, oracle.rate, peggedTokenBalance_), IERC20(WRAPPED_COLLATERAL_TOKEN).balanceOf(reservePool_) @@ -844,7 +844,7 @@ contract Minter_v3 is wrappedCollateralIn, underlyingCollateralAdded ) = _mintLeveragedAdjustments( - $.incentiveConfig[Config_v1.MINT_LEVERAGED], + $.incentiveConfig[Config_v2.MINT_LEVERAGED], wrappedCollateralIn, CollateralRatioData(underlyingCollateral_, oracle.price, oracle.rate, $.peggedTokenBalance), IERC20(WRAPPED_COLLATERAL_TOKEN).balanceOf(reservePool_) @@ -893,7 +893,7 @@ contract Minter_v3 is uint256 wrappedFee; uint256 underlyingCollateralOut; (wrappedFee, leveragedIn, wrappedCollateralOut, underlyingCollateralOut) = _redeemLeveragedAdjustments( - $.incentiveConfig[Config_v1.REDEEM_LEVERAGED], + $.incentiveConfig[Config_v2.REDEEM_LEVERAGED], leveragedIn, CollateralRatioData(underlyingCollateral_, oracle.price, oracle.rate, $.peggedTokenBalance), leveragedTokenBalance_ diff --git a/src/minter/library/Config_v2.sol b/src/minter/library/Config_v2.sol new file mode 100644 index 00000000..4337489c --- /dev/null +++ b/src/minter/library/Config_v2.sol @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.30; + +import {ConfigIncentiveLib} from "@harbor/minter/library/ConfigIncentiveLib.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; + +/// @title Config_v2 Library +/// @notice Handles validation and storage-efficient formatting for infrequently called config operations +/// @dev Extracts config validation from the main Minter contract to reduce its size, at the cost of increased gas. +/// We take this hit because upgrading the config is an infrequent cost. +/// @dev this contract doesn't modify storage so is upgrade safe +// solhint-disable-next-line contract-name-camelcase +library Config_v2 { + using ConfigIncentiveLib for ConfigIncentiveLib.ActionIncentive; + + uint public constant MINT_PEGGED = 0; // solhint-disable-line explicit-types + uint public constant REDEEM_PEGGED = 1; // solhint-disable-line explicit-types + uint public constant MINT_LEVERAGED = 2; // solhint-disable-line explicit-types + uint public constant REDEEM_LEVERAGED = 3; // solhint-disable-line explicit-types + + /// @notice Checks a given incentive config for errors and returns it ready for storage + /// @param name Label for error messages + /// @param config_ The user friendly config being checked and copied + /// @param disallowNotDiscount If true, the config may have a disallow and not a discount + /// true means it's a mint pegged or redeem leveraged config + /// false means it's a redeem pegged or mint leveraged config + /// @return out the storage efficient config + // slither-disable-next-line cyclomatic-complexity as this code is simple in what it tries to do, it's just that there are a few checks + function checkAndCopyBands( + string memory name, + IMinter.IncentiveConfig calldata config_, + bool disallowNotDiscount + ) internal pure returns (ConfigIncentiveLib.ActionIncentive memory out) { + // check the array sizes match + if (config_.incentiveRatios.length < 1) { + revert IMinter.TooFewIncentiveRatios(name, config_.incentiveRatios.length, 1); + } + if (config_.incentiveRatios.length != config_.collateralRatioBandUpperBounds.length + 1) { + revert IMinter.CollateralRatioBoundsIncentivesLengthsMismatch( + name, + config_.collateralRatioBandUpperBounds.length, + config_.incentiveRatios.length + ); + } + out = ConfigIncentiveLib.ActionIncentive(0, 0); + uint256 prevUpperBound = 0; + uint iOut = 0; // solhint-disable-line explicit-types + // solhint-disable-next-line explicit-types + for (uint i = 0; i < config_.incentiveRatios.length; i++) { + int256 incentiveRatio = ConfigIncentiveLib._incentiveRatioToStoragePrecision(config_.incentiveRatios[i]); + + // incentive ratios cannot be too precise for the storage schema + if (incentiveRatio != config_.incentiveRatios[i]) { + revert IMinter.IncentiveRatioTooPrecise(name, config_.incentiveRatios[i]); + } + + // check the incentive array values given + if (disallowNotDiscount) { + // it's mint pegged or redeem leveraged + // check against interval [0, 1] i.e. zero fees to some fees to disallow (100% fees) + if (incentiveRatio < 0 ether || incentiveRatio > 1 ether) { + revert IMinter.InvalidIncentiveRatioValue(name, i, config_.incentiveRatios[i], "must be in [0, 1]"); + } + // disallows, if they exist, must be at index 0 + if (incentiveRatio == 1 ether && i != 0) { + revert IMinter.InvalidIncentiveRatioValue( + name, + i, + config_.incentiveRatios[i], + "disallow (1) must be at index 0" + ); + } + } else { + // it's a redeem pegged or mint leveraged + // check against interval (-1, 1) i.e. some discount; to zero; to some fees + if (incentiveRatio <= -1 ether || incentiveRatio >= 1 ether) { + revert IMinter.InvalidIncentiveRatioValue( + name, + i, + config_.incentiveRatios[i], + "must be in (-1, 1)" + ); + } + } + // check collateral ratio upper bounds are strictly increasing and then copy + uint256 currentUpperBound; + if (i < config_.collateralRatioBandUpperBounds.length) { + currentUpperBound = ConfigIncentiveLib._collateralRatioToStoragePrecision( + config_.collateralRatioBandUpperBounds[i] + ); + if (currentUpperBound != config_.collateralRatioBandUpperBounds[i]) { + revert IMinter.CollateralRatioBoundTooPrecise(name, config_.collateralRatioBandUpperBounds[i]); + } + if (i == 0 && currentUpperBound < 1 ether) { + revert IMinter.InvalidCollateralRatioBoundValue( + name, + currentUpperBound, + i, + "first boundary must be >= 1" + ); + } + if (i > 0 && currentUpperBound <= 1 ether) { + revert IMinter.InvalidCollateralRatioBoundValue(name, currentUpperBound, i, "boundary must be > 1"); + } + } else { + currentUpperBound = type(uint256).max; + } + + if (i == 0) { + // check first band covers depeg territory or is a disallow band + // this allows the different math involved in a depeg scenario doesn't straddle a band + // if we didn't do it here, we would have to do it in each of the fee calculation functions + // there is also at most one depegged band and you determine if you are in the band by checking the boundary against 1 ether + // it makes the math simpler: i.e. how, otherwise, do we manage multiple incentive ratios for the depegged situation? + // especially as the actual collateral ratio (not the one we calculate as _collateralRatio()) never goes below 1 ether + if (currentUpperBound != 1 ether && incentiveRatio != 1 ether) { + revert IMinter.NoDepegBoundaryOrDisallow(name); + } + } else { + // each subsequent must be strictly increasing at the storage precision + if (currentUpperBound <= prevUpperBound) { + revert IMinter.CollateralRatioBoundValueNotIncreasing( + name, + config_.collateralRatioBandUpperBounds[i], + i, + config_.collateralRatioBandUpperBounds[i - 1] + ); + } + } + if (iOut >= ConfigIncentiveLib.MAX_BANDS) { + revert IMinter.TooManyIncentiveRatios( + name, + config_.incentiveRatios.length, + config_.incentiveRatios.length - 1 + ); + } + + ConfigIncentiveLib._setIncentiveRatio(out, iOut, incentiveRatio); + if (i < config_.collateralRatioBandUpperBounds.length) { + ConfigIncentiveLib._setCollateralRatioUpperBounds(out, iOut, currentUpperBound); + prevUpperBound = currentUpperBound; + } + iOut++; + } + + ConfigIncentiveLib._setCollateralRatioBandCount(out, iOut); + return out; + } + + function checkAndCopyIncentives( + IMinter.Config calldata config_, + ConfigIncentiveLib.ActionIncentive[4] storage out + ) external { + out[MINT_PEGGED] = checkAndCopyBands("mint pegged", config_.mintPeggedIncentiveConfig, true); + out[REDEEM_PEGGED] = checkAndCopyBands("redeem pegged", config_.redeemPeggedIncentiveConfig, false); + out[MINT_LEVERAGED] = checkAndCopyBands("mint leveraged", config_.mintLeveragedIncentiveConfig, false); + out[REDEEM_LEVERAGED] = checkAndCopyBands("redeem leveraged", config_.redeemLeveragedIncentiveConfig, true); + } + + /// @notice Converts the compact storage format back to the full IncentiveConfig + /// @param config_ The storage-efficient configuration to convert back + /// @return out The user-friendly config structure + function copyBandsBack( + ConfigIncentiveLib.ActionIncentive memory config_ + ) internal pure returns (IMinter.IncentiveConfig memory out) { + uint iOut = 0; // solhint-disable-line explicit-types + uint outBands = ConfigIncentiveLib._collateralRatioBandCount(config_); // solhint-disable-line explicit-types + uint outBounds = outBands - 1; // solhint-disable-line explicit-types + out.collateralRatioBandUpperBounds = new uint256[](outBounds); + out.incentiveRatios = new int256[](outBands); + // solhint-disable-next-line explicit-types + for (uint i = 0; i < outBounds; i++) { + uint256 ub = ConfigIncentiveLib._collateralRatioUpperBounds(config_, i); + if (ub == 1 ether - 1) { + ub = 1 ether; + } + out.collateralRatioBandUpperBounds[iOut] = ub; + out.incentiveRatios[iOut] = ConfigIncentiveLib._incentiveRatio(config_, i); + iOut++; + } + out.incentiveRatios[iOut] = ConfigIncentiveLib._incentiveRatio(config_, outBounds); + return out; + } + + function copyIncentivesBack( + ConfigIncentiveLib.ActionIncentive[4] memory config_ + ) internal pure returns (IMinter.Config memory out) { + out.mintPeggedIncentiveConfig = copyBandsBack(config_[MINT_PEGGED]); + out.redeemPeggedIncentiveConfig = copyBandsBack(config_[REDEEM_PEGGED]); + out.mintLeveragedIncentiveConfig = copyBandsBack(config_[MINT_LEVERAGED]); + out.redeemLeveragedIncentiveConfig = copyBandsBack(config_[REDEEM_LEVERAGED]); + } + + function defaultActionIncentive() internal pure returns (ConfigIncentiveLib.ActionIncentive memory out) { + // default config is a single band with no fees, discounts or disallows + // we need the mandatory depeg boundary at 1 ether + + ConfigIncentiveLib._setIncentiveRatio(out, 0, 0); // in depeg + ConfigIncentiveLib._setCollateralRatioUpperBounds(out, 0, 1 ether); // depeg boundary + ConfigIncentiveLib._setIncentiveRatio(out, 1, 0); // pegged + ConfigIncentiveLib._setCollateralRatioBandCount(out, 2); // two bands: de-pegged and pegged + + return out; + } + + function defaultIncentive(ConfigIncentiveLib.ActionIncentive[4] storage out) external { + out[0] = defaultActionIncentive(); // mint pegged + out[1] = defaultActionIncentive(); // redeem pegged + out[2] = defaultActionIncentive(); // mint leveraged + out[3] = defaultActionIncentive(); // redeem leveraged + } +} diff --git a/test/CollateralRatio.t.sol b/test/CollateralRatio.t.sol index fdd9ef4e..f41d25c8 100644 --- a/test/CollateralRatio.t.sol +++ b/test/CollateralRatio.t.sol @@ -6,12 +6,12 @@ import "@openzeppelin/contracts/utils/math/SignedMath.sol"; import "@openzeppelin/contracts/utils/math/Math.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; -import {IMinter} from "src/interfaces/IMinter.sol"; -import {IWrappedPriceOracle} from "src/interfaces/IWrappedPriceOracle.sol"; -import {TestStabilityPool2SetUp} from "test/TestStabilityPool2SetUp.sol"; -import {MockWrappedPriceOracle} from "test/mocks/MockWrappedPriceOracle.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; +import {IWrappedPriceOracle} from "@harbor/interfaces/IWrappedPriceOracle.sol"; +import {TestStabilityPool2SetUp} from "@harbor-test/TestStabilityPool2SetUp.sol"; +import {MockWrappedPriceOracle} from "@harbor-test/mocks/MockWrappedPriceOracle.sol"; -import "test/Useful.sol"; +import "@harbor-test/Useful.sol"; import {console2} from "forge-std/console2.sol"; diff --git a/test/Config.sol b/test/Config.sol index 7a4b383e..3bbb377e 100644 --- a/test/Config.sol +++ b/test/Config.sol @@ -4,7 +4,7 @@ pragma solidity >=0.8.28 <0.9.0; import {stdJson} from "forge-std/StdJson.sol"; import {Test} from "forge-std/Test.sol"; -import {IMinter} from "src/interfaces/IMinter.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; abstract contract ConfigFile is Test { function readConfigFile(string memory fileName) internal view returns (IMinter.Config memory config) { diff --git a/test/ERC20MetadataLib_v1.t.sol b/test/ERC20MetadataLib_v1.t.sol index e7a7f2db..9f80570b 100644 --- a/test/ERC20MetadataLib_v1.t.sol +++ b/test/ERC20MetadataLib_v1.t.sol @@ -3,7 +3,7 @@ pragma solidity >=0.8.28 <0.9.0; import "forge-std/Test.sol"; -import {ERC20MetadataLib_v1} from "src/util/ERC20MetadataLib_v1.sol"; +import {ERC20MetadataLib_v1} from "@harbor/util/ERC20MetadataLib_v1.sol"; /// @dev Tests ERC20MetadataLib_v1: round-trip of pack/unpack for the symbol (1..31 chars, /// delegates to Solady) and name (1..63 chars, custom mload/mstore implementation). diff --git a/test/ExplainFinishAtZero.t.sol b/test/ExplainFinishAtZero.t.sol index 43f6f09f..1bfe01c7 100644 --- a/test/ExplainFinishAtZero.t.sol +++ b/test/ExplainFinishAtZero.t.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {MockERC20} from "@bao-test/mocks/MockERC20.sol"; -import {MockLinearMultipleRewardDistributor_v3} from "test/mocks/reward/distributor/MockLinearMultipleRewardDistributor_v3.sol"; +import {MockLinearMultipleRewardDistributor_v3} from "@harbor-test/mocks/reward/distributor/MockLinearMultipleRewardDistributor_v3.sol"; import "forge-std/Test.sol"; /// @title Explain Why finishAt is Zero diff --git a/test/Genesis.t.sol b/test/Genesis.t.sol index cfabc295..50c668c8 100644 --- a/test/Genesis.t.sol +++ b/test/Genesis.t.sol @@ -10,12 +10,12 @@ import {IERC1967} from "@openzeppelin/contracts/interfaces/IERC1967.sol"; import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; -import {IGenesis} from "src/interfaces/IGenesis.sol"; -import {IMinter} from "src/interfaces/IMinter.sol"; +import {IGenesis} from "@harbor/interfaces/IGenesis.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; -import {Genesis_v1} from "src/minter/Genesis_v1.sol"; +import {Genesis_v1} from "@harbor/minter/Genesis_v1.sol"; -import {TestMinterSetUp} from "test/Minter_base.t.sol"; +import {TestMinterSetUp} from "@harbor-test/Minter_base.t.sol"; import {Token} from "@bao/Token.sol"; contract Test_GenesisBase is TestMinterSetUp { diff --git a/test/Graph.t.sol b/test/Graph.t.sol index 144253f9..f8b16440 100644 --- a/test/Graph.t.sol +++ b/test/Graph.t.sol @@ -6,7 +6,7 @@ import {Test} from "forge-std/Test.sol"; import "@openzeppelin/contracts/utils/math/SignedMath.sol"; import "@openzeppelin/contracts/utils/math/Math.sol"; -import "test/Useful.sol"; +import "@harbor-test/Useful.sol"; abstract contract TestGraph is Test { int256 NaN = type(int256).max; diff --git a/test/GraphMinter.t.sol b/test/GraphMinter.t.sol index 1b8dfa70..12e24b3e 100644 --- a/test/GraphMinter.t.sol +++ b/test/GraphMinter.t.sol @@ -4,9 +4,9 @@ pragma solidity >=0.8.28 <0.9.0; import "@openzeppelin/contracts/utils/math/SignedMath.sol"; import "@openzeppelin/contracts/utils/math/Math.sol"; -import "test/Useful.sol"; -import {TestMinterFeeSetUp} from "test/Minter_fees.t.sol"; -import {TestGraph} from "test/Graph.t.sol"; +import "@harbor-test/Useful.sol"; +import {TestMinterFeeSetUp} from "@harbor-test/Minter_fees.t.sol"; +import {TestGraph} from "@harbor-test/Graph.t.sol"; abstract contract TestGraphMinter is TestGraph, TestMinterFeeSetUp { string file; diff --git a/test/GraphReward.t.sol b/test/GraphReward.t.sol index 6d22e9d9..2d849685 100644 --- a/test/GraphReward.t.sol +++ b/test/GraphReward.t.sol @@ -7,15 +7,15 @@ import "@openzeppelin/contracts/utils/math/Math.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; -import {IMinter} from "src/interfaces/IMinter.sol"; -import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; -import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; -import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; - -import "test/Useful.sol"; -import {IWrappedPriceOracle} from "src/interfaces/IWrappedPriceOracle.sol"; -import {TestStabilityPoolSetUp} from "test/StabilityPool.t.sol"; -import {TestGraph} from "test/Graph.t.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; +import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; +import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; +import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; + +import "@harbor-test/Useful.sol"; +import {IWrappedPriceOracle} from "@harbor/interfaces/IWrappedPriceOracle.sol"; +import {TestStabilityPoolSetUp} from "@harbor-test/StabilityPool.t.sol"; +import {TestGraph} from "@harbor-test/Graph.t.sol"; abstract contract TestGraphReward is TestGraph, TestStabilityPoolSetUp { string rewardFile; diff --git a/test/Graphs.t.sol b/test/Graphs.t.sol index fa470002..36631dfc 100644 --- a/test/Graphs.t.sol +++ b/test/Graphs.t.sol @@ -6,7 +6,7 @@ import {Test} from "forge-std/Test.sol"; import "@openzeppelin/contracts/utils/math/SignedMath.sol"; import "@openzeppelin/contracts/utils/math/Math.sol"; -import "test/Useful.sol"; +import "@harbor-test/Useful.sol"; abstract contract TestGraphs is Test { int256 NaN = type(int256).max; diff --git a/test/GraphsBasicCalculations.t.sol b/test/GraphsBasicCalculations.t.sol index 0dec9ea9..02f181e3 100644 --- a/test/GraphsBasicCalculations.t.sol +++ b/test/GraphsBasicCalculations.t.sol @@ -5,14 +5,14 @@ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/utils/math/SignedMath.sol"; import "@openzeppelin/contracts/utils/math/Math.sol"; -import {IMinter} from "src/interfaces/IMinter.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; -import {IWrappedPriceOracle} from "src/interfaces/IWrappedPriceOracle.sol"; -import {MockWrappedPriceOracle} from "test/mocks/MockWrappedPriceOracle.sol"; +import {IWrappedPriceOracle} from "@harbor/interfaces/IWrappedPriceOracle.sol"; +import {MockWrappedPriceOracle} from "@harbor-test/mocks/MockWrappedPriceOracle.sol"; -import "test/Useful.sol"; -import {TestStabilityPool2SetUp} from "test/TestStabilityPool2SetUp.sol"; -import {TestGraphs} from "test/Graphs.t.sol"; +import "@harbor-test/Useful.sol"; +import {TestStabilityPool2SetUp} from "@harbor-test/TestStabilityPool2SetUp.sol"; +import {TestGraphs} from "@harbor-test/Graphs.t.sol"; contract TestGraphsBasicCalculations is TestStabilityPool2SetUp, TestGraphs { // TODO: collateral ratio diff --git a/test/GraphsFees.t.sol b/test/GraphsFees.t.sol index 06fb82f0..7c3a6263 100644 --- a/test/GraphsFees.t.sol +++ b/test/GraphsFees.t.sol @@ -4,11 +4,11 @@ pragma solidity >=0.8.28 <0.9.0; import "@openzeppelin/contracts/utils/math/SignedMath.sol"; import "@openzeppelin/contracts/utils/math/Math.sol"; -import {IMinter} from "src/interfaces/IMinter.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; -import "test/Useful.sol"; -import {TestCollateralRatioRangeSetUp} from "test/CollateralRatio.t.sol"; -import {TestGraphs} from "test/Graphs.t.sol"; +import "@harbor-test/Useful.sol"; +import {TestCollateralRatioRangeSetUp} from "@harbor-test/CollateralRatio.t.sol"; +import {TestGraphs} from "@harbor-test/Graphs.t.sol"; contract TestGraphsFees is TestGraphs, TestCollateralRatioRangeSetUp { string feesFile; diff --git a/test/GraphsInvariant.t.sol b/test/GraphsInvariant.t.sol index 61a1d096..8119b04b 100644 --- a/test/GraphsInvariant.t.sol +++ b/test/GraphsInvariant.t.sol @@ -4,11 +4,11 @@ pragma solidity >=0.8.28 <0.9.0; import "@openzeppelin/contracts/utils/math/SignedMath.sol"; import "@openzeppelin/contracts/utils/math/Math.sol"; -import {IMinter} from "src/interfaces/IMinter.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; -import "test/Useful.sol"; -import {TestCollateralRatioRangeSetUp} from "test/CollateralRatio.t.sol"; -import {TestGraphs} from "test/Graphs.t.sol"; +import "@harbor-test/Useful.sol"; +import {TestCollateralRatioRangeSetUp} from "@harbor-test/CollateralRatio.t.sol"; +import {TestGraphs} from "@harbor-test/Graphs.t.sol"; contract TestGraphsInvariant is TestGraphs, TestCollateralRatioRangeSetUp { string invariantFile; diff --git a/test/GraphsLiquidate.t.sol b/test/GraphsLiquidate.t.sol index 24d5948e..fcd26eb3 100644 --- a/test/GraphsLiquidate.t.sol +++ b/test/GraphsLiquidate.t.sol @@ -9,16 +9,16 @@ import "@openzeppelin/contracts/utils/math/Math.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; -import {IMinter} from "src/interfaces/IMinter.sol"; -import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; -import {IStabilityPoolManager} from "src/interfaces/IStabilityPoolManager.sol"; -import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; +import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; +import {IStabilityPoolManager} from "@harbor/interfaces/IStabilityPoolManager.sol"; +import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; -import {StabilityPoolManager_v1} from "src/minter/StabilityPoolManager_v1.sol"; +import {StabilityPoolManager_v1} from "@harbor/minter/StabilityPoolManager_v1.sol"; -import "test/Useful.sol"; -import {TestCollateralRatioRangeSetUp} from "test/CollateralRatio.t.sol"; -import {TestGraphs} from "test/Graphs.t.sol"; +import "@harbor-test/Useful.sol"; +import {TestCollateralRatioRangeSetUp} from "@harbor-test/CollateralRatio.t.sol"; +import {TestGraphs} from "@harbor-test/Graphs.t.sol"; contract TestGraphsLiquidatePartial is TestGraphs, TestCollateralRatioRangeSetUp { string liquidateFile; diff --git a/test/Minter_base.t.sol b/test/Minter_base.t.sol index 46a90990..4e935b51 100644 --- a/test/Minter_base.t.sol +++ b/test/Minter_base.t.sol @@ -14,23 +14,23 @@ import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; -import {Minter_v3} from "src/minter/Minter_v3.sol"; +import {Minter_v3} from "@harbor/minter/Minter_v3.sol"; import {MintableBurnableERC20_v1} from "@bao/MintableBurnableERC20_v1.sol"; -import {ReservePool_v1} from "src/minter/ReservePool_v1.sol"; +import {ReservePool_v1} from "@harbor/minter/ReservePool_v1.sol"; -import {IMinter} from "src/interfaces/IMinter.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; import {Token} from "@bao/Token.sol"; import {IMintable} from "@bao/interfaces/IMintable.sol"; -import {IWrappedPriceOracle} from "src/interfaces/IWrappedPriceOracle.sol"; +import {IWrappedPriceOracle} from "@harbor/interfaces/IWrappedPriceOracle.sol"; import {Deployed} from "@bao/Deployed.sol"; -import {MockWrappedPriceOracle} from "test/mocks/MockWrappedPriceOracle.sol"; -import {IBaoUSD} from "test/IBaoUSD.sol"; +import {MockWrappedPriceOracle} from "@harbor-test/mocks/MockWrappedPriceOracle.sol"; +import {IBaoUSD} from "@harbor-test/IBaoUSD.sol"; import {MockERC20, MockERC20Burn2Arg, MockERC20Burn1Arg, MockERC20BurnFrom} from "@bao-test/mocks/MockERC20.sol"; -import "test/Useful.sol"; -import {Array} from "test/Array.sol"; +import "@harbor-test/Useful.sol"; +import {Array} from "@harbor-test/Array.sol"; -import {ConfigFile} from "test/Config.sol"; +import {ConfigFile} from "@harbor-test/Config.sol"; abstract contract TestExtras is Test { function isNear(uint256 a, uint256 b, uint256 maxAbsDiff, uint256 maxRelDiff) internal pure returns (bool near) { diff --git a/test/Minter_feeRange.t.sol b/test/Minter_feeRange.t.sol index 26106a97..b0b5b53a 100644 --- a/test/Minter_feeRange.t.sol +++ b/test/Minter_feeRange.t.sol @@ -8,12 +8,12 @@ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/utils/math/SignedMath.sol"; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; -import {IMinter} from "src/interfaces/IMinter.sol"; -import {IWrappedPriceOracle} from "src/interfaces/IWrappedPriceOracle.sol"; -import {MockWrappedPriceOracle} from "test/mocks/MockWrappedPriceOracle.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; +import {IWrappedPriceOracle} from "@harbor/interfaces/IWrappedPriceOracle.sol"; +import {MockWrappedPriceOracle} from "@harbor-test/mocks/MockWrappedPriceOracle.sol"; -import "test/Useful.sol"; -import {TestMinterSetUp} from "test/Minter_base.t.sol"; +import "@harbor-test/Useful.sol"; +import {TestMinterSetUp} from "@harbor-test/Minter_base.t.sol"; abstract contract TestMinterFeeRangeSetUp is TestMinterSetUp { uint256 price; diff --git a/test/Minter_fees.t.sol b/test/Minter_fees.t.sol index 8671cf6c..66943207 100644 --- a/test/Minter_fees.t.sol +++ b/test/Minter_fees.t.sol @@ -6,13 +6,13 @@ pragma solidity >=0.8.28 <0.9.0; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/utils/math/SignedMath.sol"; -import {IMinter} from "src/interfaces/IMinter.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; import {Deployed} from "@bao/Deployed.sol"; -import {IWrappedPriceOracle} from "src/interfaces/IWrappedPriceOracle.sol"; -import {MockWrappedPriceOracle} from "test/mocks/MockWrappedPriceOracle.sol"; +import {IWrappedPriceOracle} from "@harbor/interfaces/IWrappedPriceOracle.sol"; +import {MockWrappedPriceOracle} from "@harbor-test/mocks/MockWrappedPriceOracle.sol"; -import "test/Useful.sol"; -import {TestMinterSetUp} from "test/Minter_base.t.sol"; +import "@harbor-test/Useful.sol"; +import {TestMinterSetUp} from "@harbor-test/Minter_base.t.sol"; // TODO: check what happens when safe price is invalid diff --git a/test/Minter_harvest.t.sol b/test/Minter_harvest.t.sol index dcd24e6b..1d94ce9e 100644 --- a/test/Minter_harvest.t.sol +++ b/test/Minter_harvest.t.sol @@ -7,15 +7,15 @@ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/utils/math/SignedMath.sol"; import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; -import {IMinter} from "src/interfaces/IMinter.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; import {Deployed} from "@bao/Deployed.sol"; import {ITokenHolder} from "@bao/interfaces/ITokenHolder.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; -import {IWrappedPriceOracle} from "src/interfaces/IWrappedPriceOracle.sol"; -import {MockWrappedPriceOracle} from "test/mocks/MockWrappedPriceOracle.sol"; +import {IWrappedPriceOracle} from "@harbor/interfaces/IWrappedPriceOracle.sol"; +import {MockWrappedPriceOracle} from "@harbor-test/mocks/MockWrappedPriceOracle.sol"; -import "test/Useful.sol"; -import {TestMinterSetUp} from "test/Minter_base.t.sol"; +import "@harbor-test/Useful.sol"; +import {TestMinterSetUp} from "@harbor-test/Minter_base.t.sol"; contract TestMinterHarvestSetUp is TestMinterSetUp { function setUpConfig() internal virtual override { diff --git a/test/Minter_liquidate.t.sol b/test/Minter_liquidate.t.sol index 68d2a87b..ec545f3a 100644 --- a/test/Minter_liquidate.t.sol +++ b/test/Minter_liquidate.t.sol @@ -4,11 +4,11 @@ pragma solidity >=0.8.28 <0.9.0; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import {IMinter} from "src/interfaces/IMinter.sol"; -import {IWrappedPriceOracle} from "src/interfaces/IWrappedPriceOracle.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; +import {IWrappedPriceOracle} from "@harbor/interfaces/IWrappedPriceOracle.sol"; -import "test/Useful.sol"; -import {TestMinterFeeSetUp} from "test/Minter_fees.t.sol"; +import "@harbor-test/Useful.sol"; +import {TestMinterFeeSetUp} from "@harbor-test/Minter_fees.t.sol"; contract TestMinterLiquidate is TestMinterFeeSetUp { using SafeERC20 for IERC20; diff --git a/test/Minter_mint.t.sol b/test/Minter_mint.t.sol index 12ff484b..fff2ce1b 100644 --- a/test/Minter_mint.t.sol +++ b/test/Minter_mint.t.sol @@ -4,12 +4,12 @@ pragma solidity >=0.8.28 <0.9.0; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import {IMinter} from "src/interfaces/IMinter.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; import {Deployed} from "@bao/Deployed.sol"; -import {IWrappedPriceOracle} from "src/interfaces/IWrappedPriceOracle.sol"; +import {IWrappedPriceOracle} from "@harbor/interfaces/IWrappedPriceOracle.sol"; -import "test/Useful.sol"; -import {TestMinterSetUp} from "test/Minter_base.t.sol"; +import "@harbor-test/Useful.sol"; +import {TestMinterSetUp} from "@harbor-test/Minter_base.t.sol"; contract TestMinterMint is TestMinterSetUp { using SafeERC20 for IERC20; diff --git a/test/Minter_mintLeveraged.t.sol b/test/Minter_mintLeveraged.t.sol index 6fbdde79..5d9a0497 100644 --- a/test/Minter_mintLeveraged.t.sol +++ b/test/Minter_mintLeveraged.t.sol @@ -9,13 +9,13 @@ import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; -import {IMinter} from "src/interfaces/IMinter.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; import {Deployed} from "@bao/Deployed.sol"; -import {IWrappedPriceOracle} from "src/interfaces/IWrappedPriceOracle.sol"; -import {MockWrappedPriceOracle} from "test/mocks/MockWrappedPriceOracle.sol"; +import {IWrappedPriceOracle} from "@harbor/interfaces/IWrappedPriceOracle.sol"; +import {MockWrappedPriceOracle} from "@harbor-test/mocks/MockWrappedPriceOracle.sol"; -import "test/Useful.sol"; -import {TestMinterMint} from "test/Minter_mint.t.sol"; +import "@harbor-test/Useful.sol"; +import {TestMinterMint} from "@harbor-test/Minter_mint.t.sol"; contract TestMinterMintLeveraged is TestMinterMint { using SafeERC20 for IERC20; diff --git a/test/Minter_mintPegged.t.sol b/test/Minter_mintPegged.t.sol index d4264b9b..30e599fc 100644 --- a/test/Minter_mintPegged.t.sol +++ b/test/Minter_mintPegged.t.sol @@ -6,12 +6,12 @@ import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; -import {IMinter} from "src/interfaces/IMinter.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; import {Deployed} from "@bao/Deployed.sol"; -import {IWrappedPriceOracle} from "src/interfaces/IWrappedPriceOracle.sol"; +import {IWrappedPriceOracle} from "@harbor/interfaces/IWrappedPriceOracle.sol"; -import "test/Useful.sol"; -import {TestMinterMint} from "test/Minter_mint.t.sol"; +import "@harbor-test/Useful.sol"; +import {TestMinterMint} from "@harbor-test/Minter_mint.t.sol"; contract TestMinterMintPegged is TestMinterMint { using SafeERC20 for IERC20; diff --git a/test/Minter_redeemLeveraged.t.sol b/test/Minter_redeemLeveraged.t.sol index 8be49648..44eb1a45 100644 --- a/test/Minter_redeemLeveraged.t.sol +++ b/test/Minter_redeemLeveraged.t.sol @@ -7,13 +7,13 @@ import {IERC20Errors} from "@openzeppelin/contracts/interfaces/draft-IERC6093.so import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; -import {IMinter} from "src/interfaces/IMinter.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; import {Deployed} from "@bao/Deployed.sol"; -import {IWrappedPriceOracle} from "src/interfaces/IWrappedPriceOracle.sol"; -import {MockWrappedPriceOracle} from "test/mocks/MockWrappedPriceOracle.sol"; +import {IWrappedPriceOracle} from "@harbor/interfaces/IWrappedPriceOracle.sol"; +import {MockWrappedPriceOracle} from "@harbor-test/mocks/MockWrappedPriceOracle.sol"; -import "test/Useful.sol"; -import {TestMinterMint} from "test/Minter_mint.t.sol"; +import "@harbor-test/Useful.sol"; +import {TestMinterMint} from "@harbor-test/Minter_mint.t.sol"; contract TestMinterRedeemLeveraged is TestMinterMint { using SafeERC20 for IERC20; diff --git a/test/Minter_redeemPegged.t.sol b/test/Minter_redeemPegged.t.sol index acbe301f..712745af 100644 --- a/test/Minter_redeemPegged.t.sol +++ b/test/Minter_redeemPegged.t.sol @@ -6,13 +6,13 @@ import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; -import {IMinter} from "src/interfaces/IMinter.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; import {Deployed} from "@bao/Deployed.sol"; -import {IWrappedPriceOracle} from "src/interfaces/IWrappedPriceOracle.sol"; -import {MockWrappedPriceOracle} from "test/mocks/MockWrappedPriceOracle.sol"; +import {IWrappedPriceOracle} from "@harbor/interfaces/IWrappedPriceOracle.sol"; +import {MockWrappedPriceOracle} from "@harbor-test/mocks/MockWrappedPriceOracle.sol"; -import {TestMinterMint} from "test/Minter_mint.t.sol"; +import {TestMinterMint} from "@harbor-test/Minter_mint.t.sol"; contract TestMinterRedeemPegged is TestMinterMint { using SafeERC20 for IERC20; diff --git a/test/Minter_slash.t.sol b/test/Minter_slash.t.sol index 102b2fed..be33771f 100644 --- a/test/Minter_slash.t.sol +++ b/test/Minter_slash.t.sol @@ -7,12 +7,12 @@ import "@openzeppelin/contracts/utils/math/SignedMath.sol"; import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; -import {IMinter} from "src/interfaces/IMinter.sol"; -import {IWrappedPriceOracle} from "src/interfaces/IWrappedPriceOracle.sol"; -import {MockWrappedPriceOracle} from "test/mocks/MockWrappedPriceOracle.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; +import {IWrappedPriceOracle} from "@harbor/interfaces/IWrappedPriceOracle.sol"; +import {MockWrappedPriceOracle} from "@harbor-test/mocks/MockWrappedPriceOracle.sol"; -import "test/Useful.sol"; -import {TestMinterSetUp} from "test/Minter_base.t.sol"; +import "@harbor-test/Useful.sol"; +import {TestMinterSetUp} from "@harbor-test/Minter_base.t.sol"; contract MinterSlashTest is TestMinterSetUp { function setUpConfig() internal virtual override { diff --git a/test/Rebalance.t.sol b/test/Rebalance.t.sol index 8bc16b9a..a62e3b44 100644 --- a/test/Rebalance.t.sol +++ b/test/Rebalance.t.sol @@ -5,20 +5,20 @@ import {UnsafeUpgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {IMinter} from "src/interfaces/IMinter.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; -import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; -import {StabilityPool_v3} from "src/minter/StabilityPool_v3.sol"; -import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; +import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; +import {StabilityPool_v3} from "@harbor/minter/StabilityPool_v3.sol"; +import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; -import {IWrappedPriceOracle} from "src/interfaces/IWrappedPriceOracle.sol"; +import {IWrappedPriceOracle} from "@harbor/interfaces/IWrappedPriceOracle.sol"; -import {MockWrappedPriceOracle} from "test/mocks/MockWrappedPriceOracle.sol"; -import "test/Useful.sol"; -import {TestStabilityPool2SetUp} from "test/TestStabilityPool2SetUp.sol"; -import {IStabilityPoolManager} from "src/interfaces/IStabilityPoolManager.sol"; -import {StabilityPoolManager_v1} from "src/minter/StabilityPoolManager_v1.sol"; +import {MockWrappedPriceOracle} from "@harbor-test/mocks/MockWrappedPriceOracle.sol"; +import "@harbor-test/Useful.sol"; +import {TestStabilityPool2SetUp} from "@harbor-test/TestStabilityPool2SetUp.sol"; +import {IStabilityPoolManager} from "@harbor/interfaces/IStabilityPoolManager.sol"; +import {StabilityPoolManager_v1} from "@harbor/minter/StabilityPoolManager_v1.sol"; contract TestLiquidate is TestStabilityPool2SetUp { address stabilityPoolManagerCollateral; diff --git a/test/ReservePool.t.sol b/test/ReservePool.t.sol index cd84b846..8e8aeb6c 100644 --- a/test/ReservePool.t.sol +++ b/test/ReservePool.t.sol @@ -13,8 +13,8 @@ import {IERC1967} from "@openzeppelin/contracts/interfaces/IERC1967.sol"; import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; -import {ReservePool_v1} from "src/minter/ReservePool_v1.sol"; -import {IReservePool} from "src/interfaces/IReservePool.sol"; +import {ReservePool_v1} from "@harbor/minter/ReservePool_v1.sol"; +import {IReservePool} from "@harbor/interfaces/IReservePool.sol"; import {Deployed} from "@bao/Deployed.sol"; diff --git a/test/StabilityPool.t.sol b/test/StabilityPool.t.sol index 416e054a..f77ca114 100644 --- a/test/StabilityPool.t.sol +++ b/test/StabilityPool.t.sol @@ -16,17 +16,17 @@ import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; import {IMintableRole} from "@bao/interfaces/IMintableRole.sol"; import {IMintable} from "@bao/interfaces/IMintable.sol"; -import {IMinter} from "src/interfaces/IMinter.sol"; -import {StabilityPool_v3} from "src/minter/StabilityPool_v3.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; +import {StabilityPool_v3} from "@harbor/minter/StabilityPool_v3.sol"; import {MintableBurnableERC20_v1} from "@bao/MintableBurnableERC20_v1.sol"; -import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; +import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; -import {IWrappedPriceOracle} from "src/interfaces/IWrappedPriceOracle.sol"; -import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; +import {IWrappedPriceOracle} from "@harbor/interfaces/IWrappedPriceOracle.sol"; +import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; -import {DecrementalFloatingPoint} from "src/math/DecrementalFloatingPoint.sol"; +import {DecrementalFloatingPoint} from "@harbor/math/DecrementalFloatingPoint.sol"; -import {TestMinterFeeSetUp} from "test/Minter_fees.t.sol"; +import {TestMinterFeeSetUp} from "@harbor-test/Minter_fees.t.sol"; // New version for testing upgrades contract StabilityPool_vN is StabilityPool_v3 { diff --git a/test/StabilityPoolBaseSetUp.t.sol b/test/StabilityPoolBaseSetUp.t.sol index 2d8d67bc..b6c259c9 100644 --- a/test/StabilityPoolBaseSetUp.t.sol +++ b/test/StabilityPoolBaseSetUp.t.sol @@ -3,10 +3,10 @@ pragma solidity >=0.8.28 <0.9.0; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; +import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; import {MockERC20} from "@bao-test/mocks/MockERC20.sol"; -import {TestStabilityPool2SetUp} from "test/TestStabilityPool2SetUp.sol"; +import {TestStabilityPool2SetUp} from "@harbor-test/TestStabilityPool2SetUp.sol"; /// @title TestStabilityPoolLossSetUp /// @notice Base setup for all stability pool loss tests diff --git a/test/StabilityPoolClaimable.t.sol b/test/StabilityPoolClaimable.t.sol index b816f3e7..e0b23464 100644 --- a/test/StabilityPoolClaimable.t.sol +++ b/test/StabilityPoolClaimable.t.sol @@ -4,12 +4,12 @@ pragma solidity >=0.8.28 <0.9.0; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {ITokenHolder} from "@bao/TokenHolder.sol"; -import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; -import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; -import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; +import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; +import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; +import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; import {MockERC20} from "@bao-test/mocks/MockERC20.sol"; -import {TestStabilityPoolRebalanceSetUp} from "test/StabilityPoolRebalance.t.sol"; +import {TestStabilityPoolRebalanceSetUp} from "@harbor-test/StabilityPoolRebalance.t.sol"; contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { address rewardToken1; diff --git a/test/StabilityPoolExtras.t.sol b/test/StabilityPoolExtras.t.sol index 4cabd43c..f241e592 100644 --- a/test/StabilityPoolExtras.t.sol +++ b/test/StabilityPoolExtras.t.sol @@ -7,9 +7,9 @@ import {IERC20Errors} from "@openzeppelin/contracts/interfaces/draft-IERC6093.so import {ITokenHolder} from "@bao/TokenHolder.sol"; import {Token} from "@bao/Token.sol"; -import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; +import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; -import {TestStabilityPoolRebalanceSetUp} from "test/StabilityPoolRebalance.t.sol"; +import {TestStabilityPoolRebalanceSetUp} from "@harbor-test/StabilityPoolRebalance.t.sol"; /// @title TestStabilityPoolExtra /// @dev This contract is designed to test additional functionalities and edge cases of the StabilityPool contract. diff --git a/test/StabilityPoolExtras2.t.sol b/test/StabilityPoolExtras2.t.sol index 82402896..6becfe97 100644 --- a/test/StabilityPoolExtras2.t.sol +++ b/test/StabilityPoolExtras2.t.sol @@ -4,13 +4,13 @@ pragma solidity >=0.8.28 <0.9.0; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; -import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; -import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; -import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; +import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; +import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; +import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; import {MockERC20} from "@bao-test/mocks/MockERC20.sol"; -import {TestStabilityPoolSetUp} from "test/StabilityPool.t.sol"; -import {StabilityPool_v3} from "src/minter/StabilityPool_v3.sol"; +import {TestStabilityPoolSetUp} from "@harbor-test/StabilityPool.t.sol"; +import {StabilityPool_v3} from "@harbor/minter/StabilityPool_v3.sol"; /// @title TestStabilityPoolExtra /// @dev This contract is designed to test additional functionalities and edge cases of the StabilityPool_v3 contract. diff --git a/test/StabilityPoolFeatures.t.sol b/test/StabilityPoolFeatures.t.sol index 0fe54618..c2603b79 100644 --- a/test/StabilityPoolFeatures.t.sol +++ b/test/StabilityPoolFeatures.t.sol @@ -3,11 +3,11 @@ pragma solidity ^0.8.30; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; -import {IWrappedPriceOracle} from "src/interfaces/IWrappedPriceOracle.sol"; +import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; +import {IWrappedPriceOracle} from "@harbor/interfaces/IWrappedPriceOracle.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; -import {StabilityPool_v3} from "src/minter/StabilityPool_v3.sol"; -import {TestStabilityPoolSetUp} from "test/StabilityPool.t.sol"; +import {StabilityPool_v3} from "@harbor/minter/StabilityPool_v3.sol"; +import {TestStabilityPoolSetUp} from "@harbor-test/StabilityPool.t.sol"; contract StabilityPoolFeatures is TestStabilityPoolSetUp { function setUp() public override(TestStabilityPoolSetUp) { diff --git a/test/StabilityPoolLoss.t.sol b/test/StabilityPoolLoss.t.sol index d01ec039..21681778 100644 --- a/test/StabilityPoolLoss.t.sol +++ b/test/StabilityPoolLoss.t.sol @@ -3,11 +3,11 @@ pragma solidity >=0.8.28 <0.9.0; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; -import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; -import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; +import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; +import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; +import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; -import {TestStabilityPoolBaseSetUp} from "test/StabilityPoolBaseSetUp.t.sol"; +import {TestStabilityPoolBaseSetUp} from "@harbor-test/StabilityPoolBaseSetUp.t.sol"; /// @title TestStabilityPoolLoss /// @notice Consolidated test suite for loss-related functionality in StabilityPool diff --git a/test/StabilityPoolManager_v1.t.sol b/test/StabilityPoolManager_v1.t.sol index 610b73d5..471c2f65 100644 --- a/test/StabilityPoolManager_v1.t.sol +++ b/test/StabilityPoolManager_v1.t.sol @@ -14,19 +14,19 @@ import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; import {ITokenHolder} from "@bao/TokenHolder.sol"; -import {IMinter} from "src/interfaces/IMinter.sol"; -import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; -import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; -import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; -import {IStabilityPoolManager} from "src/interfaces/IStabilityPoolManager.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; +import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; +import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; +import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; +import {IStabilityPoolManager} from "@harbor/interfaces/IStabilityPoolManager.sol"; -import {StabilityPoolManager_v1} from "src/minter/StabilityPoolManager_v1.sol"; +import {StabilityPoolManager_v1} from "@harbor/minter/StabilityPoolManager_v1.sol"; -import {IWrappedPriceOracle} from "src/interfaces/IWrappedPriceOracle.sol"; -import {MockWrappedPriceOracle} from "test/mocks/MockWrappedPriceOracle.sol"; -import {TestStabilityPool2SetUp} from "test/Rebalance.t.sol"; +import {IWrappedPriceOracle} from "@harbor/interfaces/IWrappedPriceOracle.sol"; +import {MockWrappedPriceOracle} from "@harbor-test/mocks/MockWrappedPriceOracle.sol"; +import {TestStabilityPool2SetUp} from "@harbor-test/Rebalance.t.sol"; -import "test/Useful.sol"; +import "@harbor-test/Useful.sol"; contract TestStabilityPoolManagerSetUp is TestStabilityPool2SetUp { address stabilityPoolManager; diff --git a/test/StabilityPoolRebalance.t.sol b/test/StabilityPoolRebalance.t.sol index d642e37a..9d26aaeb 100644 --- a/test/StabilityPoolRebalance.t.sol +++ b/test/StabilityPoolRebalance.t.sol @@ -6,14 +6,14 @@ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; import {ITokenHolder} from "@bao/TokenHolder.sol"; -import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; -import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; -import {IWrappedPriceOracle} from "src/interfaces/IWrappedPriceOracle.sol"; +import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; +import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; +import {IWrappedPriceOracle} from "@harbor/interfaces/IWrappedPriceOracle.sol"; -import {DecrementalFloatingPoint} from "src/math/DecrementalFloatingPoint.sol"; +import {DecrementalFloatingPoint} from "@harbor/math/DecrementalFloatingPoint.sol"; import {MockERC20} from "@bao-test/mocks/MockERC20.sol"; -import {TestStabilityPoolSetUp, MockStabilityPool} from "test/StabilityPool.t.sol"; +import {TestStabilityPoolSetUp, MockStabilityPool} from "@harbor-test/StabilityPool.t.sol"; abstract contract TestStabilityPoolRebalanceSetUp is TestStabilityPoolSetUp { address user3; diff --git a/test/StabilityPoolSpec.t.sol b/test/StabilityPoolSpec.t.sol index 65443c7e..41ef9446 100644 --- a/test/StabilityPoolSpec.t.sol +++ b/test/StabilityPoolSpec.t.sol @@ -6,12 +6,12 @@ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; import {ITokenHolder} from "@bao/TokenHolder.sol"; -import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; -import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; -import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; +import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; +import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; +import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; import {MockERC20} from "@bao-test/mocks/MockERC20.sol"; -import {TestStabilityPoolRebalanceSetUp} from "test/StabilityPoolRebalance.t.sol"; +import {TestStabilityPoolRebalanceSetUp} from "@harbor-test/StabilityPoolRebalance.t.sol"; /// @title StabilityPoolSpec /// @notice Specification tests for the StabilityPool contract diff --git a/test/StabilityPoolUpgradeMigration.t.sol b/test/StabilityPoolUpgradeMigration.t.sol index b7cf81fc..c9d03154 100644 --- a/test/StabilityPoolUpgradeMigration.t.sol +++ b/test/StabilityPoolUpgradeMigration.t.sol @@ -9,15 +9,15 @@ import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; import {ITokenHolder} from "@bao/TokenHolder.sol"; -import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; -import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; -import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; -import {IWrappedPriceOracle} from "src/interfaces/IWrappedPriceOracle.sol"; +import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; +import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; +import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; +import {IWrappedPriceOracle} from "@harbor/interfaces/IWrappedPriceOracle.sol"; -import {StabilityPool_v2} from "src/minter/StabilityPool_v2.sol"; -import {StabilityPool_v3} from "src/minter/StabilityPool_v3.sol"; +import {StabilityPool_v2} from "@harbor/minter/StabilityPool_v2.sol"; +import {StabilityPool_v3} from "@harbor/minter/StabilityPool_v3.sol"; -import {TestStabilityPoolSetUp} from "test/StabilityPool.t.sol"; +import {TestStabilityPoolSetUp} from "@harbor-test/StabilityPool.t.sol"; /// @title TestStabilityPoolUpgradeMigration /// @notice Tests that upgrading StabilityPool_v2 → StabilityPool_v3 via UUPS proxy preserves diff --git a/test/StabilityPool_v3_ERC20.t.sol b/test/StabilityPool_v3_ERC20.t.sol index 780d224d..359db423 100644 --- a/test/StabilityPool_v3_ERC20.t.sol +++ b/test/StabilityPool_v3_ERC20.t.sol @@ -5,12 +5,12 @@ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import {ERC20} from "@solady/tokens/ERC20.sol"; -import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; -import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; -import {StabilityPool_v3} from "src/minter/StabilityPool_v3.sol"; -import {ERC20MetadataLib_v1} from "src/util/ERC20MetadataLib_v1.sol"; +import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; +import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; +import {StabilityPool_v3} from "@harbor/minter/StabilityPool_v3.sol"; +import {ERC20MetadataLib_v1} from "@harbor/util/ERC20MetadataLib_v1.sol"; -import {DeployEURSetUp} from "test/deployment/DeployEURSetUp.t.sol"; +import {DeployEURSetUp} from "@harbor-test/deployment/DeployEURSetUp.t.sol"; import {PermitTestBase} from "@bao-test/helpers/PermitTestBase.t.sol"; /// @title TestStabilityPool_v3_ERC20 diff --git a/test/TestDepositAfterFinishAtZero.t.sol b/test/TestDepositAfterFinishAtZero.t.sol index 4a7068a3..26428afe 100644 --- a/test/TestDepositAfterFinishAtZero.t.sol +++ b/test/TestDepositAfterFinishAtZero.t.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {MockERC20} from "@bao-test/mocks/MockERC20.sol"; -import {MockLinearMultipleRewardDistributor_v3} from "test/mocks/reward/distributor/MockLinearMultipleRewardDistributor_v3.sol"; +import {MockLinearMultipleRewardDistributor_v3} from "@harbor-test/mocks/reward/distributor/MockLinearMultipleRewardDistributor_v3.sol"; import "forge-std/Test.sol"; /// @title Test Deposit After finishAt is Zero diff --git a/test/TestLinearRewardFix.t.sol b/test/TestLinearRewardFix.t.sol index 5d38eaa6..d934f147 100644 --- a/test/TestLinearRewardFix.t.sol +++ b/test/TestLinearRewardFix.t.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import "forge-std/Test.sol"; -import {LinearReward} from "src/reward/distributor/LinearReward.sol"; +import {LinearReward} from "@harbor/reward/distributor/LinearReward.sol"; contract TestLinearRewardFix is Test { using LinearReward for LinearReward.RewardData; diff --git a/test/TestStabilityPool2SetUp.sol b/test/TestStabilityPool2SetUp.sol index 44284d9d..530c4292 100644 --- a/test/TestStabilityPool2SetUp.sol +++ b/test/TestStabilityPool2SetUp.sol @@ -3,7 +3,7 @@ pragma solidity >=0.8.28 <0.9.0; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {TestStabilityPoolRebalanceSetUp} from "test/StabilityPoolRebalance.t.sol"; +import {TestStabilityPoolRebalanceSetUp} from "@harbor-test/StabilityPoolRebalance.t.sol"; contract TestStabilityPool2SetUp is TestStabilityPoolRebalanceSetUp { address stabilityPoolLeveraged; diff --git a/test/TokenDistributor.t.sol b/test/TokenDistributor.t.sol index 5ac7fd14..30d95f5b 100644 --- a/test/TokenDistributor.t.sol +++ b/test/TokenDistributor.t.sol @@ -17,12 +17,12 @@ import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; import {Token} from "@bao/Token.sol"; import {ITokenHolder} from "@bao/interfaces/ITokenHolder.sol"; -import {TokenDistributor_v1} from "src/minter/TokenDistributor_v1.sol"; -import {ITokenDistributor} from "src/interfaces/ITokenDistributor.sol"; +import {TokenDistributor_v1} from "@harbor/minter/TokenDistributor_v1.sol"; +import {ITokenDistributor} from "@harbor/interfaces/ITokenDistributor.sol"; import {Deployed} from "@bao/Deployed.sol"; -import {Array} from "test/Array.sol"; +import {Array} from "@harbor-test/Array.sol"; contract TestTokenDistributorSetUp is Test, Array { using ECDSA for bytes32; diff --git a/test/Useful.t.sol b/test/Useful.t.sol index f8095a96..91aac7df 100644 --- a/test/Useful.t.sol +++ b/test/Useful.t.sol @@ -3,7 +3,7 @@ pragma solidity >=0.8.28 <0.9.0; import {Test} from "forge-std/Test.sol"; -import {Useful} from "test/Useful.sol"; +import {Useful} from "@harbor-test/Useful.sol"; contract TestUsefulSimples is Test { bytes zeroA = new bytes(0); diff --git a/test/depeg.t.sol b/test/depeg.t.sol index f739baf3..81340f6c 100644 --- a/test/depeg.t.sol +++ b/test/depeg.t.sol @@ -1,12 +1,12 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.28 <0.9.0; -import {IMinter} from "src/interfaces/IMinter.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; -import {MockWrappedPriceOracle} from "test/mocks/MockWrappedPriceOracle.sol"; -import {TestStabilityPoolManagerSetUp} from "test/StabilityPoolManager_v1.t.sol"; +import {MockWrappedPriceOracle} from "@harbor-test/mocks/MockWrappedPriceOracle.sol"; +import {TestStabilityPoolManagerSetUp} from "@harbor-test/StabilityPoolManager_v1.t.sol"; -import "test/Useful.sol"; +import "@harbor-test/Useful.sol"; contract EverythingTest is TestStabilityPoolManagerSetUp { bool immutable isDepegged; diff --git a/test/deployment/AutoCompounderTest.t.sol b/test/deployment/AutoCompounderTest.t.sol index 3a4faccb..39f5e9c4 100644 --- a/test/deployment/AutoCompounderTest.t.sol +++ b/test/deployment/AutoCompounderTest.t.sol @@ -3,12 +3,12 @@ pragma solidity >=0.8.28 <0.9.0; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; -import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; -import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; -import {IAutoCompounder} from "src/interfaces/IAutoCompounder.sol"; -import {IMinter} from "src/interfaces/IMinter.sol"; -import {AutoCompounder_v1} from "src/autocompounding/AutoCompounder_v1.sol"; -import {DeployEURSetUp} from "test/deployment/DeployEURSetUp.t.sol"; +import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; +import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; +import {IAutoCompounder} from "@harbor/interfaces/IAutoCompounder.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; +import {AutoCompounder_v1} from "@harbor/autocompounding/AutoCompounder_v1.sol"; +import {DeployEURSetUp} from "@harbor-test/deployment/DeployEURSetUp.t.sol"; import {PermitTestBase} from "@bao-test/helpers/PermitTestBase.t.sol"; /// @title AutoCompounder tests using EUR peg (fxUSD + stETH collateral). diff --git a/test/deployment/ConfigTest.t.sol b/test/deployment/ConfigTest.t.sol index f890b904..33f08d40 100644 --- a/test/deployment/ConfigTest.t.sol +++ b/test/deployment/ConfigTest.t.sol @@ -2,14 +2,14 @@ pragma solidity >=0.8.28 <0.9.0; import {Test} from "forge-std/Test.sol"; -import {MinterMarketConfigLib, Config_MinterMarket} from "script/config/ConfigBase.sol"; -import {ConfigMarket_ETH_fxUSD_mainnet} from "script/config/markets/ConfigMarket_ETH_fxUSD_mainnet.sol"; -import {ConfigMarket_BTC_fxUSD_mainnet} from "script/config/markets/ConfigMarket_BTC_fxUSD_mainnet.sol"; -import {ConfigMarket_BTC_stETH_mainnet} from "script/config/markets/ConfigMarket_BTC_stETH_mainnet.sol"; -import {ConfigMarket_GOLD_fxUSD_mainnet} from "script/config/markets/ConfigMarket_GOLD_fxUSD_mainnet.sol"; -import {ConfigMarket_GOLD_stETH_mainnet} from "script/config/markets/ConfigMarket_GOLD_stETH_mainnet.sol"; -import {ConfigMarket_EUR_fxUSD_mainnet} from "script/config/markets/ConfigMarket_EUR_fxUSD_mainnet.sol"; -import {ConfigMarket_EUR_stETH_mainnet} from "script/config/markets/ConfigMarket_EUR_stETH_mainnet.sol"; +import {MinterMarketConfigLib, Config_MinterMarket} from "@harbor-script/config/ConfigBase.sol"; +import {ConfigMarket_ETH_fxUSD_mainnet} from "@harbor-script/config/markets/ConfigMarket_ETH_fxUSD_mainnet.sol"; +import {ConfigMarket_BTC_fxUSD_mainnet} from "@harbor-script/config/markets/ConfigMarket_BTC_fxUSD_mainnet.sol"; +import {ConfigMarket_BTC_stETH_mainnet} from "@harbor-script/config/markets/ConfigMarket_BTC_stETH_mainnet.sol"; +import {ConfigMarket_GOLD_fxUSD_mainnet} from "@harbor-script/config/markets/ConfigMarket_GOLD_fxUSD_mainnet.sol"; +import {ConfigMarket_GOLD_stETH_mainnet} from "@harbor-script/config/markets/ConfigMarket_GOLD_stETH_mainnet.sol"; +import {ConfigMarket_EUR_fxUSD_mainnet} from "@harbor-script/config/markets/ConfigMarket_EUR_fxUSD_mainnet.sol"; +import {ConfigMarket_EUR_stETH_mainnet} from "@harbor-script/config/markets/ConfigMarket_EUR_stETH_mainnet.sol"; contract ConfigTest is Test { using MinterMarketConfigLib for Config_MinterMarket; diff --git a/test/deployment/DeployETHfxUSD.t.sol b/test/deployment/DeployETHfxUSD.t.sol index afed7700..2c4f8a36 100644 --- a/test/deployment/DeployETHfxUSD.t.sol +++ b/test/deployment/DeployETHfxUSD.t.sol @@ -4,15 +4,15 @@ pragma solidity >=0.8.28 <0.9.0; import {BaoTest} from "@bao-test/BaoTest.sol"; import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; -import {Deploy_ETH_Minter} from "script/src/Deploy_ETH_Minter.sol"; -import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; -import {Config_MinterMarket} from "script/config/ConfigBase.sol"; +import {Deploy_ETH_Minter} from "@harbor-script/src/Deploy_ETH_Minter.sol"; +import {ConfigPeg} from "@harbor-script/config/pegs/ConfigPeg.sol"; +import {Config_MinterMarket} from "@harbor-script/config/ConfigBase.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {IMinter} from "src/interfaces/IMinter.sol"; -import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; -import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; -import {MockWrappedPriceOracle} from "test/mocks/MockWrappedPriceOracle.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; +import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; +import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; +import {MockWrappedPriceOracle} from "@harbor-test/mocks/MockWrappedPriceOracle.sol"; /// @title Common deployment setup for ETH::fxUSD market tests. /// @dev Deploys a full ETH::fxUSD market via production deployment scripts. diff --git a/test/deployment/DeployEURSetUp.t.sol b/test/deployment/DeployEURSetUp.t.sol index 310c27f4..a07b7a67 100644 --- a/test/deployment/DeployEURSetUp.t.sol +++ b/test/deployment/DeployEURSetUp.t.sol @@ -4,16 +4,16 @@ pragma solidity >=0.8.28 <0.9.0; import {BaoTest} from "@bao-test/BaoTest.sol"; import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; -import {Deploy_EUR_Minter} from "script/src/Deploy_EUR_Minter.sol"; -import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; -import {Config_MinterMarket} from "script/config/ConfigBase.sol"; +import {Deploy_EUR_Minter} from "@harbor-script/src/Deploy_EUR_Minter.sol"; +import {ConfigPeg} from "@harbor-script/config/pegs/ConfigPeg.sol"; +import {Config_MinterMarket} from "@harbor-script/config/ConfigBase.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {IMinter} from "src/interfaces/IMinter.sol"; -import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; -import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; -import {MockWrappedPriceOracle} from "test/mocks/MockWrappedPriceOracle.sol"; -import {IWrappedPriceOracle} from "src/interfaces/IWrappedPriceOracle.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; +import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; +import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; +import {MockWrappedPriceOracle} from "@harbor-test/mocks/MockWrappedPriceOracle.sol"; +import {IWrappedPriceOracle} from "@harbor/interfaces/IWrappedPriceOracle.sol"; /// @title Common deployment setup for EUR market tests. /// @dev Deploys EUR peg with two collaterals (fxUSD, stETH), each with collateral + leveraged SPs and ACs. diff --git a/test/deployment/MinterCappedMint.t.sol b/test/deployment/MinterCappedMint.t.sol index 9deb23f4..8eafa335 100644 --- a/test/deployment/MinterCappedMint.t.sol +++ b/test/deployment/MinterCappedMint.t.sol @@ -3,15 +3,15 @@ pragma solidity >=0.8.28 <0.9.0; import {BaoTest} from "@bao-test/BaoTest.sol"; import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; -import {Deploy_ETH_Minter} from "script/src/Deploy_ETH_Minter.sol"; -import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; -import {Config_MinterMarket} from "script/config/ConfigBase.sol"; +import {Deploy_ETH_Minter} from "@harbor-script/src/Deploy_ETH_Minter.sol"; +import {ConfigPeg} from "@harbor-script/config/pegs/ConfigPeg.sol"; +import {Config_MinterMarket} from "@harbor-script/config/ConfigBase.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {IMinter} from "src/interfaces/IMinter.sol"; -import {IMinter_v3} from "src/interfaces/IMinter_v3.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; +import {IMinter_v3} from "@harbor/interfaces/IMinter_v3.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; -import {MockWrappedPriceOracle} from "test/mocks/MockWrappedPriceOracle.sol"; +import {MockWrappedPriceOracle} from "@harbor-test/mocks/MockWrappedPriceOracle.sol"; /// @title MinterCappedMintTest /// @notice Tests for Minter_v3 fee-capped minting, deployed via production deployment scripts. diff --git a/test/deployment/RebalanceFairness.t.sol b/test/deployment/RebalanceFairness.t.sol index 80fbc5c5..0de82fc3 100644 --- a/test/deployment/RebalanceFairness.t.sol +++ b/test/deployment/RebalanceFairness.t.sol @@ -3,22 +3,22 @@ pragma solidity >=0.8.28 <0.9.0; import {BaoTest} from "@bao-test/BaoTest.sol"; import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; -import {Deploy_ETH_Minter} from "script/src/Deploy_ETH_Minter.sol"; -import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; -import {Config_MinterMarket, MinterMarketConfigLib} from "script/config/ConfigBase.sol"; +import {Deploy_ETH_Minter} from "@harbor-script/src/Deploy_ETH_Minter.sol"; +import {ConfigPeg} from "@harbor-script/config/pegs/ConfigPeg.sol"; +import {Config_MinterMarket, MinterMarketConfigLib} from "@harbor-script/config/ConfigBase.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {IMinter} from "src/interfaces/IMinter.sol"; -import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; -import {IStabilityPoolManager} from "src/interfaces/IStabilityPoolManager.sol"; -import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; +import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; +import {IStabilityPoolManager} from "@harbor/interfaces/IStabilityPoolManager.sol"; +import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; -import {StabilityPoolManager_v1} from "src/minter/StabilityPoolManager_v1.sol"; -import {MockWrappedPriceOracle} from "test/mocks/MockWrappedPriceOracle.sol"; +import {StabilityPoolManager_v1} from "@harbor/minter/StabilityPoolManager_v1.sol"; +import {MockWrappedPriceOracle} from "@harbor-test/mocks/MockWrappedPriceOracle.sol"; import {console2} from "forge-std/console2.sol"; -import {FmtLib} from "src/util/FmtLib.sol"; +import {FmtLib} from "@harbor/util/FmtLib.sol"; /// @title RebalanceFairnessTest /// @notice Worked example from doc/ideas/rebalance-fairness.md using real contract code diff --git a/test/deployment/RebalanceFairnessScan.t.sol b/test/deployment/RebalanceFairnessScan.t.sol index bd6a59a6..f6161b07 100644 --- a/test/deployment/RebalanceFairnessScan.t.sol +++ b/test/deployment/RebalanceFairnessScan.t.sol @@ -4,14 +4,14 @@ pragma solidity >=0.8.28 <0.9.0; import {RebalanceFairnessSetUp} from "./RebalanceFairness.t.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {IMinter} from "src/interfaces/IMinter.sol"; -import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; -import {IStabilityPoolManager} from "src/interfaces/IStabilityPoolManager.sol"; -import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; +import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; +import {IStabilityPoolManager} from "@harbor/interfaces/IStabilityPoolManager.sol"; +import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; -import {Useful} from "test/Useful.sol"; +import {Useful} from "@harbor-test/Useful.sol"; import {console2} from "forge-std/console2.sol"; /// @title Fairness gap scan over liquidation severity × leveraged fraction diff --git a/test/deployment/RewardSystem.t.sol b/test/deployment/RewardSystem.t.sol index 69a93561..26bb7c43 100644 --- a/test/deployment/RewardSystem.t.sol +++ b/test/deployment/RewardSystem.t.sol @@ -4,17 +4,17 @@ pragma solidity >=0.8.28 <0.9.0; import {BaoTest} from "@bao-test/BaoTest.sol"; import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; -import {Deploy_ETH_Minter} from "script/src/Deploy_ETH_Minter.sol"; -import {ConfigPeg} from "script/config/pegs/ConfigPeg.sol"; -import {Config_MinterMarket} from "script/config/ConfigBase.sol"; +import {Deploy_ETH_Minter} from "@harbor-script/src/Deploy_ETH_Minter.sol"; +import {ConfigPeg} from "@harbor-script/config/pegs/ConfigPeg.sol"; +import {Config_MinterMarket} from "@harbor-script/config/ConfigBase.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {IMinter} from "src/interfaces/IMinter.sol"; -import {IStabilityPool} from "src/interfaces/IStabilityPool.sol"; -import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; -import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; -import {MockWrappedPriceOracle} from "test/mocks/MockWrappedPriceOracle.sol"; -import {AutoCompounder_v1} from "src/autocompounding/AutoCompounder_v1.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; +import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; +import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; +import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; +import {MockWrappedPriceOracle} from "@harbor-test/mocks/MockWrappedPriceOracle.sol"; +import {AutoCompounder_v1} from "@harbor/autocompounding/AutoCompounder_v1.sol"; import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; /// @title Reward system tests — accumulator, distributor — using deployment framework diff --git a/test/math/DecrementalFloatingPoint.t.sol b/test/math/DecrementalFloatingPoint.t.sol index 9bdbb862..4cd27460 100644 --- a/test/math/DecrementalFloatingPoint.t.sol +++ b/test/math/DecrementalFloatingPoint.t.sol @@ -5,7 +5,7 @@ import "forge-std/Test.sol"; import "@openzeppelin/contracts/utils/math/Math.sol"; -import "src/math/DecrementalFloatingPoint.sol"; +import "@harbor/math/DecrementalFloatingPoint.sol"; contract MockDecrementalFloatingPoint { function encode(uint8 _exponent, uint120 _magnitude) public pure returns (uint128) { diff --git a/test/mocks/IMockMultipleRewardCompoundingAccumulator.sol b/test/mocks/IMockMultipleRewardCompoundingAccumulator.sol index 21c18502..de08a83d 100644 --- a/test/mocks/IMockMultipleRewardCompoundingAccumulator.sol +++ b/test/mocks/IMockMultipleRewardCompoundingAccumulator.sol @@ -2,8 +2,8 @@ pragma solidity >=0.8.28 <0.9.0; -import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; -import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; +import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; +import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; diff --git a/test/mocks/MockPriceOracle.sol b/test/mocks/MockPriceOracle.sol index a05a5bbe..0995b650 100644 --- a/test/mocks/MockPriceOracle.sol +++ b/test/mocks/MockPriceOracle.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; -import {IPriceOracle} from "src/interfaces/IPriceOracle.sol"; +import {IPriceOracle} from "@harbor/interfaces/IPriceOracle.sol"; contract MockPriceOracle is IPriceOracle { uint256 public latestAnswer; diff --git a/test/mocks/MockSwapper.sol b/test/mocks/MockSwapper.sol index 2c67844b..706b0f8c 100644 --- a/test/mocks/MockSwapper.sol +++ b/test/mocks/MockSwapper.sol @@ -3,7 +3,7 @@ pragma solidity >=0.8.28 <0.9.0; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import {ISwapper} from "src/interfaces/ISwapper.sol"; +import {ISwapper} from "@harbor/interfaces/ISwapper.sol"; /// @title MockSwapper /// @notice Fixed-rate swapper for testing. Swaps at a configurable rate with no DEX dependency. diff --git a/test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v2.sol b/test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v2.sol index 91aed314..21cea972 100644 --- a/test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v2.sol +++ b/test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v2.sol @@ -5,7 +5,7 @@ pragma solidity >=0.8.28 <0.9.0; // import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; -import {MultipleRewardCompoundingAccumulator_v3} from "src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol"; +import {MultipleRewardCompoundingAccumulator_v3} from "@harbor/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol"; contract MockMultipleRewardCompoundingAccumulator_v2 is Initializable, MultipleRewardCompoundingAccumulator_v3 { event AccumulateReward(address token, uint256 amount); diff --git a/test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v3.sol b/test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v3.sol index 62809f65..5e70a841 100644 --- a/test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v3.sol +++ b/test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v3.sol @@ -4,7 +4,7 @@ pragma solidity >=0.8.28 <0.9.0; import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; -import {MultipleRewardCompoundingAccumulator_v3} from "src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol"; +import {MultipleRewardCompoundingAccumulator_v3} from "@harbor/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol"; contract MockMultipleRewardCompoundingAccumulator_v3 is Initializable, MultipleRewardCompoundingAccumulator_v3 { event AccumulateReward(address token, uint256 amount); diff --git a/test/mocks/reward/distributor/MockLinearMultipleRewardDistributor_v3.sol b/test/mocks/reward/distributor/MockLinearMultipleRewardDistributor_v3.sol index a237f48f..f036357b 100644 --- a/test/mocks/reward/distributor/MockLinearMultipleRewardDistributor_v3.sol +++ b/test/mocks/reward/distributor/MockLinearMultipleRewardDistributor_v3.sol @@ -2,8 +2,8 @@ pragma solidity >=0.8.28 <0.9.0; -import {LinearMultipleRewardDistributor_v3} from "src/reward/distributor/LinearMultipleRewardDistributor_v3.sol"; -import {LinearReward} from "src/reward/distributor/LinearReward.sol"; +import {LinearMultipleRewardDistributor_v3} from "@harbor/reward/distributor/LinearMultipleRewardDistributor_v3.sol"; +import {LinearReward} from "@harbor/reward/distributor/LinearReward.sol"; contract MockLinearMultipleRewardDistributor_v3 is LinearMultipleRewardDistributor_v3 { // used to discover if the _accumulateReward virtual function has been called diff --git a/test/price/PriceOracle.t.sol b/test/price/PriceOracle.t.sol index 57fa0157..c351f54e 100644 --- a/test/price/PriceOracle.t.sol +++ b/test/price/PriceOracle.t.sol @@ -2,9 +2,9 @@ pragma solidity >=0.8.28 <0.9.0; import {Test} from "forge-std/Test.sol"; -import {PriceOracle_v1} from "src/price/PriceOracle_v1.sol"; -import {IPriceOracleErrors} from "src/interfaces/IPriceOracleErrors.sol"; -import {MockAggregator} from "test/mocks/MockAggregator.sol"; +import {PriceOracle_v1} from "@harbor/price/PriceOracle_v1.sol"; +import {IPriceOracleErrors} from "@harbor/interfaces/IPriceOracleErrors.sol"; +import {MockAggregator} from "@harbor-test/mocks/MockAggregator.sol"; contract PriceOracleTest is Test { uint64 constant MAX_ANSWER_AGE = 3600; // 1 hour diff --git a/test/reward/accumulator/ClaimEquivalence.t.sol b/test/reward/accumulator/ClaimEquivalence.t.sol index 041bfb17..4f8955f0 100644 --- a/test/reward/accumulator/ClaimEquivalence.t.sol +++ b/test/reward/accumulator/ClaimEquivalence.t.sol @@ -5,10 +5,10 @@ import {Test} from "forge-std/Test.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {MockERC20} from "@bao-test/mocks/MockERC20.sol"; -import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; -import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; +import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; +import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; -import {MockMultipleRewardCompoundingAccumulator_v3} from "test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v3.sol"; +import {MockMultipleRewardCompoundingAccumulator_v3} from "@harbor-test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v3.sol"; /// @title ClaimTest /// @notice Verifies claim() and claimHistorical() routing across all supported call signatures. diff --git a/test/reward/accumulator/MultipleRewardCompoundingAccumulator.t.sol b/test/reward/accumulator/MultipleRewardCompoundingAccumulator.t.sol index 8ec7a845..569b477e 100644 --- a/test/reward/accumulator/MultipleRewardCompoundingAccumulator.t.sol +++ b/test/reward/accumulator/MultipleRewardCompoundingAccumulator.t.sol @@ -4,12 +4,12 @@ pragma solidity >=0.8.28 <0.9.0; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {ReentrancyGuardTransient} from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol"; -import {IMultipleRewardAccumulator} from "src/interfaces/IMultipleRewardAccumulator.sol"; -import {IMockMultipleRewardCompoundingAccumulator} from "test/mocks/IMockMultipleRewardCompoundingAccumulator.sol"; +import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; +import {IMockMultipleRewardCompoundingAccumulator} from "@harbor-test/mocks/IMockMultipleRewardCompoundingAccumulator.sol"; import {Test, Vm} from "forge-std/Test.sol"; import {MockERC20} from "@bao-test/mocks/MockERC20.sol"; -import {MockMultipleRewardCompoundingAccumulator_v3} from "test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v3.sol"; +import {MockMultipleRewardCompoundingAccumulator_v3} from "@harbor-test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v3.sol"; contract MultipleRewardCompoundingAccumulatorTest is Test { // Addresses diff --git a/test/reward/distributor/LinearMultipleRewardDistributor.t.sol b/test/reward/distributor/LinearMultipleRewardDistributor.t.sol index bf97ef38..eb18ff9e 100644 --- a/test/reward/distributor/LinearMultipleRewardDistributor.t.sol +++ b/test/reward/distributor/LinearMultipleRewardDistributor.t.sol @@ -1,9 +1,9 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.28 <0.9.0; -import {IMultipleRewardDistributor} from "src/interfaces/IMultipleRewardDistributor.sol"; -import {IMockLinearMultipleRewardDistributor} from "test/mocks/IMockLinearMultipleRewardDistributor.sol"; -import {MockLinearMultipleRewardDistributor_v3} from "test/mocks/reward/distributor/MockLinearMultipleRewardDistributor_v3.sol"; +import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; +import {IMockLinearMultipleRewardDistributor} from "@harbor-test/mocks/IMockLinearMultipleRewardDistributor.sol"; +import {MockLinearMultipleRewardDistributor_v3} from "@harbor-test/mocks/reward/distributor/MockLinearMultipleRewardDistributor_v3.sol"; import {MockERC20} from "@bao-test/mocks/MockERC20.sol"; import "forge-std/Test.sol"; From cdf4050a87a932ef0af6837a519a9eccef740242 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Wed, 22 Apr 2026 11:24:00 +0100 Subject: [PATCH 055/232] moved Autocompounder to harbor-yield --- script/src/DeployMintersShared.sol | 52 ++------------------------ script/src/contracts/StabilityPool.sol | 9 +---- 2 files changed, 6 insertions(+), 55 deletions(-) diff --git a/script/src/DeployMintersShared.sol b/script/src/DeployMintersShared.sol index 0628ed3c..4724d4d2 100644 --- a/script/src/DeployMintersShared.sol +++ b/script/src/DeployMintersShared.sol @@ -9,12 +9,11 @@ import {Minter} from "./contracts/Minter.sol"; import {StabilityPool} from "./contracts/StabilityPool.sol"; import {StabilityPoolManager} from "./contracts/StabilityPoolManager.sol"; import {Genesis} from "./contracts/Genesis.sol"; -import {AutoCompounder, IAutoCompounderMarketConfig} from "./contracts/AutoCompounder.sol"; import {HarborFactoryDeployer} from "@harbor-script/src/HarborFactoryDeployer.sol"; import {DeploymentState} from "@bao-script/deployment/DeploymentState.sol"; import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; import {ConfigPeg} from "@harbor-script/config/pegs/ConfigPeg.sol"; -import {Config_MinterMarket, IMarketConfig, MinterMarketConfigLib} from "@harbor-script/config/ConfigBase.sol"; +import {Config_MinterMarket, MinterMarketConfigLib} from "@harbor-script/config/ConfigBase.sol"; import {IMinter} from "@harbor/interfaces/IMinter.sol"; import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; @@ -45,8 +44,7 @@ abstract contract DeployMintersShared is Minter, StabilityPool, StabilityPoolManager, - Genesis, - AutoCompounder + Genesis { using LibString for string; @@ -170,9 +168,6 @@ abstract contract DeployMintersShared is // Register reward tokens on SPs _registerRewardTokens(cfg, marketKey); - // Deploy Auto-Compounders (one per SP) - _deployAutoCompounders(state, cfg, marketKey); - // Deploy StabilityPoolManager _deployStabilityPoolManager(state, cfg, marketKey); @@ -221,41 +216,6 @@ abstract contract DeployMintersShared is ); } - function _deployAutoCompounders( - DeploymentTypes.State memory stateData, - IFullMinterConfig cfg, - string memory marketKey - ) internal { - address minter = _predictAddress(_key(marketKey, "minter")); - address spCollateral = _predictAddress(_key(marketKey, StabilityPoolCollateral)); - address spLeveraged = _predictAddress(_key(marketKey, StabilityPoolLeveraged)); - - // ETH price oracle: peg-scoped (same oracle for all markets with the same peg). - // Deployed by harbor-price-aggregators deploy scripts; address derived from peg name. - address pegOracle = predictEthPriceOracleAddress(IMarketConfig(address(cfg)).peg()); - - // Standalone ACs (no HarborYield) — pass address(0) as yieldManager. - deployAutoCompounder( - AutoCompounderCollateral, - stateData, - Config_MinterMarket(address(cfg)), - spCollateral, - minter, - address(0), - pegOracle - ); - - deployAutoCompounder( - AutoCompounderLeveraged, - stateData, - Config_MinterMarket(address(cfg)), - spLeveraged, - minter, - address(0), - pegOracle - ); - } - function _registerRewardTokens(IFullMinterConfig cfg, string memory marketKey) internal { address spCollateral = _predictAddress(_key(marketKey, StabilityPoolCollateral)); address spLeveraged = _predictAddress(_key(marketKey, StabilityPoolLeveraged)); @@ -306,12 +266,8 @@ abstract contract DeployMintersShared is // Grant roles — each helper predicts its own addresses from marketKey grantReservePoolRoles(marketKey); grantMinterRoles(marketKey); - grantStabilityPoolRoles(marketKey, StabilityPoolCollateral, AutoCompounderCollateral); - grantStabilityPoolRoles(marketKey, StabilityPoolLeveraged, AutoCompounderLeveraged); - - // Configure Auto-Compounders (approve compound tokens — maxFeeRatio is now an immutable set at deploy) - configureAutoCompounder(marketKey, AutoCompounderCollateral); - configureAutoCompounder(marketKey, AutoCompounderLeveraged); + grantStabilityPoolRoles(marketKey, StabilityPoolCollateral); + grantStabilityPoolRoles(marketKey, StabilityPoolLeveraged); // Configure StabilityPoolManager configureStabilityPoolManager( diff --git a/script/src/contracts/StabilityPool.sol b/script/src/contracts/StabilityPool.sol index b01c86d8..2c9bac96 100644 --- a/script/src/contracts/StabilityPool.sol +++ b/script/src/contracts/StabilityPool.sol @@ -88,11 +88,10 @@ abstract contract StabilityPool is HarborFactoryDeployer { proxy = _deployProxyAndRecord(stateData, spKey, impl, initData); } - /// @notice Grant StabilityPool roles to StabilityPoolManager and AutoCompounder. + /// @notice Grant StabilityPool roles to StabilityPoolManager. /// @param marketKey The market salt key (e.g., "ETH::fxUSD"). /// @param spType "stabilityPoolCollateral" or "stabilityPoolLeveraged". - /// @param acType The matching AC type ("autoCompounderCollateral" or "autoCompounderLeveraged"). - function grantStabilityPoolRoles(string memory marketKey, string memory spType, string memory acType) internal { + function grantStabilityPoolRoles(string memory marketKey, string memory spType) internal { string memory spKey = _key(marketKey, spType); address sp = _predictAddress(spKey); @@ -101,9 +100,5 @@ abstract contract StabilityPool is HarborFactoryDeployer { StabilityPool_v3 pool = StabilityPool_v3(sp); uint256 roles = pool.REBALANCER_ROLE() | pool.REWARD_DEPOSITOR_ROLE(); _grantRoles(spKey, sp, spm, "stabilityPoolManager", roles, "REBALANCER | REWARD_DEPOSITOR"); - - // AC gets EXEMPT_WITHDRAWAL_FEE - address ac = _predictAddress(_key(marketKey, acType)); - _grantRoles(spKey, sp, ac, acType, pool.EXEMPT_WITHDRAWAL_FEE_ROLE(), "EXEMPT_WITHDRAWAL_FEE"); } } From 646b1a8af3f514a84e84bde653d1178e4d298c60 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Wed, 22 Apr 2026 12:24:53 +0100 Subject: [PATCH 056/232] made the mintMaxFeeRatio for a ecconomic mint dynamic (at least in the config) depending on the fee structure --- lib/bao-base | 2 +- .../volatility/ConfigPriceVolatilityBase.sol | 27 +++++++++++++++++++ .../volatility/ConfigPriceVolatility_105.sol | 10 +++---- .../ConfigPriceVolatility_105_stable.sol | 10 +++---- .../volatility/ConfigPriceVolatility_115.sol | 10 +++---- .../ConfigPriceVolatility_115_stable.sol | 10 +++---- .../volatility/ConfigPriceVolatility_125.sol | 10 +++---- .../ConfigPriceVolatility_125_stable.sol | 10 +++---- .../volatility/ConfigPriceVolatility_130.sol | 10 +++---- .../ConfigPriceVolatility_130_stable.sol | 10 +++---- 10 files changed, 60 insertions(+), 49 deletions(-) create mode 100644 script/config/volatility/ConfigPriceVolatilityBase.sol diff --git a/lib/bao-base b/lib/bao-base index d9ab54f4..e9df1fce 160000 --- a/lib/bao-base +++ b/lib/bao-base @@ -1 +1 @@ -Subproject commit d9ab54f451dbfe4717aae2ee135aad809f96ddec +Subproject commit e9df1fcee2acc0955b44c316e7c573a9371e45a1 diff --git a/script/config/volatility/ConfigPriceVolatilityBase.sol b/script/config/volatility/ConfigPriceVolatilityBase.sol new file mode 100644 index 00000000..7e85950f --- /dev/null +++ b/script/config/volatility/ConfigPriceVolatilityBase.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.28 <0.9.0; + +import {IMinter} from "@harbor/interfaces/IMinter.sol"; + +/// @notice Base for all volatility configs. Provides autoCompounderMintMaxFeeRatio() +/// derived from the mint-pegged fee curve: the fee at rebalanceThreshold + 10pp. +/// The AC starts compounding once the Minter fee has dropped to that level, +/// ensuring it only mints when CR is comfortably above the rebalance threshold. +abstract contract ConfigPriceVolatilityBase { + function rebalanceThreshold() public pure virtual returns (uint256); + function minterConfig() public pure virtual returns (IMinter.Config memory); + + function autoCompounderMintMaxFeeRatio() public pure virtual returns (uint256) { + uint256 targetCR = rebalanceThreshold() + 0.10e18; + IMinter.IncentiveConfig memory cfg = minterConfig().mintPeggedIncentiveConfig; + uint256 n = cfg.collateralRatioBandUpperBounds.length; + for (uint256 i = 0; i < n; i++) { + if (targetCR < cfg.collateralRatioBandUpperBounds[i]) { + int256 ratio = cfg.incentiveRatios[i]; + return ratio > 0 ? uint256(ratio) : 0; + } + } + int256 lastRatio = cfg.incentiveRatios[n]; + return lastRatio > 0 ? uint256(lastRatio) : 0; + } +} diff --git a/script/config/volatility/ConfigPriceVolatility_105.sol b/script/config/volatility/ConfigPriceVolatility_105.sol index 015ad0d5..55c998de 100644 --- a/script/config/volatility/ConfigPriceVolatility_105.sol +++ b/script/config/volatility/ConfigPriceVolatility_105.sol @@ -2,15 +2,16 @@ pragma solidity >=0.8.28 <0.9.0; import {IMinter} from "@harbor/interfaces/IMinter.sol"; +import {ConfigPriceVolatilityBase} from "./ConfigPriceVolatilityBase.sol"; /// @notice Volatility configuration for 105% rebalance threshold markets (Month 1 fees). /// @dev Higher redeem leveraged fees for initial month after launch. -contract ConfigPriceVolatility_105 { - function rebalanceThreshold() public pure virtual returns (uint256) { +contract ConfigPriceVolatility_105 is ConfigPriceVolatilityBase { + function rebalanceThreshold() public pure virtual override returns (uint256) { return 1.05e18; } - function minterConfig() public pure returns (IMinter.Config memory) { + function minterConfig() public pure override returns (IMinter.Config memory) { uint256[] memory mintPeggedBounds = new uint256[](6); mintPeggedBounds[0] = 1.06e18; mintPeggedBounds[1] = 1.15e18; @@ -100,7 +101,4 @@ contract ConfigPriceVolatility_105 { }); } - function autoCompounderMintMaxFeeRatio() public pure virtual returns (uint256) { - return 0.05 ether; - } } diff --git a/script/config/volatility/ConfigPriceVolatility_105_stable.sol b/script/config/volatility/ConfigPriceVolatility_105_stable.sol index 6086a09c..b027fcac 100644 --- a/script/config/volatility/ConfigPriceVolatility_105_stable.sol +++ b/script/config/volatility/ConfigPriceVolatility_105_stable.sol @@ -2,14 +2,15 @@ pragma solidity >=0.8.28 <0.9.0; import {IMinter} from "@harbor/interfaces/IMinter.sol"; +import {ConfigPriceVolatilityBase} from "./ConfigPriceVolatilityBase.sol"; /// @notice Volatility configuration for 105% rebalance threshold markets. -contract ConfigPriceVolatility_105_stable { - function rebalanceThreshold() public pure virtual returns (uint256) { +contract ConfigPriceVolatility_105_stable is ConfigPriceVolatilityBase { + function rebalanceThreshold() public pure virtual override returns (uint256) { return 1.05e18; } - function minterConfig() public pure returns (IMinter.Config memory) { + function minterConfig() public pure override returns (IMinter.Config memory) { uint256[] memory mintPeggedBounds = new uint256[](6); mintPeggedBounds[0] = 1.06e18; mintPeggedBounds[1] = 1.15e18; @@ -99,7 +100,4 @@ contract ConfigPriceVolatility_105_stable { }); } - function autoCompounderMintMaxFeeRatio() public pure virtual returns (uint256) { - return 0.05 ether; - } } diff --git a/script/config/volatility/ConfigPriceVolatility_115.sol b/script/config/volatility/ConfigPriceVolatility_115.sol index 1af276d0..c2225b19 100644 --- a/script/config/volatility/ConfigPriceVolatility_115.sol +++ b/script/config/volatility/ConfigPriceVolatility_115.sol @@ -2,15 +2,16 @@ pragma solidity >=0.8.28 <0.9.0; import {IMinter} from "@harbor/interfaces/IMinter.sol"; +import {ConfigPriceVolatilityBase} from "./ConfigPriceVolatilityBase.sol"; /// @notice Volatility configuration for 115% rebalance threshold markets (Month 1 fees). /// @dev Higher redeem leveraged fees for initial month after launch. -contract ConfigPriceVolatility_115 { - function rebalanceThreshold() public pure virtual returns (uint256) { +contract ConfigPriceVolatility_115 is ConfigPriceVolatilityBase { + function rebalanceThreshold() public pure virtual override returns (uint256) { return 1.15e18; } - function minterConfig() public pure returns (IMinter.Config memory) { + function minterConfig() public pure override returns (IMinter.Config memory) { uint256[] memory mintPeggedBounds = new uint256[](6); mintPeggedBounds[0] = 1.16e18; mintPeggedBounds[1] = 1.25e18; @@ -100,7 +101,4 @@ contract ConfigPriceVolatility_115 { }); } - function autoCompounderMintMaxFeeRatio() public pure virtual returns (uint256) { - return 0.05 ether; - } } diff --git a/script/config/volatility/ConfigPriceVolatility_115_stable.sol b/script/config/volatility/ConfigPriceVolatility_115_stable.sol index 35e61019..e1d37c7a 100644 --- a/script/config/volatility/ConfigPriceVolatility_115_stable.sol +++ b/script/config/volatility/ConfigPriceVolatility_115_stable.sol @@ -2,14 +2,15 @@ pragma solidity >=0.8.28 <0.9.0; import {IMinter} from "@harbor/interfaces/IMinter.sol"; +import {ConfigPriceVolatilityBase} from "./ConfigPriceVolatilityBase.sol"; /// @notice Volatility configuration for 115% rebalance threshold markets. -contract ConfigPriceVolatility_115_stable { - function rebalanceThreshold() public pure virtual returns (uint256) { +contract ConfigPriceVolatility_115_stable is ConfigPriceVolatilityBase { + function rebalanceThreshold() public pure virtual override returns (uint256) { return 1.15e18; } - function minterConfig() public pure returns (IMinter.Config memory) { + function minterConfig() public pure override returns (IMinter.Config memory) { uint256[] memory mintPeggedBounds = new uint256[](6); mintPeggedBounds[0] = 1.16e18; mintPeggedBounds[1] = 1.25e18; @@ -99,7 +100,4 @@ contract ConfigPriceVolatility_115_stable { }); } - function autoCompounderMintMaxFeeRatio() public pure virtual returns (uint256) { - return 0.05 ether; - } } diff --git a/script/config/volatility/ConfigPriceVolatility_125.sol b/script/config/volatility/ConfigPriceVolatility_125.sol index 6405be3e..e8fac6dd 100644 --- a/script/config/volatility/ConfigPriceVolatility_125.sol +++ b/script/config/volatility/ConfigPriceVolatility_125.sol @@ -2,15 +2,16 @@ pragma solidity >=0.8.28 <0.9.0; import {IMinter} from "@harbor/interfaces/IMinter.sol"; +import {ConfigPriceVolatilityBase} from "./ConfigPriceVolatilityBase.sol"; /// @notice Volatility configuration for 125% rebalance threshold markets (Month 1 fees). /// @dev Higher redeem leveraged fees for initial month after launch. -contract ConfigPriceVolatility_125 { - function rebalanceThreshold() public pure virtual returns (uint256) { +contract ConfigPriceVolatility_125 is ConfigPriceVolatilityBase { + function rebalanceThreshold() public pure virtual override returns (uint256) { return 1.25e18; } - function minterConfig() public pure returns (IMinter.Config memory) { + function minterConfig() public pure override returns (IMinter.Config memory) { uint256[] memory mintPeggedBounds = new uint256[](6); mintPeggedBounds[0] = 1.26e18; mintPeggedBounds[1] = 1.35e18; @@ -100,7 +101,4 @@ contract ConfigPriceVolatility_125 { }); } - function autoCompounderMintMaxFeeRatio() public pure virtual returns (uint256) { - return 0.05 ether; - } } diff --git a/script/config/volatility/ConfigPriceVolatility_125_stable.sol b/script/config/volatility/ConfigPriceVolatility_125_stable.sol index 96293d76..2196ae1d 100644 --- a/script/config/volatility/ConfigPriceVolatility_125_stable.sol +++ b/script/config/volatility/ConfigPriceVolatility_125_stable.sol @@ -2,14 +2,15 @@ pragma solidity >=0.8.28 <0.9.0; import {IMinter} from "@harbor/interfaces/IMinter.sol"; +import {ConfigPriceVolatilityBase} from "./ConfigPriceVolatilityBase.sol"; /// @notice Volatility configuration for 125% rebalance threshold markets. -contract ConfigPriceVolatility_125_stable { - function rebalanceThreshold() public pure virtual returns (uint256) { +contract ConfigPriceVolatility_125_stable is ConfigPriceVolatilityBase { + function rebalanceThreshold() public pure virtual override returns (uint256) { return 1.25e18; } - function minterConfig() public pure returns (IMinter.Config memory) { + function minterConfig() public pure override returns (IMinter.Config memory) { uint256[] memory mintPeggedBounds = new uint256[](6); mintPeggedBounds[0] = 1.26e18; mintPeggedBounds[1] = 1.35e18; @@ -99,7 +100,4 @@ contract ConfigPriceVolatility_125_stable { }); } - function autoCompounderMintMaxFeeRatio() public pure virtual returns (uint256) { - return 0.05 ether; - } } diff --git a/script/config/volatility/ConfigPriceVolatility_130.sol b/script/config/volatility/ConfigPriceVolatility_130.sol index 6066ce44..9a2c8063 100644 --- a/script/config/volatility/ConfigPriceVolatility_130.sol +++ b/script/config/volatility/ConfigPriceVolatility_130.sol @@ -2,15 +2,16 @@ pragma solidity >=0.8.28 <0.9.0; import {IMinter} from "@harbor/interfaces/IMinter.sol"; +import {ConfigPriceVolatilityBase} from "./ConfigPriceVolatilityBase.sol"; /// @notice Volatility configuration for 130% rebalance threshold markets (Month 1 fees). /// @dev Higher redeem leveraged fees for initial month after launch. -contract ConfigPriceVolatility_130 { - function rebalanceThreshold() public pure virtual returns (uint256) { +contract ConfigPriceVolatility_130 is ConfigPriceVolatilityBase { + function rebalanceThreshold() public pure virtual override returns (uint256) { return 1.30e18; } - function minterConfig() public pure returns (IMinter.Config memory) { + function minterConfig() public pure override returns (IMinter.Config memory) { uint256[] memory mintPeggedBounds = new uint256[](6); mintPeggedBounds[0] = 1.31e18; mintPeggedBounds[1] = 1.40e18; @@ -100,7 +101,4 @@ contract ConfigPriceVolatility_130 { }); } - function autoCompounderMintMaxFeeRatio() public pure virtual returns (uint256) { - return 0.05 ether; - } } diff --git a/script/config/volatility/ConfigPriceVolatility_130_stable.sol b/script/config/volatility/ConfigPriceVolatility_130_stable.sol index fcb41a1b..4c74d281 100644 --- a/script/config/volatility/ConfigPriceVolatility_130_stable.sol +++ b/script/config/volatility/ConfigPriceVolatility_130_stable.sol @@ -2,14 +2,15 @@ pragma solidity >=0.8.28 <0.9.0; import {IMinter} from "@harbor/interfaces/IMinter.sol"; +import {ConfigPriceVolatilityBase} from "./ConfigPriceVolatilityBase.sol"; /// @notice Volatility configuration for 130% rebalance threshold markets. -contract ConfigPriceVolatility_130_stable { - function rebalanceThreshold() public pure virtual returns (uint256) { +contract ConfigPriceVolatility_130_stable is ConfigPriceVolatilityBase { + function rebalanceThreshold() public pure virtual override returns (uint256) { return 1.30e18; } - function minterConfig() public pure returns (IMinter.Config memory) { + function minterConfig() public pure override returns (IMinter.Config memory) { uint256[] memory mintPeggedBounds = new uint256[](6); mintPeggedBounds[0] = 1.31e18; mintPeggedBounds[1] = 1.40e18; @@ -99,7 +100,4 @@ contract ConfigPriceVolatility_130_stable { }); } - function autoCompounderMintMaxFeeRatio() public pure virtual returns (uint256) { - return 0.05 ether; - } } From fe5d4bb19d943cd1ece84b6d0edccbbb6983ab95 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Wed, 22 Apr 2026 13:26:30 +0100 Subject: [PATCH 057/232] remove autocompounder as it's in harbor-yield --- lib/bao-base | 2 +- package.json | 1 + regression/coverage.txt | 29 +- regression/gas.txt | 158 ++--- regression/sizes.txt | 43 +- .../volatility/ConfigPriceVolatility_105.sol | 1 - .../ConfigPriceVolatility_105_stable.sol | 1 - .../volatility/ConfigPriceVolatility_115.sol | 1 - .../ConfigPriceVolatility_115_stable.sol | 1 - .../volatility/ConfigPriceVolatility_125.sol | 1 - .../ConfigPriceVolatility_125_stable.sol | 1 - .../volatility/ConfigPriceVolatility_130.sol | 1 - .../ConfigPriceVolatility_130_stable.sol | 1 - script/src/contracts/AutoCompounder.sol | 107 ---- src/autocompounding/AutoCompounder_v1.sol | 361 ------------ src/interfaces/IAutoCompounder.sol | 11 + src/interfaces/IStabilityPoolManager_v2.sol | 23 + src/minter/Minter_v3.sol | 52 +- src/minter/StabilityPoolManager_v2.sol | 502 ++++++++++++++++ src/minter/library/Config_v2.sol | 2 +- test/deployment/AutoCompounderTest.t.sol | 541 ------------------ test/deployment/RewardSystem.t.sol | 1 - 22 files changed, 644 insertions(+), 1197 deletions(-) delete mode 100644 script/src/contracts/AutoCompounder.sol delete mode 100644 src/autocompounding/AutoCompounder_v1.sol create mode 100644 src/interfaces/IStabilityPoolManager_v2.sol create mode 100644 src/minter/StabilityPoolManager_v2.sol delete mode 100644 test/deployment/AutoCompounderTest.t.sol diff --git a/lib/bao-base b/lib/bao-base index e9df1fce..87a485d4 160000 --- a/lib/bao-base +++ b/lib/bao-base @@ -1 +1 @@ -Subproject commit e9df1fcee2acc0955b44c316e7c573a9371e45a1 +Subproject commit 87a485d4a105002d260168fcdbc78b36d7457b38 diff --git a/package.json b/package.json index f7d26b4e..a149b0c5 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "scripts": { "uv": "uv sync; echo \"to enable vyper builds, run:\n source ./.venv/bin/activate\"", "foundryup": "curl -L https://foundry.paradigm.xyz | bash && foundryup", + "doctor": "./lib/bao-base/run doctor", "CI": "./lib/bao-base/run CI", "clean": "./lib/bao-base/run clean", "git-diffs": "./lib/bao-base/run git-diffs", diff --git a/regression/coverage.txt b/regression/coverage.txt index 1e8a31cc..b9b81f07 100644 --- a/regression/coverage.txt +++ b/regression/coverage.txt @@ -1,8 +1,7 @@ | File | % Lines | % Statements | % Branches | % Funcs | |--------------------------------------------------------------------|--------------------|--------------------|------------------|-------------------| | script/config/ConfigBase.sol | ✓ 100% (8/8) | ✓ 100% (8/8) | ✓ 100% (0/0) | ✓ 100% (4/4) | -| script/config/ConfigTokenNames.sol | X 82% (33/40) | X 81% (26/32) | ✓ 100% (0/0) | X 78% (14/18) | -| script/config/autocompounder/ConfigAutoCompounder.sol | ✓ 100% (2/2) | ✓ 100% (1/1) | ✓ 100% (0/0) | ✓ 100% (1/1) | +| script/config/ConfigTokenNames.sol | X 52% (21/40) | X 56% (18/32) | ✓ 100% (0/0) | X 50% (9/18) | | script/config/chains/ConfigChain_mainnet.sol | X 10% (2/21) | X 15% (2/13) | ✓ 100% (0/0) | X 0% (0/8) | | script/config/collaterals/ConfigCollateral_fxUSD_mainnet.sol | X 67% (4/6) | X 60% (3/5) | ✓ 100% (0/0) | X 67% (2/3) | | script/config/collaterals/ConfigCollateral_stETH_mainnet.sol | X 67% (4/6) | X 60% (3/5) | ✓ 100% (0/0) | X 67% (2/3) | @@ -16,6 +15,7 @@ | script/config/stabilitypool/ConfigStabilityPool.sol | ✓ 100% (6/6) | ✓ 100% (3/3) | ✓ 100% (0/0) | ✓ 100% (3/3) | | script/config/stabilitypool/ConfigStabilityPoolManager.sol | ✓ 100% (6/6) | ✓ 100% (3/3) | ✓ 100% (0/0) | ✓ 100% (3/3) | | script/config/stabilitypool/ConfigStabilityPoolManagerCommon.sol | X 0% (0/21) | X 0% (0/17) | ✓ 100% (0/0) | X 0% (0/7) | +| script/config/volatility/ConfigPriceVolatilityBase.sol | X 0% (0/10) | X 0% (0/15) | X 0% (0/1) | X 0% (0/1) | | script/config/volatility/ConfigPriceVolatility_105.sol | ✓ 100% (65/65) | ✓ 100% (71/71) | ✓ 100% (0/0) | ✓ 100% (2/2) | | script/config/volatility/ConfigPriceVolatility_105_stable.sol | X 0% (0/65) | X 0% (0/71) | ✓ 100% (0/0) | X 0% (0/2) | | script/config/volatility/ConfigPriceVolatility_115.sol | X 0% (0/65) | X 0% (0/71) | ✓ 100% (0/0) | X 0% (0/2) | @@ -24,7 +24,7 @@ | script/config/volatility/ConfigPriceVolatility_125_stable.sol | X 0% (0/65) | X 0% (0/71) | ✓ 100% (0/0) | X 0% (0/2) | | script/config/volatility/ConfigPriceVolatility_130.sol | ✓ 100% (65/65) | ✓ 100% (71/71) | ✓ 100% (0/0) | ✓ 100% (2/2) | | script/config/volatility/ConfigPriceVolatility_130_stable.sol | ✓ 100% (65/65) | ✓ 100% (71/71) | ✓ 100% (0/0) | ✓ 100% (2/2) | -| script/src/DeployMintersShared.sol | X 85% (80/94) | X 84% (94/112) | X 25% (1/4) | X 82% (9/11) | +| script/src/DeployMintersShared.sol | X 83% (70/84) | X 82% (81/99) | X 25% (1/4) | X 80% (8/10) | | script/src/Deploy_BTC_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | | script/src/Deploy_ETH_Minter.sol | ✓ 100% (4/4) | ✓ 100% (3/3) | ✓ 100% (0/0) | ✓ 100% (1/1) | | script/src/Deploy_EUR_Minter.sol | ✓ 100% (5/5) | ✓ 100% (4/4) | ✓ 100% (0/0) | ✓ 100% (1/1) | @@ -32,16 +32,12 @@ | script/src/Deploy_MCAP_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | | script/src/Deploy_SILVER_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | | script/src/HarborFactoryDeployer.sol | X 56% (9/16) | X 73% (8/11) | ✓ 100% (0/0) | X 20% (1/5) | -| script/src/contracts/AutoCompounder.sol | ✓ 100% (23/23) | ✓ 100% (33/33) | ✓ 100% (0/0) | ✓ 100% (3/3) | | script/src/contracts/Genesis.sol | X 77% (10/13) | X 73% (11/15) | ✓ 100% (0/0) | X 67% (2/3) | -| script/src/contracts/HarborYield.sol | X 0% (0/27) | X 0% (0/37) | ✓ 100% (0/0) | X 0% (0/3) | | script/src/contracts/LeveragedToken.sol | ✓ 100% (18/18) | ✓ 100% (26/26) | ✓ 100% (0/0) | ✓ 100% (2/2) | | script/src/contracts/Minter.sol | X 62% (32/52) | X 63% (38/60) | ✓ 100% (0/0) | X 60% (6/10) | | script/src/contracts/PeggedToken.sol | X 85% (23/27) | X 94% (34/36) | X 50% (3/6) | ✓ 100% (2/2) | -| script/src/contracts/StabilityPool.sol | ✓ 100% (31/31) | ✓ 100% (50/50) | ✓ 100% (0/0) | ✓ 100% (3/3) | +| script/src/contracts/StabilityPool.sol | ✓ 100% (29/29) | ✓ 100% (47/47) | ✓ 100% (0/0) | ✓ 100% (3/3) | | script/src/contracts/StabilityPoolManager.sol | X 53% (17/32) | X 50% (18/36) | ✓ 100% (0/0) | X 50% (3/6) | -| src/autocompounding/AutoCompounder_v1.sol | X 97% (64/66) | X 99% (66/67) | X 75% (3/4) | X 93% (13/14) | -| src/autocompounding/HarborYield_v1.sol | X 94% (197/209) | X 95% (231/242) | X 88% (22/25) | X 88% (30/34) | | src/math/DecrementalFloatingPoint.sol | ✓ 100% (31/31) | ✓ 100% (33/33) | ✓ 100% (9/9) | ✓ 100% (6/6) | | src/minter/Genesis_v1.sol | X 99% (88/89) | ✓ 100% (88/88) | ✓ 100% (14/14) | X 92% (11/12) | | src/minter/Minter_v1.sol | X 0% (0/601) | X 0% (0/649) | X 0% (0/102) | X 0% (0/68) | @@ -54,17 +50,18 @@ | src/minter/StabilityPool_v3.sol | ✓ 100% (234/234) | ✓ 100% (255/255) | ✓ 100% (33/33) | ✓ 100% (29/29) | | src/minter/TokenDistributor_v1.sol | X 96% (94/98) | X 97% (112/116) | X 77% (10/13) | X 93% (14/15) | | src/minter/library/ConfigIncentiveLib.sol | ✓ 100% (24/24) | ✓ 100% (17/17) | ✓ 100% (2/2) | ✓ 100% (9/9) | -| src/minter/library/Config_v1.sol | X 96% (76/79) | X 97% (92/95) | X 90% (18/20) | ✓ 100% (6/6) | +| src/minter/library/Config_v1.sol | X 0% (0/79) | X 0% (0/95) | X 0% (0/20) | X 0% (0/6) | +| src/minter/library/Config_v2.sol | X 96% (76/79) | X 97% (92/95) | X 90% (18/20) | ✓ 100% (6/6) | | src/price/PriceOracle_v1.sol | X 97% (31/32) | X 97% (36/37) | X 85% (11/13) | ✓ 100% (3/3) | | src/price/StakedETHWrappedPriceOracle_v1.sol | X 0% (0/29) | X 0% (0/27) | X 0% (0/5) | X 0% (0/4) | -| src/reward/accumulator/MultipleRewardCompoundingAccumulator.sol | X 91% (124/136) | X 90% (154/171) | X 75% (12/16) | X 90% (19/21) | -| src/reward/accumulator/MultipleRewardCompoundingAccumulator_v2.sol | X 96% (141/147) | X 96% (177/184) | X 83% (15/18) | ✓ 100% (22/22) | -| src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol | X 96% (145/151) | X 97% (177/183) | X 85% (17/20) | X 96% (24/25) | -| src/reward/distributor/LinearMultipleRewardDistributor.sol | ✓ 100% (77/77) | ✓ 100% (86/86) | ✓ 100% (12/12) | ✓ 100% (14/14) | -| src/reward/distributor/LinearMultipleRewardDistributor_v2.sol | ✓ 100% (77/77) | ✓ 100% (86/86) | ✓ 100% (12/12) | ✓ 100% (14/14) | -| src/reward/distributor/LinearMultipleRewardDistributor_v3.sol | X 96% (78/81) | X 97% (85/88) | X 75% (9/12) | ✓ 100% (16/16) | +| src/reward/accumulator/MultipleRewardCompoundingAccumulator.sol | X 0% (0/136) | X 0% (0/171) | X 0% (0/16) | X 0% (0/21) | +| src/reward/accumulator/MultipleRewardCompoundingAccumulator_v2.sol | X 73% (107/147) | X 74% (137/184) | X 61% (11/18) | X 68% (15/22) | +| src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol | X 98% (148/151) | X 98% (191/194) | X 82% (14/17) | ✓ 100% (22/22) | +| src/reward/distributor/LinearMultipleRewardDistributor.sol | X 0% (0/77) | X 0% (0/86) | X 0% (0/12) | X 0% (0/14) | +| src/reward/distributor/LinearMultipleRewardDistributor_v2.sol | X 58% (45/77) | X 63% (54/86) | X 25% (3/12) | X 50% (7/14) | +| src/reward/distributor/LinearMultipleRewardDistributor_v3.sol | ✓ 100% (77/77) | ✓ 100% (86/86) | ✓ 100% (12/12) | ✓ 100% (14/14) | | src/reward/distributor/LinearReward.sol | ✓ 100% (25/25) | ✓ 100% (27/27) | ✓ 100% (6/6) | ✓ 100% (2/2) | | src/util/ERC20MetadataLib_v1.sol | ✓ 100% (24/24) | ✓ 100% (22/22) | ✓ 100% (4/4) | ✓ 100% (4/4) | | src/util/FmtLib.sol | X 79% (15/19) | X 73% (19/26) | X 50% (2/4) | ✓ 100% (1/1) | | src/util/WordCodec.sol | ✓ 100% (15/15) | ✓ 100% (14/14) | ✓ 100% (0/0) | ✓ 100% (4/4) | -| Total | X 62% (5123/8244) | X 61% (5436/8898) | X 50% (461/916) | X 64% (780/1225) | +| Total | X 56% (4456/7962) | X 55% (4718/8603) | X 43% (392/905) | X 56% (646/1160) | diff --git a/regression/gas.txt b/regression/gas.txt index d0cf9157..fa716845 100644 --- a/regression/gas.txt +++ b/regression/gas.txt @@ -1,69 +1,3 @@ -src/autocompounding/AutoCompounder_v1.sol:AutoCompounder_v1 -| function name | max | -|-----------------------|-----------| -| DOMAIN_SEPARATOR | 6.520e+02 | -| MINTER | 3.250e+02 | -| PEGGED_TOKEN | 3.050e+02 | -| STABILITY_POOL | 2.840e+02 | -| WRAPPED_COLLATERAL | 2.830e+02 | -| allowance | 2.732e+03 | -| approveCompoundTokens | 7.002e+04 | -| asset | 3.030e+02 | -| balanceOf | 2.577e+03 | -| compound | 3.948e+05 | -| decimals | 3.980e+02 | -| deposit | 2.098e+05 | -| depositPeggedToken | 3.209e+05 | -| initialize | 7.067e+04 | -| maxFeeRatio | 2.391e+03 | -| name | 5.050e+02 | -| nonces | 2.599e+03 | -| owner | 2.380e+03 | -| permit | 5.061e+04 | -| previewRedeem | 3.572e+04 | -| redeem | 9.616e+04 | -| setMaxFeeRatio | 2.562e+04 | -| sweep | 4.524e+04 | -| symbol | 5.990e+02 | -| totalAssets | 7.818e+04 | -| transferOwnership | 1.202e+04 | - -src/autocompounding/HarborYield_v1.sol:HarborYield_v1 -| function name | max | -|------------------------|-----------| -| COMPOUNDER_ROLE | 2.520e+02 | -| DOMAIN_SEPARATOR | 6.240e+02 | -| REDISTRIBUTOR_ROLE | 2.720e+02 | -| activateVault | 1.149e+04 | -| addAutoCompounderVault | 1.280e+05 | -| addEquivalentVault | 1.281e+05 | -| allowance | 2.703e+03 | -| approve | 2.445e+04 | -| asset | 3.130e+02 | -| balanceOf | 2.615e+03 | -| compound | 1.974e+05 | -| convertToAssets | 6.420e+04 | -| convertToShares | 4.041e+04 | -| deactivateVault | 1.146e+04 | -| deposit | 1.874e+05 | -| grantRoles | 2.633e+04 | -| initialize | 7.062e+04 | -| maxPegDriftBps | 2.399e+03 | -| name | 5.440e+02 | -| nonces | 2.570e+03 | -| permit | 5.059e+04 | -| previewDeposit | 4.042e+04 | -| previewRedeem | 4.046e+04 | -| redeem | 1.302e+05 | -| redistribute | 2.750e+05 | -| setMaxPegDriftBps | 2.572e+04 | -| setVaultWeight | 1.733e+04 | -| totalAssets | 6.166e+04 | -| totalSupply | 2.349e+03 | -| totalWeight | 2.392e+03 | -| vaultAt | 8.257e+03 | -| vaultCount | 2.420e+03 | - src/minter/Genesis_v1.sol:Genesis_v1 | function name | max | |--------------------------|-----------| @@ -94,9 +28,9 @@ src/minter/Minter_v3.sol:Minter_v3 | collateralTokenBalance | 2.358e+03 | | config | 4.895e+04 | | feeReceiver | 2.442e+03 | -| freeMintLeveragedToken | 1.492e+05 | +| freeMintLeveragedToken | 1.438e+05 | | freeMintPeggedToken | 1.674e+05 | -| freeRedeemLeveragedToken | 9.676e+04 | +| freeRedeemLeveragedToken | 8.668e+04 | | freeRedeemPeggedToken | 1.346e+05 | | grantRoles | 2.633e+04 | | harvestable | 2.981e+04 | @@ -159,7 +93,7 @@ src/minter/StabilityPoolManager_v1.sol:StabilityPoolManager_v1 | hasStabilityPool | 5.370e+02 | | initialize | 7.069e+04 | | owner | 2.423e+03 | -| rebalance | 5.542e+05 | +| rebalance | 5.540e+05 | | rebalanceBountyRatio | 2.354e+03 | | rebalanceThreshold | 2.347e+03 | | rebalanceable | 2.926e+04 | @@ -195,52 +129,50 @@ src/minter/StabilityPool_v2.sol:StabilityPool_v2 | sweep | 4.020e+04 | | totalAssetSupply | 2.489e+03 | | transferOwnership | 1.207e+04 | -| upgradeToAndCall | 1.092e+04 | +| upgradeToAndCall | 1.094e+04 | src/minter/StabilityPool_v3.sol:StabilityPool_v3 -| function name | max | -|----------------------------------------|-----------| -| ASSET_TOKEN | 3.050e+02 | -| DOMAIN_SEPARATOR | 6.860e+02 | -| EXEMPT_WITHDRAWAL_FEE_ROLE | 2.860e+02 | -| LIQUIDATION_TOKEN | 3.500e+02 | -| REBALANCER_ROLE | 2.840e+02 | -| REWARD_DEPOSITOR_ROLE | 3.060e+02 | -| REWARD_MANAGER_ROLE | 3.270e+02 | -| allowance | 2.699e+03 | -| approve | 2.442e+04 | -| assetBalanceOf | 8.053e+03 | -| balanceOf | 5.789e+03 | -| checkpoint | 1.465e+05 | -| claim(address) | 2.246e+05 | -| claim(address,address) | 1.535e+05 | -| claim(address,address,address,uint256) | 2.161e+05 | -| claimable | 2.488e+04 | -| claimed | 7.472e+03 | -| decimals | 2.670e+02 | -| deposit | 2.848e+05 | -| depositReward | 6.726e+04 | -| getWithdrawalRequest | 2.767e+03 | -| grantRoles | 2.638e+04 | -| historicalRewardTokens | 5.180e+03 | -| initialize | 2.042e+05 | -| name | 5.720e+02 | -| nonces | 2.654e+03 | -| notifyLiquidation | 1.235e+05 | -| owner | 2.446e+03 | -| permit | 5.063e+04 | -| proxiableUUID | 3.640e+02 | -| registerRewardToken | 8.857e+04 | -| requestWithdrawal | 2.501e+04 | -| sweep | 4.024e+04 | -| symbol | 5.770e+02 | -| totalAssetSupply | 2.423e+03 | -| totalSupply | 2.424e+03 | -| transfer | 1.880e+05 | -| transferFrom | 1.313e+05 | -| transferOwnership | 1.204e+04 | -| unregisterRewardToken | 9.144e+04 | -| withdraw | 2.585e+05 | +| function name | max | +|------------------------|-----------| +| ASSET_TOKEN | 2.820e+02 | +| DOMAIN_SEPARATOR | 6.190e+02 | +| LIQUIDATION_TOKEN | 3.500e+02 | +| REBALANCER_ROLE | 3.060e+02 | +| REWARD_DEPOSITOR_ROLE | 3.060e+02 | +| REWARD_MANAGER_ROLE | 3.270e+02 | +| allowance | 2.721e+03 | +| approve | 2.442e+04 | +| assetBalanceOf | 8.053e+03 | +| balanceOf | 5.789e+03 | +| checkpoint | 1.465e+05 | +| claim(address) | 2.636e+05 | +| claim(address,address) | 1.535e+05 | +| claimable | 2.488e+04 | +| claimed | 7.472e+03 | +| decimals | 2.890e+02 | +| deposit | 2.848e+05 | +| depositReward | 6.726e+04 | +| getWithdrawalRequest | 2.767e+03 | +| grantRoles | 2.638e+04 | +| historicalRewardTokens | 5.202e+03 | +| initialize | 2.042e+05 | +| name | 5.720e+02 | +| nonces | 2.654e+03 | +| notifyLiquidation | 1.235e+05 | +| owner | 2.446e+03 | +| permit | 5.063e+04 | +| proxiableUUID | 3.860e+02 | +| registerRewardToken | 8.850e+04 | +| requestWithdrawal | 2.503e+04 | +| sweep | 4.016e+04 | +| symbol | 5.770e+02 | +| totalAssetSupply | 2.423e+03 | +| totalSupply | 2.424e+03 | +| transfer | 1.880e+05 | +| transferFrom | 1.313e+05 | +| transferOwnership | 1.207e+04 | +| unregisterRewardToken | 9.144e+04 | +| withdraw | 2.586e+05 | src/minter/TokenDistributor_v1.sol:TokenDistributor_v1 | function name | max | diff --git a/regression/sizes.txt b/regression/sizes.txt index 90189ae4..b6e164c1 100644 --- a/regression/sizes.txt +++ b/regression/sizes.txt @@ -1,33 +1,33 @@ | Contract | Runtime Size (B) | Runtime Margin (B) | Initcode Size (B) | Deploy Gas | Deploy Cost ($) | |-----------------------------------|--------------------|----------------------|---------------------|--------------|-------------------| -| AutoCompounder_v1 | 11,885 | 12,691 | 13,457 | 2,511,570 | 251.16 | | ConfigIncentiveLib | 85 | 24,491 | 135 | 18,350 | 1.84 | -| ConfigMarket_BTC_fxUSD_mainnet | 6,856 | 17,720 | 6,884 | 1,440,040 | 144.00 | -| ConfigMarket_BTC_stETH_mainnet | 6,882 | 17,694 | 6,910 | 1,445,500 | 144.55 | -| ConfigMarket_ETH_fxUSD_mainnet | 6,856 | 17,720 | 6,884 | 1,440,040 | 144.00 | -| ConfigMarket_EUR_fxUSD_mainnet | 6,844 | 17,732 | 6,872 | 1,437,520 | 143.75 | -| ConfigMarket_EUR_stETH_mainnet | 6,870 | 17,706 | 6,898 | 1,442,980 | 144.30 | -| ConfigMarket_GOLD_fxUSD_mainnet | 6,860 | 17,716 | 6,888 | 1,440,880 | 144.09 | -| ConfigMarket_GOLD_stETH_mainnet | 6,886 | 17,690 | 6,914 | 1,446,340 | 144.63 | -| ConfigMarket_MCAP_fxUSD_mainnet | 6,862 | 17,714 | 6,890 | 1,441,300 | 144.13 | -| ConfigMarket_MCAP_stETH_mainnet | 6,888 | 17,688 | 6,916 | 1,446,760 | 144.68 | -| ConfigMarket_SILVER_fxUSD_mainnet | 6,856 | 17,720 | 6,884 | 1,440,040 | 144.00 | -| ConfigMarket_SILVER_stETH_mainnet | 6,882 | 17,694 | 6,910 | 1,445,500 | 144.55 | +| ConfigMarket_BTC_fxUSD_mainnet | 7,097 | 17,479 | 7,125 | 1,490,650 | 149.06 | +| ConfigMarket_BTC_stETH_mainnet | 7,123 | 17,453 | 7,151 | 1,496,110 | 149.61 | +| ConfigMarket_ETH_fxUSD_mainnet | 7,097 | 17,479 | 7,125 | 1,490,650 | 149.06 | +| ConfigMarket_EUR_fxUSD_mainnet | 7,085 | 17,491 | 7,113 | 1,488,130 | 148.81 | +| ConfigMarket_EUR_stETH_mainnet | 7,111 | 17,465 | 7,139 | 1,493,590 | 149.36 | +| ConfigMarket_GOLD_fxUSD_mainnet | 7,101 | 17,475 | 7,129 | 1,491,490 | 149.15 | +| ConfigMarket_GOLD_stETH_mainnet | 7,127 | 17,449 | 7,155 | 1,496,950 | 149.69 | +| ConfigMarket_MCAP_fxUSD_mainnet | 7,103 | 17,473 | 7,131 | 1,491,910 | 149.19 | +| ConfigMarket_MCAP_stETH_mainnet | 7,129 | 17,447 | 7,157 | 1,497,370 | 149.74 | +| ConfigMarket_SILVER_fxUSD_mainnet | 7,097 | 17,479 | 7,125 | 1,490,650 | 149.06 | +| ConfigMarket_SILVER_stETH_mainnet | 7,123 | 17,453 | 7,151 | 1,496,110 | 149.61 | | ConfigPeg_BTC | 766 | 23,810 | 794 | 161,140 | 16.11 | | ConfigPeg_ETH | 766 | 23,810 | 794 | 161,140 | 16.11 | | ConfigPeg_EUR | 768 | 23,808 | 796 | 161,560 | 16.16 | | ConfigPeg_GOLD | 770 | 23,806 | 798 | 161,980 | 16.20 | | ConfigPeg_MCAP | 772 | 23,804 | 800 | 162,400 | 16.24 | | ConfigPeg_SILVER | 794 | 23,782 | 822 | 167,020 | 16.70 | -| ConfigPriceVolatility_105 | 3,019 | 21,557 | 3,047 | 634,270 | 63.43 | -| ConfigPriceVolatility_105_stable | 3,019 | 21,557 | 3,047 | 634,270 | 63.43 | -| ConfigPriceVolatility_115 | 3,019 | 21,557 | 3,047 | 634,270 | 63.43 | -| ConfigPriceVolatility_115_stable | 3,019 | 21,557 | 3,047 | 634,270 | 63.43 | -| ConfigPriceVolatility_125 | 3,019 | 21,557 | 3,047 | 634,270 | 63.43 | -| ConfigPriceVolatility_125_stable | 3,019 | 21,557 | 3,047 | 634,270 | 63.43 | -| ConfigPriceVolatility_130 | 3,019 | 21,557 | 3,047 | 634,270 | 63.43 | -| ConfigPriceVolatility_130_stable | 3,019 | 21,557 | 3,047 | 634,270 | 63.43 | +| ConfigPriceVolatility_105 | 3,292 | 21,284 | 3,320 | 691,600 | 69.16 | +| ConfigPriceVolatility_105_stable | 3,292 | 21,284 | 3,320 | 691,600 | 69.16 | +| ConfigPriceVolatility_115 | 3,292 | 21,284 | 3,320 | 691,600 | 69.16 | +| ConfigPriceVolatility_115_stable | 3,292 | 21,284 | 3,320 | 691,600 | 69.16 | +| ConfigPriceVolatility_125 | 3,292 | 21,284 | 3,320 | 691,600 | 69.16 | +| ConfigPriceVolatility_125_stable | 3,292 | 21,284 | 3,320 | 691,600 | 69.16 | +| ConfigPriceVolatility_130 | 3,292 | 21,284 | 3,320 | 691,600 | 69.16 | +| ConfigPriceVolatility_130_stable | 3,292 | 21,284 | 3,320 | 691,600 | 69.16 | | Config_v1 | 3,789 | 20,787 | 3,841 | 796,210 | 79.62 | +| Config_v2 | 3,789 | 20,787 | 3,841 | 796,210 | 79.62 | | DecrementalFloatingPoint | 85 | 24,491 | 135 | 18,350 | 1.84 | | ERC20MetadataLib_v1 | 85 | 24,491 | 135 | 18,350 | 1.84 | | FakeAccessControl | 1,626 | 22,950 | 1,654 | 341,740 | 34.17 | @@ -37,7 +37,6 @@ | FakeUUPSUpgradeable | 3,236 | 21,340 | 3,293 | 680,130 | 68.01 | | FmtLib | 85 | 24,491 | 135 | 18,350 | 1.84 | | Genesis_v1 | 7,486 | 17,090 | 8,423 | 1,581,430 | 158.14 | -| HarborYield_v1 | 15,982 | 8,594 | 16,977 | 3,366,170 | 336.62 | | LinearReward | 85 | 24,491 | 135 | 18,350 | 1.84 | | MinterMarketConfigLib | 85 | 24,491 | 135 | 18,350 | 1.84 | | Minter_v1 | 23,681 | 895 | 25,515 | 4,991,350 | 499.14 | @@ -48,7 +47,7 @@ | StabilityPoolManager_v1 | 11,209 | 13,367 | 13,057 | 2,372,370 | 237.24 | | StabilityPool_v1 | 21,092 | 3,484 | 23,085 | 4,449,250 | 444.93 | | StabilityPool_v2 | 20,711 | 3,865 | 22,579 | 4,367,990 | 436.80 | -| StabilityPool_v3 | 23,760 | 816 | 26,139 | 5,013,390 | 501.34 | +| StabilityPool_v3 | 23,412 | 1,164 | 25,791 | 4,940,310 | 494.03 | | StakedETHWrappedPriceOracle_v1 | 3,695 | 20,881 | 4,653 | 785,530 | 78.55 | | TokenDistributor_v1 | 10,116 | 14,460 | 10,365 | 2,126,850 | 212.69 | | WordCodec | 85 | 24,491 | 135 | 18,350 | 1.84 | diff --git a/script/config/volatility/ConfigPriceVolatility_105.sol b/script/config/volatility/ConfigPriceVolatility_105.sol index 55c998de..12c87632 100644 --- a/script/config/volatility/ConfigPriceVolatility_105.sol +++ b/script/config/volatility/ConfigPriceVolatility_105.sol @@ -100,5 +100,4 @@ contract ConfigPriceVolatility_105 is ConfigPriceVolatilityBase { }) }); } - } diff --git a/script/config/volatility/ConfigPriceVolatility_105_stable.sol b/script/config/volatility/ConfigPriceVolatility_105_stable.sol index b027fcac..c7ced6cd 100644 --- a/script/config/volatility/ConfigPriceVolatility_105_stable.sol +++ b/script/config/volatility/ConfigPriceVolatility_105_stable.sol @@ -99,5 +99,4 @@ contract ConfigPriceVolatility_105_stable is ConfigPriceVolatilityBase { }) }); } - } diff --git a/script/config/volatility/ConfigPriceVolatility_115.sol b/script/config/volatility/ConfigPriceVolatility_115.sol index c2225b19..59099e93 100644 --- a/script/config/volatility/ConfigPriceVolatility_115.sol +++ b/script/config/volatility/ConfigPriceVolatility_115.sol @@ -100,5 +100,4 @@ contract ConfigPriceVolatility_115 is ConfigPriceVolatilityBase { }) }); } - } diff --git a/script/config/volatility/ConfigPriceVolatility_115_stable.sol b/script/config/volatility/ConfigPriceVolatility_115_stable.sol index e1d37c7a..44fef451 100644 --- a/script/config/volatility/ConfigPriceVolatility_115_stable.sol +++ b/script/config/volatility/ConfigPriceVolatility_115_stable.sol @@ -99,5 +99,4 @@ contract ConfigPriceVolatility_115_stable is ConfigPriceVolatilityBase { }) }); } - } diff --git a/script/config/volatility/ConfigPriceVolatility_125.sol b/script/config/volatility/ConfigPriceVolatility_125.sol index e8fac6dd..1b115aeb 100644 --- a/script/config/volatility/ConfigPriceVolatility_125.sol +++ b/script/config/volatility/ConfigPriceVolatility_125.sol @@ -100,5 +100,4 @@ contract ConfigPriceVolatility_125 is ConfigPriceVolatilityBase { }) }); } - } diff --git a/script/config/volatility/ConfigPriceVolatility_125_stable.sol b/script/config/volatility/ConfigPriceVolatility_125_stable.sol index 2196ae1d..e8b7190b 100644 --- a/script/config/volatility/ConfigPriceVolatility_125_stable.sol +++ b/script/config/volatility/ConfigPriceVolatility_125_stable.sol @@ -99,5 +99,4 @@ contract ConfigPriceVolatility_125_stable is ConfigPriceVolatilityBase { }) }); } - } diff --git a/script/config/volatility/ConfigPriceVolatility_130.sol b/script/config/volatility/ConfigPriceVolatility_130.sol index 9a2c8063..7325b34c 100644 --- a/script/config/volatility/ConfigPriceVolatility_130.sol +++ b/script/config/volatility/ConfigPriceVolatility_130.sol @@ -100,5 +100,4 @@ contract ConfigPriceVolatility_130 is ConfigPriceVolatilityBase { }) }); } - } diff --git a/script/config/volatility/ConfigPriceVolatility_130_stable.sol b/script/config/volatility/ConfigPriceVolatility_130_stable.sol index 4c74d281..8d3a92b0 100644 --- a/script/config/volatility/ConfigPriceVolatility_130_stable.sol +++ b/script/config/volatility/ConfigPriceVolatility_130_stable.sol @@ -99,5 +99,4 @@ contract ConfigPriceVolatility_130_stable is ConfigPriceVolatilityBase { }) }); } - } diff --git a/script/src/contracts/AutoCompounder.sol b/script/src/contracts/AutoCompounder.sol deleted file mode 100644 index c131937a..00000000 --- a/script/src/contracts/AutoCompounder.sol +++ /dev/null @@ -1,107 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.28 <0.9.0; - -import {console2 as console} from "forge-std/console2.sol"; -import {HarborFactoryDeployer} from "@harbor-script/src/HarborFactoryDeployer.sol"; -import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; - -import {AutoCompounder_v1} from "@harbor/autocompounding/AutoCompounder_v1.sol"; -import {Config_MinterMarket, MinterMarketConfigLib, IMarketConfig} from "@harbor-script/config/ConfigBase.sol"; -import {ConfigTokenNames} from "@harbor-script/config/ConfigTokenNames.sol"; - -/// @notice Config interface for auto-compounder deployment parameters. -interface IAutoCompounderMarketConfig { - function autoCompounderMintMaxFeeRatio() external pure returns (uint256); -} - -/// @notice Harbor AutoCompounder deployment logic. -/// @dev Each market has TWO auto-compounders: Collateral and Leveraged (one per stability pool). -/// Post-deployment: approveCompoundTokens. -/// EXEMPT_WITHDRAWAL_FEE_ROLE is granted via grantStabilityPoolAutoCompounderRole (using predicted address). -/// -/// yieldManager: pass address(0) for standalone ACs (MAX_FEE_RATIO is used instead). -/// For HY-connected ACs (harbor-yield repo), pass the HY predicted address and -/// maxFeeRatio = 0 (exactly one of the two must be non-zero). -/// pegOracle: IWrappedPriceOracle for the wrapped collateral. Required; provides the -/// gas floor for compound() via maxUnderlyingPrice. -abstract contract AutoCompounder is HarborFactoryDeployer { - string AutoCompounderCollateral = "autoCompounderCollateral"; - string AutoCompounderLeveraged = "autoCompounderLeveraged"; - - // ========== AUTO-COMPOUNDER DEPLOYMENT ========== - - /// @notice Predict the address of the ETH price oracle for a peg. - /// @dev Salt: {saltPrefix}::{peg}::ethPriceAggregator. Deployed by harbor-price-aggregators scripts. - function predictEthPriceOracleAddress(string memory peg) internal returns (address) { - return _predictAddress(string.concat(peg, "::ethPriceAggregator")); - } - - /// @notice Deploy AutoCompounder impl only, record in state. - /// @param yieldManager HarborYield address, or address(0) for standalone AC. - /// @param pegOracle IWrappedPriceOracle for the peg/ETH price (gas floor). Required. - function deployAutoCompounderImplementation( - string memory acType, - DeploymentTypes.State memory stateData, - Config_MinterMarket marketConfig, - address stabilityPool, - address minter, - address yieldManager, - address pegOracle - ) internal virtual returns (address impl) { - string memory marketKey = MinterMarketConfigLib.salt(marketConfig); - string memory acKey = _key(marketKey, acType); - console.log(" > %s", acKey); - - ConfigTokenNames names = ConfigTokenNames(address(marketConfig)); - bool isCollateral = keccak256(bytes(acType)) == keccak256("autoCompounderCollateral"); - string memory tokenName = isCollateral ? names.acCollateralName() : names.acLeveragedName(); - string memory tokenSymbol = isCollateral ? names.acCollateralSymbol() : names.acLeveragedSymbol(); - - uint256 maxFeeRatio = yieldManager == address(0) - ? IAutoCompounderMarketConfig(address(marketConfig)).autoCompounderMintMaxFeeRatio() - : 0; - - impl = address(new AutoCompounder_v1(stabilityPool, minter, yieldManager, maxFeeRatio, pegOracle, tokenName, tokenSymbol)); - console.log(" Impl: %s", impl); - console.log(" Name: %s", tokenName); - console.log(" Symbol: %s", tokenSymbol); - - _recordImplementation( - stateData, - acKey, - "@harbor/autocompounding/AutoCompounder_v1.sol", - "AutoCompounder_v1", - impl - ); - } - - /// @notice Deploy AutoCompounder impl+proxy, record in state. - /// @param yieldManager HarborYield address, or address(0) for standalone AC. - /// @param pegOracle IWrappedPriceOracle for the wrapped collateral (required). - function deployAutoCompounder( - string memory acType, - DeploymentTypes.State memory stateData, - Config_MinterMarket marketConfig, - address stabilityPool, - address minter, - address yieldManager, - address pegOracle - ) internal returns (address proxy) { - string memory marketKey = MinterMarketConfigLib.salt(marketConfig); - string memory acKey = _key(marketKey, acType); - - address impl = deployAutoCompounderImplementation(acType, stateData, marketConfig, stabilityPool, minter, yieldManager, pegOracle); - - bytes memory initData = abi.encodeCall(AutoCompounder_v1.initialize, (address(this), owner())); - - proxy = _deployProxyAndRecord(stateData, acKey, impl, initData); - } - - /// @notice Post-deployment configuration: approve compound tokens. - /// @param marketKey The market salt key (e.g., "ETH::fxUSD"). - /// @param acType "autoCompounderCollateral" or "autoCompounderLeveraged". - function configureAutoCompounder(string memory marketKey, string memory acType) internal { - address acProxy = _predictAddress(_key(marketKey, acType)); - AutoCompounder_v1(acProxy).approveCompoundTokens(); - } -} diff --git a/src/autocompounding/AutoCompounder_v1.sol b/src/autocompounding/AutoCompounder_v1.sol deleted file mode 100644 index d37daabc..00000000 --- a/src/autocompounding/AutoCompounder_v1.sol +++ /dev/null @@ -1,361 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.30; - -import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; -import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; -import {ERC4626} from "@solady/tokens/ERC4626.sol"; -import {ReentrancyGuardTransientUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardTransientUpgradeable.sol"; -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; - -import {HarborOwnable} from "@bao/HarborOwnable.sol"; -import {Token} from "@bao/Token.sol"; -import {TokenHolder, ITokenHolder} from "@bao/TokenHolder.sol"; - -import {IAutoCompounder} from "@harbor/interfaces/IAutoCompounder.sol"; -import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; -import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; -import {IMinter} from "@harbor/interfaces/IMinter.sol"; -import {IMinter_v3} from "@harbor/interfaces/IMinter_v3.sol"; -import {IWrappedPriceOracle} from "@harbor/interfaces/IWrappedPriceOracle.sol"; -import {IYieldManager} from "@harbor/interfaces/IYieldManager.sol"; -import {ERC20MetadataLib_v1} from "@harbor/util/ERC20MetadataLib_v1.sol"; - -/// @title AutoCompounder_v1 -/// @notice Level 1 auto-compounder: non-rebasing ERC4626 vault wrapping a rebasing stability pool position. -/// @dev The ERC4626 asset is the SP token (rebasing ERC20). Share count is fixed on deposit; share price -/// moves as totalAssets changes from harvest rewards, compounding, and rebalance losses. -/// compound() claims all wrapped collateral rewards, mints pegged tokens via the Minter (fee-capped), -/// and redeposits to the SP. Any residual wCOLn that cannot be profitably minted (fee too high or -/// minPegged not met) is routed to YIELD_MANAGER.distribute() if a yield manager is registered. -/// totalAssets() includes the SP position plus unclaimed wrapped collateral valued via Minter dry run. -/// Works for both collateral and leveraged stability pools. -// solhint-disable-next-line contract-name-capwords -contract AutoCompounder_v1 is - Initializable, - UUPSUpgradeable, - ERC4626, - ReentrancyGuardTransientUpgradeable, - HarborOwnable, - TokenHolder, - IAutoCompounder -{ - using SafeERC20 for IERC20; - - /*////////////////////////////////////////////////////////////////////////// - ERRORS - //////////////////////////////////////////////////////////////////////////*/ - - /// @dev Thrown when compound() finds nothing to compound. - error NothingToCompound(); - - /// @dev Thrown when depositPeggedToken receives zero shares. - error DepositPeggedTokenZeroShares(); - - /// @dev Thrown when neither a yield manager nor a local maxFeeRatio is provided. - /// Exactly one must be set: a yield manager that supplies mintMaxFeeRatio() dynamically, - /// or a non-zero maxFeeRatio constant for standalone use. - error MaxFeeRatioSourceRequired(); - - /// @dev Thrown when both a yield manager and a non-zero maxFeeRatio are provided. - /// Exactly one must be set: yield manager (dynamic) xor maxFeeRatio constant (standalone). - error MaxFeeRatioSourceConflict(); - - /*////////////////////////////////////////////////////////////////////////// - EVENTS - //////////////////////////////////////////////////////////////////////////*/ - - /// @notice Emitted on every compound() call. - /// @param caller The address that triggered the compound. - /// @param collateralTotal Total wrapped collateral processed (claimed from SP + any pre-existing balance). - /// @param peggedMinted Amount of pegged tokens minted and redeposited to the SP (0 if minting failed/skipped). - /// @param residual Amount of wrapped collateral routed to YIELD_MANAGER.distribute() (0 if none). - event Compounded(address indexed caller, uint256 collateralTotal, uint256 peggedMinted, uint256 residual); - - /*////////////////////////////////////////////////////////////////////////// - CONSTANTS - //////////////////////////////////////////////////////////////////////////*/ - - /// @notice Upper-bound gas estimate for a compound() execution, used to compute the - /// Yearn-style minimum pegged output floor. Sized conservatively at 500k to - /// cover Minter mintPeggedToken (~125k), SP claim and deposit, and overhead. - uint256 private constant MAX_COMPOUND_GAS = 500_000; - - /*////////////////////////////////////////////////////////////////////////// - IMMUTABLES - //////////////////////////////////////////////////////////////////////////*/ - - /// @notice The stability pool this vault wraps. - /// @custom:oz-upgrades-unsafe-allow state-variable-immutable - address public immutable STABILITY_POOL; // solhint-disable-line immutable-vars-naming - - /// @notice The minter for this market. - /// @custom:oz-upgrades-unsafe-allow state-variable-immutable - address public immutable MINTER; // solhint-disable-line immutable-vars-naming - - /// @notice The wrapped collateral token (reward token from harvests). - /// @custom:oz-upgrades-unsafe-allow state-variable-immutable - address public immutable WRAPPED_COLLATERAL; // solhint-disable-line immutable-vars-naming - - /// @notice The pegged token - the SP's underlying asset. - /// @custom:oz-upgrades-unsafe-allow state-variable-immutable - address public immutable PEGGED_TOKEN; // solhint-disable-line immutable-vars-naming - - /// @notice The yield manager (HarborYield) this AC is registered in. - /// Mutually exclusive with MAX_FEE_RATIO: exactly one of YIELD_MANAGER or MAX_FEE_RATIO - /// must be non-zero (enforced in constructor). - /// When set: compound() reads mintMaxFeeRatio() from here (portfolio-wide policy), routes - /// residual wCOLn via distribute(), and calls snapshotPerformance() after each compound. - /// @custom:oz-upgrades-unsafe-allow state-variable-immutable - address public immutable YIELD_MANAGER; // solhint-disable-line immutable-vars-naming - - /// @notice Maximum Minter fee ratio for standalone ACs (18 decimals, e.g. 0.05 ether = 5%). - /// Mutually exclusive with YIELD_MANAGER: exactly one must be non-zero. - /// Set at construction; linked to the Minter's fee tier configuration. - /// Zero when YIELD_MANAGER is set — mintMaxFeeRatio() is read from there instead. - /// @custom:oz-upgrades-unsafe-allow state-variable-immutable - uint256 public immutable MAX_FEE_RATIO; // solhint-disable-line immutable-vars-naming - - /// @notice Oracle providing the peg reference asset price in ETH (IWrappedPriceOracle). - /// Required: used every compound() to compute the Yearn-style gas floor: - /// minPeggedOut = block.basefee × MAX_COMPOUND_GAS × maxUnderlyingPrice / 1e18 - /// For the haETH peg a trivial constant oracle returning 1e18 is sufficient. - /// @custom:oz-upgrades-unsafe-allow state-variable-immutable - address public immutable PEG_ORACLE; // solhint-disable-line immutable-vars-naming - - /// @dev ERC20 name stored as two bytes32 (up to 64 characters) - /// @custom:oz-upgrades-unsafe-allow state-variable-immutable - bytes32 private immutable _ERC20_NAME_0; - /// @custom:oz-upgrades-unsafe-allow state-variable-immutable - bytes32 private immutable _ERC20_NAME_1; - - /// @dev ERC20 symbol stored as bytes32 (up to 32 characters) - /// @custom:oz-upgrades-unsafe-allow state-variable-immutable - bytes32 private immutable _ERC20_SYMBOL; - - /*////////////////////////////////////////////////////////////////////////// - CONSTRUCTOR / INITIALIZER - //////////////////////////////////////////////////////////////////////////*/ - - /// @custom:oz-upgrades-unsafe-allow constructor - /// @param yieldManager_ HarborYield address, or address(0) for standalone. Mutually exclusive with maxFeeRatio_. - /// @param maxFeeRatio_ Local fee cap (18 dec), or 0 when yieldManager_ is set. Mutually exclusive with yieldManager_. - /// @param pegOracle_ Required IWrappedPriceOracle for the gas floor calculation. - constructor( - address stabilityPool_, - address minter_, - address yieldManager_, - uint256 maxFeeRatio_, - address pegOracle_, - string memory name_, - string memory symbol_ - ) { - _disableInitializers(); - Token.ensureNonZeroAddress(stabilityPool_); - Token.ensureNonZeroAddress(minter_); - Token.ensureNonZeroAddress(pegOracle_); - // Exactly one of {yieldManager, maxFeeRatio} must be set. - if (yieldManager_ == address(0) && maxFeeRatio_ == 0) { - revert MaxFeeRatioSourceRequired(); - } - if (yieldManager_ != address(0) && maxFeeRatio_ != 0) { - revert MaxFeeRatioSourceConflict(); - } - // slither-disable-next-line missing-zero-check - STABILITY_POOL = stabilityPool_; - // slither-disable-next-line missing-zero-check - MINTER = minter_; - WRAPPED_COLLATERAL = IMinter(minter_).WRAPPED_COLLATERAL_TOKEN(); - PEGGED_TOKEN = IMinter(minter_).PEGGED_TOKEN(); - assert(IStabilityPool(stabilityPool_).ASSET_TOKEN() == PEGGED_TOKEN); - // slither-disable-next-line missing-zero-check - YIELD_MANAGER = yieldManager_; - MAX_FEE_RATIO = maxFeeRatio_; - // slither-disable-next-line missing-zero-check - PEG_ORACLE = pegOracle_; - (_ERC20_NAME_0, _ERC20_NAME_1) = ERC20MetadataLib_v1.packName(name_); - _ERC20_SYMBOL = ERC20MetadataLib_v1.packSymbol(symbol_); - } - - /// @notice Initialize the auto-compounder. - /// @param deployerOwner_ The initial owner (typically the FactoryDeployer). - /// @param pendingOwner_ The final owner (typically the Harbor multisig). - function initialize(address deployerOwner_, address pendingOwner_) external initializer { - _initializeOwner(deployerOwner_, pendingOwner_); - __UUPSUpgradeable_init(); - __ReentrancyGuardTransient_init(); - } - - /*////////////////////////////////////////////////////////////////////////// - UUPS - //////////////////////////////////////////////////////////////////////////*/ - - function _authorizeUpgrade(address) internal override onlyOwner {} // solhint-disable-line no-empty-blocks - - /*////////////////////////////////////////////////////////////////////////// - ADMIN - //////////////////////////////////////////////////////////////////////////*/ - - /// @notice Set permanent token approvals for the compound flow. - /// @dev Called by the deployer after proxy creation. Approves the SP to spend pegged tokens - /// and the Minter to spend wrapped collateral. - function approveCompoundTokens() external onlyOwner { - IERC20(PEGGED_TOKEN).forceApprove(STABILITY_POOL, type(uint256).max); - IERC20(WRAPPED_COLLATERAL).forceApprove(MINTER, type(uint256).max); - } - - /// @notice The effective maximum fee ratio for compound minting. - /// For yield-manager ACs reads dynamically from YIELD_MANAGER (portfolio-wide policy). - /// For standalone ACs returns the immutable MAX_FEE_RATIO set at construction. - function mintMaxFeeRatio() external view returns (uint256) { - if (YIELD_MANAGER != address(0)) { - return IYieldManager(YIELD_MANAGER).mintMaxFeeRatio(); - } - return MAX_FEE_RATIO; - } - - /*////////////////////////////////////////////////////////////////////////// - ERC20 / ERC4626 METADATA - //////////////////////////////////////////////////////////////////////////*/ - - /// @notice The ERC4626 asset — the underlying rebasing StabilityPool share token. - function asset() public view override returns (address) { - return STABILITY_POOL; - } - - /// @notice ERC20 name, packed into constructor immutables. - function name() public view override returns (string memory) { - return ERC20MetadataLib_v1.unpackName(_ERC20_NAME_0, _ERC20_NAME_1); - } - - /// @notice ERC20 symbol, packed into constructor immutables. - function symbol() public view override returns (string memory) { - return ERC20MetadataLib_v1.unpackSymbol(_ERC20_SYMBOL); - } - - /*////////////////////////////////////////////////////////////////////////// - ERC4626 OVERRIDES - //////////////////////////////////////////////////////////////////////////*/ - - /// @notice Total assets under management, in SP share units. - /// @dev SP.balanceOf(this) + claimable wrapped collateral valued in pegged token terms via Minter dry run. - function totalAssets() public view override returns (uint256) { - uint256 spPosition = IERC20(STABILITY_POOL).balanceOf(address(this)); - uint256 claimableCollateral = IMultipleRewardAccumulator(STABILITY_POOL).claimable( - address(this), - WRAPPED_COLLATERAL - ); - if (claimableCollateral == 0) { - return spPosition; - } - // Use dry run with no fee cap to get price and rate, then value the claimable collateral. - // price = underlying collateral price in peg terms (18 dec) - // rate = wrapped-to-underlying rate (18 dec) - // claimableValue = claimableCollateral * rate * price / 1e36 - // slither-disable-next-line unused-return - (, , , , uint256 price, uint256 rate) = IMinter_v3(MINTER).mintPeggedTokenDryRun( - claimableCollateral, - type(uint256).max - ); - return spPosition + Math.mulDiv(claimableCollateral, price * rate, 1e36); - } - - /*////////////////////////////////////////////////////////////////////////// - COMPOUND - //////////////////////////////////////////////////////////////////////////*/ - - /// @inheritdoc IAutoCompounder - function compound() external nonReentrant { - // Claim all active reward tokens from SP to this contract (includes WRAPPED_COLLATERAL). - IMultipleRewardAccumulator(STABILITY_POOL).claim(); - - // Total available: just claimed + any pre-existing balance (e.g. residual from a prior standalone compound). - uint256 total = IERC20(WRAPPED_COLLATERAL).balanceOf(address(this)); - if (total == 0) { - revert NothingToCompound(); - } - - // Yearn-style gas floor: skip minting when gas cost exceeds the pegged output value. - // minPeggedOut = block.basefee × MAX_COMPOUND_GAS × (peg units per ETH) / 1e18 - // Use maxUnderlyingPrice (conservative): higher price → higher floor → fewer unprofitable calls. - // slither-disable-next-line unused-return - (, uint256 maxPegPerEth, ,) = IWrappedPriceOracle(PEG_ORACLE).latestAnswer(); - uint256 minPegged = Math.mulDiv(block.basefee, MAX_COMPOUND_GAS * maxPegPerEth, 1e18); - - // Fee cap: yield manager knows the opportunity cost of alternative DEX paths; - // standalone ACs use the immutable set at construction. - uint256 maxFee = YIELD_MANAGER != address(0) - ? IYieldManager(YIELD_MANAGER).mintMaxFeeRatio() - : MAX_FEE_RATIO; - - // Mint pegged tokens from claimed collateral. mintPeggedToken reverts if minPegged cannot - // be met, or returns (0, 0) if the fee exceeds maxFee (when minPegged == 0, but here - // minPegged > 0 so any failure reverts). Catch all failures and route wCOLn instead. - uint256 peggedMinted; - uint256 residual; - try IMinter_v3(MINTER).mintPeggedToken(total, address(this), minPegged, maxFee) - returns (uint256 peggedOut, uint256 collateralUsed) { - if (peggedOut > 0) { - // Deposit minted pegged tokens back into the SP. - // slither-disable-next-line unused-return - IStabilityPool(STABILITY_POOL).deposit(peggedOut, address(this), 0); - peggedMinted = peggedOut; - } - residual = total - collateralUsed; - } catch { - residual = total; - } - - // Route residual wCOLn to the yield manager for alternative conversion. - if (residual > 0 && YIELD_MANAGER != address(0)) { - IERC20(WRAPPED_COLLATERAL).safeTransfer(YIELD_MANAGER, residual); - IYieldManager(YIELD_MANAGER).distribute(WRAPPED_COLLATERAL, residual); - } - - // Ask the yield manager to snapshot all vault rates now that state has changed. - if (YIELD_MANAGER != address(0)) { - IYieldManager(YIELD_MANAGER).snapshotPerformance(); - } - - emit Compounded(msg.sender, total, peggedMinted, residual); - } - - /*////////////////////////////////////////////////////////////////////////// - CONVENIENCE DEPOSITS - //////////////////////////////////////////////////////////////////////////*/ - - /// @inheritdoc IAutoCompounder - // slither-disable-next-line reentrancy-no-eth - function depositPeggedToken(uint256 peggedAmount, address receiver) external nonReentrant returns (uint256 shares) { - peggedAmount = Token.allOf(msg.sender, PEGGED_TOKEN, peggedAmount); - - // Snapshot exchange rate BEFORE the SP deposit changes totalAssets - uint256 assetsBefore = totalAssets(); - uint256 supplyBefore = totalSupply(); - - // Transfer pegged tokens from caller, deposit to SP - IERC20(PEGGED_TOKEN).safeTransferFrom(msg.sender, address(this), peggedAmount); - uint256 spBalanceBefore = IERC20(STABILITY_POOL).balanceOf(address(this)); - // slither-disable-next-line unused-return - IStabilityPool(STABILITY_POOL).deposit(peggedAmount, address(this), 0); - uint256 spReceived = IERC20(STABILITY_POOL).balanceOf(address(this)) - spBalanceBefore; - - // Compute shares at the pre-deposit exchange rate (matches ERC4626._convertToShares) - shares = Math.mulDiv(spReceived, supplyBefore + 1, assetsBefore + 1); - // slither-disable-next-line incorrect-equality - if (shares == 0) { - revert DepositPeggedTokenZeroShares(); - } - _mint(receiver, shares); - } - - /*////////////////////////////////////////////////////////////////////////// - SWEEP - //////////////////////////////////////////////////////////////////////////*/ - - /// @inheritdoc TokenHolder - function _checkSweeper() internal view override(TokenHolder) { - _checkOwner(); - } -} diff --git a/src/interfaces/IAutoCompounder.sol b/src/interfaces/IAutoCompounder.sol index 38a18d10..414abe01 100644 --- a/src/interfaces/IAutoCompounder.sol +++ b/src/interfaces/IAutoCompounder.sol @@ -28,4 +28,15 @@ interface IAutoCompounder { /// AC holdings. // solhint-disable-next-line func-name-mixedcase function MINTER() external view returns (address); + + /// @notice Claim the caller's proportional share of active SP reward tokens. + /// Claims the AC's full pending rewards from the SP, then forwards + /// `delta × callerShares / totalSupply` to `receiver` for each token. + /// @param receiver Address to receive the claimed tokens. If address(0), tokens go to msg.sender. + function claim(address receiver) external; + + /// @notice Claim the caller's proportional share of historical (unregistered) SP reward tokens. + /// @param tokens The list of historical reward tokens to claim. + /// @param receiver Address to receive the claimed tokens. If address(0), tokens go to msg.sender. + function claimHistorical(address[] memory tokens, address receiver) external; } diff --git a/src/interfaces/IStabilityPoolManager_v2.sol b/src/interfaces/IStabilityPoolManager_v2.sol new file mode 100644 index 00000000..e9a84b1e --- /dev/null +++ b/src/interfaces/IStabilityPoolManager_v2.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.28 <0.9.0; + +import {IStabilityPoolManager} from "@harbor/interfaces/IStabilityPoolManager.sol"; + +interface IStabilityPoolManager_v2 is IStabilityPoolManager { + /// @notice Emitted when an auto-compounder is registered or unregistered for a stability pool. + /// @param sp The stability pool address. + /// @param ac The auto-compounder address (address(0) = unregistered). + event AutoCompounderSet(address indexed sp, address indexed ac); + + /// @notice Register or unregister an auto-compounder for a stability pool. + /// @dev Only one auto-compounder per stability pool. Pass address(0) to unregister. + /// The stability pool must be one of the two registered pools. + /// @param sp The stability pool address. + /// @param ac The auto-compounder address, or address(0) to remove. + function setAutoCompounder(address sp, address ac) external; + + /// @notice Get the auto-compounder registered for a stability pool. + /// @param sp The stability pool address. + /// @return The registered auto-compounder, or address(0) if none. + function autoCompounder(address sp) external view returns (address); +} diff --git a/src/minter/Minter_v3.sol b/src/minter/Minter_v3.sol index f0f4f3d2..548ae2e4 100644 --- a/src/minter/Minter_v3.sol +++ b/src/minter/Minter_v3.sol @@ -1094,7 +1094,7 @@ contract Minter_v3 is bytes32 private constant _MINTER_STORAGE = 0x92e73fe9557052b4a0b810a38eb7ef595ff750f166ca39d63b3f4c74937fef00; /// @notice Returns a reference to the contract state - function _getMinterStorage() internal pure returns (MinterStorage storage $) { + function _getMinterStorage() private pure returns (MinterStorage storage $) { // solhint-disable-next-line no-inline-assembly assembly { $.slot := _MINTER_STORAGE @@ -1105,7 +1105,7 @@ contract Minter_v3 is // ----------------- /// @notice Updates the price oracle address. - function _updatePriceOracle(address priceOracle_) internal { + function _updatePriceOracle(address priceOracle_) private { MinterStorage storage $ = _getMinterStorage(); address old = $.priceOracle; $.priceOracle = priceOracle_; @@ -1116,7 +1116,7 @@ contract Minter_v3 is // ------------ /// @notice Updates the fee receiver address. - function _updateFeeReceiver(address feeReceiver_) internal { + function _updateFeeReceiver(address feeReceiver_) private { MinterStorage storage $ = _getMinterStorage(); address old = $.feeReceiver; $.feeReceiver = feeReceiver_; @@ -1127,7 +1127,7 @@ contract Minter_v3 is // ----------- /// @notice Updates the reserve pool address. - function _updateReservePool(address reservePool_) internal { + function _updateReservePool(address reservePool_) private { MinterStorage storage $ = _getMinterStorage(); address old = $.reservePool; $.reservePool = reservePool_; @@ -1144,7 +1144,7 @@ contract Minter_v3 is /// @param peggedOut The amount of pegged to be transferred to the `receiver`. /// @param receiver The address of the receiver. - function _mintPeggedToken(uint256 wrappedCollateralIn, uint256 peggedOut, address receiver) internal { + function _mintPeggedToken(uint256 wrappedCollateralIn, uint256 peggedOut, address receiver) private { emit MintPeggedToken(_msgSender(), receiver, wrappedCollateralIn, peggedOut); // mint the tokens to the receiver @@ -1156,7 +1156,7 @@ contract Minter_v3 is } /// @notice burn pegged tokens in the way the like to burn - function _burnPeggedToken(uint256 amount) internal { + function _burnPeggedToken(uint256 amount) private { if (_BURN_SIGNATURE == BurnSignature.Burn2Arg) { IBurnable2Arg(PEGGED_TOKEN).burn(_msgSender(), amount); } else if (_BURN_SIGNATURE == BurnSignature.BurnFrom) { @@ -1175,7 +1175,7 @@ contract Minter_v3 is /// @param wrappedCollateralOut The amount of collateral to be transferred to the `receiver`. /// @param receiver The address of the receiver. - function _redeemPeggedToken(uint256 peggedIn, uint256 wrappedCollateralOut, address receiver) internal { + function _redeemPeggedToken(uint256 peggedIn, uint256 wrappedCollateralOut, address receiver) private { // tell the world emit RedeemPeggedToken(_msgSender(), receiver, peggedIn, wrappedCollateralOut, 0); @@ -1193,7 +1193,7 @@ contract Minter_v3 is /// @param leveragedOut The amount of leveraged to be transferred to the `receiver`. /// @param receiver The address of the receiver. - function _mintLeveragedToken(uint256 wrappedCollateralIn, uint256 leveragedOut, address receiver) internal { + function _mintLeveragedToken(uint256 wrappedCollateralIn, uint256 leveragedOut, address receiver) private { // slither-disable-next-line incorrect-equality if (leveragedOut == 0) { revert ReturnZeroAmount(LEVERAGED_TOKEN); @@ -1214,7 +1214,7 @@ contract Minter_v3 is /// @param collateralOut The amount of collateral to be transferred to the `receiver`. /// @param receiver The address of the receiver. - function _redeemLeveragedToken(uint256 leveragedIn, uint256 collateralOut, address receiver) internal { + function _redeemLeveragedToken(uint256 leveragedIn, uint256 collateralOut, address receiver) private { // tell the world emit RedeemLeveragedToken(_msgSender(), receiver, leveragedIn, collateralOut); // burn the leveraged @@ -1235,7 +1235,7 @@ contract Minter_v3 is address token_, uint256 amountIn, uint256 tokenBalance_ - ) internal pure returns (uint256 amountOut) { + ) private pure returns (uint256 amountOut) { amountOut = _redeemableQuiet(amountIn, tokenBalance_); // slither-disable-next-line incorrect-equality if (amountOut == 0) { @@ -1243,7 +1243,7 @@ contract Minter_v3 is } } - function _redeemableQuiet(uint256 amountIn, uint256 tokenBalance_) internal pure returns (uint256 amountOut) { + function _redeemableQuiet(uint256 amountIn, uint256 tokenBalance_) private pure returns (uint256 amountOut) { amountOut = Math.min(amountIn, tokenBalance_); } @@ -1302,7 +1302,7 @@ contract Minter_v3 is CollateralRatioData memory cr, uint256 maxFeeE36 ) - internal + private pure returns ( uint256 wrappedFee, @@ -1440,7 +1440,7 @@ contract Minter_v3 is CollateralRatioData memory cr, uint256 reserveWrappedCapacity ) - internal + private pure returns ( uint256 wrappedFee, @@ -1573,7 +1573,7 @@ contract Minter_v3 is CollateralRatioData memory cr, uint256 reserveWrappedCapacity ) - internal + private view returns ( uint256 wrappedFee, @@ -1735,7 +1735,7 @@ contract Minter_v3 is CollateralRatioData memory cr, uint256 leveragedTokenBalance_ ) - internal + private pure returns ( uint256 wrappedFee, @@ -1830,7 +1830,7 @@ contract Minter_v3 is uint256 peggedTokenBalance_, bool atLower ) - internal + private pure returns ( uint band // solhint-disable-line explicit-types @@ -1859,7 +1859,7 @@ contract Minter_v3 is uint256 peggedTokenBalance_, uint256 collateralTokenBalance_, uint256 collateralPrice - ) internal pure returns (uint256 navE36) { + ) private pure returns (uint256 navE36) { if (peggedTokenBalance_ > 0) { (, navE36) = _tokenValuesE36(peggedTokenBalance_, collateralTokenBalance_, collateralPrice); navE36 = Math.mulDiv(navE36, 1 ether, peggedTokenBalance_); @@ -1872,7 +1872,7 @@ contract Minter_v3 is uint256 peggedTokenBalance_, uint256 collateralTokenBalance_, uint256 collateralPrice - ) internal pure returns (uint256 collateralValueE36, uint256 peggedValueE36) { + ) private pure returns (uint256 collateralValueE36, uint256 peggedValueE36) { collateralValueE36 = collateralTokenBalance_ * collateralPrice; peggedValueE36 = peggedTokenBalance_ * 1 ether; // the value of the pegged cannot be greater than the value of the collateral @@ -1903,7 +1903,7 @@ contract Minter_v3 is } } - function _round(uint256 numerator, uint256 denominator) internal pure returns (uint256 result) { + function _round(uint256 numerator, uint256 denominator) private pure returns (uint256 result) { unchecked { result = numerator / denominator; uint256 remainder = numerator % denominator; @@ -1918,7 +1918,7 @@ contract Minter_v3 is function _divAccumulateError( uint256 preDivideE54, int256 errorE54 - ) internal pure returns (uint256 postDivideE36, int256 newErrorE54) { + ) private pure returns (uint256 postDivideE36, int256 newErrorE54) { unchecked { postDivideE36 = preDivideE54 / 1 ether; // scaled to 1e36 newErrorE54 = errorE54 + (int256(preDivideE54) % 1 ether); @@ -1936,7 +1936,7 @@ contract Minter_v3 is uint256 peggedTokenBalance_, uint256 collateralTokenBalance_, uint256 collateralPrice - ) internal pure returns (uint256 leveragedTokens) { + ) private pure returns (uint256 leveragedTokens) { // we use leverage ratio for this calculation as it is capped if (leveragedTokenBalance_ > 0) { uint256 leverageRatio_ = _leverageRatio(peggedTokenBalance_, collateralTokenBalance_, collateralPrice); @@ -1973,7 +1973,7 @@ contract Minter_v3 is uint256 collateralTokenBalance_, uint256 collateralPrice, uint256 peggedTokenBalance_ - ) internal pure returns (uint256 collateralRatio_) { + ) private pure returns (uint256 collateralRatio_) { // Hot path: pegged > 0 → just compute the ratio (covers collateral==0 or price==0 as 0). // slither-disable-next-line incorrect-equality if (peggedTokenBalance_ != 0) { @@ -1993,7 +1993,7 @@ contract Minter_v3 is } /// @notice Returns the amount of leveraged tokens being managed - function _leveragedTokenBalance() internal view returns (uint256) { + function _leveragedTokenBalance() private view returns (uint256) { return IERC20(LEVERAGED_TOKEN).totalSupply(); } @@ -2007,7 +2007,7 @@ contract Minter_v3 is /// @notice Returns the safe price for the collateral token. /// @dev Checks safe price non-zero. - function _fetchMid(address priceOracle_) internal view returns (OracleData memory) { + function _fetchMid(address priceOracle_) private view returns (OracleData memory) { (uint256 minPrice, uint256 maxPrice, uint256 minRate, uint256 maxRate) = IWrappedPriceOracle(priceOracle_) .latestAnswer(); return OracleData(_round(minPrice + maxPrice, 2), _round(minRate + maxRate, 2)); @@ -2016,7 +2016,7 @@ contract Minter_v3 is /// @notice Returns the min price for the collateral token. /// If the safe price is valid it is returned, else the min price. /// @dev Checks the returned price is non-zero. - function _fetchMin(address priceOracle_) internal view returns (OracleData memory) { + function _fetchMin(address priceOracle_) private view returns (OracleData memory) { // slither-disable-next-line unused-return (uint256 minPrice, , uint256 minRate, ) = IWrappedPriceOracle(priceOracle_).latestAnswer(); return OracleData(minPrice, minRate); @@ -2025,7 +2025,7 @@ contract Minter_v3 is /// @notice Returns the max price for the collateral token. /// If the safe price is valid it is returned, else the max price. /// @dev Checks the returned price is non-zero. - function _fetchMax(address priceOracle_) internal view returns (OracleData memory) { + function _fetchMax(address priceOracle_) private view returns (OracleData memory) { // slither-disable-next-line unused-return (, uint256 maxPrice, , uint256 maxRate) = IWrappedPriceOracle(priceOracle_).latestAnswer(); return OracleData(maxPrice, maxRate); diff --git a/src/minter/StabilityPoolManager_v2.sol b/src/minter/StabilityPoolManager_v2.sol new file mode 100644 index 00000000..6ef2b083 --- /dev/null +++ b/src/minter/StabilityPoolManager_v2.sol @@ -0,0 +1,502 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.30; + +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {ERC165Upgradeable} from "@openzeppelin/contracts-upgradeable/utils/introspection/ERC165Upgradeable.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC20Errors} from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {ReentrancyGuardTransientUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardTransientUpgradeable.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; + +import {BaoOwnableRoles} from "@bao/BaoOwnableRoles.sol"; +import {ITokenHolder} from "@bao/TokenHolder.sol"; +import {Token} from "@bao/Token.sol"; + +import {IStabilityPoolManager} from "@harbor/interfaces/IStabilityPoolManager.sol"; +import {IStabilityPoolManager_v2} from "@harbor/interfaces/IStabilityPoolManager_v2.sol"; +import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; +import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; +import {IAutoCompounder} from "@harbor/interfaces/IAutoCompounder.sol"; + +/// @title StabilityPoolManager_v2 +/// @author Based on original Liquidator and Harvester contracts +/// @notice Manages stability pools for rebalancing and harvesting operations. +/// Extends v1 with auto-compounder integration: after each harvest() or rebalance(), +/// compound() is triggered on any registered AutoCompounder for each stability pool. +/// @dev Uses UUPS proxy, erc7201 storage (same slot as v1 — struct extended safely). +/// @custom:oz-upgrades +// solhint-disable-next-line contract-name-camelcase +contract StabilityPoolManager_v2 is + Initializable, + UUPSUpgradeable, + BaoOwnableRoles, + ERC165Upgradeable, + ReentrancyGuardTransientUpgradeable, + IStabilityPoolManager_v2 +{ + using SafeERC20 for IERC20; + + /************* + * Variables * + *************/ + + // Immutable variables + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + address public immutable MINTER; + + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + address public immutable PEGGED_TOKEN; + + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + address public immutable WRAPPED_COLLATERAL_TOKEN; + + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + address public immutable LEVERAGED_TOKEN; + + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + address public immutable TREASURY; + + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + address private immutable _STABILITY_POOL_COLLATERAL; + + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + address private immutable _STABILITY_POOL_LEVERAGED; + + // Share-with-proxy Storage + // ------------------------ + /// @custom:storage-location erc7201:bao.storage.StabilityPoolManager + struct StabilityPoolManagerStorage { + /// @notice Fixed bounty amount for rebalancing + uint256 rebalanceBountyRatio; + /// @notice The collateral ratio at which rebalancing should occur + uint256 rebalanceThreshold; + /// @notice Percentage-based bounty for harvesting (as a ratio of the harvested amount) + uint256 harvestBountyRatio; + /// @notice Percentage-based cut for harvesting (as a ratio of the harvested amount) + uint256 harvestCutRatio; + /// @notice The fee receiver that receives the harvest cut + // @custom:security non-reentrant + address feeReceiver; + /// @notice Auto-compounder registered for each stability pool (0 = none). + mapping(address sp => address ac) autoCompounder; + } + + // chisel eval 'keccak256(abi.encode(uint256(keccak256("bao.storage.StabilityPoolManager")) - 1)) & ~bytes32(uint256(0xff))' + bytes32 private constant _STABILITYPOOL_MANAGER_STORAGE = + 0x3cb83b3e94c8a4ad8337f0089bb72418805efcd5c4adb4969513c1b21fc84100; + + function _getStabilityPoolManagerStorage() private pure returns (StabilityPoolManagerStorage storage $) { + // solhint-disable-next-line no-inline-assembly + assembly { + $.slot := _STABILITYPOOL_MANAGER_STORAGE + } + } + + /// @notice In UUPS proxies the constructor sets immutables + /// @custom:oz-upgrades-unsafe-allow constructor + constructor(address minter_, address treasury_, address stabilityPoolCollateral, address stabilityPoolLeveraged) { + _disableInitializers(); + + Token.ensureContract(minter_); + // slither-disable-next-line missing-zero-check + MINTER = minter_; + + // slither-disable-next-line missing-zero-check + PEGGED_TOKEN = IMinter(minter_).PEGGED_TOKEN(); + Token.sanityCheckERC20Token(PEGGED_TOKEN); + + // slither-disable-next-line missing-zero-check + WRAPPED_COLLATERAL_TOKEN = IMinter(minter_).WRAPPED_COLLATERAL_TOKEN(); + Token.sanityCheckERC20Token(WRAPPED_COLLATERAL_TOKEN); + + // slither-disable-next-line missing-zero-check + LEVERAGED_TOKEN = IMinter(minter_).LEVERAGED_TOKEN(); + Token.sanityCheckERC20Token(LEVERAGED_TOKEN); + + Token.ensureNonZeroAddress(treasury_); + // slither-disable-next-line missing-zero-check + TREASURY = treasury_; + + // Validate and store the stability pools + Token.ensureContract(stabilityPoolCollateral); + // slither-disable-next-line missing-zero-check + _STABILITY_POOL_COLLATERAL = stabilityPoolCollateral; + Token.ensureContract(stabilityPoolLeveraged); + // slither-disable-next-line missing-zero-check + _STABILITY_POOL_LEVERAGED = stabilityPoolLeveraged; + } + + /// @notice Initialize the contract with starting configuration + /// @param owner_ The owner address + function initialize(address owner_) external initializer { + _initializeOwner(owner_); + __UUPSUpgradeable_init(); + __ERC165_init(); + __ReentrancyGuardTransient_init(); + } + + /// @notice The check that allows this contract to be upgraded + /// @dev In UUPS proxies the implementation is responsible for upgrading itself + function _authorizeUpgrade(address) internal override onlyOwner {} // solhint-disable-line no-empty-blocks + + /** + * @dev See {IERC165-supportsInterface}. + */ + function supportsInterface( + bytes4 interfaceId + ) public view virtual override(BaoOwnableRoles, ERC165Upgradeable) returns (bool) { + return + interfaceId == type(IStabilityPoolManager).interfaceId || + interfaceId == type(ITokenHolder).interfaceId || + super.supportsInterface(interfaceId); + } + + /************************* + * Public View Functions * + *************************/ + + /// @inheritdoc IStabilityPoolManager + function stabilityPools() external view returns (address[] memory pools) { + pools = new address[](2); + pools[0] = _STABILITY_POOL_COLLATERAL; + pools[1] = _STABILITY_POOL_LEVERAGED; + } + + /// @inheritdoc IStabilityPoolManager + function hasStabilityPool(address stabilityPool) external view returns (bool) { + return (_STABILITY_POOL_COLLATERAL == stabilityPool) || _STABILITY_POOL_LEVERAGED == stabilityPool; + } + + /// @inheritdoc IStabilityPoolManager + function harvestable() external view returns (uint256) { + return IMinter(MINTER).harvestable(); + } + + function _rebalanceable( + uint256 collateralRatio, + uint256 rebalanceThreshold_ + ) private pure returns (bool rebalanceable_) { + // Check if collateral ratio is below the rebalance threshold + rebalanceable_ = collateralRatio < rebalanceThreshold_; + } + + /// @inheritdoc IStabilityPoolManager + function rebalanceable() external view returns (bool rebalanceable_) { + StabilityPoolManagerStorage storage $ = _getStabilityPoolManagerStorage(); + rebalanceable_ = _rebalanceable(IMinter(MINTER).collateralRatio(), $.rebalanceThreshold); + } + + /// @inheritdoc IStabilityPoolManager + function harvestBountyRatio() external view returns (uint256 harvestBountyRatio_) { + StabilityPoolManagerStorage storage $ = _getStabilityPoolManagerStorage(); + harvestBountyRatio_ = $.harvestBountyRatio; + } + + /// @inheritdoc IStabilityPoolManager + function harvestCutRatio() external view returns (uint256 harvestCutRatio_) { + StabilityPoolManagerStorage storage $ = _getStabilityPoolManagerStorage(); + harvestCutRatio_ = $.harvestCutRatio; + } + /// @inheritdoc IStabilityPoolManager + function rebalanceBountyRatio() external view returns (uint256 rebalanceBountyRatio_) { + StabilityPoolManagerStorage storage $ = _getStabilityPoolManagerStorage(); + rebalanceBountyRatio_ = $.rebalanceBountyRatio; + } + + /// @inheritdoc IStabilityPoolManager + function rebalanceThreshold() external view returns (uint256) { + StabilityPoolManagerStorage storage $ = _getStabilityPoolManagerStorage(); + return $.rebalanceThreshold; + } + + /// @inheritdoc IStabilityPoolManager + function feeReceiver() external view override returns (address) { + StabilityPoolManagerStorage storage $ = _getStabilityPoolManagerStorage(); + return $.feeReceiver; + } + + /// @inheritdoc IStabilityPoolManager_v2 + function autoCompounder(address sp) external view override returns (address) { + StabilityPoolManagerStorage storage $ = _getStabilityPoolManagerStorage(); + return $.autoCompounder[sp]; + } + + /************************* + * Admin Functions * + *************************/ + + /// @notice Updates the rebalance threshold collateral ratio + /// @param newRatio The new rebalance threshold + function updateRebalanceThreshold(uint256 newRatio) external onlyOwner { + if (newRatio <= 1 ether) { + revert InvalidRebalanceThreshold(newRatio); + } + StabilityPoolManagerStorage storage $ = _getStabilityPoolManagerStorage(); + $.rebalanceThreshold = newRatio; + + emit RebalanceThresholdUpdated(newRatio); + } + + /// @inheritdoc IStabilityPoolManager + function updateRebalanceBountyRatio(uint256 rebalanceRatio_) external onlyOwner { + if (rebalanceRatio_ > 1 ether) { + revert InvalidRebalanceBountyRatio(rebalanceRatio_); + } + StabilityPoolManagerStorage storage $ = _getStabilityPoolManagerStorage(); + $.rebalanceBountyRatio = rebalanceRatio_; + + emit RebalanceBountyUpdated(rebalanceRatio_); + } + + /// @inheritdoc IStabilityPoolManager + function updateHarvestBountyRatio(uint256 harvestRatio_) external onlyOwner { + if (harvestRatio_ > 1 ether) { + revert InvalidHarvestBountyRatio(harvestRatio_); + } + StabilityPoolManagerStorage storage $ = _getStabilityPoolManagerStorage(); + $.harvestBountyRatio = harvestRatio_; + + emit HarvestBountyUpdated(harvestRatio_); + } + + /// @inheritdoc IStabilityPoolManager + function updateHarvestCutRatio(uint256 harvestCutRatio_) external onlyOwner { + if (harvestCutRatio_ > 1 ether) { + revert InvalidHarvestBountyRatio(harvestCutRatio_); + } + StabilityPoolManagerStorage storage $ = _getStabilityPoolManagerStorage(); + $.harvestCutRatio = harvestCutRatio_; + + emit HarvestCutUpdated(harvestCutRatio_); + } + + function updateFeeReceiver(address feeReceiver_) external override onlyOwner { + StabilityPoolManagerStorage storage $ = _getStabilityPoolManagerStorage(); + address old = $.feeReceiver; + $.feeReceiver = feeReceiver_; + emit UpdateFeeReceiver(old, feeReceiver_); + } + + /// @inheritdoc IStabilityPoolManager_v2 + function setAutoCompounder(address sp, address ac) external override onlyOwner { + if (sp != _STABILITY_POOL_COLLATERAL && sp != _STABILITY_POOL_LEVERAGED) { + revert InvalidStabilityPool(sp); + } + StabilityPoolManagerStorage storage $ = _getStabilityPoolManagerStorage(); + $.autoCompounder[sp] = ac; + emit AutoCompounderSet(sp, ac); + } + + /************************* + * Core Functions * + *************************/ + + function _poolHoldings() + private + view + returns (uint256 totalPoolHolding, uint256 poolHoldingCollateral, uint256 poolHoldingLeveraged) + { + poolHoldingCollateral = IERC20(PEGGED_TOKEN).balanceOf(_STABILITY_POOL_COLLATERAL); + poolHoldingLeveraged = IERC20(PEGGED_TOKEN).balanceOf(_STABILITY_POOL_LEVERAGED); + totalPoolHolding = poolHoldingCollateral + poolHoldingLeveraged; + } + + /// @dev Trigger compound() on any registered auto-compounder for each stability pool. + /// Failures (including NothingToCompound) are silently swallowed — compound is an + /// optional optimisation step and must not block harvest/rebalance. + function _compoundRegistered() private { + StabilityPoolManagerStorage storage $ = _getStabilityPoolManagerStorage(); + address acColl = $.autoCompounder[_STABILITY_POOL_COLLATERAL]; + if (acColl != address(0)) { + // solhint-disable-next-line no-empty-blocks + try IAutoCompounder(acColl).compound() {} catch {} + } + address acLev = $.autoCompounder[_STABILITY_POOL_LEVERAGED]; + if (acLev != address(0)) { + // solhint-disable-next-line no-empty-blocks + try IAutoCompounder(acLev).compound() {} catch {} + } + } + + /// @inheritdoc IStabilityPoolManager + function rebalance( + address bountyReceiver, + uint256 minPeggedLiquidated + ) external nonReentrant returns (uint256 peggedLiquidated) { + if (bountyReceiver == address(0)) { + revert IERC20Errors.ERC20InvalidReceiver(bountyReceiver); + } + StabilityPoolManagerStorage storage $ = _getStabilityPoolManagerStorage(); + uint256 rebalanceThreshold_ = $.rebalanceThreshold; + if (!_rebalanceable(IMinter(MINTER).collateralRatio(), rebalanceThreshold_)) { + // it's an lower bound for non-rebalance mode + revert CollateralRatioNotBelowRebalanceThreshold(IMinter(MINTER).collateralRatio(), rebalanceThreshold_); + } + + // sum up the relative sizes of the stability pools - this is the pegged token holdings + // note that these holdings are depleted by the liquidation process + (uint256 totalPoolHolding, uint256 poolHoldingCollateral, uint256 poolHoldingLeveraged) = _poolHoldings(); + // slither-disable-next-line incorrect-equality + if (totalPoolHolding == 0) { + revert NoTokensToLiquidate(PEGGED_TOKEN); + } + + // Get the amount of pegged tokens needed to be liquidated to reach target collateral ratio + (uint256 peggedForCollateral, uint256 peggedForLeveraged) = IMinter(MINTER).redeemPeggedForCollateralRatio( + rebalanceThreshold_ + ); + + // Distribute between pools based on weighted holdings if both have tokens + if (poolHoldingCollateral > 0 && poolHoldingLeveraged > 0) { + // Weight each pool by its holdings, adjusting the leveraged pool by effectiveness + uint256 weightedLeveraged = Math.mulDiv(poolHoldingLeveraged, peggedForCollateral, peggedForLeveraged); + uint256 totalWeight = poolHoldingCollateral + weightedLeveraged; + + // Calculate the proportional contribution of each pool + uint256 collateralLiquidationFraction = Math.mulDiv(poolHoldingCollateral, 1 ether, totalWeight); + uint256 leveragedLiquidationFraction = 1 ether - collateralLiquidationFraction; + + // Apply the fractions to determine how much each pool should liquidate + peggedForCollateral = Math.mulDiv( + peggedForCollateral, + collateralLiquidationFraction, + 1 ether, + Math.Rounding.Ceil + ); + + peggedForLeveraged = Math.mulDiv( + peggedForLeveraged, + leveragedLiquidationFraction, + 1 ether, + Math.Rounding.Ceil + ); + } + + // Cap the liquidation amounts to what each pool actually holds + peggedForCollateral = Math.min(peggedForCollateral, poolHoldingCollateral); + peggedForLeveraged = Math.min(peggedForLeveraged, poolHoldingLeveraged); + peggedLiquidated = peggedForCollateral + peggedForLeveraged; + + // make sure we're going to liquidate at least the minimum + if (peggedLiquidated < minPeggedLiquidated) { + revert InsufficientLiquidation(PEGGED_TOKEN, peggedLiquidated, minPeggedLiquidated); + } + + // do the actual liquidation for each pool + // * take the pegged tokens to be liquidated + // * liquidate them into the other token (collateral/leveraged) + // * extract the feed and transfer to the fee receiver + // * transfer the remainder to the stability pool, notifying it of that "reward" + + uint256 rebalanceBountyRatio_ = $.rebalanceBountyRatio; + + // allow the minter to burn my pegged tokens I've just swept up + IERC20(PEGGED_TOKEN).safeIncreaseAllowance(MINTER, peggedLiquidated); + + // sweep the pegged from each pool - this just snaffles the tokens, no accounting: that is done later + if (peggedForCollateral > 0) { + ITokenHolder(_STABILITY_POOL_COLLATERAL).sweep(PEGGED_TOKEN, peggedForCollateral, address(this)); + } + if (peggedForLeveraged > 0) { + ITokenHolder(_STABILITY_POOL_LEVERAGED).sweep(PEGGED_TOKEN, peggedForLeveraged, address(this)); + } + + // now liquidate the tokens to be liquidated for the reward + (uint256 wrappedCollateralReturned, uint256 leveragedReturned) = IMinter(MINTER).freeRedeemPeggedToken( + peggedForCollateral, + peggedForLeveraged, + address(this) + ); + + if (peggedForCollateral > 0) { + // extract the collateral bounty + uint256 collateralBounty = (wrappedCollateralReturned * rebalanceBountyRatio_) / 1 ether; + wrappedCollateralReturned -= collateralBounty; + IERC20(WRAPPED_COLLATERAL_TOKEN).safeTransfer(bountyReceiver, collateralBounty); + // transfer the amounts and update the stability pool accounts + IERC20(WRAPPED_COLLATERAL_TOKEN).safeTransfer(_STABILITY_POOL_COLLATERAL, wrappedCollateralReturned); + IStabilityPool(_STABILITY_POOL_COLLATERAL).notifyLiquidation( + peggedForCollateral, + wrappedCollateralReturned + ); + } + if (peggedForLeveraged > 0) { + // extract the leveraged bounty + uint256 leveragedBounty = (leveragedReturned * rebalanceBountyRatio_) / 1 ether; + leveragedReturned -= leveragedBounty; + IERC20(LEVERAGED_TOKEN).safeTransfer(bountyReceiver, leveragedBounty); + // transfer the amounts and update the stability pool accounts + IERC20(LEVERAGED_TOKEN).safeTransfer(_STABILITY_POOL_LEVERAGED, leveragedReturned); + IStabilityPool(_STABILITY_POOL_LEVERAGED).notifyLiquidation(peggedForLeveraged, leveragedReturned); + } + + emit Rebalanced(peggedLiquidated, wrappedCollateralReturned, leveragedReturned); + _compoundRegistered(); + } + + function _harvestToPool(uint256 amount, address pool) private { + if (amount > 0) { + IERC20(WRAPPED_COLLATERAL_TOKEN).forceApprove(pool, amount); + IMultipleRewardDistributor(pool).depositReward(WRAPPED_COLLATERAL_TOKEN, amount); + IERC20(WRAPPED_COLLATERAL_TOKEN).forceApprove(pool, 0); + } + } + + /// @inheritdoc IStabilityPoolManager + function harvest( + address bountyReceiver, + uint256 minBounty + ) external nonReentrant returns (uint256 harvestedAmount) { + if (bountyReceiver == address(0)) { + revert IERC20Errors.ERC20InvalidReceiver(bountyReceiver); + } + // Check if there's anything to harvest + uint256 harvestableAmount = IMinter(MINTER).harvestable(); + if (harvestableAmount == 0) { + revert NoHarvestable(); + } + uint256 harvestableRemaining = harvestableAmount; + + // Calculate bounty + StabilityPoolManagerStorage storage $ = _getStabilityPoolManagerStorage(); + uint256 bountyAmount = (harvestableAmount * $.harvestBountyRatio) / 1 ether; + if (bountyAmount < minBounty) { + revert InsufficientBounty(WRAPPED_COLLATERAL_TOKEN, bountyAmount, minBounty); + } + uint256 cutAmount = (harvestableAmount * $.harvestCutRatio) / 1 ether; + + // harvest everything - one loss recorded in stability pool (which is expensive in gas) + ITokenHolder(MINTER).sweep(WRAPPED_COLLATERAL_TOKEN, harvestableAmount, address(this)); + + // distribute the harvest deductions + if (bountyAmount > 0) { + IERC20(WRAPPED_COLLATERAL_TOKEN).safeTransfer(bountyReceiver, bountyAmount); + harvestableRemaining -= bountyAmount; + } + if (cutAmount > 0) { + address cutReceiver = $.feeReceiver == address(0) ? TREASURY : $.feeReceiver; + IERC20(WRAPPED_COLLATERAL_TOKEN).safeTransfer(cutReceiver, cutAmount); + harvestableRemaining -= cutAmount; + } + + // Calculate total pool balances (similar to Harvester_v1) + (uint256 totalPoolHolding, uint256 poolHoldingCollateral, ) = _poolHoldings(); + + // Distribute proportionally based on current holdings + if (totalPoolHolding > 0) { + uint256 harvestedToCollateral = Math.mulDiv(harvestableRemaining, poolHoldingCollateral, totalPoolHolding); + _harvestToPool(harvestedToCollateral, _STABILITY_POOL_COLLATERAL); + _harvestToPool(harvestableRemaining - harvestedToCollateral, _STABILITY_POOL_LEVERAGED); + } else { + // Send to treasury if no pools have a balance + IERC20(WRAPPED_COLLATERAL_TOKEN).safeTransfer(TREASURY, harvestableRemaining); + } + + emit Harvested(harvestableAmount); + _compoundRegistered(); + return harvestableAmount; + } +} diff --git a/src/minter/library/Config_v2.sol b/src/minter/library/Config_v2.sol index 4337489c..0a096146 100644 --- a/src/minter/library/Config_v2.sol +++ b/src/minter/library/Config_v2.sol @@ -9,7 +9,7 @@ import {IMinter} from "@harbor/interfaces/IMinter.sol"; /// @dev Extracts config validation from the main Minter contract to reduce its size, at the cost of increased gas. /// We take this hit because upgrading the config is an infrequent cost. /// @dev this contract doesn't modify storage so is upgrade safe -// solhint-disable-next-line contract-name-camelcase +// solhint-disable-next-line contract-name-capwords library Config_v2 { using ConfigIncentiveLib for ConfigIncentiveLib.ActionIncentive; diff --git a/test/deployment/AutoCompounderTest.t.sol b/test/deployment/AutoCompounderTest.t.sol deleted file mode 100644 index 39f5e9c4..00000000 --- a/test/deployment/AutoCompounderTest.t.sol +++ /dev/null @@ -1,541 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.28 <0.9.0; - -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; -import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; -import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; -import {IAutoCompounder} from "@harbor/interfaces/IAutoCompounder.sol"; -import {IMinter} from "@harbor/interfaces/IMinter.sol"; -import {AutoCompounder_v1} from "@harbor/autocompounding/AutoCompounder_v1.sol"; -import {DeployEURSetUp} from "@harbor-test/deployment/DeployEURSetUp.t.sol"; -import {PermitTestBase} from "@bao-test/helpers/PermitTestBase.t.sol"; - -/// @title AutoCompounder tests using EUR peg (fxUSD + stETH collateral). -/// Run: forge test --mc AutoCompounderTest --fork-url mainnet -vv -contract AutoCompounderTest is DeployEURSetUp, PermitTestBase { - address alice = makeAddr("alice"); - address bob = makeAddr("bob"); - - function _permitTarget() internal view override returns (address) { - return acCollFxUSD; - } - - // ── Deployment verification ──────────────────────────────────────── - - function test_deployment_immutables() public view { - assertEq(AutoCompounder_v1(acCollFxUSD).STABILITY_POOL(), spCollFxUSD); - assertEq(AutoCompounder_v1(acCollFxUSD).MINTER(), minterFxUSD); - assertEq(AutoCompounder_v1(acCollFxUSD).WRAPPED_COLLATERAL(), wrappedCollateralFxUSD); - assertEq(AutoCompounder_v1(acCollFxUSD).PEGGED_TOKEN(), pegged); - - assertEq(AutoCompounder_v1(acCollStETH).STABILITY_POOL(), spCollStETH); - assertEq(AutoCompounder_v1(acCollStETH).MINTER(), minterStETH); - assertEq(AutoCompounder_v1(acCollStETH).WRAPPED_COLLATERAL(), wrappedCollateralStETH); - assertEq(AutoCompounder_v1(acCollStETH).PEGGED_TOKEN(), pegged); - } - - function test_deployment_metadata() public view { - assertGt(bytes(IERC4626(acCollFxUSD).name()).length, 0, "fxUSD AC name"); - assertGt(bytes(IERC4626(acCollFxUSD).symbol()).length, 0, "fxUSD AC symbol"); - assertEq(IERC4626(acCollFxUSD).decimals(), 18); - - assertGt(bytes(IERC4626(acCollStETH).name()).length, 0, "stETH AC name"); - assertGt(bytes(IERC4626(acCollStETH).symbol()).length, 0, "stETH AC symbol"); - assertEq(IERC4626(acCollStETH).decimals(), 18); - } - - function test_deployment_mintMaxFeeRatio() public view { - assertEq(AutoCompounder_v1(acCollFxUSD).mintMaxFeeRatio(), 0.05 ether, "fxUSD AC mintMaxFeeRatio"); - assertEq(AutoCompounder_v1(acCollStETH).mintMaxFeeRatio(), 0.05 ether, "stETH AC mintMaxFeeRatio"); - } - - function test_deployment_asset() public view { - assertEq(IERC4626(acCollFxUSD).asset(), spCollFxUSD, "fxUSD AC asset is SP"); - assertEq(IERC4626(acCollStETH).asset(), spCollStETH, "stETH AC asset is SP"); - } - - // ── Deposit / Withdraw round-trip ────────────────────────────────── - - function test_depositWithdraw_roundTrip() public { - // Alice deposits pegged -> SP -> gets SP tokens -> deposits to AC - _mintAndDepositToSP(minterFxUSD, spCollFxUSD, alice, 10 ether); - uint256 spBalance = IERC20(spCollFxUSD).balanceOf(alice); - assertGt(spBalance, 0, "alice has SP tokens"); - - // Approve AC and deposit SP tokens - vm.startPrank(alice); - IERC20(spCollFxUSD).approve(acCollFxUSD, spBalance); - uint256 shares = IERC4626(acCollFxUSD).deposit(spBalance, alice); - vm.stopPrank(); - - assertGt(shares, 0, "alice got AC shares"); - assertEq(IERC20(spCollFxUSD).balanceOf(alice), 0, "SP tokens moved to AC"); - assertEq(IERC4626(acCollFxUSD).balanceOf(alice), shares, "AC shares in alice's balance"); - - // Redeem all AC shares -> get SP tokens back - vm.prank(alice); - uint256 spReturned = IERC4626(acCollFxUSD).redeem(shares, alice, alice); - - assertEq(spReturned, spBalance, "got same SP tokens back"); - assertEq(IERC4626(acCollFxUSD).balanceOf(alice), 0, "no AC shares left"); - assertEq(IERC20(spCollFxUSD).balanceOf(alice), spBalance, "SP tokens returned"); - } - - // ── depositPeggedToken ───────────────────────────────────────────── - - function test_depositPeggedToken() public { - uint256 peggedAmount = 10 ether; - _mintPegged(minterFxUSD, alice, peggedAmount); - - vm.startPrank(alice); - IERC20(pegged).approve(acCollFxUSD, peggedAmount); - uint256 shares = IAutoCompounder(acCollFxUSD).depositPeggedToken(peggedAmount, alice); - vm.stopPrank(); - - assertGt(shares, 0, "alice got AC shares"); - assertEq(IERC20(pegged).balanceOf(alice), 0, "pegged tokens consumed"); - assertGt(IERC4626(acCollFxUSD).totalAssets(), 0, "AC has assets"); - } - - function test_depositPeggedToken_equivalentToDeposit() public { - uint256 amount = 10 ether; - - // Alice deposits via depositPeggedToken (pegged -> SP -> AC in one call) - _mintPegged(minterFxUSD, alice, amount); - vm.startPrank(alice); - IERC20(pegged).approve(acCollFxUSD, amount); - uint256 sharesPegged = IAutoCompounder(acCollFxUSD).depositPeggedToken(amount, alice); - vm.stopPrank(); - - // Bob deposits via deposit (pegged -> SP manually, then SP tokens -> AC) - _mintAndDepositToSP(minterFxUSD, spCollFxUSD, bob, amount); - uint256 spBal = IERC20(spCollFxUSD).balanceOf(bob); - vm.startPrank(bob); - IERC20(spCollFxUSD).approve(acCollFxUSD, spBal); - uint256 sharesDeposit = IERC4626(acCollFxUSD).deposit(spBal, bob); - vm.stopPrank(); - - // Same collateral amount should produce same shares (second depositor buys at same rate) - assertEq(sharesPegged, sharesDeposit, "depositPeggedToken and deposit produce equal shares"); - - // Both should redeem to the same SP token amount - uint256 redeemAlice = IERC4626(acCollFxUSD).previewRedeem(sharesPegged); - uint256 redeemBob = IERC4626(acCollFxUSD).previewRedeem(sharesDeposit); - assertEq(redeemAlice, redeemBob, "equal redemption value"); - } - - function test_depositPeggedToken_doesNotAffectExistingUsers() public { - // Charlie is an existing depositor - address charlie = makeAddr("charlie"); - _mintAndDepositToSP(minterFxUSD, spCollFxUSD, charlie, 50 ether); - uint256 spBalCharlie = IERC20(spCollFxUSD).balanceOf(charlie); - vm.prank(charlie); - IERC20(spCollFxUSD).approve(acCollFxUSD, spBalCharlie); - vm.prank(charlie); - IERC4626(acCollFxUSD).deposit(spBalCharlie, charlie); - - uint256 charlieRedeemBefore = IERC4626(acCollFxUSD).previewRedeem(IERC4626(acCollFxUSD).balanceOf(charlie)); - - // Alice enters via depositPeggedToken - _mintPegged(minterFxUSD, alice, 10 ether); - vm.startPrank(alice); - IERC20(pegged).approve(acCollFxUSD, 10 ether); - IAutoCompounder(acCollFxUSD).depositPeggedToken(10 ether, alice); - vm.stopPrank(); - - uint256 charlieRedeemAfterPegged = IERC4626(acCollFxUSD).previewRedeem( - IERC4626(acCollFxUSD).balanceOf(charlie) - ); - - // Bob enters via deposit (SP tokens) - _mintAndDepositToSP(minterFxUSD, spCollFxUSD, bob, 10 ether); - uint256 spBalBob = IERC20(spCollFxUSD).balanceOf(bob); - vm.prank(bob); - IERC20(spCollFxUSD).approve(acCollFxUSD, spBalBob); - vm.prank(bob); - IERC4626(acCollFxUSD).deposit(spBalBob, bob); - - uint256 charlieRedeemAfterBoth = IERC4626(acCollFxUSD).previewRedeem(IERC4626(acCollFxUSD).balanceOf(charlie)); - - // Charlie's redemption value should be unchanged by either deposit path - assertEq(charlieRedeemAfterPegged, charlieRedeemBefore, "depositPeggedToken did not dilute charlie"); - assertEq(charlieRedeemAfterBoth, charlieRedeemBefore, "deposit did not dilute charlie"); - } - - function test_depositPeggedToken_maxAmount() public { - uint256 peggedAmount = 10 ether; - _mintPegged(minterFxUSD, alice, peggedAmount); - - vm.startPrank(alice); - IERC20(pegged).approve(acCollFxUSD, type(uint256).max); - uint256 shares = IAutoCompounder(acCollFxUSD).depositPeggedToken(type(uint256).max, alice); - vm.stopPrank(); - - assertGt(shares, 0, "alice got AC shares"); - assertEq(IERC20(pegged).balanceOf(alice), 0, "all pegged tokens consumed"); - } - - // ── Compound: full mint ──────────────────────────────────────────── - - function test_compound_fullMint() public { - // Setup: healthy CR via leveraged tokens, so minting pegged during compound has low fee - _setupHealthyMarket(minterFxUSD, spCollFxUSD, alice, 100 ether, 100 ether); - uint256 spBal = IERC20(spCollFxUSD).balanceOf(alice); - vm.startPrank(alice); - IERC20(spCollFxUSD).approve(acCollFxUSD, spBal); - IERC4626(acCollFxUSD).deposit(spBal, alice); - vm.stopPrank(); - - uint256 totalAssetsBefore = IERC4626(acCollFxUSD).totalAssets(); - - // Deposit small reward (0.5% of pool - keeps CR impact minimal) - _depositReward(spCollFxUSD, wrappedCollateralFxUSD, wrappedCollateralFxUSD, 5 ether); - skip(2 weeks); // let rewards fully accrue - - // Verify claimable exists - uint256 claimable = IMultipleRewardAccumulator(spCollFxUSD).claimable(acCollFxUSD, wrappedCollateralFxUSD); - assertGt(claimable, 0, "AC has claimable rewards"); - - // totalAssets should include claimable value - uint256 totalAssetsWithRewards = IERC4626(acCollFxUSD).totalAssets(); - assertGt(totalAssetsWithRewards, totalAssetsBefore, "totalAssets includes claimable"); - - // Compound - anyone can call - vm.prank(bob); - IAutoCompounder(acCollFxUSD).compound(); - - // After compound: all claimable consumed, SP position grew - uint256 claimableAfter = IMultipleRewardAccumulator(spCollFxUSD).claimable(acCollFxUSD, wrappedCollateralFxUSD); - assertEq(claimableAfter, 0, "all rewards claimed"); - uint256 totalAssetsAfter = IERC4626(acCollFxUSD).totalAssets(); - // totalAssets preserved (claimable converted to SP position, minus small minting fee) - assertApproxEqRel(totalAssetsAfter, totalAssetsWithRewards, 0.05 ether, "totalAssets preserved"); - } - - // ── Compound: fee too high -> claims but does not mint ────────────── - - function test_compound_feeTooHigh_claimsButDoesNotMint() public { - // Setup: healthy CR - _setupHealthyMarket(minterFxUSD, spCollFxUSD, alice, 100 ether, 100 ether); - uint256 spBal = IERC20(spCollFxUSD).balanceOf(alice); - vm.startPrank(alice); - IERC20(spCollFxUSD).approve(acCollFxUSD, spBal); - IERC4626(acCollFxUSD).deposit(spBal, alice); - vm.stopPrank(); - - // Deposit rewards - _depositReward(spCollFxUSD, wrappedCollateralFxUSD, wrappedCollateralFxUSD, 5 ether); - skip(2 weeks); - - // Push Minter fees above MAX_FEE_RATIO. - // Minter config requires a disallow sentinel (1e18) at index 0 (depeg band). The test market's CR (~200%) - // is below the band upper bound (1000%), so incentiveRatios[0] = 1e18 (disallow) applies. - // 1e18 fee >> MAX_FEE_RATIO (0.05e18) → mintPeggedToken returns (0, 0) → compound() routes as residual. - IMinter.IncentiveConfig memory highFeeConfig = IMinter.IncentiveConfig({ - collateralRatioBandUpperBounds: new uint256[](1), - incentiveRatios: new int256[](2) - }); - highFeeConfig.collateralRatioBandUpperBounds[0] = 10e18; // band upper bound at 1000% CR - highFeeConfig.incentiveRatios[0] = 1e18; // disallow below band (depeg sentinel, valid at index 0) - highFeeConfig.incentiveRatios[1] = 0.1e18; // 10% fee above band (unreachable given test CR) - - IMinter.Config memory highFeeFullConfig = IMinter.Config({ - mintPeggedIncentiveConfig: highFeeConfig, - redeemPeggedIncentiveConfig: IMinter(minterFxUSD).config().redeemPeggedIncentiveConfig, - mintLeveragedIncentiveConfig: IMinter(minterFxUSD).config().mintLeveragedIncentiveConfig, - redeemLeveragedIncentiveConfig: IMinter(minterFxUSD).config().redeemLeveragedIncentiveConfig - }); - vm.prank(HARBOR_MULTISIG); - IMinter(minterFxUSD).updateConfig(highFeeFullConfig); - - // Track the raw SP token balance (not totalAssets — that includes claimable which drops to 0 after claim()) - uint256 spBalanceBefore = IERC20(spCollFxUSD).balanceOf(acCollFxUSD); - - // Compound should not revert, but should not mint (fee too high → try/catch skips minting) - IAutoCompounder(acCollFxUSD).compound(); - - // SP balance unchanged — no new haXXX deposited to the SP - uint256 spBalanceAfter = IERC20(spCollFxUSD).balanceOf(acCollFxUSD); - assertEq(spBalanceAfter, spBalanceBefore, "SP balance unchanged: minting skipped"); - - // Claimable is now 0 — claim() always runs in compound() - uint256 claimableAfter = IMultipleRewardAccumulator(spCollFxUSD).claimable(acCollFxUSD, wrappedCollateralFxUSD); - assertEq(claimableAfter, 0, "all rewards claimed from SP"); - } - - // ── Compound: nothing to compound -> revert ───────────────────────── - - function test_compound_nothingToCompound_reverts() public { - // Setup: alice deposits, no rewards - _mintAndDepositToSP(minterFxUSD, spCollFxUSD, alice, 10 ether); - uint256 spBal = IERC20(spCollFxUSD).balanceOf(alice); - vm.startPrank(alice); - IERC20(spCollFxUSD).approve(acCollFxUSD, spBal); - IERC4626(acCollFxUSD).deposit(spBal, alice); - vm.stopPrank(); - - vm.expectRevert(AutoCompounder_v1.NothingToCompound.selector); - IAutoCompounder(acCollFxUSD).compound(); - } - - // ── Share price increases after compound ──────────────────────────── - - function test_compound_sharePriceUp() public { - // Healthy CR, then alice and bob deposit equal amounts - _mintLeveraged(minterFxUSD, address(this), 200 ether); - _mintAndDepositToSP(minterFxUSD, spCollFxUSD, alice, 50 ether); - _mintAndDepositToSP(minterFxUSD, spCollFxUSD, bob, 50 ether); - - uint256 spBalAlice = IERC20(spCollFxUSD).balanceOf(alice); - uint256 spBalBob = IERC20(spCollFxUSD).balanceOf(bob); - - vm.prank(alice); - IERC20(spCollFxUSD).approve(acCollFxUSD, spBalAlice); - vm.prank(alice); - uint256 sharesAlice = IERC4626(acCollFxUSD).deposit(spBalAlice, alice); - - vm.prank(bob); - IERC20(spCollFxUSD).approve(acCollFxUSD, spBalBob); - vm.prank(bob); - uint256 sharesBob = IERC4626(acCollFxUSD).deposit(spBalBob, bob); - - assertEq(sharesAlice, sharesBob, "equal deposits -> equal shares"); - - uint256 previewBefore = IERC4626(acCollFxUSD).previewRedeem(sharesAlice); - - // Deposit rewards and compound - _depositReward(spCollFxUSD, wrappedCollateralFxUSD, wrappedCollateralFxUSD, 10 ether); - skip(2 weeks); - IAutoCompounder(acCollFxUSD).compound(); - - uint256 previewAfter = IERC4626(acCollFxUSD).previewRedeem(sharesAlice); - assertGt(previewAfter, previewBefore, "share price increased after compound"); - } - - // ── No dilution on deposit when queue non-empty ──────────────────── - - function test_noDilution_withPendingRewards() public { - // Healthy CR, alice deposits first - _mintLeveraged(minterFxUSD, address(this), 200 ether); - _mintAndDepositToSP(minterFxUSD, spCollFxUSD, alice, 50 ether); - uint256 spBalAlice = IERC20(spCollFxUSD).balanceOf(alice); - vm.prank(alice); - IERC20(spCollFxUSD).approve(acCollFxUSD, spBalAlice); - vm.prank(alice); - IERC4626(acCollFxUSD).deposit(spBalAlice, alice); - - // Rewards accrue - _depositReward(spCollFxUSD, wrappedCollateralFxUSD, wrappedCollateralFxUSD, 10 ether); - skip(2 weeks); - - // Bob deposits AFTER rewards accrued but BEFORE compound - _mintAndDepositToSP(minterFxUSD, spCollFxUSD, bob, 50 ether); - uint256 spBalBob = IERC20(spCollFxUSD).balanceOf(bob); - vm.prank(bob); - IERC20(spCollFxUSD).approve(acCollFxUSD, spBalBob); - vm.prank(bob); - uint256 sharesBob = IERC4626(acCollFxUSD).deposit(spBalBob, bob); - - // Bob should get FEWER shares than alice (alice's shares are worth more due to pending rewards) - uint256 sharesAlice = IERC4626(acCollFxUSD).balanceOf(alice); - assertLt(sharesBob, sharesAlice, "bob gets fewer shares - no dilution"); - } - - // ── Two collaterals: independent compounding ─────────────────────── - - function test_twoCollaterals_independentCompound() public { - // Healthy CR for both markets, deposit to both - _mintLeveraged(minterFxUSD, address(this), 100 ether); - _mintLeveraged(minterStETH, address(this), 100 ether); - _mintAndDepositToSP(minterFxUSD, spCollFxUSD, alice, 50 ether); - _mintAndDepositToSP(minterStETH, spCollStETH, alice, 50 ether); - - uint256 spBalFx = IERC20(spCollFxUSD).balanceOf(alice); - uint256 spBalSt = IERC20(spCollStETH).balanceOf(alice); - - vm.startPrank(alice); - IERC20(spCollFxUSD).approve(acCollFxUSD, spBalFx); - IERC4626(acCollFxUSD).deposit(spBalFx, alice); - IERC20(spCollStETH).approve(acCollStETH, spBalSt); - IERC4626(acCollStETH).deposit(spBalSt, alice); - vm.stopPrank(); - - // Deposit rewards only to fxUSD SP - _depositReward(spCollFxUSD, wrappedCollateralFxUSD, wrappedCollateralFxUSD, 5 ether); - skip(2 weeks); - - // Compound fxUSD AC - should succeed - IAutoCompounder(acCollFxUSD).compound(); - - // stETH AC - nothing to compound - vm.expectRevert(AutoCompounder_v1.NothingToCompound.selector); - IAutoCompounder(acCollStETH).compound(); - } - - // ── totalAssets consistency ───────────────────────────────────────── - - function test_totalAssets_withClaimable() public { - _setupHealthyMarket(minterFxUSD, spCollFxUSD, alice, 100 ether, 100 ether); - uint256 spBal = IERC20(spCollFxUSD).balanceOf(alice); - vm.prank(alice); - IERC20(spCollFxUSD).approve(acCollFxUSD, spBal); - vm.prank(alice); - IERC4626(acCollFxUSD).deposit(spBal, alice); - - uint256 totalAssetsBefore = IERC4626(acCollFxUSD).totalAssets(); - - // Deposit rewards - _depositReward(spCollFxUSD, wrappedCollateralFxUSD, wrappedCollateralFxUSD, 10 ether); - skip(2 weeks); - - uint256 totalAssetsAfter = IERC4626(acCollFxUSD).totalAssets(); - - // totalAssets should have increased by ~reward amount (at 1:1 price/rate) - assertGt(totalAssetsAfter, totalAssetsBefore, "totalAssets increased"); - assertApproxEqRel(totalAssetsAfter - totalAssetsBefore, 10 ether, 0.01 ether, "increase ~= reward amount"); - } - - // ── totalAssets zero claimable -> just SP position ────────────────── - - function test_totalAssets_noClaimable() public { - _mintAndDepositToSP(minterFxUSD, spCollFxUSD, alice, 10 ether); - uint256 spBal = IERC20(spCollFxUSD).balanceOf(alice); - vm.prank(alice); - IERC20(spCollFxUSD).approve(acCollFxUSD, spBal); - vm.prank(alice); - IERC4626(acCollFxUSD).deposit(spBal, alice); - - uint256 totalAssets = IERC4626(acCollFxUSD).totalAssets(); - uint256 spPosition = IERC20(spCollFxUSD).balanceOf(acCollFxUSD); - assertEq(totalAssets, spPosition, "totalAssets == SP position when no claimable"); - } - - // ── Sweep ────────────────────────────────────────────────────────── - - function test_sweep_rescuesStuckTokens() public { - // Accidentally send some tokens to the AC - deal(wrappedCollateralFxUSD, acCollFxUSD, 1 ether); - - address sweepReceiver = makeAddr("sweepReceiver"); - vm.prank(HARBOR_MULTISIG); - AutoCompounder_v1(acCollFxUSD).sweep(wrappedCollateralFxUSD, 1 ether, sweepReceiver); - - assertEq(IERC20(wrappedCollateralFxUSD).balanceOf(sweepReceiver), 1 ether, "swept to receiver"); - } -} - -/// @title AutoCompounder tests for alias and liquidation reward paths. -/// @dev Inherits AutoCompounderTest setup; tests reward flows through harvest and liquidation. -/// -/// Reward paths for collateral SP: -/// - Harvest: depositReward(wrappedCollateral, amount) -> linear distribution over period -/// - Rebalance: notifyLiquidation(liquidated, returned) -> _accumulateReward(wrappedCollateral) -> instant -/// - Both flow through claimable(AC, wrappedCollateral) and are drained by compound() -/// -/// Run: forge test --mc AutoCompounderRewardTest --fork-url mainnet -vv -contract AutoCompounderRewardTest is AutoCompounderTest { - // ── Helpers ──────────────────────────────────────────────────────── - - /// @dev Simulate a liquidation on the collateral SP: burns pegged, distributes wCOL as reward. - function _simulateLiquidation( - address sp, - address spm, - address wCol, - uint256 peggedLiquidated, - uint256 collateralReturned - ) internal { - deal(wCol, sp, IERC20(wCol).balanceOf(sp) + collateralReturned); - vm.prank(spm); - IStabilityPool(sp).notifyLiquidation(peggedLiquidated, collateralReturned); - } - - // ── Compound via depositReward ──────────────────────────────────── - - function test_compound_viaDepositReward() public { - _setupHealthyMarket(minterFxUSD, spCollFxUSD, alice, 100 ether, 100 ether); - uint256 spBal = IERC20(spCollFxUSD).balanceOf(alice); - vm.prank(alice); - IERC20(spCollFxUSD).approve(acCollFxUSD, spBal); - vm.prank(alice); - IERC4626(acCollFxUSD).deposit(spBal, alice); - - uint256 totalAssetsBefore = IERC4626(acCollFxUSD).totalAssets(); - - _depositReward(spCollFxUSD, wrappedCollateralFxUSD, wrappedCollateralFxUSD, 5 ether); - skip(2 weeks); - - uint256 claimable = IMultipleRewardAccumulator(spCollFxUSD).claimable(acCollFxUSD, wrappedCollateralFxUSD); - assertGt(claimable, 0, "AC has claimable"); - - IAutoCompounder(acCollFxUSD).compound(); - - uint256 claimableAfter = IMultipleRewardAccumulator(spCollFxUSD).claimable(acCollFxUSD, wrappedCollateralFxUSD); - assertEq(claimableAfter, 0, "all rewards claimed"); - assertGt(IERC4626(acCollFxUSD).totalAssets(), totalAssetsBefore, "totalAssets grew"); - } - - // ── Compound via liquidation (notifyLiquidation -> _accumulateReward) ── - - function test_compound_viaLiquidation() public { - _setupHealthyMarket(minterFxUSD, spCollFxUSD, alice, 100 ether, 100 ether); - uint256 spBal = IERC20(spCollFxUSD).balanceOf(alice); - vm.prank(alice); - IERC20(spCollFxUSD).approve(acCollFxUSD, spBal); - vm.prank(alice); - IERC4626(acCollFxUSD).deposit(spBal, alice); - - uint256 totalAssetsBefore = IERC4626(acCollFxUSD).totalAssets(); - - // Simulate liquidation: burn 10 pegged, return 10 wrappedCollateral (instant, not linear) - _simulateLiquidation(spCollFxUSD, spmFxUSD, wrappedCollateralFxUSD, 10 ether, 10 ether); - - // Claimable should be available immediately (no skip needed) - uint256 claimable = IMultipleRewardAccumulator(spCollFxUSD).claimable(acCollFxUSD, wrappedCollateralFxUSD); - assertGt(claimable, 0, "AC has claimable from liquidation"); - - // totalAssets reflects the claimable (minus the pegged loss from liquidation) - // The SP position dropped by ~10 ether (loss), but gained ~10 ether claimable wCOLn - // At price=1, rate=1 these roughly cancel out - uint256 totalAssetsAfterLiq = IERC4626(acCollFxUSD).totalAssets(); - assertApproxEqRel( - totalAssetsAfterLiq, - totalAssetsBefore, - 0.01 ether, - "totalAssets roughly preserved through liquidation" - ); - - IAutoCompounder(acCollFxUSD).compound(); - - uint256 claimableAfter = IMultipleRewardAccumulator(spCollFxUSD).claimable(acCollFxUSD, wrappedCollateralFxUSD); - assertEq(claimableAfter, 0, "liquidation rewards compounded"); - } - - // ── Compound: harvest + liquidation combined ─────────────────────── - - function test_compound_harvestPlusLiquidation() public { - _setupHealthyMarket(minterFxUSD, spCollFxUSD, alice, 100 ether, 100 ether); - uint256 spBal = IERC20(spCollFxUSD).balanceOf(alice); - vm.prank(alice); - IERC20(spCollFxUSD).approve(acCollFxUSD, spBal); - vm.prank(alice); - IERC4626(acCollFxUSD).deposit(spBal, alice); - - // Harvest reward (linear) - _depositReward(spCollFxUSD, wrappedCollateralFxUSD, wrappedCollateralFxUSD, 3 ether); - skip(2 weeks); - - // Liquidation reward (instant) - _simulateLiquidation(spCollFxUSD, spmFxUSD, wrappedCollateralFxUSD, 5 ether, 5 ether); - - // Both should be claimable - uint256 claimable = IMultipleRewardAccumulator(spCollFxUSD).claimable(acCollFxUSD, wrappedCollateralFxUSD); - assertGt(claimable, 3 ether, "claimable includes harvest + liquidation"); - - // Single compound drains everything - IAutoCompounder(acCollFxUSD).compound(); - - uint256 claimableAfter = IMultipleRewardAccumulator(spCollFxUSD).claimable(acCollFxUSD, wrappedCollateralFxUSD); - assertEq(claimableAfter, 0, "single compound drained all reward sources"); - } -} diff --git a/test/deployment/RewardSystem.t.sol b/test/deployment/RewardSystem.t.sol index 26bb7c43..afe7911b 100644 --- a/test/deployment/RewardSystem.t.sol +++ b/test/deployment/RewardSystem.t.sol @@ -14,7 +14,6 @@ import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; import {MockWrappedPriceOracle} from "@harbor-test/mocks/MockWrappedPriceOracle.sol"; -import {AutoCompounder_v1} from "@harbor/autocompounding/AutoCompounder_v1.sol"; import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; /// @title Reward system tests — accumulator, distributor — using deployment framework From 553719fabd8d22926dccc74955d6fa7840963c6c Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Wed, 22 Apr 2026 16:24:09 +0100 Subject: [PATCH 058/232] update deploy to SPM_v2 --- script/src/contracts/StabilityPoolManager.sol | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/script/src/contracts/StabilityPoolManager.sol b/script/src/contracts/StabilityPoolManager.sol index b9431ce0..8dc7d926 100644 --- a/script/src/contracts/StabilityPoolManager.sol +++ b/script/src/contracts/StabilityPoolManager.sol @@ -6,8 +6,9 @@ import {HarborFactoryDeployer} from "@harbor-script/src/HarborFactoryDeployer.so import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; -import {StabilityPoolManager_v1} from "@harbor/minter/StabilityPoolManager_v1.sol"; +import {StabilityPoolManager_v2} from "@harbor/minter/StabilityPoolManager_v2.sol"; import {TokenDistributor_v1} from "@harbor/minter/TokenDistributor_v1.sol"; +import {IStabilityPoolManager} from "@harbor/interfaces/IStabilityPoolManager.sol"; /// @notice Harbor StabilityPoolManager deployment logic (including SPMFeeReceiver). /// @dev SPM coordinates the two stability pools per market. @@ -25,7 +26,7 @@ abstract contract StabilityPoolManager is HarborFactoryDeployer { // ========== STABILITY POOL MANAGER DEPLOYMENT ========== - /// @notice Deploy StabilityPoolManager_v1 impl only, record in state. + /// @notice Deploy StabilityPoolManager_v2 impl only, record in state. function deployStabilityPoolManagerImplementation( DeploymentTypes.State memory stateData, string memory spmKey, @@ -34,19 +35,19 @@ abstract contract StabilityPoolManager is HarborFactoryDeployer { address stabilityPoolCollateral, address stabilityPoolLeveraged ) internal virtual returns (address impl) { - impl = address(new StabilityPoolManager_v1(minter, treasury, stabilityPoolCollateral, stabilityPoolLeveraged)); + impl = address(new StabilityPoolManager_v2(minter, treasury, stabilityPoolCollateral, stabilityPoolLeveraged)); console.log(" Impl: %s", impl); _recordImplementation( stateData, spmKey, - "@harbor/minter/StabilityPoolManager_v1.sol", - "StabilityPoolManager_v1", + "@harbor/minter/StabilityPoolManager_v2.sol", + "StabilityPoolManager_v2", impl ); } - /// @notice Deploy StabilityPoolManager_v1 impl+proxy, record in state. + /// @notice Deploy StabilityPoolManager_v2 impl+proxy, record in state. function deployStabilityPoolManager( DeploymentTypes.State memory stateData, string memory marketKey, @@ -67,7 +68,7 @@ abstract contract StabilityPoolManager is HarborFactoryDeployer { stabilityPoolLeveraged ); - bytes memory initData = abi.encodeCall(StabilityPoolManager_v1.initialize, (owner())); + bytes memory initData = abi.encodeCall(StabilityPoolManager_v2.initialize, (owner())); proxy = _deployProxyViaStubAndRecord(stateData, spmKey, impl, initData); } @@ -76,12 +77,12 @@ abstract contract StabilityPoolManager is HarborFactoryDeployer { /// @param marketKey The market salt key (e.g., "ETH::fxUSD"). /// @param config The SPM configuration parameters. function configureStabilityPoolManager(string memory marketKey, SPMConfig memory config) internal { - StabilityPoolManager_v1 spm = StabilityPoolManager_v1(_predictAddress(_key(marketKey, "stabilityPoolManager"))); - spm.updateRebalanceThreshold(config.rebalanceThreshold); - spm.updateRebalanceBountyRatio(config.rebalanceBountyRatio); - spm.updateHarvestBountyRatio(config.harvestBountyRatio); - spm.updateHarvestCutRatio(config.harvestCutRatio); - spm.updateFeeReceiver(config.feeReceiver); + address spm = _predictAddress(_key(marketKey, "stabilityPoolManager")); + IStabilityPoolManager(spm).updateRebalanceThreshold(config.rebalanceThreshold); + IStabilityPoolManager(spm).updateRebalanceBountyRatio(config.rebalanceBountyRatio); + IStabilityPoolManager(spm).updateHarvestBountyRatio(config.harvestBountyRatio); + IStabilityPoolManager(spm).updateHarvestCutRatio(config.harvestCutRatio); + IStabilityPoolManager(spm).updateFeeReceiver(config.feeReceiver); } // ========== SPM FEE RECEIVER (TOKEN DISTRIBUTOR) DEPLOYMENT ========== From 7d6a77858a0cfffc10d1e898b852cf43aec0faf5 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Wed, 22 Apr 2026 18:31:11 +0100 Subject: [PATCH 059/232] stop cut + bounty exceeding 100% fix lint errors switch tests to use StabilityPoolManager_v2 --- foundry.toml | 6 +- regression/coverage.txt | 5 +- regression/gas.txt | 44 +++------- regression/sizes.txt | 5 +- .../ConfigStabilityPoolManager.sol | 2 +- src/interfaces/IStabilityPoolManager_v2.sol | 12 +++ src/minter/StabilityPoolManager_v2.sol | 8 +- test/GraphsLiquidate.t.sol | 18 ++-- test/Rebalance.t.sol | 10 +-- test/StabilityPoolManager_v1.t.sol | 87 +++++++++---------- test/deployment/RebalanceFairness.t.sol | 9 +- test/deployment/RewardSystem.t.sol | 1 - 12 files changed, 97 insertions(+), 110 deletions(-) diff --git a/foundry.toml b/foundry.toml index f26bc398..7ae8cb33 100644 --- a/foundry.toml +++ b/foundry.toml @@ -46,13 +46,11 @@ remappings = [ fuzz.gas_report_samples = 64 # gas report doesn't need so many test cases # gas_reports_ignore takes contract names, not paths — use gas_reports whitelist instead gas_reports = [ - "HarborYield_v1", - "AutoCompounder_v1", + "Config_v2", "Genesis_v1", "Minter_v3", "ReservePool_v1", - "StabilityPoolManager_v1", - "StabilityPool_v2", + "StabilityPoolManager_v2", "StabilityPool_v3", "TokenDistributor_v1", "StringPacking_v1", diff --git a/regression/coverage.txt b/regression/coverage.txt index b9b81f07..241c6b58 100644 --- a/regression/coverage.txt +++ b/regression/coverage.txt @@ -44,7 +44,8 @@ | src/minter/Minter_v2.sol | X 0% (0/601) | X 0% (0/649) | X 0% (0/102) | X 0% (0/68) | | src/minter/Minter_v3.sol | X 99% (609/617) | X 99% (660/667) | X 93% (98/105) | X 99% (70/71) | | src/minter/ReservePool_v1.sol | X 94% (15/16) | ✓ 100% (15/15) | ✓ 100% (3/3) | X 80% (4/5) | -| src/minter/StabilityPoolManager_v1.sol | X 99% (165/166) | X 99% (176/177) | X 95% (20/21) | ✓ 100% (24/24) | +| src/minter/StabilityPoolManager_v1.sol | X 0% (0/166) | X 0% (0/177) | X 0% (0/21) | X 0% (0/24) | +| src/minter/StabilityPoolManager_v2.sol | X 93% (175/189) | X 92% (188/204) | X 71% (20/28) | X 93% (25/27) | | src/minter/StabilityPool_v1.sol | X 0% (0/203) | X 0% (0/223) | X 0% (0/33) | X 0% (0/22) | | src/minter/StabilityPool_v2.sol | X 61% (122/199) | X 58% (127/219) | X 19% (6/31) | X 73% (16/22) | | src/minter/StabilityPool_v3.sol | ✓ 100% (234/234) | ✓ 100% (255/255) | ✓ 100% (33/33) | ✓ 100% (29/29) | @@ -64,4 +65,4 @@ | src/util/ERC20MetadataLib_v1.sol | ✓ 100% (24/24) | ✓ 100% (22/22) | ✓ 100% (4/4) | ✓ 100% (4/4) | | src/util/FmtLib.sol | X 79% (15/19) | X 73% (19/26) | X 50% (2/4) | ✓ 100% (1/1) | | src/util/WordCodec.sol | ✓ 100% (15/15) | ✓ 100% (14/14) | ✓ 100% (0/0) | ✓ 100% (4/4) | -| Total | X 56% (4456/7962) | X 55% (4718/8603) | X 43% (392/905) | X 56% (646/1160) | +| Total | X 55% (4466/8151) | X 54% (4730/8807) | X 42% (392/933) | X 55% (647/1187) | diff --git a/regression/gas.txt b/regression/gas.txt index fa716845..04b60050 100644 --- a/regression/gas.txt +++ b/regression/gas.txt @@ -82,55 +82,31 @@ src/minter/ReservePool_v1.sol:ReservePool_v1 | sweep | 2.642e+03 | | transferOwnership | 1.204e+04 | -src/minter/StabilityPoolManager_v1.sol:StabilityPoolManager_v1 +src/minter/StabilityPoolManager_v2.sol:StabilityPoolManager_v2 | function name | max | |----------------------------|-----------| | feeReceiver | 2.441e+03 | -| harvest | 4.442e+05 | -| harvestBountyRatio | 2.369e+03 | +| harvest | 4.487e+05 | +| harvestBountyRatio | 2.347e+03 | | harvestCutRatio | 2.371e+03 | | harvestable | 3.052e+04 | -| hasStabilityPool | 5.370e+02 | +| hasStabilityPool | 5.810e+02 | | initialize | 7.069e+04 | | owner | 2.423e+03 | -| rebalance | 5.540e+05 | +| rebalance | 5.585e+05 | | rebalanceBountyRatio | 2.354e+03 | -| rebalanceThreshold | 2.347e+03 | -| rebalanceable | 2.926e+04 | +| rebalanceThreshold | 2.370e+03 | +| rebalanceable | 2.933e+04 | | stabilityPools | 9.030e+02 | | supportsInterface | 5.690e+02 | | transferOwnership | 1.204e+04 | | updateFeeReceiver | 2.627e+04 | -| updateHarvestBountyRatio | 2.568e+04 | -| updateHarvestCutRatio | 2.572e+04 | -| updateRebalanceBountyRatio | 2.565e+04 | +| updateHarvestBountyRatio | 2.788e+04 | +| updateHarvestCutRatio | 2.791e+04 | +| updateRebalanceBountyRatio | 2.567e+04 | | updateRebalanceThreshold | 2.571e+04 | | upgradeToAndCall | 1.087e+04 | -src/minter/StabilityPool_v2.sol:StabilityPool_v2 -| function name | max | -|-----------------------|-----------| -| ASSET_TOKEN | 3.270e+02 | -| REBALANCER_ROLE | 2.620e+02 | -| REWARD_DEPOSITOR_ROLE | 2.840e+02 | -| REWARD_MANAGER_ROLE | 3.270e+02 | -| assetBalanceOf | 8.049e+03 | -| claim | 1.436e+05 | -| claimable | 2.336e+04 | -| claimed | 7.479e+03 | -| deposit | 2.800e+05 | -| depositReward | 6.533e+04 | -| getWithdrawalRequest | 2.745e+03 | -| grantRoles | 2.636e+04 | -| initialize | 2.041e+05 | -| notifyLiquidation | 1.370e+05 | -| registerRewardToken | 7.292e+04 | -| requestWithdrawal | 2.504e+04 | -| sweep | 4.020e+04 | -| totalAssetSupply | 2.489e+03 | -| transferOwnership | 1.207e+04 | -| upgradeToAndCall | 1.094e+04 | - src/minter/StabilityPool_v3.sol:StabilityPool_v3 | function name | max | |------------------------|-----------| diff --git a/regression/sizes.txt b/regression/sizes.txt index b6e164c1..38d79c63 100644 --- a/regression/sizes.txt +++ b/regression/sizes.txt @@ -4,8 +4,8 @@ | ConfigMarket_BTC_fxUSD_mainnet | 7,097 | 17,479 | 7,125 | 1,490,650 | 149.06 | | ConfigMarket_BTC_stETH_mainnet | 7,123 | 17,453 | 7,151 | 1,496,110 | 149.61 | | ConfigMarket_ETH_fxUSD_mainnet | 7,097 | 17,479 | 7,125 | 1,490,650 | 149.06 | -| ConfigMarket_EUR_fxUSD_mainnet | 7,085 | 17,491 | 7,113 | 1,488,130 | 148.81 | -| ConfigMarket_EUR_stETH_mainnet | 7,111 | 17,465 | 7,139 | 1,493,590 | 149.36 | +| ConfigMarket_EUR_fxUSD_mainnet | 7,099 | 17,477 | 7,127 | 1,491,070 | 149.11 | +| ConfigMarket_EUR_stETH_mainnet | 7,125 | 17,451 | 7,153 | 1,496,530 | 149.65 | | ConfigMarket_GOLD_fxUSD_mainnet | 7,101 | 17,475 | 7,129 | 1,491,490 | 149.15 | | ConfigMarket_GOLD_stETH_mainnet | 7,127 | 17,449 | 7,155 | 1,496,950 | 149.69 | | ConfigMarket_MCAP_fxUSD_mainnet | 7,103 | 17,473 | 7,131 | 1,491,910 | 149.19 | @@ -45,6 +45,7 @@ | PriceOracle_v1 | 1,958 | 22,618 | 2,010 | 411,700 | 41.17 | | ReservePool_v1 | 4,760 | 19,816 | 5,009 | 1,002,090 | 100.21 | | StabilityPoolManager_v1 | 11,209 | 13,367 | 13,057 | 2,372,370 | 237.24 | +| StabilityPoolManager_v2 | 12,234 | 12,342 | 14,110 | 2,587,900 | 258.79 | | StabilityPool_v1 | 21,092 | 3,484 | 23,085 | 4,449,250 | 444.93 | | StabilityPool_v2 | 20,711 | 3,865 | 22,579 | 4,367,990 | 436.80 | | StabilityPool_v3 | 23,412 | 1,164 | 25,791 | 4,940,310 | 494.03 | diff --git a/script/config/stabilitypool/ConfigStabilityPoolManager.sol b/script/config/stabilitypool/ConfigStabilityPoolManager.sol index c1ffa07b..9caaa02b 100644 --- a/script/config/stabilitypool/ConfigStabilityPoolManager.sol +++ b/script/config/stabilitypool/ConfigStabilityPoolManager.sol @@ -12,6 +12,6 @@ abstract contract ConfigStabilityPoolManager { } function harvestCutRatio() public pure virtual returns (uint256) { - return 1.00e18; + return 99e16; } } diff --git a/src/interfaces/IStabilityPoolManager_v2.sol b/src/interfaces/IStabilityPoolManager_v2.sol index e9a84b1e..d02efb0c 100644 --- a/src/interfaces/IStabilityPoolManager_v2.sol +++ b/src/interfaces/IStabilityPoolManager_v2.sol @@ -3,12 +3,24 @@ pragma solidity >=0.8.28 <0.9.0; import {IStabilityPoolManager} from "@harbor/interfaces/IStabilityPoolManager.sol"; +// solhint-disable-next-line contract-name-capwords interface IStabilityPoolManager_v2 is IStabilityPoolManager { + /*////////////////////////////////////////////////////////////// + ERRORS + //////////////////////////////////////////////////////////////*/ + error InvalidHarvestRatioSum(uint256 bountyRatio, uint256 cutRatio); + /// @notice Emitted when an auto-compounder is registered or unregistered for a stability pool. /// @param sp The stability pool address. /// @param ac The auto-compounder address (address(0) = unregistered). event AutoCompounderSet(address indexed sp, address indexed ac); + /*////////////////////////////////////////////////////////////// + PUBLIC READ FUNCTIONS + //////////////////////////////////////////////////////////////*/ + // solhint-disable-next-line func-name-mixedcase + function MINTER() external view returns (address); + /// @notice Register or unregister an auto-compounder for a stability pool. /// @dev Only one auto-compounder per stability pool. Pass address(0) to unregister. /// The stability pool must be one of the two registered pools. diff --git a/src/minter/StabilityPoolManager_v2.sol b/src/minter/StabilityPoolManager_v2.sol index 6ef2b083..6a140c31 100644 --- a/src/minter/StabilityPoolManager_v2.sol +++ b/src/minter/StabilityPoolManager_v2.sol @@ -28,7 +28,7 @@ import {IAutoCompounder} from "@harbor/interfaces/IAutoCompounder.sol"; /// compound() is triggered on any registered AutoCompounder for each stability pool. /// @dev Uses UUPS proxy, erc7201 storage (same slot as v1 — struct extended safely). /// @custom:oz-upgrades -// solhint-disable-next-line contract-name-camelcase +// solhint-disable-next-line contract-name-capwords contract StabilityPoolManager_v2 is Initializable, UUPSUpgradeable, @@ -257,6 +257,9 @@ contract StabilityPoolManager_v2 is revert InvalidHarvestBountyRatio(harvestRatio_); } StabilityPoolManagerStorage storage $ = _getStabilityPoolManagerStorage(); + if (harvestRatio_ + $.harvestCutRatio > 1 ether) { + revert InvalidHarvestRatioSum(harvestRatio_, $.harvestCutRatio); + } $.harvestBountyRatio = harvestRatio_; emit HarvestBountyUpdated(harvestRatio_); @@ -268,6 +271,9 @@ contract StabilityPoolManager_v2 is revert InvalidHarvestBountyRatio(harvestCutRatio_); } StabilityPoolManagerStorage storage $ = _getStabilityPoolManagerStorage(); + if ($.harvestBountyRatio + harvestCutRatio_ > 1 ether) { + revert InvalidHarvestRatioSum($.harvestBountyRatio, harvestCutRatio_); + } $.harvestCutRatio = harvestCutRatio_; emit HarvestCutUpdated(harvestCutRatio_); diff --git a/test/GraphsLiquidate.t.sol b/test/GraphsLiquidate.t.sol index fcd26eb3..662e25d4 100644 --- a/test/GraphsLiquidate.t.sol +++ b/test/GraphsLiquidate.t.sol @@ -14,7 +14,7 @@ import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; import {IStabilityPoolManager} from "@harbor/interfaces/IStabilityPoolManager.sol"; import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; -import {StabilityPoolManager_v1} from "@harbor/minter/StabilityPoolManager_v1.sol"; +import {StabilityPoolManager_v2} from "@harbor/minter/StabilityPoolManager_v2.sol"; import "@harbor-test/Useful.sol"; import {TestCollateralRatioRangeSetUp} from "@harbor-test/CollateralRatio.t.sol"; @@ -50,9 +50,9 @@ contract TestGraphsLiquidatePartial is TestGraphs, TestCollateralRatioRangeSetUp // set up the stability pool managers stabilityPoolManagerCollateral = UnsafeUpgrades.deployUUPSProxy( address( - new StabilityPoolManager_v1(minter, treasury, stabilityPoolCollateral, stabilityPoolLeveragedEmpty) + new StabilityPoolManager_v2(minter, treasury, stabilityPoolCollateral, stabilityPoolLeveragedEmpty) ), - abi.encodeCall(StabilityPoolManager_v1.initialize, owner) + abi.encodeCall(StabilityPoolManager_v2.initialize, owner) ); IStabilityPoolManager(stabilityPoolManagerCollateral).updateRebalanceThreshold(1.3 ether); vm.startPrank(owner); @@ -63,9 +63,9 @@ contract TestGraphsLiquidatePartial is TestGraphs, TestCollateralRatioRangeSetUp stabilityPoolManagerLeveraged = UnsafeUpgrades.deployUUPSProxy( address( - new StabilityPoolManager_v1(minter, treasury, stabilityPoolCollateralEmpty, stabilityPoolLeveraged) + new StabilityPoolManager_v2(minter, treasury, stabilityPoolCollateralEmpty, stabilityPoolLeveraged) ), - abi.encodeCall(StabilityPoolManager_v1.initialize, owner) + abi.encodeCall(StabilityPoolManager_v2.initialize, owner) ); IStabilityPoolManager(stabilityPoolManagerLeveraged).updateRebalanceThreshold(1.3 ether); vm.startPrank(owner); @@ -75,8 +75,8 @@ contract TestGraphsLiquidatePartial is TestGraphs, TestCollateralRatioRangeSetUp vm.stopPrank(); stabilityPoolManagerBoth = UnsafeUpgrades.deployUUPSProxy( - address(new StabilityPoolManager_v1(minter, treasury, stabilityPoolCollateral, stabilityPoolLeveraged)), - abi.encodeCall(StabilityPoolManager_v1.initialize, owner) + address(new StabilityPoolManager_v2(minter, treasury, stabilityPoolCollateral, stabilityPoolLeveraged)), + abi.encodeCall(StabilityPoolManager_v2.initialize, owner) ); IStabilityPoolManager(stabilityPoolManagerBoth).updateRebalanceThreshold(1.3 ether); vm.startPrank(owner); @@ -223,8 +223,8 @@ contract TestGraphsLiquidate is TestGraphs, TestCollateralRatioRangeSetUp { // set up the stability pool managers stabilityPoolManager = UnsafeUpgrades.deployUUPSProxy( - address(new StabilityPoolManager_v1(minter, treasury, stabilityPoolCollateral, stabilityPoolLeveraged)), - abi.encodeCall(StabilityPoolManager_v1.initialize, owner) + address(new StabilityPoolManager_v2(minter, treasury, stabilityPoolCollateral, stabilityPoolLeveraged)), + abi.encodeCall(StabilityPoolManager_v2.initialize, owner) ); IStabilityPoolManager(stabilityPoolManager).updateRebalanceThreshold(1.3 ether); vm.startPrank(owner); diff --git a/test/Rebalance.t.sol b/test/Rebalance.t.sol index a62e3b44..64e7933a 100644 --- a/test/Rebalance.t.sol +++ b/test/Rebalance.t.sol @@ -18,7 +18,7 @@ import {MockWrappedPriceOracle} from "@harbor-test/mocks/MockWrappedPriceOracle. import "@harbor-test/Useful.sol"; import {TestStabilityPool2SetUp} from "@harbor-test/TestStabilityPool2SetUp.sol"; import {IStabilityPoolManager} from "@harbor/interfaces/IStabilityPoolManager.sol"; -import {StabilityPoolManager_v1} from "@harbor/minter/StabilityPoolManager_v1.sol"; +import {StabilityPoolManager_v2} from "@harbor/minter/StabilityPoolManager_v2.sol"; contract TestLiquidate is TestStabilityPool2SetUp { address stabilityPoolManagerCollateral; @@ -60,18 +60,18 @@ contract TestLiquidate is TestStabilityPool2SetUp { stabilityPoolManagerCollateral = UnsafeUpgrades.deployUUPSProxy( address( - new StabilityPoolManager_v1(minter, treasury, stabilityPoolCollateral, stabilityPoolLeveragedEmpty) + new StabilityPoolManager_v2(minter, treasury, stabilityPoolCollateral, stabilityPoolLeveragedEmpty) ), - abi.encodeCall(StabilityPoolManager_v1.initialize, owner) + abi.encodeCall(StabilityPoolManager_v2.initialize, owner) ); IStabilityPoolManager(stabilityPoolManagerCollateral).updateRebalanceThreshold(1.3 ether); IBaoOwnable(stabilityPoolManagerCollateral).transferOwnership(owner); stabilityPoolManagerLeveraged = UnsafeUpgrades.deployUUPSProxy( address( - new StabilityPoolManager_v1(minter, treasury, stabilityPoolCollateralEmpty, stabilityPoolLeveraged) + new StabilityPoolManager_v2(minter, treasury, stabilityPoolCollateralEmpty, stabilityPoolLeveraged) ), - abi.encodeCall(StabilityPoolManager_v1.initialize, owner) + abi.encodeCall(StabilityPoolManager_v2.initialize, owner) ); IStabilityPoolManager(stabilityPoolManagerLeveraged).updateRebalanceThreshold(1.3 ether); IBaoOwnable(stabilityPoolManagerLeveraged).transferOwnership(owner); diff --git a/test/StabilityPoolManager_v1.t.sol b/test/StabilityPoolManager_v1.t.sol index 471c2f65..1091f161 100644 --- a/test/StabilityPoolManager_v1.t.sol +++ b/test/StabilityPoolManager_v1.t.sol @@ -19,8 +19,9 @@ import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDist import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; import {IStabilityPoolManager} from "@harbor/interfaces/IStabilityPoolManager.sol"; +import {IStabilityPoolManager_v2} from "@harbor/interfaces/IStabilityPoolManager_v2.sol"; -import {StabilityPoolManager_v1} from "@harbor/minter/StabilityPoolManager_v1.sol"; +import {StabilityPoolManager_v2} from "@harbor/minter/StabilityPoolManager_v2.sol"; import {IWrappedPriceOracle} from "@harbor/interfaces/IWrappedPriceOracle.sol"; import {MockWrappedPriceOracle} from "@harbor-test/mocks/MockWrappedPriceOracle.sol"; @@ -47,8 +48,8 @@ contract TestStabilityPoolManagerSetUp is TestStabilityPool2SetUp { // IERC20(peggedToken).approve(stabilityPoolLeveraged, type(uint256).max); stabilityPoolManager = UnsafeUpgrades.deployUUPSProxy( - address(new StabilityPoolManager_v1(minter, treasury, stabilityPoolCollateral, stabilityPoolLeveraged)), - abi.encodeCall(StabilityPoolManager_v1.initialize, owner) + address(new StabilityPoolManager_v2(minter, treasury, stabilityPoolCollateral, stabilityPoolLeveraged)), + abi.encodeCall(StabilityPoolManager_v2.initialize, owner) ); IBaoOwnable(stabilityPoolManager).transferOwnership(owner); @@ -74,14 +75,14 @@ contract TestStabilityPoolManagerInit is TestStabilityPoolManagerSetUp { function setUp_impl() internal virtual { stabilityPoolManagerImpl = address( - new StabilityPoolManager_v1(minter, treasury, stabilityPoolCollateral, stabilityPoolLeveraged) + new StabilityPoolManager_v2(minter, treasury, stabilityPoolCollateral, stabilityPoolLeveraged) ); } function setUp_proxy() internal virtual { stabilityPoolManager = UnsafeUpgrades.deployUUPSProxy( stabilityPoolManagerImpl, - abi.encodeCall(StabilityPoolManager_v1.initialize, (owner)) + abi.encodeCall(StabilityPoolManager_v2.initialize, (owner)) ); } @@ -167,9 +168,9 @@ contract TestStabilityPoolManagerBasic is TestStabilityPoolManagerSetUp { function test_setBounty() public { // Test setting bounty vm.expectRevert(IBaoOwnable.Unauthorized.selector); - StabilityPoolManager_v1(stabilityPoolManager).updateRebalanceBountyRatio(0.02 ether); + IStabilityPoolManager(stabilityPoolManager).updateRebalanceBountyRatio(0.02 ether); vm.prank(owner); - StabilityPoolManager_v1(stabilityPoolManager).updateRebalanceBountyRatio(0.02 ether); + IStabilityPoolManager(stabilityPoolManager).updateRebalanceBountyRatio(0.02 ether); assertEq( IStabilityPoolManager(stabilityPoolManager).rebalanceBountyRatio(), 0.02 ether, @@ -177,9 +178,9 @@ contract TestStabilityPoolManagerBasic is TestStabilityPoolManagerSetUp { ); vm.expectRevert(IBaoOwnable.Unauthorized.selector); - StabilityPoolManager_v1(stabilityPoolManager).updateHarvestBountyRatio(0.01 ether); + IStabilityPoolManager(stabilityPoolManager).updateHarvestBountyRatio(0.01 ether); vm.prank(owner); - StabilityPoolManager_v1(stabilityPoolManager).updateHarvestBountyRatio(0.01 ether); + IStabilityPoolManager(stabilityPoolManager).updateHarvestBountyRatio(0.01 ether); assertEq( IStabilityPoolManager(stabilityPoolManager).harvestBountyRatio(), 0.01 ether, @@ -192,7 +193,7 @@ contract TestStabilityPoolManagerBasic is TestStabilityPoolManagerSetUp { uint256 newRatio = 140 ether / 100; // 140% vm.prank(owner); - StabilityPoolManager_v1(stabilityPoolManager).updateRebalanceThreshold(newRatio); + IStabilityPoolManager(stabilityPoolManager).updateRebalanceThreshold(newRatio); assertEq( IStabilityPoolManager(stabilityPoolManager).rebalanceThreshold(), @@ -202,7 +203,7 @@ contract TestStabilityPoolManagerBasic is TestStabilityPoolManagerSetUp { // Test unauthorized access vm.expectRevert(IBaoOwnable.Unauthorized.selector); - StabilityPoolManager_v1(stabilityPoolManager).updateRebalanceThreshold(135 ether / 100); + IStabilityPoolManager(stabilityPoolManager).updateRebalanceThreshold(135 ether / 100); } function test_rebalanceable() public { @@ -221,7 +222,7 @@ contract TestStabilityPoolManagerBasic is TestStabilityPoolManagerSetUp { // Update rebalance ratio and test again vm.prank(owner); - StabilityPoolManager_v1(stabilityPoolManager).updateRebalanceThreshold(currentCR + 2); + IStabilityPoolManager(stabilityPoolManager).updateRebalanceThreshold(currentCR + 2); assertTrue(IStabilityPoolManager(stabilityPoolManager).rebalanceable(), "Should be rebalanceable again"); } @@ -444,7 +445,7 @@ contract TestStabilityPoolManagerRebalance is TestStabilityPoolManagerSetUp { // function test_rebalanceWithZeroCollateral_() public { // // Setup for rebalance // vm.prank(owner); - // StabilityPoolManager_v1(stabilityPoolManager).updateRebalanceThreshold(1.5 ether); + // IStabilityPoolManager(stabilityPoolManager).updateRebalanceThreshold(1.5 ether); // // Make pools have some balances // deal(peggedToken, stabilityPoolCollateral, 5 ether); @@ -493,7 +494,7 @@ contract TestStabilityPoolManagerRebalance is TestStabilityPoolManagerSetUp { // function test_rebalanceWithZeroLeveraged_() public { // // Setup for rebalance // vm.prank(owner); - // StabilityPoolManager_v1(stabilityPoolManager).updateRebalanceThreshold(1.5 ether); + // IStabilityPoolManager(stabilityPoolManager).updateRebalanceThreshold(1.5 ether); // // Make pools have some balances // deal(peggedToken, stabilityPoolCollateral, 5 ether); @@ -535,7 +536,7 @@ contract TestStabilityPoolManagerRebalance is TestStabilityPoolManagerSetUp { // } } -contract MockStabilityPoolManagerUpgraded is StabilityPoolManager_v1 { +contract MockStabilityPoolManagerUpgraded is StabilityPoolManager_v2 { bool public upgradeSuccessful; // Keep the same constructor signature @@ -544,7 +545,7 @@ contract MockStabilityPoolManagerUpgraded is StabilityPoolManager_v1 { address treasury_, address stabilityPoolCollateral, address stabilityPoolLeveraged - ) StabilityPoolManager_v1(minter_, treasury_, stabilityPoolCollateral, stabilityPoolLeveraged) {} + ) StabilityPoolManager_v2(minter_, treasury_, stabilityPoolCollateral, stabilityPoolLeveraged) {} // Add a new function that would only be available in the upgraded version function newFunctionOnlyInUpgrade() external pure returns (bool) { @@ -732,7 +733,7 @@ contract TestStabilityPoolManagerHarvest is TestStabilityPoolManagerSetUp { uint256 pool2Before = IERC20(wrappedCollateralToken).balanceOf(stabilityPoolLeveraged); vm.prank(owner); - StabilityPoolManager_v1(stabilityPoolManager).updateHarvestBountyRatio(0.05 ether); + IStabilityPoolManager(stabilityPoolManager).updateHarvestBountyRatio(0.05 ether); // Execute harvest vm.prank(harvester); @@ -801,7 +802,7 @@ contract TestStabilityPoolManagerHarvest is TestStabilityPoolManagerSetUp { function test_harvestFailures() public { vm.prank(owner); - StabilityPoolManager_v1(stabilityPoolManager).updateHarvestBountyRatio(0.05 ether); + IStabilityPoolManager(stabilityPoolManager).updateHarvestBountyRatio(0.05 ether); // Test with minimum bounty too high vm.prank(harvester); @@ -901,14 +902,14 @@ contract TestStabilityPoolManagerHarvest is TestStabilityPoolManagerSetUp { function test_harvestWithCutRatioAndFeeReceiver_() public { // Set up cut ratio and fee receiver vm.prank(owner); - StabilityPoolManager_v1(stabilityPoolManager).updateHarvestCutRatio(0.2 ether); // 20% cut + IStabilityPoolManager(stabilityPoolManager).updateHarvestCutRatio(0.2 ether); // 20% cut vm.prank(owner); - StabilityPoolManager_v1(stabilityPoolManager).updateFeeReceiver(feeReceiver); + IStabilityPoolManager(stabilityPoolManager).updateFeeReceiver(feeReceiver); // Set up bounty ratio vm.prank(owner); - StabilityPoolManager_v1(stabilityPoolManager).updateHarvestBountyRatio(0.1 ether); // 10% bounty + IStabilityPoolManager(stabilityPoolManager).updateHarvestBountyRatio(0.1 ether); // 10% bounty // Set up pools with balances deal(peggedToken, stabilityPoolCollateral, 7 ether); @@ -973,7 +974,7 @@ contract TestStabilityPoolManagerHarvest is TestStabilityPoolManagerSetUp { // Set up bounty ratio vm.prank(owner); - StabilityPoolManager_v1(stabilityPoolManager).updateHarvestBountyRatio(0.1 ether); // 10% bounty + IStabilityPoolManager(stabilityPoolManager).updateHarvestBountyRatio(0.1 ether); // 10% bounty // Record initial balances uint256 harvesterBefore = IERC20(wrappedCollateralToken).balanceOf(harvester); @@ -1011,7 +1012,7 @@ contract TestStabilityPoolManagerHarvest is TestStabilityPoolManagerSetUp { function test_harvestWithCutButNoFeeReceiver_() public { // Set up cut ratio but keep fee receiver as address(0) vm.prank(owner); - StabilityPoolManager_v1(stabilityPoolManager).updateHarvestCutRatio(0.2 ether); // 20% cut + IStabilityPoolManager(stabilityPoolManager).updateHarvestCutRatio(0.2 ether); // 20% cut // Verify fee receiver is zero address assertEq( @@ -1113,7 +1114,7 @@ contract TestStabilityPoolManagerCutAndFeeReceiver is TestStabilityPoolManagerSe // Set cut ratio to 10% vm.prank(owner); - StabilityPoolManager_v1(stabilityPoolManager).updateHarvestCutRatio(0.1 ether); + IStabilityPoolManager(stabilityPoolManager).updateHarvestCutRatio(0.1 ether); // Verify it was set correctly assertEq( @@ -1125,12 +1126,12 @@ contract TestStabilityPoolManagerCutAndFeeReceiver is TestStabilityPoolManagerSe // Try to set it over 100% which should fail vm.prank(owner); vm.expectRevert(abi.encodeWithSelector(IStabilityPoolManager.InvalidHarvestBountyRatio.selector, 1.1 ether)); - StabilityPoolManager_v1(stabilityPoolManager).updateHarvestCutRatio(1.1 ether); + IStabilityPoolManager(stabilityPoolManager).updateHarvestCutRatio(1.1 ether); // Try with non-owner which should fail vm.prank(address(0xBEEF)); vm.expectRevert(); - StabilityPoolManager_v1(stabilityPoolManager).updateHarvestCutRatio(0.2 ether); + IStabilityPoolManager(stabilityPoolManager).updateHarvestCutRatio(0.2 ether); } function test_updateFeeReceiver_() public { @@ -1145,7 +1146,7 @@ contract TestStabilityPoolManagerCutAndFeeReceiver is TestStabilityPoolManagerSe vm.prank(owner); vm.expectEmit(true, true, false, false); emit IStabilityPoolManager.UpdateFeeReceiver(address(0), feeReceiver); - StabilityPoolManager_v1(stabilityPoolManager).updateFeeReceiver(feeReceiver); + IStabilityPoolManager(stabilityPoolManager).updateFeeReceiver(feeReceiver); // Verify it was set correctly assertEq( @@ -1159,12 +1160,12 @@ contract TestStabilityPoolManagerCutAndFeeReceiver is TestStabilityPoolManagerSe vm.prank(owner); vm.expectEmit(true, true, false, false); emit IStabilityPoolManager.UpdateFeeReceiver(feeReceiver, newFeeReceiver); - StabilityPoolManager_v1(stabilityPoolManager).updateFeeReceiver(newFeeReceiver); + IStabilityPoolManager(stabilityPoolManager).updateFeeReceiver(newFeeReceiver); // Try with non-owner which should fail vm.prank(address(0xBEEF)); vm.expectRevert(); - StabilityPoolManager_v1(stabilityPoolManager).updateFeeReceiver(address(0xDEAD)); + IStabilityPoolManager(stabilityPoolManager).updateFeeReceiver(address(0xDEAD)); } function test_harvestWithCutAndFeeReceiver_(uint256 bounty, uint256 cut) public { @@ -1172,19 +1173,13 @@ contract TestStabilityPoolManagerCutAndFeeReceiver is TestStabilityPoolManagerSe cut = bound(cut, 0, 1 ether - bounty); // Bound result 374701533993322492 // Bound result 347222887455236596 - // Set up fee receiver and harvest cut ratio - vm.prank(owner); - StabilityPoolManager_v1(stabilityPoolManager).updateFeeReceiver(feeReceiver); - - vm.prank(owner); - StabilityPoolManager_v1(stabilityPoolManager).updateHarvestCutRatio(0.1 ether); // 10% cut - - // Set bounty ratio for testing - vm.prank(owner); - StabilityPoolManager_v1(stabilityPoolManager).updateHarvestBountyRatio(bounty); // 10% bounty - - vm.prank(owner); - StabilityPoolManager_v1(stabilityPoolManager).updateHarvestCutRatio(cut); // 20% bounty + // Set cut first (bounty=0 so any cut ≤ 100% is valid), then bounty. + // Fuzz bounds guarantee bounty + cut ≤ 100%, so the second update is always valid. + vm.startPrank(owner); + IStabilityPoolManager(stabilityPoolManager).updateFeeReceiver(feeReceiver); + IStabilityPoolManager(stabilityPoolManager).updateHarvestCutRatio(cut); + IStabilityPoolManager(stabilityPoolManager).updateHarvestBountyRatio(bounty); + vm.stopPrank(); MockWrappedPriceOracle(priceOracle).setLatestAnswer( startPrice, @@ -1236,10 +1231,10 @@ contract TestStabilityPoolManagerCutAndFeeReceiver is TestStabilityPoolManagerSe function test_harvestWithoutSufficientTokens_() public { // Set up fee receiver and harvest cut ratio vm.prank(owner); - StabilityPoolManager_v1(stabilityPoolManager).updateFeeReceiver(feeReceiver); + IStabilityPoolManager(stabilityPoolManager).updateFeeReceiver(feeReceiver); vm.prank(owner); - StabilityPoolManager_v1(stabilityPoolManager).updateHarvestCutRatio(0.1 ether); // 10% cut + IStabilityPoolManager(stabilityPoolManager).updateHarvestCutRatio(0.1 ether); // 10% cut // Set up harvester role uint256 harvesterRole = IMinter(minter).HARVESTER_ROLE(); @@ -1324,14 +1319,14 @@ contract TestStabilityPoolManagerUpgradeable is TestStabilityPoolManagerSetUp { // Check the existing functionality still works assertEq( - StabilityPoolManager_v1(stabilityPoolManager).MINTER(), + IStabilityPoolManager_v2(stabilityPoolManager).MINTER(), minter, "Immutable variables should remain after upgrade" ); // Check that the storage values are preserved vm.prank(owner); - StabilityPoolManager_v1(stabilityPoolManager).updateHarvestBountyRatio(0.1 ether); + IStabilityPoolManager(stabilityPoolManager).updateHarvestBountyRatio(0.1 ether); assertEq( IStabilityPoolManager(stabilityPoolManager).harvestBountyRatio(), 0.1 ether, diff --git a/test/deployment/RebalanceFairness.t.sol b/test/deployment/RebalanceFairness.t.sol index 0de82fc3..c4121af3 100644 --- a/test/deployment/RebalanceFairness.t.sol +++ b/test/deployment/RebalanceFairness.t.sol @@ -14,7 +14,6 @@ import {IStabilityPoolManager} from "@harbor/interfaces/IStabilityPoolManager.so import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; -import {StabilityPoolManager_v1} from "@harbor/minter/StabilityPoolManager_v1.sol"; import {MockWrappedPriceOracle} from "@harbor-test/mocks/MockWrappedPriceOracle.sol"; import {console2} from "forge-std/console2.sol"; @@ -98,10 +97,10 @@ contract RebalanceFairnessSetUp is BaoTest, Deploy_ETH_Minter { IMinter(minter).updatePriceOracle(address(mockOracle)); // Override harvest config: set cut to 0 so harvest goes to pools, not treasury - vm.startPrank(StabilityPoolManager_v1(stabilityPoolManager).owner()); - StabilityPoolManager_v1(stabilityPoolManager).updateHarvestCutRatio(0); - StabilityPoolManager_v1(stabilityPoolManager).updateHarvestBountyRatio(0); - StabilityPoolManager_v1(stabilityPoolManager).updateRebalanceBountyRatio(0); + vm.startPrank(IBaoOwnable(stabilityPoolManager).owner()); + IStabilityPoolManager(stabilityPoolManager).updateHarvestCutRatio(0); + IStabilityPoolManager(stabilityPoolManager).updateHarvestBountyRatio(0); + IStabilityPoolManager(stabilityPoolManager).updateRebalanceBountyRatio(0); vm.stopPrank(); // Create actors diff --git a/test/deployment/RewardSystem.t.sol b/test/deployment/RewardSystem.t.sol index afe7911b..b365a5dd 100644 --- a/test/deployment/RewardSystem.t.sol +++ b/test/deployment/RewardSystem.t.sol @@ -14,7 +14,6 @@ import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; import {MockWrappedPriceOracle} from "@harbor-test/mocks/MockWrappedPriceOracle.sol"; -import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; /// @title Reward system tests — accumulator, distributor — using deployment framework contract RewardSystemSetUp is BaoTest, Deploy_ETH_Minter { From 39f767e0522ec8e7986af7e1f55a5ec59dbc5312 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Sat, 25 Apr 2026 09:58:54 +0100 Subject: [PATCH 060/232] support autocompounder integration moved SafeBatch into bao-base Tidied up some and some renaming --- lib/bao-base | 2 +- script/Deploy_Minter_v2_mainnet.s.sol | 5 +- script/Deploy_StabilityPool_v3_mainnet.s.sol | 5 +- .../Grant_Minter_ZeroFeeRoles_mainnet.s.sol | 5 +- script/Pause_SPL_ETH_fxUSD.s.sol | 5 +- script/Remediate_Accumulators.s.sol | 5 +- script/Remediate_SPL_ETH_fxUSD.s.sol | 5 +- script/UpdateVolatility_OGPlus.s.sol | 5 +- script/UpdateVolatility_test3_SILVER.s.sol | 5 +- .../ConfigStabilityPoolManagerCommon.sol | 4 +- script/safe/SafeBatch.s.sol | 198 ------------------ script/src/Deploy_BTC_Minter.sol | 4 +- script/src/Deploy_ETH_Minter.sol | 4 +- script/src/Deploy_EUR_Minter.sol | 4 +- script/src/Deploy_GOLD_Minter.sol | 4 +- script/src/Deploy_MCAP_Minter.sol | 4 +- script/src/Deploy_SILVER_Minter.sol | 4 +- script/src/HarborDeployer.sol | 58 +++++ script/src/HarborFactoryDeployer.sol | 74 ------- ...oyMintersShared.sol => MinterDeployer.sol} | 4 +- script/src/contracts/Genesis.sol | 4 +- script/src/contracts/LeveragedToken.sol | 4 +- script/src/contracts/Minter.sol | 4 +- script/src/contracts/PeggedToken.sol | 4 +- script/src/contracts/StabilityPool.sol | 4 +- script/src/contracts/StabilityPoolManager.sol | 4 +- .../minter-v2-upgrade/MinterUpgradeTest.t.sol | 4 +- .../minter-v2-upgrade/RebalanceCheck.t.sol | 4 +- script/verify/roles/MainnetRoles.t.sol | 4 +- .../sp-v3-migration/SPv3MigrationTest.t.sol | 4 +- .../spl-remediation/SPLRemediationTest.t.sol | 4 +- .../spl-remediation/V2ReplaySimulation.t.sol | 4 +- test/mocks/MockMinter.sol | 25 +++ 33 files changed, 148 insertions(+), 329 deletions(-) delete mode 100644 script/safe/SafeBatch.s.sol create mode 100644 script/src/HarborDeployer.sol delete mode 100644 script/src/HarborFactoryDeployer.sol rename script/src/{DeployMintersShared.sol => MinterDeployer.sol} (98%) diff --git a/lib/bao-base b/lib/bao-base index 87a485d4..a42a0620 160000 --- a/lib/bao-base +++ b/lib/bao-base @@ -1 +1 @@ -Subproject commit 87a485d4a105002d260168fcdbc78b36d7457b38 +Subproject commit a42a0620d56e55495e29658bd7f6a02d8c6d8bb6 diff --git a/script/Deploy_Minter_v2_mainnet.s.sol b/script/Deploy_Minter_v2_mainnet.s.sol index 5772dcdc..b7f115cd 100644 --- a/script/Deploy_Minter_v2_mainnet.s.sol +++ b/script/Deploy_Minter_v2_mainnet.s.sol @@ -16,7 +16,8 @@ import {Deploy_GOLD_Minter} from "@harbor-script/src/Deploy_GOLD_Minter.sol"; import {Deploy_MCAP_Minter} from "@harbor-script/src/Deploy_MCAP_Minter.sol"; import {Deploy_SILVER_Minter} from "@harbor-script/src/Deploy_SILVER_Minter.sol"; -import {SafeBatch} from "@harbor-script/safe/SafeBatch.s.sol"; +import {Script} from "forge-std/Script.sol"; +import {HarborDeployer} from "@harbor-script/src/HarborDeployer.sol"; import {console2} from "forge-std/console2.sol"; @@ -30,7 +31,7 @@ interface IFullMinterConfig { /// @dev Broadcasts implementation deployments, then queues UUPS upgrade calls as a Safe batch. /// Run via: script/run-script Deploy_Minter_v2_mainnet --salt harbor_v1 --network mainnet --broadcast contract Deploy_Minter_v2_mainnet is - SafeBatch, + Script, Deploy_BTC_Minter, Deploy_ETH_Minter, Deploy_EUR_Minter, diff --git a/script/Deploy_StabilityPool_v3_mainnet.s.sol b/script/Deploy_StabilityPool_v3_mainnet.s.sol index 16849de6..344afd21 100644 --- a/script/Deploy_StabilityPool_v3_mainnet.s.sol +++ b/script/Deploy_StabilityPool_v3_mainnet.s.sol @@ -17,7 +17,8 @@ import {Deploy_GOLD_Minter} from "@harbor-script/src/Deploy_GOLD_Minter.sol"; import {Deploy_MCAP_Minter} from "@harbor-script/src/Deploy_MCAP_Minter.sol"; import {Deploy_SILVER_Minter} from "@harbor-script/src/Deploy_SILVER_Minter.sol"; -import {SafeBatch} from "@harbor-script/safe/SafeBatch.s.sol"; +import {Script} from "forge-std/Script.sol"; +import {HarborDeployer} from "@harbor-script/src/HarborDeployer.sol"; interface IFullMinterConfig { function wrappedCollateralToken() external view returns (address); @@ -29,7 +30,7 @@ interface IFullMinterConfig { /// /// Run via: script/run-script Deploy_StabilityPool_v3_mainnet --salt harbor_v1 --network mainnet --broadcast contract Deploy_StabilityPool_v3_mainnet is - SafeBatch, + Script, Deploy_BTC_Minter, Deploy_ETH_Minter, Deploy_EUR_Minter, diff --git a/script/Grant_Minter_ZeroFeeRoles_mainnet.s.sol b/script/Grant_Minter_ZeroFeeRoles_mainnet.s.sol index 1b9df89e..334ff127 100644 --- a/script/Grant_Minter_ZeroFeeRoles_mainnet.s.sol +++ b/script/Grant_Minter_ZeroFeeRoles_mainnet.s.sol @@ -15,13 +15,14 @@ import {Deploy_GOLD_Minter} from "@harbor-script/src/Deploy_GOLD_Minter.sol"; import {Deploy_MCAP_Minter} from "@harbor-script/src/Deploy_MCAP_Minter.sol"; import {Deploy_SILVER_Minter} from "@harbor-script/src/Deploy_SILVER_Minter.sol"; -import {SafeBatch} from "@harbor-script/safe/SafeBatch.s.sol"; +import {Script} from "forge-std/Script.sol"; +import {HarborDeployer} from "@harbor-script/src/HarborDeployer.sol"; /// @notice Grant ZERO_FEE_ROLE to all StabilityPoolManagers on their minters. /// @dev This role was missing from the initial deployment. Queue as a separate Safe batch. /// Run via: script/run-script Grant_Minter_ZeroFeeRoles_mainnet --salt harbor_v1 --network mainnet --broadcast contract Grant_Minter_ZeroFeeRoles_mainnet is - SafeBatch, + Script, Deploy_BTC_Minter, Deploy_ETH_Minter, Deploy_EUR_Minter, diff --git a/script/Pause_SPL_ETH_fxUSD.s.sol b/script/Pause_SPL_ETH_fxUSD.s.sol index fd7b1303..4d120138 100644 --- a/script/Pause_SPL_ETH_fxUSD.s.sol +++ b/script/Pause_SPL_ETH_fxUSD.s.sol @@ -2,12 +2,13 @@ pragma solidity >=0.8.28 <0.9.0; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; -import {SafeBatch} from "@harbor-script/safe/SafeBatch.s.sol"; +import {Script} from "forge-std/Script.sol"; +import {HarborDeployer} from "@harbor-script/src/HarborDeployer.sol"; /// @notice Queue a Safe transaction to pause the ETH::fxUSD stabilityPoolLeveraged /// by upgrading its proxy to BaoPauser_v1. /// @dev Run via: script/run-script Pause_SPL_ETH_fxUSD --salt harbor_v1 --network mainnet --local -contract Pause_SPL_ETH_fxUSD is SafeBatch { +contract Pause_SPL_ETH_fxUSD is Script, HarborDeployer { address constant BAO_PAUSER = 0xd8785d5C51aaDEb3AD1D015Cd67C8A34dBf58f61; function build() internal override { diff --git a/script/Remediate_Accumulators.s.sol b/script/Remediate_Accumulators.s.sol index 01d1af62..d62a3f1a 100644 --- a/script/Remediate_Accumulators.s.sol +++ b/script/Remediate_Accumulators.s.sol @@ -16,7 +16,8 @@ import {Deploy_GOLD_Minter} from "@harbor-script/src/Deploy_GOLD_Minter.sol"; import {Deploy_MCAP_Minter} from "@harbor-script/src/Deploy_MCAP_Minter.sol"; import {Deploy_SILVER_Minter} from "@harbor-script/src/Deploy_SILVER_Minter.sol"; -import {SafeBatch} from "@harbor-script/safe/SafeBatch.s.sol"; +import {Script} from "forge-std/Script.sol"; +import {HarborDeployer} from "@harbor-script/src/HarborDeployer.sol"; /// @notice Force-migrate accumulator storage from V1 (uint192) to V2 (uint256) format /// for all stability pools across all markets. @@ -33,7 +34,7 @@ import {SafeBatch} from "@harbor-script/safe/SafeBatch.s.sol"; /// Run via: /// script/run-script Remediate_Accumulators --salt harbor_v1 --network mainnet --broadcast --local contract Remediate_Accumulators is - SafeBatch, + Script, Deploy_BTC_Minter, Deploy_ETH_Minter, Deploy_EUR_Minter, diff --git a/script/Remediate_SPL_ETH_fxUSD.s.sol b/script/Remediate_SPL_ETH_fxUSD.s.sol index 27b0c977..e89a79a1 100644 --- a/script/Remediate_SPL_ETH_fxUSD.s.sol +++ b/script/Remediate_SPL_ETH_fxUSD.s.sol @@ -4,7 +4,8 @@ pragma solidity >=0.8.28 <0.9.0; import {LibString} from "@solady/utils/LibString.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import {PostRebalanceRemediationForStabilityPool_v2} from "@harbor-script/verify/spl-remediation/PostRebalanceRemediationForStabilityPool_v2.sol"; -import {SafeBatch} from "@harbor-script/safe/SafeBatch.s.sol"; +import {Script} from "forge-std/Script.sol"; +import {HarborDeployer} from "@harbor-script/src/HarborDeployer.sol"; import {WellKnownAddress} from "@bao-script/deployment/FactoryDeployer.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; @@ -36,7 +37,7 @@ interface Ownable { /// @dev See doc/remediation-ETH-fxUSD-SPL.md for full context. /// @dev Run via: /// script/run-script Remediate_SPL_ETH_fxUSD --salt harbor_v1 --network mainnet --broadcast --local -contract Remediate_SPL_ETH_fxUSD is SafeBatch { +contract Remediate_SPL_ETH_fxUSD is Script, HarborDeployer { using LibString for address; // ── Addresses ──────────────────────────────────────────────────────── diff --git a/script/UpdateVolatility_OGPlus.s.sol b/script/UpdateVolatility_OGPlus.s.sol index 2de91f20..464a16a1 100644 --- a/script/UpdateVolatility_OGPlus.s.sol +++ b/script/UpdateVolatility_OGPlus.s.sol @@ -3,7 +3,8 @@ pragma solidity >=0.8.28 <0.9.0; import {console2 as console} from "forge-std/console2.sol"; -import {SafeBatch} from "@harbor-script/safe/SafeBatch.s.sol"; +import {Script} from "forge-std/Script.sol"; +import {HarborDeployer} from "@harbor-script/src/HarborDeployer.sol"; import {IMinter} from "@harbor/interfaces/IMinter.sol"; import {IStabilityPoolManager} from "@harbor/interfaces/IStabilityPoolManager.sol"; import {ConfigPriceVolatility_130_stable} from "@harbor-script/config/volatility/ConfigPriceVolatility_130_stable.sol"; @@ -17,7 +18,7 @@ import {ConfigPriceVolatility_105} from "@harbor-script/config/volatility/Config /// @notice Update volatility config for SILVER::fxUSD to 125. /// @dev Run with: ./script/safe-batch UpdateVolatility_OGPlus --salt harbor_v1 -contract UpdateVolatility_OGPlus is SafeBatch { +contract UpdateVolatility_OGPlus is Script, HarborDeployer { function build() internal override { // BTC-fxUSD queue( diff --git a/script/UpdateVolatility_test3_SILVER.s.sol b/script/UpdateVolatility_test3_SILVER.s.sol index 2ff78afa..6151d8c3 100644 --- a/script/UpdateVolatility_test3_SILVER.s.sol +++ b/script/UpdateVolatility_test3_SILVER.s.sol @@ -3,7 +3,8 @@ pragma solidity >=0.8.28 <0.9.0; import {console2 as console} from "forge-std/console2.sol"; -import {SafeBatch} from "@harbor-script/safe/SafeBatch.s.sol"; +import {Script} from "forge-std/Script.sol"; +import {HarborDeployer} from "@harbor-script/src/HarborDeployer.sol"; import {IMinter} from "@harbor/interfaces/IMinter.sol"; import {IStabilityPoolManager} from "@harbor/interfaces/IStabilityPoolManager.sol"; import {ConfigPriceVolatility_125} from "@harbor-script/config/volatility/ConfigPriceVolatility_125.sol"; @@ -11,7 +12,7 @@ import {ConfigPriceVolatility_130} from "@harbor-script/config/volatility/Config /// @notice Update volatility config for SILVER::fxUSD to 125. /// @dev Run with: ./script/generate-safe-batch UpdateVolatility_test3_SILVER --salt test3 -contract UpdateVolatility_test3_SILVER is SafeBatch { +contract UpdateVolatility_test3_SILVER is Script, HarborDeployer { function build() internal override { queue( _saltString(_key("SILVER", "fxUSD", "minter")), diff --git a/script/config/stabilitypool/ConfigStabilityPoolManagerCommon.sol b/script/config/stabilitypool/ConfigStabilityPoolManagerCommon.sol index 39b6da5a..74ec640f 100644 --- a/script/config/stabilitypool/ConfigStabilityPoolManagerCommon.sol +++ b/script/config/stabilitypool/ConfigStabilityPoolManagerCommon.sol @@ -2,11 +2,11 @@ pragma solidity >=0.8.28 <0.9.0; import {ConfigStabilityPoolManager} from "./ConfigStabilityPoolManager.sol"; -import {HarborFactoryDeployer} from "@harbor-script/src/HarborFactoryDeployer.sol"; +import {HarborDeployer} from "@harbor-script/src/HarborDeployer.sol"; /// @notice Shared stability pool manager fee receiver and parameter defaults. /// @dev Keeps stability pool manager concerns separate from minter config. -abstract contract ConfigStabilityPoolManagerCommon is ConfigStabilityPoolManager, HarborFactoryDeployer { +abstract contract ConfigStabilityPoolManagerCommon is ConfigStabilityPoolManager, HarborDeployer { function feeReceiverName() public pure returns (string memory) { return "StabilityPoolManager Cut Receiver"; } diff --git a/script/safe/SafeBatch.s.sol b/script/safe/SafeBatch.s.sol deleted file mode 100644 index 5e8a005e..00000000 --- a/script/safe/SafeBatch.s.sol +++ /dev/null @@ -1,198 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.28 <0.9.0; - -import {Script} from "forge-std/Script.sol"; -import {console2 as console} from "forge-std/console2.sol"; -import {LibString} from "@solady/utils/LibString.sol"; -import {HarborFactoryDeployer} from "@harbor-script/src/HarborFactoryDeployer.sol"; -import {DeploymentState} from "@bao-script/deployment/DeploymentState.sol"; - -/// @notice Base contract for generating Safe Transaction Builder JSON batches. -/// @dev Inherit from this contract and override `build()` to define transactions. -/// -/// Environment variables (set by script/run-script): -/// NETWORK Network name (required) -/// SAFE_BATCH_NAME Filename prefix for the batch JSON -/// SAFE_BATCH_DESCRIPTION Description field in the batch JSON -/// SAFE_BATCH_TIMESTAMP ISO 8601 timestamp for filename -/// EXECUTE_LOCAL When "true", execute queued transactions on local anvil -/// -/// Example: -/// ```solidity -/// contract MyBatch is SafeBatch { -/// function build() internal override { -/// string memory salt = _saltString(_key("BTC", "fxUSD", "minter")); -/// queue(salt, abi.encodeCall(IMinter.updateConfig, (cfg)), "updateConfig(130)"); -/// } -/// } -/// ``` -abstract contract SafeBatch is Script, HarborFactoryDeployer { - using LibString for string; - using LibString for address; - using LibString for uint256; - - // ───────────────────────────────────────────────────────────────────────── - // State - // ───────────────────────────────────────────────────────────────────────── - - struct Transaction { - address target; - bytes data; - string description; - } - - Transaction[] internal _transactions; - Transaction[] internal _allTransactions; // accumulated across flushes for local execution - address private _signer; - - // ───────────────────────────────────────────────────────────────────────── - // DSL: Signer Context - // ───────────────────────────────────────────────────────────────────────── - - /// @notice Set the signer for subsequent flush() calls in local execution mode. - /// Analogous to vm.startPrank(). Resets to owner() when stopSigner() is called. - function startSigner(address signer_) internal { - _signer = signer_; - } - - /// @notice Clear the signer override, reverting to owner() for local execution. - function stopSigner() internal { - _signer = address(0); - } - - // ───────────────────────────────────────────────────────────────────────── - // DSL: Transaction Building - // ───────────────────────────────────────────────────────────────────────── - - /// @notice Queue a transaction to be included in the batch. - function queue(address target, bytes memory data, string memory description) internal { - _transactions.push(Transaction({target: target, data: data, description: description})); - } - - /// @notice Queue a transaction using a full salt string for address prediction. - /// @param fullSalt The complete salt string (e.g., from _saltString()) - /// @param data The encoded call data - /// @param description Description to append after salt - function queue(string memory fullSalt, bytes memory data, string memory description) internal { - queue(_predictAddressFromFullSalt(fullSalt), data, string.concat(fullSalt, ".", description)); - } - - /// @notice Queue a transaction with auto-generated description. - function queue(address target, bytes memory data) internal { - queue(target, data, target.toHexString()); - } - - /// @notice Save the current queued transactions as a named batch, execute locally if - /// EXECUTE_LOCAL is set, and clear the queue for the next batch. - /// @param suffix Appended to the filename, e.g. "01_grant_roles". Use "" for no suffix. - /// @param description Description field in the batch JSON. - function flush(string memory suffix, string memory description) internal { - _saveAndExecute(suffix, description); - delete _transactions; - } - - // ───────────────────────────────────────────────────────────────────────── - // build()/run() pattern - // ───────────────────────────────────────────────────────────────────────── - - /// @notice Override to define transactions. - function build() internal virtual; - - /// @notice Main entry point. Run with: script/run-script --salt --network - /// @param salt_ The salt prefix (e.g., "harbor_v1") - function run(string memory salt_) public { - _setSaltPrefix(salt_); - build(); - _saveAndExecute("", vm.envOr("SAFE_BATCH_DESCRIPTION", string(""))); - _executeLocal(); - } - - // ───────────────────────────────────────────────────────────────────────── - // Persistence - // ───────────────────────────────────────────────────────────────────────── - - /// @dev Save queued transactions to a JSON file and accumulate for local execution. - /// Uses _signer if set via startSigner(), otherwise defaults to owner(). - /// Appends the signer's registered name (from nameSigner()) to the filename. - /// Local execution is deferred to _executeLocal() in run() so that all - /// transactions across multiple flushes execute in a single broadcast. - function _saveAndExecute(string memory suffix, string memory description) internal { - if (_transactions.length == 0) { - console.log("No transactions queued - nothing to execute"); - return; - } - - address batchSigner = _signer != address(0) ? _signer : owner(); - - // Save batch file - string memory name = vm.envOr("SAFE_BATCH_NAME", string("batch")); - string memory timestamp = vm.envOr("SAFE_BATCH_TIMESTAMP", block.timestamp.toString()); - string memory batchDir = string.concat(DeploymentState.resolveDirectory(), "/batch"); - vm.createDir(batchDir, true); - string memory signerLabel = _addressLabel(batchSigner); - string memory fileSuffix = bytes(suffix).length > 0 ? string.concat(suffix, "@", signerLabel) : signerLabel; - string memory filename = string.concat(name, "_", timestamp, "_", fileSuffix, ".json"); - string memory path = string.concat(batchDir, "/", filename); - vm.writeJson(_buildSafeJson(description), path); - console.log("Safe batch saved to: %s", path); - console.log(" Transactions:", _transactions.length); - - // Accumulate for deferred local execution - for (uint256 i = 0; i < _transactions.length; i++) { - _allTransactions.push(_transactions[i]); - } - } - - /// @dev Execute all accumulated transactions in a single broadcast. - /// Called once at the end of run() to ensure correct ordering. - function _executeLocal() internal { - if (!vm.envOr("EXECUTE_LOCAL", false) || _allTransactions.length == 0) return; - - vm.startBroadcast(owner()); - for (uint256 i = 0; i < _allTransactions.length; i++) { - console.log("Executing:", _allTransactions[i].description); - (bool ok, bytes memory ret) = _allTransactions[i].target.call(_allTransactions[i].data); - if (!ok) { - assembly { - revert(add(ret, 32), mload(ret)) - } - } - } - vm.stopBroadcast(); - } - - // ───────────────────────────────────────────────────────────────────────── - // Internal: JSON Generation - // ───────────────────────────────────────────────────────────────────────── - - function _buildSafeJson(string memory description) internal view returns (string memory) { - string memory txArray = "["; - for (uint256 i = 0; i < _transactions.length; i++) { - if (i > 0) { - txArray = string.concat(txArray, ","); - } - txArray = string.concat( - txArray, - '{"to":"', - _transactions[i].target.toHexString(), - '","value":"0","data":"', - vm.toString(_transactions[i].data), - '"}' - ); - } - txArray = string.concat(txArray, "]"); - - return - string.concat( - '{"version":"1.0","chainId":"', - block.chainid.toString(), - '","createdAt":', - (block.timestamp * 1000).toString(), - ',"meta":{"description":"', - description, - '"},"transactions":', - txArray, - "}" - ); - } -} diff --git a/script/src/Deploy_BTC_Minter.sol b/script/src/Deploy_BTC_Minter.sol index 0948a5e2..e44193ae 100644 --- a/script/src/Deploy_BTC_Minter.sol +++ b/script/src/Deploy_BTC_Minter.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {console2 as console} from "forge-std/console2.sol"; -import {DeployMintersShared} from "./DeployMintersShared.sol"; +import {MinterDeployer} from "./MinterDeployer.sol"; import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; import {ConfigPeg} from "@harbor-script/config/pegs/ConfigPeg.sol"; import {ConfigPeg_BTC} from "@harbor-script/config/pegs/ConfigPeg_BTC.sol"; @@ -11,7 +11,7 @@ import {ConfigMarket_BTC_stETH_mainnet} from "@harbor-script/config/markets/Conf import {Config_MinterMarket} from "@harbor-script/config/ConfigBase.sol"; /// @notice BTC-specific minter deployment functionality. -abstract contract Deploy_BTC_Minter is DeployMintersShared { +abstract contract Deploy_BTC_Minter is MinterDeployer { /// @notice Create BTC-specific config objects. function createBTCMintersConfig() internal returns (ConfigPeg peg, Config_MinterMarket[] memory markets) { peg = new ConfigPeg_BTC(); diff --git a/script/src/Deploy_ETH_Minter.sol b/script/src/Deploy_ETH_Minter.sol index 63c72dc8..cbaeced8 100644 --- a/script/src/Deploy_ETH_Minter.sol +++ b/script/src/Deploy_ETH_Minter.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {console2 as console} from "forge-std/console2.sol"; -import {DeployMintersShared} from "./DeployMintersShared.sol"; +import {MinterDeployer} from "./MinterDeployer.sol"; import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; import {ConfigPeg} from "@harbor-script/config/pegs/ConfigPeg.sol"; import {ConfigPeg_ETH} from "@harbor-script/config/pegs/ConfigPeg_ETH.sol"; @@ -10,7 +10,7 @@ import {ConfigMarket_ETH_fxUSD_mainnet} from "@harbor-script/config/markets/Conf import {Config_MinterMarket} from "@harbor-script/config/ConfigBase.sol"; /// @notice ETH-specific minter deployment functionality. -abstract contract Deploy_ETH_Minter is DeployMintersShared { +abstract contract Deploy_ETH_Minter is MinterDeployer { /// @notice Create ETH-specific config objects. function createETHMintersConfig() internal returns (ConfigPeg peg, Config_MinterMarket[] memory markets) { peg = new ConfigPeg_ETH(); diff --git a/script/src/Deploy_EUR_Minter.sol b/script/src/Deploy_EUR_Minter.sol index 3ccc3b9d..3fa76755 100644 --- a/script/src/Deploy_EUR_Minter.sol +++ b/script/src/Deploy_EUR_Minter.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {console2 as console} from "forge-std/console2.sol"; -import {DeployMintersShared} from "./DeployMintersShared.sol"; +import {MinterDeployer} from "./MinterDeployer.sol"; import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; import {ConfigPeg} from "@harbor-script/config/pegs/ConfigPeg.sol"; import {ConfigPeg_EUR} from "@harbor-script/config/pegs/ConfigPeg_EUR.sol"; @@ -11,7 +11,7 @@ import {ConfigMarket_EUR_stETH_mainnet} from "@harbor-script/config/markets/Conf import {Config_MinterMarket} from "@harbor-script/config/ConfigBase.sol"; /// @notice EUR-specific minter deployment functionality. -abstract contract Deploy_EUR_Minter is DeployMintersShared { +abstract contract Deploy_EUR_Minter is MinterDeployer { /// @notice Create EUR-specific config objects. function createEURMintersConfig() internal returns (ConfigPeg peg, Config_MinterMarket[] memory markets) { peg = new ConfigPeg_EUR(); diff --git a/script/src/Deploy_GOLD_Minter.sol b/script/src/Deploy_GOLD_Minter.sol index ca2c8b45..688320dd 100644 --- a/script/src/Deploy_GOLD_Minter.sol +++ b/script/src/Deploy_GOLD_Minter.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {console2 as console} from "forge-std/console2.sol"; -import {DeployMintersShared} from "./DeployMintersShared.sol"; +import {MinterDeployer} from "./MinterDeployer.sol"; import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; import {ConfigPeg} from "@harbor-script/config/pegs/ConfigPeg.sol"; import {ConfigPeg_GOLD} from "@harbor-script/config/pegs/ConfigPeg_GOLD.sol"; @@ -11,7 +11,7 @@ import {ConfigMarket_GOLD_stETH_mainnet} from "@harbor-script/config/markets/Con import {Config_MinterMarket} from "@harbor-script/config/ConfigBase.sol"; /// @notice GOLD-specific minter deployment functionality. -abstract contract Deploy_GOLD_Minter is DeployMintersShared { +abstract contract Deploy_GOLD_Minter is MinterDeployer { /// @notice Create GOLD-specific config objects. function createGOLDMintersConfig() internal returns (ConfigPeg peg, Config_MinterMarket[] memory markets) { peg = new ConfigPeg_GOLD(); diff --git a/script/src/Deploy_MCAP_Minter.sol b/script/src/Deploy_MCAP_Minter.sol index 30ca0eb7..d6fe3c24 100644 --- a/script/src/Deploy_MCAP_Minter.sol +++ b/script/src/Deploy_MCAP_Minter.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {console2 as console} from "forge-std/console2.sol"; -import {DeployMintersShared} from "./DeployMintersShared.sol"; +import {MinterDeployer} from "./MinterDeployer.sol"; import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; import {ConfigPeg} from "@harbor-script/config/pegs/ConfigPeg.sol"; import {ConfigPeg_MCAP} from "@harbor-script/config/pegs/ConfigPeg_MCAP.sol"; @@ -11,7 +11,7 @@ import {ConfigMarket_MCAP_stETH_mainnet} from "@harbor-script/config/markets/Con import {Config_MinterMarket} from "@harbor-script/config/ConfigBase.sol"; /// @notice MCAP-specific minter deployment functionality. -abstract contract Deploy_MCAP_Minter is DeployMintersShared { +abstract contract Deploy_MCAP_Minter is MinterDeployer { /// @notice Create MCAP-specific config objects. function createMCAPMintersConfig() internal returns (ConfigPeg peg, Config_MinterMarket[] memory markets) { peg = new ConfigPeg_MCAP(); diff --git a/script/src/Deploy_SILVER_Minter.sol b/script/src/Deploy_SILVER_Minter.sol index 897527ec..bac36246 100644 --- a/script/src/Deploy_SILVER_Minter.sol +++ b/script/src/Deploy_SILVER_Minter.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {console2 as console} from "forge-std/console2.sol"; -import {DeployMintersShared} from "./DeployMintersShared.sol"; +import {MinterDeployer} from "./MinterDeployer.sol"; import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; import {ConfigPeg} from "@harbor-script/config/pegs/ConfigPeg.sol"; import {ConfigPeg_SILVER} from "@harbor-script/config/pegs/ConfigPeg_SILVER.sol"; @@ -11,7 +11,7 @@ import {ConfigMarket_SILVER_stETH_mainnet} from "@harbor-script/config/markets/C import {Config_MinterMarket} from "@harbor-script/config/ConfigBase.sol"; /// @notice SILVER-specific minter deployment functionality. -abstract contract Deploy_SILVER_Minter is DeployMintersShared { +abstract contract Deploy_SILVER_Minter is MinterDeployer { /// @notice Create SILVER-specific config objects. function createSILVERMintersConfig() internal returns (ConfigPeg peg, Config_MinterMarket[] memory markets) { peg = new ConfigPeg_SILVER(); diff --git a/script/src/HarborDeployer.sol b/script/src/HarborDeployer.sol new file mode 100644 index 00000000..569067d6 --- /dev/null +++ b/script/src/HarborDeployer.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.28 <0.9.0; + +import {console2 as console} from "forge-std/console2.sol"; +import {Deployer} from "@bao-script/deployment/Deployer.sol"; +import {WellKnownAddress} from "@bao-script/deployment/FactoryDeployer.sol"; +import {IHarborRoles} from "@bao/interfaces/IHarborRoles.sol"; + +/// @notice Harbor-specific deployment base for scripts and tests. +/// @dev Inherit from this for all Harbor deployment contracts. +/// Provides owner()/treasury(), well-known address labels, Safe batch machinery, +/// and centralized role granting. Add `is Script` at the concrete script level +/// for forge broadcast context. +abstract contract HarborDeployer is Deployer { + address private constant TREASURY_OWNER = 0x9bABfC1A1952a6ed2caC1922BFfE80c0506364a2; + + function treasury() public view virtual override returns (address) { + return TREASURY_OWNER; + } + + function owner() public view virtual override returns (address) { + return TREASURY_OWNER; + } + + function getWellKnownAddresses() public view virtual override returns (WellKnownAddress[] memory addrs) { + addrs = new WellKnownAddress[](3); + addrs[0] = WellKnownAddress({addr: TREASURY_OWNER, label: "harbor_multisig"}); + addrs[1] = WellKnownAddress({addr: baoFactory(), label: "baoFactory"}); + addrs[2] = WellKnownAddress({addr: 0xf1674FE69b2920b4de51E909cbf060dd78724CD8, label: "bao_auto"}); + } + + // ─── Role granting ───────────────────────────────────────────────────────── + + function _grantRoles( + string memory granterLabel, + address target, + address grantee, + string memory granteeLabel, + uint256 roles, + string memory roleDescription + ) internal { + console.log(" %s: %s role -> %s", granterLabel, roleDescription, granteeLabel); + IHarborRoles(target).grantRoles(grantee, roles); + } + + function _logManualRoleGrant( + string memory granterLabel, + address target, + address grantee, + string memory granteeLabel, + uint256 roles, + string memory roleDescription + ) internal pure { + console.log(" %s: %s role -> %s (MANUAL TX REQUIRED)", granterLabel, roleDescription, granteeLabel); + console.log(" To: %s", target); + console.log(" Call: grantRoles(%s, %s)", grantee, roles); + } +} diff --git a/script/src/HarborFactoryDeployer.sol b/script/src/HarborFactoryDeployer.sol deleted file mode 100644 index f45b64de..00000000 --- a/script/src/HarborFactoryDeployer.sol +++ /dev/null @@ -1,74 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.28 <0.9.0; - -import {console2 as console} from "forge-std/console2.sol"; -import {FactoryDeployer, WellKnownAddress} from "@bao-script/deployment/FactoryDeployer.sol"; -import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; - -/// @notice Harbor-specific FactoryDeployer that implements treasury() and owner(). -/// @dev All Harbor deployment contracts should inherit from this instead of FactoryDeployer directly. -/// @dev Provides single implementation of treasury/owner - single source of truth for Harbor addresses. -/// @dev Provides centralized role granting with consistent logging. -abstract contract HarborFactoryDeployer is FactoryDeployer { - /// @notice Harbor treasury and owner address (single address for both roles). - address private constant TREASURY_OWNER = 0x9bABfC1A1952a6ed2caC1922BFfE80c0506364a2; - - /// @notice Harbor treasury address (same as owner). - function treasury() public pure override returns (address) { - return TREASURY_OWNER; - } - - /// @notice Harbor owner address (same as treasury). - function owner() public pure override returns (address) { - return TREASURY_OWNER; - } - - /// @notice Well-known addresses for Harbor, used in batch filenames and logging. - function getWellKnownAddresses() public view virtual override returns (WellKnownAddress[] memory addrs) { - addrs = new WellKnownAddress[](3); - addrs[0] = WellKnownAddress({addr: TREASURY_OWNER, label: "harbor_multisig"}); - addrs[1] = WellKnownAddress({addr: baoFactory(), label: "baoFactory"}); - addrs[2] = WellKnownAddress({addr: 0xf1674FE69b2920b4de51E909cbf060dd78724CD8, label: "bao_auto"}); - } - - // ========== ROLE GRANTING ========== - - /// @notice Grant roles with consistent logging. - /// @param granterLabel Human-readable label for the contract granting roles - /// @param target Contract receiving the role grant call - /// @param grantee Address receiving the roles - /// @param granteeLabel Human-readable label for the grantee (e.g., "stabilityPoolManager") - /// @param roles Bitmask of roles to grant - /// @param roleDescription Human-readable description (e.g., "MINTER | BURNER") - function _grantRoles( - string memory granterLabel, - address target, - address grantee, - string memory granteeLabel, - uint256 roles, - string memory roleDescription - ) internal { - console.log(" %s: %s role -> %s", granterLabel, roleDescription, granteeLabel); - IBaoRoles(target).grantRoles(grantee, roles); - } - - /// @notice Log manual TX required for role grant (when contract already deployed with different owner). - /// @param granterLabel Human-readable label for the contract granting roles - /// @param target Contract that needs the role grant - /// @param grantee Address that should receive the roles - /// @param granteeLabel Human-readable label for the grantee - /// @param roles Bitmask of roles to grant - /// @param roleDescription Human-readable description of the roles - function _logManualRoleGrant( - string memory granterLabel, - address target, - address grantee, - string memory granteeLabel, - uint256 roles, - string memory roleDescription - ) internal pure { - console.log(" %s: %s role -> %s (MANUAL TX REQUIRED)", granterLabel, roleDescription, granteeLabel); - console.log(" To: %s", target); - console.log(" Call: grantRoles(%s, %s)", grantee, roles); - } -} diff --git a/script/src/DeployMintersShared.sol b/script/src/MinterDeployer.sol similarity index 98% rename from script/src/DeployMintersShared.sol rename to script/src/MinterDeployer.sol index 4724d4d2..3e0ec9d0 100644 --- a/script/src/DeployMintersShared.sol +++ b/script/src/MinterDeployer.sol @@ -9,7 +9,7 @@ import {Minter} from "./contracts/Minter.sol"; import {StabilityPool} from "./contracts/StabilityPool.sol"; import {StabilityPoolManager} from "./contracts/StabilityPoolManager.sol"; import {Genesis} from "./contracts/Genesis.sol"; -import {HarborFactoryDeployer} from "@harbor-script/src/HarborFactoryDeployer.sol"; +import {HarborDeployer} from "@harbor-script/src/HarborDeployer.sol"; import {DeploymentState} from "@bao-script/deployment/DeploymentState.sol"; import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; import {ConfigPeg} from "@harbor-script/config/pegs/ConfigPeg.sol"; @@ -38,7 +38,7 @@ interface IFullMinterConfig { /// @notice Shared functionality for all minter deployment contracts. /// @dev Provides common infrastructure and deployment primitives. -abstract contract DeployMintersShared is +abstract contract MinterDeployer is PeggedToken, LeveragedToken, Minter, diff --git a/script/src/contracts/Genesis.sol b/script/src/contracts/Genesis.sol index f944da5b..768d25aa 100644 --- a/script/src/contracts/Genesis.sol +++ b/script/src/contracts/Genesis.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {console2 as console} from "forge-std/console2.sol"; -import {HarborFactoryDeployer} from "@harbor-script/src/HarborFactoryDeployer.sol"; +import {HarborDeployer} from "@harbor-script/src/HarborDeployer.sol"; import {DeploymentState} from "@bao-script/deployment/DeploymentState.sol"; import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; @@ -17,7 +17,7 @@ import {Genesis_v1} from "@harbor/minter/Genesis_v1.sol"; /// @dev Genesis Ecosystem: /// @dev - Genesis is a special contract for initial token minting during launch /// @dev - Genesis needs: ZERO_FEE_ROLE on Minter (obtained via Minter deployment) -abstract contract Genesis is HarborFactoryDeployer { +abstract contract Genesis is HarborDeployer { // ========== GENESIS DEPLOYMENT ========== /// @notice Deploy Genesis_v1 impl only, record in state. diff --git a/script/src/contracts/LeveragedToken.sol b/script/src/contracts/LeveragedToken.sol index 7d3d9dd7..58e7a8ae 100644 --- a/script/src/contracts/LeveragedToken.sol +++ b/script/src/contracts/LeveragedToken.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {console2 as console} from "forge-std/console2.sol"; -import {HarborFactoryDeployer} from "@harbor-script/src/HarborFactoryDeployer.sol"; +import {HarborDeployer} from "@harbor-script/src/HarborDeployer.sol"; import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; import {MintableBurnableERC20_v1} from "@bao/MintableBurnableERC20_v1.sol"; import {IMintableRole} from "@bao/interfaces/IMintableRole.sol"; @@ -11,7 +11,7 @@ import {Config_MinterMarket, IMarketConfig, MinterMarketConfigLib} from "@harbor import {ConfigTokenNames} from "@harbor-script/config/ConfigTokenNames.sol"; /// @notice Harbor leveraged token deployment logic. /// @dev Leveraged tokens are unique per market (e.g., hsFXUSD-BTC for BTC::fxUSD market). -abstract contract LeveragedToken is HarborFactoryDeployer { +abstract contract LeveragedToken is HarborDeployer { // ========== LEVERAGED TOKEN DEPLOYMENT ========== /// @notice Deploy MintableBurnableERC20_v1 impl only (for leveraged token), record in state. diff --git a/script/src/contracts/Minter.sol b/script/src/contracts/Minter.sol index d49c072a..822f2373 100644 --- a/script/src/contracts/Minter.sol +++ b/script/src/contracts/Minter.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {console2 as console} from "forge-std/console2.sol"; -import {HarborFactoryDeployer} from "@harbor-script/src/HarborFactoryDeployer.sol"; +import {HarborDeployer} from "@harbor-script/src/HarborDeployer.sol"; import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; import {Config_MinterMarket, IMarketConfig, MinterMarketConfigLib} from "@harbor-script/config/ConfigBase.sol"; import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; @@ -21,7 +21,7 @@ import {IMinter} from "@harbor/interfaces/IMinter.sol"; /// @dev - Minter needs: wrappedCollateral, peggedToken, leveragedToken, priceOracle, reservePool, feeReceiver /// @dev - Minter grants: HARVESTER_ROLE to StabilityPoolManager, ZERO_FEE_ROLE to Genesis /// @dev - ReservePool grants: REQUESTER_ROLE to Minter -abstract contract Minter is HarborFactoryDeployer { +abstract contract Minter is HarborDeployer { // ========== MINTER DEPLOYMENT ========== function deployMinterImplementation( diff --git a/script/src/contracts/PeggedToken.sol b/script/src/contracts/PeggedToken.sol index 20be2c1a..8bef81c8 100644 --- a/script/src/contracts/PeggedToken.sol +++ b/script/src/contracts/PeggedToken.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {console2 as console} from "forge-std/console2.sol"; -import {HarborFactoryDeployer} from "@harbor-script/src/HarborFactoryDeployer.sol"; +import {HarborDeployer} from "@harbor-script/src/HarborDeployer.sol"; import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; import {MintableBurnableERC20_v1} from "@bao/MintableBurnableERC20_v1.sol"; import {IMintableRole} from "@bao/interfaces/IMintableRole.sol"; @@ -14,7 +14,7 @@ import {LibString} from "@solady/utils/LibString.sol"; /// @notice Harbor pegged token deployment logic. /// @dev Pegged tokens are one per peg (ETH, BTC, GOLD, EUR), shared by all markets with that peg. /// @dev If a pegged token already exists, logs the manual grantRoles transactions required. -abstract contract PeggedToken is HarborFactoryDeployer { +abstract contract PeggedToken is HarborDeployer { using LibString for string; // ========== PEGGED TOKEN DEPLOYMENT ========== diff --git a/script/src/contracts/StabilityPool.sol b/script/src/contracts/StabilityPool.sol index 2c9bac96..0046a685 100644 --- a/script/src/contracts/StabilityPool.sol +++ b/script/src/contracts/StabilityPool.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {console2 as console} from "forge-std/console2.sol"; -import {HarborFactoryDeployer} from "@harbor-script/src/HarborFactoryDeployer.sol"; +import {HarborDeployer} from "@harbor-script/src/HarborDeployer.sol"; import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; @@ -22,7 +22,7 @@ interface IStabilityPoolMarketConfig { /// @notice Harbor StabilityPool deployment logic. /// @dev Each market has TWO stability pools: Collateral (wrapped collateral) and Leveraged (leveraged token). /// @dev Both pools grant: REBALANCER_ROLE, REWARD_DEPOSITOR_ROLE to StabilityPoolManager. -abstract contract StabilityPool is HarborFactoryDeployer { +abstract contract StabilityPool is HarborDeployer { string StabilityPoolCollateral = "stabilityPoolCollateral"; string StabilityPoolLeveraged = "stabilityPoolLeveraged"; diff --git a/script/src/contracts/StabilityPoolManager.sol b/script/src/contracts/StabilityPoolManager.sol index 8dc7d926..0496f92b 100644 --- a/script/src/contracts/StabilityPoolManager.sol +++ b/script/src/contracts/StabilityPoolManager.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {console2 as console} from "forge-std/console2.sol"; -import {HarborFactoryDeployer} from "@harbor-script/src/HarborFactoryDeployer.sol"; +import {HarborDeployer} from "@harbor-script/src/HarborDeployer.sol"; import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; import {IBaoFactory} from "@bao-factory/IBaoFactory.sol"; @@ -14,7 +14,7 @@ import {IStabilityPoolManager} from "@harbor/interfaces/IStabilityPoolManager.so /// @dev SPM coordinates the two stability pools per market. /// @dev SPM grants: HARVESTER_ROLE on Minter (obtained via Minter deployment). /// @dev SPM needs: REBALANCER_ROLE, REWARD_DEPOSITOR_ROLE on both stability pools. -abstract contract StabilityPoolManager is HarborFactoryDeployer { +abstract contract StabilityPoolManager is HarborDeployer { /// @notice StabilityPoolManager configuration. struct SPMConfig { uint256 rebalanceThreshold; diff --git a/script/verify/minter-v2-upgrade/MinterUpgradeTest.t.sol b/script/verify/minter-v2-upgrade/MinterUpgradeTest.t.sol index 56daf6e5..211af1c5 100644 --- a/script/verify/minter-v2-upgrade/MinterUpgradeTest.t.sol +++ b/script/verify/minter-v2-upgrade/MinterUpgradeTest.t.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {BaoTest} from "@bao-test/BaoTest.sol"; -import {HarborFactoryDeployer} from "@harbor-script/src/HarborFactoryDeployer.sol"; +import {HarborDeployer} from "@harbor-script/src/HarborDeployer.sol"; import {IMinter} from "@harbor/interfaces/IMinter.sol"; import {IStabilityPoolManager} from "@harbor/interfaces/IStabilityPoolManager.sol"; import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; @@ -18,7 +18,7 @@ import {StabilityPoolManager_v1} from "@harbor/minter/StabilityPoolManager_v1.so /// 1. script/anvil --block 24687073 /// 2. ./script/run-script Deploy_Minter_v2_mainnet --network mainnet --salt harbor_v1 --broadcast --local /// 3. forge test --match-path script/test/MinterUpgradeTest.t.sol --fork-url local -vv -contract MinterUpgradeTest is BaoTest, HarborFactoryDeployer { +contract MinterUpgradeTest is BaoTest, HarborDeployer { function setUp() public { vm.createSelectFork(vm.rpcUrl("local")); _setSaltPrefix("harbor_v1"); diff --git a/script/verify/minter-v2-upgrade/RebalanceCheck.t.sol b/script/verify/minter-v2-upgrade/RebalanceCheck.t.sol index 785d2e93..f4637f99 100644 --- a/script/verify/minter-v2-upgrade/RebalanceCheck.t.sol +++ b/script/verify/minter-v2-upgrade/RebalanceCheck.t.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {BaoTest} from "@bao-test/BaoTest.sol"; -import {HarborFactoryDeployer} from "@harbor-script/src/HarborFactoryDeployer.sol"; +import {HarborDeployer} from "@harbor-script/src/HarborDeployer.sol"; import {StabilityPoolManager_v1} from "@harbor/minter/StabilityPoolManager_v1.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; @@ -13,7 +13,7 @@ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IMinter} from "@harbor/interfaces/IMinter.sol"; import {Minter_v2} from "@harbor/minter/Minter_v2.sol"; -abstract contract RebalanceCheckBase is BaoTest, HarborFactoryDeployer { +abstract contract RebalanceCheckBase is BaoTest, HarborDeployer { uint256 constant FORK_BLOCK = 24687073; address constant USER = 0xb9ab9578a34a05c86124c399735fdE44dEc80E7F; diff --git a/script/verify/roles/MainnetRoles.t.sol b/script/verify/roles/MainnetRoles.t.sol index effa8046..2a13954f 100644 --- a/script/verify/roles/MainnetRoles.t.sol +++ b/script/verify/roles/MainnetRoles.t.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {Test} from "forge-std/Test.sol"; -import {HarborFactoryDeployer} from "@harbor-script/src/HarborFactoryDeployer.sol"; +import {HarborDeployer} from "@harbor-script/src/HarborDeployer.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; import {IMinter} from "@harbor/interfaces/IMinter.sol"; @@ -11,7 +11,7 @@ import {IMinter} from "@harbor/interfaces/IMinter.sol"; /// @dev Run against any fork: /// forge test --mp script/test/MainnetRoles.t.sol --fork-url mainnet -vv /// forge test --mp script/test/MainnetRoles.t.sol --fork-url local -vv -contract MainnetRoles is Test, HarborFactoryDeployer { +contract MainnetRoles is Test, HarborDeployer { struct Market { string peg; string collateral; diff --git a/script/verify/sp-v3-migration/SPv3MigrationTest.t.sol b/script/verify/sp-v3-migration/SPv3MigrationTest.t.sol index ac32c414..eb907c96 100644 --- a/script/verify/sp-v3-migration/SPv3MigrationTest.t.sol +++ b/script/verify/sp-v3-migration/SPv3MigrationTest.t.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {BaoTest} from "@bao-test/BaoTest.sol"; -import {HarborFactoryDeployer} from "@harbor-script/src/HarborFactoryDeployer.sol"; +import {HarborDeployer} from "@harbor-script/src/HarborDeployer.sol"; import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; @@ -20,7 +20,7 @@ import {console2 as console} from "forge-std/console2.sol"; /// Verifies all user-visible values are preserved and post-migration operations work. /// /// Run: forge test --mc SPv3MigrationTest --fork-url mainnet -vv -contract SPv3MigrationTest is BaoTest, HarborFactoryDeployer { +contract SPv3MigrationTest is BaoTest, HarborDeployer { // ── Addresses ─────────────────────────────────────────────────────────── address spc; // collateral stability pool (ETH::fxUSD) diff --git a/script/verify/spl-remediation/SPLRemediationTest.t.sol b/script/verify/spl-remediation/SPLRemediationTest.t.sol index 6619a541..82b25938 100644 --- a/script/verify/spl-remediation/SPLRemediationTest.t.sol +++ b/script/verify/spl-remediation/SPLRemediationTest.t.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {BaoTest} from "@bao-test/BaoTest.sol"; -import {HarborFactoryDeployer} from "@harbor-script/src/HarborFactoryDeployer.sol"; +import {HarborDeployer} from "@harbor-script/src/HarborDeployer.sol"; import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; @@ -25,7 +25,7 @@ interface IStabilityPoolImmutables { // Subclasses differ only in setUp (how the remediated state is established). // ═══════════════════════════════════════════════════════════════════════════ -abstract contract SPLTestBase is BaoTest, HarborFactoryDeployer { +abstract contract SPLTestBase is BaoTest, HarborDeployer { address spl; address spc; address minter; diff --git a/script/verify/spl-remediation/V2ReplaySimulation.t.sol b/script/verify/spl-remediation/V2ReplaySimulation.t.sol index 843f0bb8..e5346139 100644 --- a/script/verify/spl-remediation/V2ReplaySimulation.t.sol +++ b/script/verify/spl-remediation/V2ReplaySimulation.t.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {BaoTest} from "@bao-test/BaoTest.sol"; -import {HarborFactoryDeployer} from "@harbor-script/src/HarborFactoryDeployer.sol"; +import {HarborDeployer} from "@harbor-script/src/HarborDeployer.sol"; import {StabilityPoolManager_v1} from "@harbor/minter/StabilityPoolManager_v1.sol"; import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; @@ -18,7 +18,7 @@ import {console2 as console} from "forge-std/console2.sol"; /// @notice Replays the exact sequence of on-chain events (5 rebalances + user /// claims/withdrawals/redeems) from a single fork, mocking oracle prices. /// test_v1Replay validates against mainnet. test_v2Replay produces the "correct world". -contract V2ReplaySimulation is BaoTest, HarborFactoryDeployer { +contract V2ReplaySimulation is BaoTest, HarborDeployer { uint256 constant FORK_BLOCK = 24687073; address spm; diff --git a/test/mocks/MockMinter.sol b/test/mocks/MockMinter.sol index 8e573582..0963de78 100644 --- a/test/mocks/MockMinter.sol +++ b/test/mocks/MockMinter.sol @@ -42,4 +42,29 @@ contract MockMinter is BaoOwnableRoles /*, IMinter */ { function setPeggedTokenPrice(uint256 price) external { _peggedTokenPrice = price; } + + /// @notice Dry-run mint returning price and rate used by HarborYield._computeDistributeMinOut. + /// Returns price = _peggedTokenPrice, rate = 1e18. Other fields are zeroed. + function mintPeggedTokenDryRun( + uint256, /* collateralIn */ + uint256 /* maxFeeRatio */ + ) + external + view + returns ( + int256 incentiveRatio, + uint256 fee, + uint256 collateralTaken, + uint256 peggedMinted, + uint256 price, + uint256 rate + ) + { + incentiveRatio = 0; + fee = 0; + collateralTaken = 0; + peggedMinted = 0; + price = _peggedTokenPrice; + rate = 1 ether; + } } From 2e90ca4b0641c07d489aa2baadfaeb369f355be3 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Sat, 25 Apr 2026 10:17:21 +0100 Subject: [PATCH 061/232] added yarn ci --- lib/bao-base | 2 +- package.json | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/bao-base b/lib/bao-base index a42a0620..4a87745c 160000 --- a/lib/bao-base +++ b/lib/bao-base @@ -1 +1 @@ -Subproject commit a42a0620d56e55495e29658bd7f6a02d8c6d8bb6 +Subproject commit 4a87745c0c31107c1a91a492a7a691b037249027 diff --git a/package.json b/package.json index a149b0c5..3c2d25f1 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "foundryup": "curl -L https://foundry.paradigm.xyz | bash && foundryup", "doctor": "./lib/bao-base/run doctor", "CI": "./lib/bao-base/run CI", + "ci": "./lib/bao-base/run ci", "clean": "./lib/bao-base/run clean", "git-diffs": "./lib/bao-base/run git-diffs", "prettier": "prettier --log-level warn '{src,test,script}/**/*.sol'", From 174b36f112d8ce288682e6c5d17380d1160a907e Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Sat, 25 Apr 2026 11:08:28 +0100 Subject: [PATCH 062/232] removed predictaddressfromfullsalt --- lib/bao-base | 2 +- script/Deploy_Minter_v2_mainnet.s.sol | 2 +- script/Deploy_StabilityPool_v3_mainnet.s.sol | 4 ++-- script/Grant_Minter_ZeroFeeRoles_mainnet.s.sol | 2 +- script/Pause_SPL_ETH_fxUSD.s.sol | 2 +- script/Remediate_Accumulators.s.sol | 16 ++++++++-------- script/Remediate_SPL_ETH_fxUSD.s.sol | 10 +++++----- script/UpdateVolatility_OGPlus.s.sol | 16 ++++++++-------- script/UpdateVolatility_test3_SILVER.s.sol | 6 +++--- .../minter-v2-upgrade/MinterUpgradeTest.t.sol | 2 +- script/verify/roles/MainnetRoles.t.sol | 12 ++++++------ 11 files changed, 37 insertions(+), 37 deletions(-) diff --git a/lib/bao-base b/lib/bao-base index 4a87745c..a9737ee2 160000 --- a/lib/bao-base +++ b/lib/bao-base @@ -1 +1 @@ -Subproject commit 4a87745c0c31107c1a91a492a7a691b037249027 +Subproject commit a9737ee274f03f9755e79093aacd93c14654f4fa diff --git a/script/Deploy_Minter_v2_mainnet.s.sol b/script/Deploy_Minter_v2_mainnet.s.sol index b7f115cd..073ff8fa 100644 --- a/script/Deploy_Minter_v2_mainnet.s.sol +++ b/script/Deploy_Minter_v2_mainnet.s.sol @@ -59,7 +59,7 @@ contract Deploy_Minter_v2_mainnet is // Queue Safe upgrade transactions queue( - _saltString(key), + key, abi.encodeCall(UUPSUpgradeable.upgradeToAndCall, (impl, "")), string.concat("upgrade to Minter_v2 ", impl.toHexString()) ); diff --git a/script/Deploy_StabilityPool_v3_mainnet.s.sol b/script/Deploy_StabilityPool_v3_mainnet.s.sol index 344afd21..f8c4adda 100644 --- a/script/Deploy_StabilityPool_v3_mainnet.s.sol +++ b/script/Deploy_StabilityPool_v3_mainnet.s.sol @@ -65,12 +65,12 @@ contract Deploy_StabilityPool_v3_mainnet is // Queue Safe upgrade transactions queue( - _saltString(_key(marketKey, StabilityPoolLeveraged)), + _key(marketKey, StabilityPoolLeveraged), abi.encodeCall(UUPSUpgradeable.upgradeToAndCall, (implLeveraged, "")), string.concat("upgrade to StabilityPool_v3 ", implLeveraged.toHexString()) ); queue( - _saltString(_key(marketKey, StabilityPoolCollateral)), + _key(marketKey, StabilityPoolCollateral), abi.encodeCall(UUPSUpgradeable.upgradeToAndCall, (implCollateral, "")), string.concat("upgrade to StabilityPool_v3 ", implCollateral.toHexString()) ); diff --git a/script/Grant_Minter_ZeroFeeRoles_mainnet.s.sol b/script/Grant_Minter_ZeroFeeRoles_mainnet.s.sol index 334ff127..730117fe 100644 --- a/script/Grant_Minter_ZeroFeeRoles_mainnet.s.sol +++ b/script/Grant_Minter_ZeroFeeRoles_mainnet.s.sol @@ -43,7 +43,7 @@ contract Grant_Minter_ZeroFeeRoles_mainnet is console.log(" %s: grant ZERO_FEE_ROLE to SPM %s", marketKey, spm.toHexString()); queue( - _saltString(_key(marketKey, "minter")), + _key(marketKey, "minter"), abi.encodeCall(IBaoRoles.grantRoles, (spm, zeroFeeRole)), string.concat("grant ZERO_FEE_ROLE to SPM on ", marketKey, "::minter") ); diff --git a/script/Pause_SPL_ETH_fxUSD.s.sol b/script/Pause_SPL_ETH_fxUSD.s.sol index 4d120138..e4d79781 100644 --- a/script/Pause_SPL_ETH_fxUSD.s.sol +++ b/script/Pause_SPL_ETH_fxUSD.s.sol @@ -13,7 +13,7 @@ contract Pause_SPL_ETH_fxUSD is Script, HarborDeployer { function build() internal override { queue( - _saltString(_key("ETH", "fxUSD", "stabilityPoolLeveraged")), + _key("ETH", "fxUSD", "stabilityPoolLeveraged"), abi.encodeCall(UUPSUpgradeable.upgradeToAndCall, (BAO_PAUSER, "")), "pause: upgrade to BaoPauser_v1" ); diff --git a/script/Remediate_Accumulators.s.sol b/script/Remediate_Accumulators.s.sol index d62a3f1a..0df33228 100644 --- a/script/Remediate_Accumulators.s.sol +++ b/script/Remediate_Accumulators.s.sol @@ -62,12 +62,12 @@ contract Remediate_Accumulators is } function _remediatePool(string memory marketKey, string memory spType) internal { - string memory fullSalt = _saltString(_key(marketKey, spType)); - address pool = _predictAddressFromFullSalt(fullSalt); + string memory key = _key(marketKey, spType); + address pool = _predictAddress(key); // Read current implementation (to restore after remediation) address currentImpl = address(uint160(uint256(vm.load(pool, IMPL_SLOT)))); - require(currentImpl.code.length != 0, string.concat("no impl for ", fullSalt)); + require(currentImpl.code.length != 0, string.concat("no impl for ", _saltString(key))); // Read active reward tokens before upgrade (pauser fallback would revert) address[] memory tokens = IMultipleRewardDistributor(pool).activeRewardTokens(); @@ -76,15 +76,15 @@ contract Remediate_Accumulators is address[] memory holders = _getHolders(marketKey, spType); if (holders.length == 0) { - console.log(" > %s: no holders, skipping", fullSalt); + console.log(" > %s: no holders, skipping", _saltString(key)); return; } - console.log(" > %s: %d holders, %d tokens", fullSalt, holders.length, tokens.length); + console.log(" > %s: %d holders, %d tokens", _saltString(key), holders.length, tokens.length); // 1. Upgrade to migration contract queue( - fullSalt, + key, abi.encodeCall(UUPSUpgradeable.upgradeToAndCall, (migImpl, "")), "upgrade to ForceMigrateAccumulator_v1" ); @@ -93,12 +93,12 @@ contract Remediate_Accumulators is queue( pool, abi.encodeCall(ForceMigrateAccumulator_v1.remediate, (tokens, holders)), - string.concat("remediate ", fullSalt) + string.concat("remediate ", _saltString(key)) ); // 3. Restore to original implementation queue( - fullSalt, + key, abi.encodeCall(UUPSUpgradeable.upgradeToAndCall, (currentImpl, "")), string.concat("restore to ", currentImpl.toHexString()) ); diff --git a/script/Remediate_SPL_ETH_fxUSD.s.sol b/script/Remediate_SPL_ETH_fxUSD.s.sol index e89a79a1..e3c48e0b 100644 --- a/script/Remediate_SPL_ETH_fxUSD.s.sol +++ b/script/Remediate_SPL_ETH_fxUSD.s.sol @@ -70,11 +70,11 @@ contract Remediate_SPL_ETH_fxUSD is Script, HarborDeployer { } function build() internal override { - string memory splSalt = _saltString(_key("ETH", "fxUSD", "stabilityPoolLeveraged")); - address spl = _predictAddressFromFullSalt(splSalt); + string memory splKey = _key("ETH", "fxUSD", "stabilityPoolLeveraged"); + address spl = _predictAddress(splKey); require( - LEVERAGED == _predictAddressFromFullSalt(_saltString(_key("ETH", "fxUSD", "leveraged"))), + LEVERAGED == _predictAddress(_key("ETH", "fxUSD", "leveraged")), "LEVERAGED is not the correct address" ); @@ -125,7 +125,7 @@ contract Remediate_SPL_ETH_fxUSD is Script, HarborDeployer { // 3. Upgrade SPL to remediation contract and execute remediate() queue( - splSalt, + splKey, abi.encodeCall( UUPSUpgradeable.upgradeToAndCall, (REMEDIATOR_IMPL, abi.encodeCall(PostRebalanceRemediationForStabilityPool_v2.remediate, ())) @@ -135,7 +135,7 @@ contract Remediate_SPL_ETH_fxUSD is Script, HarborDeployer { // 4. Restore SPL to StabilityPool_v2 queue( - splSalt, + splKey, abi.encodeCall(UUPSUpgradeable.upgradeToAndCall, (EXISTING_V2_IMPL, "")), string.concat("restore SPL: ", EXISTING_V2_IMPL.toHexString()) ); diff --git a/script/UpdateVolatility_OGPlus.s.sol b/script/UpdateVolatility_OGPlus.s.sol index 464a16a1..cabd5d79 100644 --- a/script/UpdateVolatility_OGPlus.s.sol +++ b/script/UpdateVolatility_OGPlus.s.sol @@ -22,49 +22,49 @@ contract UpdateVolatility_OGPlus is Script, HarborDeployer { function build() internal override { // BTC-fxUSD queue( - _saltString(_key("BTC", "fxUSD", "minter")), + _key("BTC", "fxUSD", "minter"), abi.encodeCall(IMinter.updateConfig, (new ConfigPriceVolatility_130_stable().minterConfig())), "updateConfig(130)" ); // BTC-stETH queue( - _saltString(_key("BTC", "stETH", "minter")), + _key("BTC", "stETH", "minter"), abi.encodeCall(IMinter.updateConfig, (new ConfigPriceVolatility_125_stable().minterConfig())), "updateConfig(125)" ); queue( - _saltString(_key("BTC", "stETH", "stabilityPoolManager")), + _key("BTC", "stETH", "stabilityPoolManager"), abi.encodeCall(IStabilityPoolManager.updateRebalanceThreshold, (125e16)), "updateRebalanceThreshold(125)" ); // ETH-fxUSD queue( - _saltString(_key("ETH", "fxUSD", "minter")), + _key("ETH", "fxUSD", "minter"), abi.encodeCall(IMinter.updateConfig, (new ConfigPriceVolatility_130_stable().minterConfig())), "updateConfig(130)" ); // EUR-fxUSD queue( - _saltString(_key("EUR", "fxUSD", "minter")), + _key("EUR", "fxUSD", "minter"), abi.encodeCall(IMinter.updateConfig, (new ConfigPriceVolatility_105().minterConfig())), "updateConfig(105 month1)" ); queue( - _saltString(_key("EUR", "fxUSD", "stabilityPoolManager")), + _key("EUR", "fxUSD", "stabilityPoolManager"), abi.encodeCall(IStabilityPoolManager.updateRebalanceThreshold, (105e16)), "updateRebalanceThreshold(105)" ); // GOLD-fxUSD queue( - _saltString(_key("GOLD", "fxUSD", "minter")), + _key("GOLD", "fxUSD", "minter"), abi.encodeCall(IMinter.updateConfig, (new ConfigPriceVolatility_115().minterConfig())), "updateConfig(105 month1)" ); queue( - _saltString(_key("GOLD", "fxUSD", "stabilityPoolManager")), + _key("GOLD", "fxUSD", "stabilityPoolManager"), abi.encodeCall(IStabilityPoolManager.updateRebalanceThreshold, (115e16)), "updateRebalanceThreshold(115)" ); diff --git a/script/UpdateVolatility_test3_SILVER.s.sol b/script/UpdateVolatility_test3_SILVER.s.sol index 6151d8c3..9269d533 100644 --- a/script/UpdateVolatility_test3_SILVER.s.sol +++ b/script/UpdateVolatility_test3_SILVER.s.sol @@ -15,19 +15,19 @@ import {ConfigPriceVolatility_130} from "@harbor-script/config/volatility/Config contract UpdateVolatility_test3_SILVER is Script, HarborDeployer { function build() internal override { queue( - _saltString(_key("SILVER", "fxUSD", "minter")), + _key("SILVER", "fxUSD", "minter"), abi.encodeCall(IMinter.updateConfig, (new ConfigPriceVolatility_125().minterConfig())), "updateConfig(125_month1)" ); queue( - _saltString(_key("SILVER", "fxUSD", "stabilityPoolManager")), + _key("SILVER", "fxUSD", "stabilityPoolManager"), abi.encodeCall(IStabilityPoolManager.updateRebalanceThreshold, (125e16)), "updateRebalanceThreshold(125)" ); queue( - _saltString(_key("SILVER", "stETH", "minter")), + _key("SILVER", "stETH", "minter"), abi.encodeCall(IMinter.updateConfig, (new ConfigPriceVolatility_130().minterConfig())), "updateConfig(130_month1)" ); diff --git a/script/verify/minter-v2-upgrade/MinterUpgradeTest.t.sol b/script/verify/minter-v2-upgrade/MinterUpgradeTest.t.sol index 211af1c5..c7500734 100644 --- a/script/verify/minter-v2-upgrade/MinterUpgradeTest.t.sol +++ b/script/verify/minter-v2-upgrade/MinterUpgradeTest.t.sol @@ -25,7 +25,7 @@ contract MinterUpgradeTest is BaoTest, HarborDeployer { } function _predict(string memory marketKey, string memory suffix) internal returns (address) { - return _predictAddressFromFullSalt(_saltString(_key(marketKey, suffix))); + return _predictAddress(_key(marketKey, suffix)); } // ---- ETH::fxUSD rebalance tests (the market with known sub-threshold CR) ---- diff --git a/script/verify/roles/MainnetRoles.t.sol b/script/verify/roles/MainnetRoles.t.sol index 2a13954f..396b610d 100644 --- a/script/verify/roles/MainnetRoles.t.sol +++ b/script/verify/roles/MainnetRoles.t.sol @@ -44,8 +44,8 @@ contract MainnetRoles is Test, HarborDeployer { function test_allSPMs_haveZeroFeeRole() public { for (uint256 i = 0; i < markets.length; i++) { string memory marketKey = string.concat(markets[i].peg, "::", markets[i].collateral); - address minter = _predictAddressFromFullSalt(_saltString(_key(marketKey, "minter"))); - address spm = _predictAddressFromFullSalt(_saltString(_key(marketKey, "stabilityPoolManager"))); + address minter = _predictAddress(_key(marketKey, "minter")); + address spm = _predictAddress(_key(marketKey, "stabilityPoolManager")); uint256 zeroFeeRole = IMinter(minter).ZERO_FEE_ROLE(); assertTrue( @@ -58,8 +58,8 @@ contract MainnetRoles is Test, HarborDeployer { function test_allSPMs_haveHarvesterRole() public { for (uint256 i = 0; i < markets.length; i++) { string memory marketKey = string.concat(markets[i].peg, "::", markets[i].collateral); - address minter = _predictAddressFromFullSalt(_saltString(_key(marketKey, "minter"))); - address spm = _predictAddressFromFullSalt(_saltString(_key(marketKey, "stabilityPoolManager"))); + address minter = _predictAddress(_key(marketKey, "minter")); + address spm = _predictAddress(_key(marketKey, "stabilityPoolManager")); uint256 harvesterRole = IMinter(minter).HARVESTER_ROLE(); assertTrue( @@ -72,8 +72,8 @@ contract MainnetRoles is Test, HarborDeployer { function test_allGenesis_haveZeroFeeRole() public { for (uint256 i = 0; i < markets.length; i++) { string memory marketKey = string.concat(markets[i].peg, "::", markets[i].collateral); - address minter = _predictAddressFromFullSalt(_saltString(_key(marketKey, "minter"))); - address genesis = _predictAddressFromFullSalt(_saltString(_key(marketKey, "genesis"))); + address minter = _predictAddress(_key(marketKey, "minter")); + address genesis = _predictAddress(_key(marketKey, "genesis")); uint256 zeroFeeRole = IMinter(minter).ZERO_FEE_ROLE(); assertTrue( From ff4614180ba98666f30bc4d98c93fdcafd120a4e Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Sat, 25 Apr 2026 11:35:43 +0100 Subject: [PATCH 063/232] fixed bao-base --- lib/bao-base | 2 +- regression/coverage.txt | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/bao-base b/lib/bao-base index a9737ee2..795ad223 160000 --- a/lib/bao-base +++ b/lib/bao-base @@ -1 +1 @@ -Subproject commit a9737ee274f03f9755e79093aacd93c14654f4fa +Subproject commit 795ad223e0ed0dc5d6c65b6fa9e1fd896c4fcbea diff --git a/regression/coverage.txt b/regression/coverage.txt index 241c6b58..f21edd3e 100644 --- a/regression/coverage.txt +++ b/regression/coverage.txt @@ -24,14 +24,14 @@ | script/config/volatility/ConfigPriceVolatility_125_stable.sol | X 0% (0/65) | X 0% (0/71) | ✓ 100% (0/0) | X 0% (0/2) | | script/config/volatility/ConfigPriceVolatility_130.sol | ✓ 100% (65/65) | ✓ 100% (71/71) | ✓ 100% (0/0) | ✓ 100% (2/2) | | script/config/volatility/ConfigPriceVolatility_130_stable.sol | ✓ 100% (65/65) | ✓ 100% (71/71) | ✓ 100% (0/0) | ✓ 100% (2/2) | -| script/src/DeployMintersShared.sol | X 83% (70/84) | X 82% (81/99) | X 25% (1/4) | X 80% (8/10) | | script/src/Deploy_BTC_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | | script/src/Deploy_ETH_Minter.sol | ✓ 100% (4/4) | ✓ 100% (3/3) | ✓ 100% (0/0) | ✓ 100% (1/1) | | script/src/Deploy_EUR_Minter.sol | ✓ 100% (5/5) | ✓ 100% (4/4) | ✓ 100% (0/0) | ✓ 100% (1/1) | | script/src/Deploy_GOLD_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | | script/src/Deploy_MCAP_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | | script/src/Deploy_SILVER_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | -| script/src/HarborFactoryDeployer.sol | X 56% (9/16) | X 73% (8/11) | ✓ 100% (0/0) | X 20% (1/5) | +| script/src/HarborDeployer.sol | X 56% (9/16) | X 73% (8/11) | ✓ 100% (0/0) | X 20% (1/5) | +| script/src/MinterDeployer.sol | X 83% (70/84) | X 82% (81/99) | X 25% (1/4) | X 80% (8/10) | | script/src/contracts/Genesis.sol | X 77% (10/13) | X 73% (11/15) | ✓ 100% (0/0) | X 67% (2/3) | | script/src/contracts/LeveragedToken.sol | ✓ 100% (18/18) | ✓ 100% (26/26) | ✓ 100% (0/0) | ✓ 100% (2/2) | | script/src/contracts/Minter.sol | X 62% (32/52) | X 63% (38/60) | ✓ 100% (0/0) | X 60% (6/10) | @@ -65,4 +65,4 @@ | src/util/ERC20MetadataLib_v1.sol | ✓ 100% (24/24) | ✓ 100% (22/22) | ✓ 100% (4/4) | ✓ 100% (4/4) | | src/util/FmtLib.sol | X 79% (15/19) | X 73% (19/26) | X 50% (2/4) | ✓ 100% (1/1) | | src/util/WordCodec.sol | ✓ 100% (15/15) | ✓ 100% (14/14) | ✓ 100% (0/0) | ✓ 100% (4/4) | -| Total | X 55% (4466/8151) | X 54% (4730/8807) | X 42% (392/933) | X 55% (647/1187) | +| Total | X 55% (4466/8105) | X 54% (4730/8752) | X 42% (392/929) | X 55% (647/1178) | From 50710b466e6f553c971dc3947cfacd2192251769 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Sat, 25 Apr 2026 15:18:55 +0100 Subject: [PATCH 064/232] update bao-base formatting --- lib/bao-base | 2 +- package.json | 2 +- regression/gas.txt | 2 +- script/src/MinterDeployer.sol | 9 +-------- test/mocks/MockMinter.sol | 2 +- 5 files changed, 5 insertions(+), 12 deletions(-) diff --git a/lib/bao-base b/lib/bao-base index 795ad223..d4ccb30a 160000 --- a/lib/bao-base +++ b/lib/bao-base @@ -1 +1 @@ -Subproject commit 795ad223e0ed0dc5d6c65b6fa9e1fd896c4fcbea +Subproject commit d4ccb30a255978b8b47f0b63e5817db0a279f679 diff --git a/package.json b/package.json index 3c2d25f1..6778f7ec 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "foundryup": "curl -L https://foundry.paradigm.xyz | bash && foundryup", "doctor": "./lib/bao-base/run doctor", "CI": "./lib/bao-base/run CI", - "ci": "./lib/bao-base/run ci", + "CI-act": "./lib/bao-base/run CI-act", "clean": "./lib/bao-base/run clean", "git-diffs": "./lib/bao-base/run git-diffs", "prettier": "prettier --log-level warn '{src,test,script}/**/*.sol'", diff --git a/regression/gas.txt b/regression/gas.txt index 04b60050..a1e0842d 100644 --- a/regression/gas.txt +++ b/regression/gas.txt @@ -46,7 +46,7 @@ src/minter/Minter_v3.sol:Minter_v3 | mintPeggedToken(uint256,address,uint256) | 1.911e+05 | | mintPeggedToken(uint256,address,uint256,uint256) | 1.251e+05 | | mintPeggedTokenDryRun(uint256) | 6.401e+04 | -| mintPeggedTokenDryRun(uint256,uint256) | 4.556e+04 | +| mintPeggedTokenDryRun(uint256,uint256) | 3.644e+04 | | mintPeggedTokenIncentiveRatio | 3.012e+04 | | owner | 2.402e+03 | | peggedTokenBalance | 2.409e+03 | diff --git a/script/src/MinterDeployer.sol b/script/src/MinterDeployer.sol index 3e0ec9d0..12fd93cb 100644 --- a/script/src/MinterDeployer.sol +++ b/script/src/MinterDeployer.sol @@ -38,14 +38,7 @@ interface IFullMinterConfig { /// @notice Shared functionality for all minter deployment contracts. /// @dev Provides common infrastructure and deployment primitives. -abstract contract MinterDeployer is - PeggedToken, - LeveragedToken, - Minter, - StabilityPool, - StabilityPoolManager, - Genesis -{ +abstract contract MinterDeployer is PeggedToken, LeveragedToken, Minter, StabilityPool, StabilityPoolManager, Genesis { using LibString for string; // ========== MARKET LOOKUP ========== diff --git a/test/mocks/MockMinter.sol b/test/mocks/MockMinter.sol index 0963de78..eb1a6f69 100644 --- a/test/mocks/MockMinter.sol +++ b/test/mocks/MockMinter.sol @@ -46,7 +46,7 @@ contract MockMinter is BaoOwnableRoles /*, IMinter */ { /// @notice Dry-run mint returning price and rate used by HarborYield._computeDistributeMinOut. /// Returns price = _peggedTokenPrice, rate = 1e18. Other fields are zeroed. function mintPeggedTokenDryRun( - uint256, /* collateralIn */ + uint256 /* collateralIn */, uint256 /* maxFeeRatio */ ) external From 00a3a604e82d1c51b5c5aae94833c0afccd3ae75 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Sat, 25 Apr 2026 19:27:58 +0100 Subject: [PATCH 065/232] move AC/HY interface --- src/interfaces/IYieldManager.sol | 27 --------------------------- 1 file changed, 27 deletions(-) delete mode 100644 src/interfaces/IYieldManager.sol diff --git a/src/interfaces/IYieldManager.sol b/src/interfaces/IYieldManager.sol deleted file mode 100644 index 337996c2..00000000 --- a/src/interfaces/IYieldManager.sol +++ /dev/null @@ -1,27 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.28 <0.9.0; - -/// @title IYieldManager -/// @notice Interface an AutoCompounder calls on its registered yield manager (HarborYield). -/// @dev AutoCompounder reads mintMaxFeeRatio() to cap minting fees, routes residual wCOLn via -/// distribute(), and triggers a performance snapshot after each compound. -interface IYieldManager { - /// @notice Maximum Minter fee ratio the AC should accept for haXXX minting (18 decimals). - /// @dev Computed by HarborYield from its knowledge of alternative conversion paths. - /// When the Minter fee exceeds this threshold it is cheaper to route wCOLn to - /// distribute() for a DEX swap into an equivalent vault. - /// Standalone ACs (YIELD_MANAGER == address(0)) use their own storage value instead. - function mintMaxFeeRatio() external view returns (uint256); - - /// @notice Receive residual wrapped collateral from an AC and route it to the most under-weight vault. - /// @dev The AC must transfer `amount` of `token` to this contract before calling. - /// Only callable by a registered AutoCompounder vault. - /// @param token The wrapped collateral token transferred. - /// @param amount The amount transferred. - function distribute(address token, uint256 amount) external; - - /// @notice Snapshot the current `convertToAssets(1e18)` rate for every registered vault. - /// @dev Permissionless. Stores `(timestamp, rate)` in a per-vault ring buffer. - /// Called by AC.compound() after state has changed — the natural trigger. - function snapshotPerformance() external; -} From 770b4f400a7dd84f3c2c6e9389355d2ea9fa1b82 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Sun, 26 Apr 2026 10:39:52 +0100 Subject: [PATCH 066/232] remove files that have been moved to harbor-yield --- src/interfaces/IHarborYield.sol | 107 -------------------------------- src/interfaces/ISwapper.sol | 25 -------- test/mocks/MockSwapper.sol | 49 --------------- 3 files changed, 181 deletions(-) delete mode 100644 src/interfaces/IHarborYield.sol delete mode 100644 src/interfaces/ISwapper.sol delete mode 100644 test/mocks/MockSwapper.sol diff --git a/src/interfaces/IHarborYield.sol b/src/interfaces/IHarborYield.sol deleted file mode 100644 index 697068c3..00000000 --- a/src/interfaces/IHarborYield.sol +++ /dev/null @@ -1,107 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.28 <0.9.0; - -/// @title IHarborYield -/// @notice Interface for the HarborYield vault (Level 2, one per peg). -/// @dev Manages multiple ERC4626 vaults that share the same peg, with target weight -/// distribution and compound/swap capabilities. -interface IHarborYield { - // ── Events ────────────────────────────────────────────────────────── - - event VaultAdded(address indexed vault, address indexed asset, uint64 weight); - event VaultDeactivated(address indexed vault); - event VaultActivated(address indexed vault); - event VaultWeightUpdated(address indexed vault, uint64 weight); - - event Compounded(address indexed caller, address fromVault, address toVault, uint256 amountIn, uint256 amountOut); - event Redistributed( - address indexed caller, - address fromVault, - address toVault, - uint256 amountIn, - uint256 amountOut - ); - - /// @notice Emitted when the owner updates the maximum peg drift allowed for - /// equivalent-vault registration and rebalance swaps. - event MaxPegDriftBpsUpdated(uint64 newMaxPegDriftBps); - - // ── Deposit ───────────────────────────────────────────────────────── - - /// @notice Deposit an asset into the HarborYield. The asset must belong to a registered, active vault. - /// @param asset_ The asset token to deposit (e.g., stETH, fxUSD, hpETH.stETH). - /// @param amount The amount to deposit. Use type(uint256).max for full balance. - /// @param receiver The address to receive hyXXX shares. - /// @return shares The amount of hyXXX shares minted. - function deposit(address asset_, uint256 amount, address receiver) external returns (uint256 shares); - - // ── Redeem ────────────────────────────────────────────────────────── - - /// @notice Redeem hyXXX shares for a proportional mix of all held vault assets. - /// @param shares The amount of hyXXX shares to burn. - /// @param receiver The address to receive the redeemed assets. - /// @param owner The address whose shares are burned. - function redeem(uint256 shares, address receiver, address owner) external; - - // ── Compound ──────────────────────────────────────────────────────── - - /// @notice Convert holdings in one vault to another via the swapper. - /// Used to compound equivalent tokens into AC holdings when profitable. - /// @param fromVault The source ERC4626 vault to redeem from. - /// @param toVault The target ERC4626 vault to deposit into. - /// @param vaultShareAmount Amount of source vault shares to redeem. - /// @param minAmountOut Minimum output from the swap (slippage protection). - /// @param swapData Adapter-specific route data for the swapper. - function compound( - address fromVault, - address toVault, - uint256 vaultShareAmount, - uint256 minAmountOut, - bytes calldata swapData - ) external; - - // ── Redistribute ──────────────────────────────────────────────────── - - /// @notice Move holdings toward the target weight distribution. - /// Finds the most over-weight vault and the most under-weight vault, - /// then transfers value from source to target. - /// @param maxVaultSharesPerVault Cap on vault shares redeemed (prevents overshooting). - /// @param minAmountOut Minimum output from any swap (slippage protection). - /// @param swapData Adapter-specific route data for the swapper (used if assets differ). - function redistribute(uint256 maxVaultSharesPerVault, uint256 minAmountOut, bytes calldata swapData) external; - - // ── Views ─────────────────────────────────────────────────────────── - - /// @notice The peg token used to value HarborYield shares (e.g. haEUR). - /// @dev HarborYield is not a standard ERC-4626 vault (multi-asset, proportional redeem), - /// but exposes `asset()`/`totalAssets`/`convertTo*`/`preview*` for interop with - /// aggregators and price feeds. The mutation surface (`deposit`, `redeem`) is - /// non-standard and does not transact in the peg token directly. - function asset() external view returns (address); - - /// @notice Total value of all managed holdings, in peg units. - function totalAssets() external view returns (uint256); - - /// @notice Convert a peg-unit amount to HarborYield share units at the current rate (rounded down). - function convertToShares(uint256 assets) external view returns (uint256); - - /// @notice Convert a HarborYield share amount to peg units at the current rate (rounded down). - function convertToAssets(uint256 shares) external view returns (uint256); - - /// @notice Preview the shares that would be minted by a hypothetical peg-unit deposit. - /// Informational only; HarborYield's actual `deposit` path uses a vault-specific asset. - function previewDeposit(uint256 assets) external view returns (uint256); - - /// @notice Preview the peg-unit value of redeeming `shares`. - /// Informational only; HarborYield's actual `redeem` pays a proportional mix. - function previewRedeem(uint256 shares) external view returns (uint256); - - /// @notice The number of managed vaults. - function vaultCount() external view returns (uint256); - - /// @notice Get the managed vault info at a given index. - function vaultAt(uint256 index) external view returns (address vault, address asset_, bool active, uint64 weight); - - /// @notice The cached total of all vault weights. - function totalWeight() external view returns (uint256); -} diff --git a/src/interfaces/ISwapper.sol b/src/interfaces/ISwapper.sol deleted file mode 100644 index 3a3785ef..00000000 --- a/src/interfaces/ISwapper.sol +++ /dev/null @@ -1,25 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.28 <0.9.0; - -/// @title ISwapper -/// @notice Generic interface for token-to-token swaps. -/// @dev Implementations may wrap 1inch, Uniswap, Paraswap, or any DEX aggregator. The -/// caller provides adapter-specific route data via the `data` parameter, and uses -/// `minAmountOut` for slippage protection. There is no on-chain preview — real -/// aggregator routes are computed off-chain and passed in via `data`. -interface ISwapper { - /// @notice Swap one token for another. - /// @param fromToken The token to swap from. - /// @param toToken The token to swap to. - /// @param amountIn Amount of fromToken to swap. - /// @param minAmountOut Minimum acceptable output (slippage protection). - /// @param data Adapter-specific route data (e.g., 1inch encoded route). - /// @return amountOut Actual amount of toToken received. - function swap( - address fromToken, - address toToken, - uint256 amountIn, - uint256 minAmountOut, - bytes calldata data - ) external returns (uint256 amountOut); -} diff --git a/test/mocks/MockSwapper.sol b/test/mocks/MockSwapper.sol deleted file mode 100644 index 706b0f8c..00000000 --- a/test/mocks/MockSwapper.sol +++ /dev/null @@ -1,49 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.28 <0.9.0; - -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import {ISwapper} from "@harbor/interfaces/ISwapper.sol"; - -/// @title MockSwapper -/// @notice Fixed-rate swapper for testing. Swaps at a configurable rate with no DEX dependency. -/// @dev Requires pre-funding output tokens via `deal()`. For forge tests only. -contract MockSwapper is ISwapper { - using SafeERC20 for IERC20; - - /// @notice Fixed rate: amountOut = amountIn * rate / 1e18 - uint256 public rate; - - /// @notice If true, the next swap will revert (for testing error handling). - bool public shouldRevert; - - constructor(uint256 rate_) { - rate = rate_; - } - - function setRate(uint256 rate_) external { - rate = rate_; - } - - function setShouldRevert(bool shouldRevert_) external { - shouldRevert = shouldRevert_; - } - - function swap( - address fromToken, - address toToken, - uint256 amountIn, - uint256 minAmountOut, - bytes calldata - ) external override returns (uint256 amountOut) { - if (shouldRevert) { - revert("MockSwapper: forced revert"); - } - - amountOut = (amountIn * rate) / 1e18; - require(amountOut >= minAmountOut, "MockSwapper: slippage"); - - IERC20(fromToken).safeTransferFrom(msg.sender, address(this), amountIn); - IERC20(toToken).safeTransfer(msg.sender, amountOut); - } -} From eaccb16070df4df754c7261496c9dd8fa6a6ade1 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Mon, 27 Apr 2026 09:15:06 +0100 Subject: [PATCH 067/232] move the MockERC4626 into harbor-yield --- test/mocks/MockERC4626Vault.sol | 50 --------------------------------- 1 file changed, 50 deletions(-) delete mode 100644 test/mocks/MockERC4626Vault.sol diff --git a/test/mocks/MockERC4626Vault.sol b/test/mocks/MockERC4626Vault.sol deleted file mode 100644 index 64c6c81b..00000000 --- a/test/mocks/MockERC4626Vault.sol +++ /dev/null @@ -1,50 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.28 <0.9.0; - -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; -import {ERC4626} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; - -import {MockERC20} from "@bao-test/mocks/MockERC20.sol"; - -/// @title MockERC4626Vault -/// @notice Minimal ERC4626 vault for tests. Wraps an underlying MockERC20 and exposes an -/// `addYield` hook that mints extra underlying into the vault, simulating yield accrual. -/// @dev Used by HarborYield_v1 unit tests and anywhere else an ERC4626 stand-in is needed without -/// pulling in the full Minter/SP/AC deployment. -/// -/// Optionally implements the `IAutoCompounder` introspection surface (`PEGGED_TOKEN()` and -/// `MINTER()`) so tests can register a mock as an AC via `HarborYield.addAutoCompounderVault`. -/// Configure via `configureAsAutoCompounder` after construction; both fields default to -/// `address(0)`, i.e. "not an AC" (the getters return zero, which triggers `WrongPegToken` -/// at registration — correct behaviour for a non-AC vault). -contract MockERC4626Vault is ERC4626 { - address public _pegged; - address public _minter; - - constructor(IERC20 asset_, string memory name_, string memory symbol_) ERC4626(asset_) ERC20(name_, symbol_) {} - - /// @dev Drop extra underlying into the vault, simulating yield accrual. - function addYield(uint256 amount) external { - MockERC20(asset()).mint(address(this), amount); - } - - /// @notice Set the values returned by `PEGGED_TOKEN()` and `MINTER()`, so this mock can - /// stand in as an AutoCompounder in HarborYield tests. - function configureAsAutoCompounder(address pegged_, address minter_) external { - _pegged = pegged_; - _minter = minter_; - } - - // solhint-disable func-name-mixedcase - /// @notice `IAutoCompounder.PEGGED_TOKEN()` getter for test registration as an AC. - function PEGGED_TOKEN() external view returns (address) { - return _pegged; - } - - /// @notice `IAutoCompounder.MINTER()` getter for test registration as an AC. - function MINTER() external view returns (address) { - return _minter; - } - // solhint-enable func-name-mixedcase -} From 8121327c09ff5ada057b893e7ff57f04a19cd403 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Mon, 27 Apr 2026 10:26:38 +0100 Subject: [PATCH 068/232] update bao-base --- lib/bao-base | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/bao-base b/lib/bao-base index d4ccb30a..b7c83f46 160000 --- a/lib/bao-base +++ b/lib/bao-base @@ -1 +1 @@ -Subproject commit d4ccb30a255978b8b47f0b63e5817db0a279f679 +Subproject commit b7c83f467cc3e2e76ba930584bee719755ed76fe From db0d2211bb16727269ed146d51c22dcb2cd43cb9 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Mon, 27 Apr 2026 20:07:26 +0100 Subject: [PATCH 069/232] cut doen auto-compounder interface --- src/interfaces/IAutoCompounder.sol | 34 +----------------------------- 1 file changed, 1 insertion(+), 33 deletions(-) diff --git a/src/interfaces/IAutoCompounder.sol b/src/interfaces/IAutoCompounder.sol index 414abe01..7b54973f 100644 --- a/src/interfaces/IAutoCompounder.sol +++ b/src/interfaces/IAutoCompounder.sol @@ -2,41 +2,9 @@ pragma solidity >=0.8.28 <0.9.0; /// @title IAutoCompounder -/// @notice Interface for the Level 1 Auto-Compounder (ERC4626). -/// @dev Wraps a rebasing StabilityPool into a non-rebasing StabilityPool share. +/// @notice Minimal interface used by StabilityPoolManager_v2. Full interface lives in harbor-yield. interface IAutoCompounder { /// @notice Compound pending rewards: claim wrapped collateral, mint pegged tokens, redeposit to SP. - /// Only claims what can be profitably minted. Remainder stays as unclaimed in SP. /// Permissionless - anyone can trigger. function compound() external; - - /// @notice Deposit pegged tokens directly - deposits to SP first, then mints AC shares. - /// @param peggedAmount Amount of pegged tokens to deposit. - /// @param receiver Address to receive the AC shares. - /// @return shares Amount of AC shares minted. - function depositPeggedToken(uint256 peggedAmount, address receiver) external returns (uint256 shares); - - /// @notice The pegged token (e.g., haEUR) that the underlying StabilityPool holds. - /// @dev Exposed as a public immutable on the implementation; here to allow peg verification - /// from upstream holders (e.g., HarborYield) without coupling to the concrete type. - // solhint-disable-next-line func-name-mixedcase - function PEGGED_TOKEN() external view returns (address); - - /// @notice The Minter for this AC's market. - /// @dev Exposed as a public immutable on the implementation; used by upstream holders - /// (e.g., HarborYield) to read `peggedTokenPrice()` for depeg-aware valuation of - /// AC holdings. - // solhint-disable-next-line func-name-mixedcase - function MINTER() external view returns (address); - - /// @notice Claim the caller's proportional share of active SP reward tokens. - /// Claims the AC's full pending rewards from the SP, then forwards - /// `delta × callerShares / totalSupply` to `receiver` for each token. - /// @param receiver Address to receive the claimed tokens. If address(0), tokens go to msg.sender. - function claim(address receiver) external; - - /// @notice Claim the caller's proportional share of historical (unregistered) SP reward tokens. - /// @param tokens The list of historical reward tokens to claim. - /// @param receiver Address to receive the claimed tokens. If address(0), tokens go to msg.sender. - function claimHistorical(address[] memory tokens, address receiver) external; } From 12717ffcaaef947a4d0ad716338a02ba898d7f13 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Mon, 27 Apr 2026 21:00:06 +0100 Subject: [PATCH 070/232] update bao-base --- lib/bao-base | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/bao-base b/lib/bao-base index b7c83f46..b47d9daf 160000 --- a/lib/bao-base +++ b/lib/bao-base @@ -1 +1 @@ -Subproject commit b7c83f467cc3e2e76ba930584bee719755ed76fe +Subproject commit b47d9dafd98cdf3af8fbf938d6e5557bea6f19ee From d3a3aa438d26803f44c153e18547d3d7eb21c8d7 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Tue, 28 Apr 2026 17:07:23 +0100 Subject: [PATCH 071/232] remove unused mock --- lib/bao-base | 2 +- regression/coverage.txt | 2 +- test/mocks/MockMinter.sol | 70 --------------------------------------- 3 files changed, 2 insertions(+), 72 deletions(-) delete mode 100644 test/mocks/MockMinter.sol diff --git a/lib/bao-base b/lib/bao-base index b47d9daf..e183eb18 160000 --- a/lib/bao-base +++ b/lib/bao-base @@ -1 +1 @@ -Subproject commit b47d9dafd98cdf3af8fbf938d6e5557bea6f19ee +Subproject commit e183eb188eb7992ff6eef68d3806da85c418500b diff --git a/regression/coverage.txt b/regression/coverage.txt index f21edd3e..da8f8e04 100644 --- a/regression/coverage.txt +++ b/regression/coverage.txt @@ -65,4 +65,4 @@ | src/util/ERC20MetadataLib_v1.sol | ✓ 100% (24/24) | ✓ 100% (22/22) | ✓ 100% (4/4) | ✓ 100% (4/4) | | src/util/FmtLib.sol | X 79% (15/19) | X 73% (19/26) | X 50% (2/4) | ✓ 100% (1/1) | | src/util/WordCodec.sol | ✓ 100% (15/15) | ✓ 100% (14/14) | ✓ 100% (0/0) | ✓ 100% (4/4) | -| Total | X 55% (4466/8105) | X 54% (4730/8752) | X 42% (392/929) | X 55% (647/1178) | +| Total | X 55% (4466/8065) | X 54% (4730/8725) | X 43% (392/920) | X 55% (647/1166) | diff --git a/test/mocks/MockMinter.sol b/test/mocks/MockMinter.sol deleted file mode 100644 index eb1a6f69..00000000 --- a/test/mocks/MockMinter.sol +++ /dev/null @@ -1,70 +0,0 @@ -// SPDX-License-Identifier: MIT -// coding standards by https://www.rareskills.io/post/solidity-style-guide -// and https://docs.soliditylang.org/en/latest/style-guide.html -pragma solidity 0.8.30; - -import {BaoOwnableRoles} from "@bao/BaoOwnableRoles.sol"; - -contract MockMinter is BaoOwnableRoles /*, IMinter */ { - //////////////// - // Immutables // - //////////////// - - // these variables are set in the constructor, not the initializer, to improve contract size and gas usage - // to change them the contract must be upgraded - /// @custom:oz-upgrades-unsafe-allow state-variable-immutable - address public immutable WRAPPED_COLLATERAL_TOKEN; // this is the wrapped token - /// @custom:oz-upgrades-unsafe-allow state-variable-immutable - address public immutable PEGGED_TOKEN; - /// @custom:oz-upgrades-unsafe-allow state-variable-immutable - address public immutable LEVERAGED_TOKEN; - // the type of burn signature for burning pegged tokens - - /// @notice Configurable pegged-token price. Defaults to 1 ether (pegged 1:1). - /// Tests can lower this to simulate a haXXX depeg. - uint256 private _peggedTokenPrice = 1 ether; - - constructor(address _wrappedCollateralToken, address _peggedToken, address _leveragedToken) { - require(_wrappedCollateralToken != address(0), "MockMinter: zero wrapped collateral"); - require(_peggedToken != address(0), "MockMinter: zero pegged token"); - require(_leveragedToken != address(0), "MockMinter: zero leveraged token"); - WRAPPED_COLLATERAL_TOKEN = _wrappedCollateralToken; - PEGGED_TOKEN = _peggedToken; - LEVERAGED_TOKEN = _leveragedToken; - } - - /// @notice Return the current pegged-token price (18 decimals). Matches `IMinter.peggedTokenPrice`. - function peggedTokenPrice() external view returns (uint256) { - return _peggedTokenPrice; - } - - /// @notice Configure the pegged-token price for test scenarios. - function setPeggedTokenPrice(uint256 price) external { - _peggedTokenPrice = price; - } - - /// @notice Dry-run mint returning price and rate used by HarborYield._computeDistributeMinOut. - /// Returns price = _peggedTokenPrice, rate = 1e18. Other fields are zeroed. - function mintPeggedTokenDryRun( - uint256 /* collateralIn */, - uint256 /* maxFeeRatio */ - ) - external - view - returns ( - int256 incentiveRatio, - uint256 fee, - uint256 collateralTaken, - uint256 peggedMinted, - uint256 price, - uint256 rate - ) - { - incentiveRatio = 0; - fee = 0; - collateralTaken = 0; - peggedMinted = 0; - price = _peggedTokenPrice; - rate = 1 ether; - } -} From 9debeef4683d976c5264aab6a16906dd5dbc4543 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Wed, 29 Apr 2026 15:38:47 +0100 Subject: [PATCH 072/232] update bao-base --- lib/bao-base | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/bao-base b/lib/bao-base index e183eb18..b98e798a 160000 --- a/lib/bao-base +++ b/lib/bao-base @@ -1 +1 @@ -Subproject commit e183eb188eb7992ff6eef68d3806da85c418500b +Subproject commit b98e798a7340cd5df167bb47a003172b17a26955 From 114b18ff4b9a414e766cb6f3621dfd2702cfaa31 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Wed, 29 Apr 2026 16:49:26 +0100 Subject: [PATCH 073/232] update regressions and warnings fixed fixable bare and relative import issues removed unneeded files --- .validate-ignore | 32 ++++++++++++++++--- foundry.lock | 2 +- lib/bao-base | 2 +- regression/gas.txt | 4 +-- script/config/ConfigTokenNames.sol | 2 +- .../ConfigCollateral_fxUSD_mainnet.sol | 2 +- .../ConfigCollateral_stETH_mainnet.sol | 2 +- .../ConfigMarket_BTC_fxUSD_mainnet.sol | 16 +++++----- .../ConfigMarket_BTC_stETH_mainnet.sol | 16 +++++----- .../ConfigMarket_ETH_fxUSD_mainnet.sol | 16 +++++----- .../ConfigMarket_EUR_fxUSD_mainnet.sol | 16 +++++----- .../ConfigMarket_EUR_stETH_mainnet.sol | 16 +++++----- .../ConfigMarket_GOLD_fxUSD_mainnet.sol | 16 +++++----- .../ConfigMarket_GOLD_stETH_mainnet.sol | 16 +++++----- .../ConfigMarket_MCAP_fxUSD_mainnet.sol | 16 +++++----- .../ConfigMarket_MCAP_stETH_mainnet.sol | 16 +++++----- .../ConfigMarket_SILVER_fxUSD_mainnet.sol | 16 +++++----- .../ConfigMarket_SILVER_stETH_mainnet.sol | 16 +++++----- script/config/pegs/ConfigPeg.sol | 2 +- script/config/pegs/ConfigPeg_BTC.sol | 2 +- script/config/pegs/ConfigPeg_ETH.sol | 2 +- script/config/pegs/ConfigPeg_EUR.sol | 2 +- script/config/pegs/ConfigPeg_GOLD.sol | 2 +- script/config/pegs/ConfigPeg_MCAP.sol | 2 +- script/config/pegs/ConfigPeg_SILVER.sol | 2 +- .../ConfigStabilityPoolManagerCommon.sol | 2 +- .../volatility/ConfigPriceVolatility_105.sol | 2 +- .../ConfigPriceVolatility_105_stable.sol | 2 +- .../volatility/ConfigPriceVolatility_115.sol | 2 +- .../ConfigPriceVolatility_115_stable.sol | 2 +- .../volatility/ConfigPriceVolatility_125.sol | 2 +- .../ConfigPriceVolatility_125_stable.sol | 2 +- .../volatility/ConfigPriceVolatility_130.sol | 2 +- .../ConfigPriceVolatility_130_stable.sol | 2 +- script/src/Deploy_BTC_Minter.sol | 2 +- script/src/Deploy_ETH_Minter.sol | 2 +- script/src/Deploy_EUR_Minter.sol | 2 +- script/src/Deploy_GOLD_Minter.sol | 2 +- script/src/Deploy_MCAP_Minter.sol | 2 +- script/src/Deploy_SILVER_Minter.sol | 2 +- script/src/MinterDeployer.sol | 12 +++---- .../LinearMultipleRewardDistributor_v3.sol | 2 +- test/deployment/RebalanceFairnessScan.t.sol | 22 ++++++------- 43 files changed, 162 insertions(+), 140 deletions(-) diff --git a/.validate-ignore b/.validate-ignore index dcd810b5..94c33e3e 100644 --- a/.validate-ignore +++ b/.validate-ignore @@ -1,5 +1,5 @@ # Syntax: check file -# Supported checks: naming, pragma, storage, upgrades +# Supported checks: naming, pragma, storage, upgrades, base-imports, relative-imports # These contracts predate the filename=contract-name convention. The class # names intentionally omit the _v2 suffix (they are abstract bases, not @@ -7,7 +7,29 @@ naming src/reward/accumulator/MultipleRewardCompoundingAccumulator_v2.sol naming src/reward/distributor/LinearMultipleRewardDistributor_v2.sol -# One-off remediation contract deployed during the 1.2 incident response. -# Uses a version range intentionally so it can be compiled against any -# compatible compiler; it was never intended to be a long-lived deployable. -pragma src/minter/PostRebalanceRemediationForStabilityPool_v2.sol +# Deployed contracts that predate the remapped-import convention. +# Their source files cannot be modified (audit traceability). +bare-imports src/reward/accumulator/MultipleRewardCompoundingAccumulator.sol +bare-imports src/reward/accumulator/MultipleRewardCompoundingAccumulator_v2.sol +bare-imports src/reward/distributor/LinearMultipleRewardDistributor.sol +bare-imports src/reward/distributor/LinearMultipleRewardDistributor_v2.sol +bare-imports src/price/StakedETHWrappedPriceOracle_v1.sol +bare-imports src/price/PriceOracle_v1.sol +bare-imports src/minter/StabilityPoolManager_v1.sol +bare-imports src/minter/library/ConfigIncentiveLib.sol +bare-imports src/minter/library/Config_v1.sol +bare-imports src/minter/ReservePool_v1.sol +bare-imports src/minter/StabilityPool_v1.sol +bare-imports src/minter/StabilityPool_v2.sol +bare-imports src/minter/TokenDistributor_v1.sol +bare-imports src/minter/Minter_v1.sol +bare-imports src/minter/Minter_v2.sol +bare-imports src/minter/Genesis_v1.sol + +# Deployed contracts and interfaces transitively imported by them — +# cannot be modified (audit traceability). +relative-imports src/reward/distributor/LinearMultipleRewardDistributor.sol +relative-imports src/reward/distributor/LinearMultipleRewardDistributor_v2.sol +relative-imports src/price/StakedETHWrappedPriceOracle_v1.sol +relative-imports src/interfaces/IPriceOracle.sol +relative-imports src/interfaces/IWrappedPriceOracle.sol diff --git a/foundry.lock b/foundry.lock index 90de2ff7..1420e67a 100644 --- a/foundry.lock +++ b/foundry.lock @@ -1,6 +1,6 @@ { "lib/bao-base": { - "rev": "2016ba72c99dc5a0c2cc46cd3f715160ca8a63d4" + "rev": "b98e798a7340cd5df167bb47a003172b17a26955" }, "lib/chainlink-brownie-contracts": { "rev": "5cb41fbc9b525338b6098da5ea7dd0b7e92f89e4" diff --git a/lib/bao-base b/lib/bao-base index b98e798a..e69cf27c 160000 --- a/lib/bao-base +++ b/lib/bao-base @@ -1 +1 @@ -Subproject commit b98e798a7340cd5df167bb47a003172b17a26955 +Subproject commit e69cf27cce81d4e2902bad1776edfdfeec4de553 diff --git a/regression/gas.txt b/regression/gas.txt index a1e0842d..91ab7381 100644 --- a/regression/gas.txt +++ b/regression/gas.txt @@ -46,7 +46,7 @@ src/minter/Minter_v3.sol:Minter_v3 | mintPeggedToken(uint256,address,uint256) | 1.911e+05 | | mintPeggedToken(uint256,address,uint256,uint256) | 1.251e+05 | | mintPeggedTokenDryRun(uint256) | 6.401e+04 | -| mintPeggedTokenDryRun(uint256,uint256) | 3.644e+04 | +| mintPeggedTokenDryRun(uint256,uint256) | 4.556e+04 | | mintPeggedTokenIncentiveRatio | 3.012e+04 | | owner | 2.402e+03 | | peggedTokenBalance | 2.409e+03 | @@ -57,7 +57,7 @@ src/minter/Minter_v3.sol:Minter_v3 | redeemLeveragedTokenIncentiveRatio | 2.924e+04 | | redeemPeggedForCollateralRatio | 1.961e+04 | | redeemPeggedToken | 1.323e+05 | -| redeemPeggedTokenDryRun | 6.178e+04 | +| redeemPeggedTokenDryRun | 6.170e+04 | | redeemPeggedTokenIncentiveRatio | 3.011e+04 | | reservePool | 2.411e+03 | | reset | 2.869e+04 | diff --git a/script/config/ConfigTokenNames.sol b/script/config/ConfigTokenNames.sol index 7008c8d1..654cff60 100644 --- a/script/config/ConfigTokenNames.sol +++ b/script/config/ConfigTokenNames.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {LibString} from "@solady/utils/LibString.sol"; -import {IMarketConfig} from "./ConfigBase.sol"; +import {IMarketConfig} from "@harbor-script/config/ConfigBase.sol"; /// @notice Mixin that derives all Harbor token names and symbols from peg() and collateral(). /// @dev Inherited by market configs alongside ConfigPeg and ConfigCollateral. diff --git a/script/config/collaterals/ConfigCollateral_fxUSD_mainnet.sol b/script/config/collaterals/ConfigCollateral_fxUSD_mainnet.sol index 81c1f48c..56221ec4 100644 --- a/script/config/collaterals/ConfigCollateral_fxUSD_mainnet.sol +++ b/script/config/collaterals/ConfigCollateral_fxUSD_mainnet.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.28 <0.9.0; -import {ConfigChain_mainnet} from "../chains/ConfigChain_mainnet.sol"; +import {ConfigChain_mainnet} from "@harbor-script/config/chains/ConfigChain_mainnet.sol"; /// @notice Collateral configuration for fxUSD markets. /// @dev Addresses come from the chain config that this is composed with. diff --git a/script/config/collaterals/ConfigCollateral_stETH_mainnet.sol b/script/config/collaterals/ConfigCollateral_stETH_mainnet.sol index 6a95a0d3..d6faff63 100644 --- a/script/config/collaterals/ConfigCollateral_stETH_mainnet.sol +++ b/script/config/collaterals/ConfigCollateral_stETH_mainnet.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.28 <0.9.0; -import {ConfigChain_mainnet} from "../chains/ConfigChain_mainnet.sol"; +import {ConfigChain_mainnet} from "@harbor-script/config/chains/ConfigChain_mainnet.sol"; /// @notice Collateral configuration for stETH markets. /// @dev Addresses come from the chain config that this is composed with. diff --git a/script/config/markets/ConfigMarket_BTC_fxUSD_mainnet.sol b/script/config/markets/ConfigMarket_BTC_fxUSD_mainnet.sol index b102c55e..f6cc45c6 100644 --- a/script/config/markets/ConfigMarket_BTC_fxUSD_mainnet.sol +++ b/script/config/markets/ConfigMarket_BTC_fxUSD_mainnet.sol @@ -1,14 +1,14 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.28 <0.9.0; -import {ConfigChain_mainnet} from "../chains/ConfigChain_mainnet.sol"; -import {ConfigPeg_BTC} from "../pegs/ConfigPeg_BTC.sol"; -import {ConfigCollateral_fxUSD_mainnet} from "../collaterals/ConfigCollateral_fxUSD_mainnet.sol"; -import {ConfigPriceVolatility_130_stable} from "../volatility/ConfigPriceVolatility_130_stable.sol"; -import {ConfigStabilityPool} from "../stabilitypool/ConfigStabilityPool.sol"; -import {ConfigStabilityPoolManager} from "../stabilitypool/ConfigStabilityPoolManager.sol"; -import {Config_MinterMarket} from "../ConfigBase.sol"; -import {ConfigTokenNames} from "../ConfigTokenNames.sol"; +import {ConfigChain_mainnet} from "@harbor-script/config/chains/ConfigChain_mainnet.sol"; +import {ConfigPeg_BTC} from "@harbor-script/config/pegs/ConfigPeg_BTC.sol"; +import {ConfigCollateral_fxUSD_mainnet} from "@harbor-script/config/collaterals/ConfigCollateral_fxUSD_mainnet.sol"; +import {ConfigPriceVolatility_130_stable} from "@harbor-script/config/volatility/ConfigPriceVolatility_130_stable.sol"; +import {ConfigStabilityPool} from "@harbor-script/config/stabilitypool/ConfigStabilityPool.sol"; +import {ConfigStabilityPoolManager} from "@harbor-script/config/stabilitypool/ConfigStabilityPoolManager.sol"; +import {Config_MinterMarket} from "@harbor-script/config/ConfigBase.sol"; +import {ConfigTokenNames} from "@harbor-script/config/ConfigTokenNames.sol"; /// @notice Market configuration for BTC::fxUSD. contract ConfigMarket_BTC_fxUSD_mainnet is diff --git a/script/config/markets/ConfigMarket_BTC_stETH_mainnet.sol b/script/config/markets/ConfigMarket_BTC_stETH_mainnet.sol index b75285d7..be5d9be4 100644 --- a/script/config/markets/ConfigMarket_BTC_stETH_mainnet.sol +++ b/script/config/markets/ConfigMarket_BTC_stETH_mainnet.sol @@ -1,14 +1,14 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.28 <0.9.0; -import {ConfigChain_mainnet} from "../chains/ConfigChain_mainnet.sol"; -import {ConfigPeg_BTC} from "../pegs/ConfigPeg_BTC.sol"; -import {ConfigCollateral_stETH_mainnet} from "../collaterals/ConfigCollateral_stETH_mainnet.sol"; -import {ConfigPriceVolatility_125_stable} from "../volatility/ConfigPriceVolatility_125_stable.sol"; -import {ConfigStabilityPool} from "../stabilitypool/ConfigStabilityPool.sol"; -import {ConfigStabilityPoolManager} from "../stabilitypool/ConfigStabilityPoolManager.sol"; -import {Config_MinterMarket} from "../ConfigBase.sol"; -import {ConfigTokenNames} from "../ConfigTokenNames.sol"; +import {ConfigChain_mainnet} from "@harbor-script/config/chains/ConfigChain_mainnet.sol"; +import {ConfigPeg_BTC} from "@harbor-script/config/pegs/ConfigPeg_BTC.sol"; +import {ConfigCollateral_stETH_mainnet} from "@harbor-script/config/collaterals/ConfigCollateral_stETH_mainnet.sol"; +import {ConfigPriceVolatility_125_stable} from "@harbor-script/config/volatility/ConfigPriceVolatility_125_stable.sol"; +import {ConfigStabilityPool} from "@harbor-script/config/stabilitypool/ConfigStabilityPool.sol"; +import {ConfigStabilityPoolManager} from "@harbor-script/config/stabilitypool/ConfigStabilityPoolManager.sol"; +import {Config_MinterMarket} from "@harbor-script/config/ConfigBase.sol"; +import {ConfigTokenNames} from "@harbor-script/config/ConfigTokenNames.sol"; /// @notice Market configuration for BTC::stETH. contract ConfigMarket_BTC_stETH_mainnet is diff --git a/script/config/markets/ConfigMarket_ETH_fxUSD_mainnet.sol b/script/config/markets/ConfigMarket_ETH_fxUSD_mainnet.sol index dd64f4e3..5b413e57 100644 --- a/script/config/markets/ConfigMarket_ETH_fxUSD_mainnet.sol +++ b/script/config/markets/ConfigMarket_ETH_fxUSD_mainnet.sol @@ -1,14 +1,14 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.28 <0.9.0; -import {ConfigChain_mainnet} from "../chains/ConfigChain_mainnet.sol"; -import {ConfigPeg_ETH} from "../pegs/ConfigPeg_ETH.sol"; -import {ConfigCollateral_fxUSD_mainnet} from "../collaterals/ConfigCollateral_fxUSD_mainnet.sol"; -import {ConfigPriceVolatility_130_stable} from "../volatility/ConfigPriceVolatility_130_stable.sol"; -import {ConfigStabilityPool} from "../stabilitypool/ConfigStabilityPool.sol"; -import {ConfigStabilityPoolManager} from "../stabilitypool/ConfigStabilityPoolManager.sol"; -import {ConfigTokenNames} from "../ConfigTokenNames.sol"; -import {Config_MinterMarket} from "../ConfigBase.sol"; +import {ConfigChain_mainnet} from "@harbor-script/config/chains/ConfigChain_mainnet.sol"; +import {ConfigPeg_ETH} from "@harbor-script/config/pegs/ConfigPeg_ETH.sol"; +import {ConfigCollateral_fxUSD_mainnet} from "@harbor-script/config/collaterals/ConfigCollateral_fxUSD_mainnet.sol"; +import {ConfigPriceVolatility_130_stable} from "@harbor-script/config/volatility/ConfigPriceVolatility_130_stable.sol"; +import {ConfigStabilityPool} from "@harbor-script/config/stabilitypool/ConfigStabilityPool.sol"; +import {ConfigStabilityPoolManager} from "@harbor-script/config/stabilitypool/ConfigStabilityPoolManager.sol"; +import {ConfigTokenNames} from "@harbor-script/config/ConfigTokenNames.sol"; +import {Config_MinterMarket} from "@harbor-script/config/ConfigBase.sol"; /// @notice Market configuration for ETH::fxUSD. contract ConfigMarket_ETH_fxUSD_mainnet is diff --git a/script/config/markets/ConfigMarket_EUR_fxUSD_mainnet.sol b/script/config/markets/ConfigMarket_EUR_fxUSD_mainnet.sol index 6dfb657e..f553156f 100644 --- a/script/config/markets/ConfigMarket_EUR_fxUSD_mainnet.sol +++ b/script/config/markets/ConfigMarket_EUR_fxUSD_mainnet.sol @@ -1,14 +1,14 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.28 <0.9.0; -import {ConfigChain_mainnet} from "../chains/ConfigChain_mainnet.sol"; -import {ConfigPeg_EUR} from "../pegs/ConfigPeg_EUR.sol"; -import {ConfigCollateral_fxUSD_mainnet} from "../collaterals/ConfigCollateral_fxUSD_mainnet.sol"; -import {ConfigPriceVolatility_105} from "../volatility/ConfigPriceVolatility_105.sol"; -import {ConfigStabilityPool} from "../stabilitypool/ConfigStabilityPool.sol"; -import {ConfigStabilityPoolManager} from "../stabilitypool/ConfigStabilityPoolManager.sol"; -import {Config_MinterMarket} from "../ConfigBase.sol"; -import {ConfigTokenNames} from "../ConfigTokenNames.sol"; +import {ConfigChain_mainnet} from "@harbor-script/config/chains/ConfigChain_mainnet.sol"; +import {ConfigPeg_EUR} from "@harbor-script/config/pegs/ConfigPeg_EUR.sol"; +import {ConfigCollateral_fxUSD_mainnet} from "@harbor-script/config/collaterals/ConfigCollateral_fxUSD_mainnet.sol"; +import {ConfigPriceVolatility_105} from "@harbor-script/config/volatility/ConfigPriceVolatility_105.sol"; +import {ConfigStabilityPool} from "@harbor-script/config/stabilitypool/ConfigStabilityPool.sol"; +import {ConfigStabilityPoolManager} from "@harbor-script/config/stabilitypool/ConfigStabilityPoolManager.sol"; +import {Config_MinterMarket} from "@harbor-script/config/ConfigBase.sol"; +import {ConfigTokenNames} from "@harbor-script/config/ConfigTokenNames.sol"; /// @notice Market configuration for EUR::fxUSD. contract ConfigMarket_EUR_fxUSD_mainnet is diff --git a/script/config/markets/ConfigMarket_EUR_stETH_mainnet.sol b/script/config/markets/ConfigMarket_EUR_stETH_mainnet.sol index deafde1c..e913318e 100644 --- a/script/config/markets/ConfigMarket_EUR_stETH_mainnet.sol +++ b/script/config/markets/ConfigMarket_EUR_stETH_mainnet.sol @@ -1,14 +1,14 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.28 <0.9.0; -import {ConfigChain_mainnet} from "../chains/ConfigChain_mainnet.sol"; -import {ConfigPeg_EUR} from "../pegs/ConfigPeg_EUR.sol"; -import {ConfigCollateral_stETH_mainnet} from "../collaterals/ConfigCollateral_stETH_mainnet.sol"; -import {ConfigPriceVolatility_130} from "../volatility/ConfigPriceVolatility_130.sol"; -import {ConfigStabilityPool} from "../stabilitypool/ConfigStabilityPool.sol"; -import {ConfigStabilityPoolManager} from "../stabilitypool/ConfigStabilityPoolManager.sol"; -import {Config_MinterMarket} from "../ConfigBase.sol"; -import {ConfigTokenNames} from "../ConfigTokenNames.sol"; +import {ConfigChain_mainnet} from "@harbor-script/config/chains/ConfigChain_mainnet.sol"; +import {ConfigPeg_EUR} from "@harbor-script/config/pegs/ConfigPeg_EUR.sol"; +import {ConfigCollateral_stETH_mainnet} from "@harbor-script/config/collaterals/ConfigCollateral_stETH_mainnet.sol"; +import {ConfigPriceVolatility_130} from "@harbor-script/config/volatility/ConfigPriceVolatility_130.sol"; +import {ConfigStabilityPool} from "@harbor-script/config/stabilitypool/ConfigStabilityPool.sol"; +import {ConfigStabilityPoolManager} from "@harbor-script/config/stabilitypool/ConfigStabilityPoolManager.sol"; +import {Config_MinterMarket} from "@harbor-script/config/ConfigBase.sol"; +import {ConfigTokenNames} from "@harbor-script/config/ConfigTokenNames.sol"; /// @notice Market configuration for EUR::stETH. contract ConfigMarket_EUR_stETH_mainnet is diff --git a/script/config/markets/ConfigMarket_GOLD_fxUSD_mainnet.sol b/script/config/markets/ConfigMarket_GOLD_fxUSD_mainnet.sol index 3fb856af..d067606d 100644 --- a/script/config/markets/ConfigMarket_GOLD_fxUSD_mainnet.sol +++ b/script/config/markets/ConfigMarket_GOLD_fxUSD_mainnet.sol @@ -1,14 +1,14 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.28 <0.9.0; -import {ConfigChain_mainnet} from "../chains/ConfigChain_mainnet.sol"; -import {ConfigPeg_GOLD} from "../pegs/ConfigPeg_GOLD.sol"; -import {ConfigCollateral_fxUSD_mainnet} from "../collaterals/ConfigCollateral_fxUSD_mainnet.sol"; -import {ConfigPriceVolatility_115} from "../volatility/ConfigPriceVolatility_115.sol"; -import {ConfigStabilityPool} from "../stabilitypool/ConfigStabilityPool.sol"; -import {ConfigStabilityPoolManager} from "../stabilitypool/ConfigStabilityPoolManager.sol"; -import {Config_MinterMarket} from "../ConfigBase.sol"; -import {ConfigTokenNames} from "../ConfigTokenNames.sol"; +import {ConfigChain_mainnet} from "@harbor-script/config/chains/ConfigChain_mainnet.sol"; +import {ConfigPeg_GOLD} from "@harbor-script/config/pegs/ConfigPeg_GOLD.sol"; +import {ConfigCollateral_fxUSD_mainnet} from "@harbor-script/config/collaterals/ConfigCollateral_fxUSD_mainnet.sol"; +import {ConfigPriceVolatility_115} from "@harbor-script/config/volatility/ConfigPriceVolatility_115.sol"; +import {ConfigStabilityPool} from "@harbor-script/config/stabilitypool/ConfigStabilityPool.sol"; +import {ConfigStabilityPoolManager} from "@harbor-script/config/stabilitypool/ConfigStabilityPoolManager.sol"; +import {Config_MinterMarket} from "@harbor-script/config/ConfigBase.sol"; +import {ConfigTokenNames} from "@harbor-script/config/ConfigTokenNames.sol"; /// @notice Market configuration for GOLD::fxUSD. contract ConfigMarket_GOLD_fxUSD_mainnet is diff --git a/script/config/markets/ConfigMarket_GOLD_stETH_mainnet.sol b/script/config/markets/ConfigMarket_GOLD_stETH_mainnet.sol index c22187c1..290c5ef6 100644 --- a/script/config/markets/ConfigMarket_GOLD_stETH_mainnet.sol +++ b/script/config/markets/ConfigMarket_GOLD_stETH_mainnet.sol @@ -1,14 +1,14 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.28 <0.9.0; -import {ConfigChain_mainnet} from "../chains/ConfigChain_mainnet.sol"; -import {ConfigPeg_GOLD} from "../pegs/ConfigPeg_GOLD.sol"; -import {ConfigCollateral_stETH_mainnet} from "../collaterals/ConfigCollateral_stETH_mainnet.sol"; -import {ConfigPriceVolatility_130} from "../volatility/ConfigPriceVolatility_130.sol"; -import {ConfigStabilityPool} from "../stabilitypool/ConfigStabilityPool.sol"; -import {ConfigStabilityPoolManager} from "../stabilitypool/ConfigStabilityPoolManager.sol"; -import {Config_MinterMarket} from "../ConfigBase.sol"; -import {ConfigTokenNames} from "../ConfigTokenNames.sol"; +import {ConfigChain_mainnet} from "@harbor-script/config/chains/ConfigChain_mainnet.sol"; +import {ConfigPeg_GOLD} from "@harbor-script/config/pegs/ConfigPeg_GOLD.sol"; +import {ConfigCollateral_stETH_mainnet} from "@harbor-script/config/collaterals/ConfigCollateral_stETH_mainnet.sol"; +import {ConfigPriceVolatility_130} from "@harbor-script/config/volatility/ConfigPriceVolatility_130.sol"; +import {ConfigStabilityPool} from "@harbor-script/config/stabilitypool/ConfigStabilityPool.sol"; +import {ConfigStabilityPoolManager} from "@harbor-script/config/stabilitypool/ConfigStabilityPoolManager.sol"; +import {Config_MinterMarket} from "@harbor-script/config/ConfigBase.sol"; +import {ConfigTokenNames} from "@harbor-script/config/ConfigTokenNames.sol"; /// @notice Market configuration for GOLD::stETH. contract ConfigMarket_GOLD_stETH_mainnet is diff --git a/script/config/markets/ConfigMarket_MCAP_fxUSD_mainnet.sol b/script/config/markets/ConfigMarket_MCAP_fxUSD_mainnet.sol index 4f6877b0..04594278 100644 --- a/script/config/markets/ConfigMarket_MCAP_fxUSD_mainnet.sol +++ b/script/config/markets/ConfigMarket_MCAP_fxUSD_mainnet.sol @@ -1,14 +1,14 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.28 <0.9.0; -import {ConfigChain_mainnet} from "../chains/ConfigChain_mainnet.sol"; -import {ConfigPeg_MCAP} from "../pegs/ConfigPeg_MCAP.sol"; -import {ConfigCollateral_fxUSD_mainnet} from "../collaterals/ConfigCollateral_fxUSD_mainnet.sol"; -import {ConfigPriceVolatility_130} from "../volatility/ConfigPriceVolatility_130.sol"; -import {ConfigStabilityPool} from "../stabilitypool/ConfigStabilityPool.sol"; -import {ConfigStabilityPoolManager} from "../stabilitypool/ConfigStabilityPoolManager.sol"; -import {Config_MinterMarket} from "../ConfigBase.sol"; -import {ConfigTokenNames} from "../ConfigTokenNames.sol"; +import {ConfigChain_mainnet} from "@harbor-script/config/chains/ConfigChain_mainnet.sol"; +import {ConfigPeg_MCAP} from "@harbor-script/config/pegs/ConfigPeg_MCAP.sol"; +import {ConfigCollateral_fxUSD_mainnet} from "@harbor-script/config/collaterals/ConfigCollateral_fxUSD_mainnet.sol"; +import {ConfigPriceVolatility_130} from "@harbor-script/config/volatility/ConfigPriceVolatility_130.sol"; +import {ConfigStabilityPool} from "@harbor-script/config/stabilitypool/ConfigStabilityPool.sol"; +import {ConfigStabilityPoolManager} from "@harbor-script/config/stabilitypool/ConfigStabilityPoolManager.sol"; +import {Config_MinterMarket} from "@harbor-script/config/ConfigBase.sol"; +import {ConfigTokenNames} from "@harbor-script/config/ConfigTokenNames.sol"; /// @notice Market configuration for MCAP::fxUSD. contract ConfigMarket_MCAP_fxUSD_mainnet is diff --git a/script/config/markets/ConfigMarket_MCAP_stETH_mainnet.sol b/script/config/markets/ConfigMarket_MCAP_stETH_mainnet.sol index 67ce8d2d..5bb5277f 100644 --- a/script/config/markets/ConfigMarket_MCAP_stETH_mainnet.sol +++ b/script/config/markets/ConfigMarket_MCAP_stETH_mainnet.sol @@ -1,14 +1,14 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.28 <0.9.0; -import {ConfigChain_mainnet} from "../chains/ConfigChain_mainnet.sol"; -import {ConfigPeg_MCAP} from "../pegs/ConfigPeg_MCAP.sol"; -import {ConfigCollateral_stETH_mainnet} from "../collaterals/ConfigCollateral_stETH_mainnet.sol"; -import {ConfigPriceVolatility_130} from "../volatility/ConfigPriceVolatility_130.sol"; -import {ConfigStabilityPool} from "../stabilitypool/ConfigStabilityPool.sol"; -import {ConfigStabilityPoolManager} from "../stabilitypool/ConfigStabilityPoolManager.sol"; -import {Config_MinterMarket} from "../ConfigBase.sol"; -import {ConfigTokenNames} from "../ConfigTokenNames.sol"; +import {ConfigChain_mainnet} from "@harbor-script/config/chains/ConfigChain_mainnet.sol"; +import {ConfigPeg_MCAP} from "@harbor-script/config/pegs/ConfigPeg_MCAP.sol"; +import {ConfigCollateral_stETH_mainnet} from "@harbor-script/config/collaterals/ConfigCollateral_stETH_mainnet.sol"; +import {ConfigPriceVolatility_130} from "@harbor-script/config/volatility/ConfigPriceVolatility_130.sol"; +import {ConfigStabilityPool} from "@harbor-script/config/stabilitypool/ConfigStabilityPool.sol"; +import {ConfigStabilityPoolManager} from "@harbor-script/config/stabilitypool/ConfigStabilityPoolManager.sol"; +import {Config_MinterMarket} from "@harbor-script/config/ConfigBase.sol"; +import {ConfigTokenNames} from "@harbor-script/config/ConfigTokenNames.sol"; /// @notice Market configuration for MCAP::stETH. contract ConfigMarket_MCAP_stETH_mainnet is diff --git a/script/config/markets/ConfigMarket_SILVER_fxUSD_mainnet.sol b/script/config/markets/ConfigMarket_SILVER_fxUSD_mainnet.sol index 0bd45d78..bcfcc332 100644 --- a/script/config/markets/ConfigMarket_SILVER_fxUSD_mainnet.sol +++ b/script/config/markets/ConfigMarket_SILVER_fxUSD_mainnet.sol @@ -1,14 +1,14 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.28 <0.9.0; -import {ConfigChain_mainnet} from "../chains/ConfigChain_mainnet.sol"; -import {ConfigPeg_SILVER} from "../pegs/ConfigPeg_SILVER.sol"; -import {ConfigCollateral_fxUSD_mainnet} from "../collaterals/ConfigCollateral_fxUSD_mainnet.sol"; -import {ConfigPriceVolatility_125} from "../volatility/ConfigPriceVolatility_125.sol"; -import {ConfigStabilityPool} from "../stabilitypool/ConfigStabilityPool.sol"; -import {ConfigStabilityPoolManager} from "../stabilitypool/ConfigStabilityPoolManager.sol"; -import {Config_MinterMarket} from "../ConfigBase.sol"; -import {ConfigTokenNames} from "../ConfigTokenNames.sol"; +import {ConfigChain_mainnet} from "@harbor-script/config/chains/ConfigChain_mainnet.sol"; +import {ConfigPeg_SILVER} from "@harbor-script/config/pegs/ConfigPeg_SILVER.sol"; +import {ConfigCollateral_fxUSD_mainnet} from "@harbor-script/config/collaterals/ConfigCollateral_fxUSD_mainnet.sol"; +import {ConfigPriceVolatility_125} from "@harbor-script/config/volatility/ConfigPriceVolatility_125.sol"; +import {ConfigStabilityPool} from "@harbor-script/config/stabilitypool/ConfigStabilityPool.sol"; +import {ConfigStabilityPoolManager} from "@harbor-script/config/stabilitypool/ConfigStabilityPoolManager.sol"; +import {Config_MinterMarket} from "@harbor-script/config/ConfigBase.sol"; +import {ConfigTokenNames} from "@harbor-script/config/ConfigTokenNames.sol"; /// @notice Market configuration for SILVER::fxUSD. contract ConfigMarket_SILVER_fxUSD_mainnet is diff --git a/script/config/markets/ConfigMarket_SILVER_stETH_mainnet.sol b/script/config/markets/ConfigMarket_SILVER_stETH_mainnet.sol index b7657f73..660124c9 100644 --- a/script/config/markets/ConfigMarket_SILVER_stETH_mainnet.sol +++ b/script/config/markets/ConfigMarket_SILVER_stETH_mainnet.sol @@ -1,14 +1,14 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.28 <0.9.0; -import {ConfigChain_mainnet} from "../chains/ConfigChain_mainnet.sol"; -import {ConfigPeg_SILVER} from "../pegs/ConfigPeg_SILVER.sol"; -import {ConfigCollateral_stETH_mainnet} from "../collaterals/ConfigCollateral_stETH_mainnet.sol"; -import {ConfigPriceVolatility_130} from "../volatility/ConfigPriceVolatility_130.sol"; -import {ConfigStabilityPool} from "../stabilitypool/ConfigStabilityPool.sol"; -import {ConfigStabilityPoolManager} from "../stabilitypool/ConfigStabilityPoolManager.sol"; -import {Config_MinterMarket} from "../ConfigBase.sol"; -import {ConfigTokenNames} from "../ConfigTokenNames.sol"; +import {ConfigChain_mainnet} from "@harbor-script/config/chains/ConfigChain_mainnet.sol"; +import {ConfigPeg_SILVER} from "@harbor-script/config/pegs/ConfigPeg_SILVER.sol"; +import {ConfigCollateral_stETH_mainnet} from "@harbor-script/config/collaterals/ConfigCollateral_stETH_mainnet.sol"; +import {ConfigPriceVolatility_130} from "@harbor-script/config/volatility/ConfigPriceVolatility_130.sol"; +import {ConfigStabilityPool} from "@harbor-script/config/stabilitypool/ConfigStabilityPool.sol"; +import {ConfigStabilityPoolManager} from "@harbor-script/config/stabilitypool/ConfigStabilityPoolManager.sol"; +import {Config_MinterMarket} from "@harbor-script/config/ConfigBase.sol"; +import {ConfigTokenNames} from "@harbor-script/config/ConfigTokenNames.sol"; /// @notice Market configuration for SILVER::stETH. contract ConfigMarket_SILVER_stETH_mainnet is diff --git a/script/config/pegs/ConfigPeg.sol b/script/config/pegs/ConfigPeg.sol index 287be7fc..b818549a 100644 --- a/script/config/pegs/ConfigPeg.sol +++ b/script/config/pegs/ConfigPeg.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.28 <0.9.0; -import {ConfigBase} from "../ConfigBase.sol"; +import {ConfigBase} from "@harbor-script/config/ConfigBase.sol"; import {LibString} from "@solady/utils/LibString.sol"; /// @notice Base contract for peg configurations. diff --git a/script/config/pegs/ConfigPeg_BTC.sol b/script/config/pegs/ConfigPeg_BTC.sol index c5361863..73922cd8 100644 --- a/script/config/pegs/ConfigPeg_BTC.sol +++ b/script/config/pegs/ConfigPeg_BTC.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.28 <0.9.0; -import {ConfigPeg} from "./ConfigPeg.sol"; +import {ConfigPeg} from "@harbor-script/config/pegs/ConfigPeg.sol"; /// @notice Configuration for BTC peg. /// @dev Deployed pegged token: haBTC ("Harbor anchored BTC") diff --git a/script/config/pegs/ConfigPeg_ETH.sol b/script/config/pegs/ConfigPeg_ETH.sol index 94c392d5..52fb2a04 100644 --- a/script/config/pegs/ConfigPeg_ETH.sol +++ b/script/config/pegs/ConfigPeg_ETH.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.28 <0.9.0; -import {ConfigPeg} from "./ConfigPeg.sol"; +import {ConfigPeg} from "@harbor-script/config/pegs/ConfigPeg.sol"; /// @notice Configuration for ETH peg. /// @dev Deployed pegged token: haETH ("Harbor anchored ETH") diff --git a/script/config/pegs/ConfigPeg_EUR.sol b/script/config/pegs/ConfigPeg_EUR.sol index 703aa65f..ebca5a97 100644 --- a/script/config/pegs/ConfigPeg_EUR.sol +++ b/script/config/pegs/ConfigPeg_EUR.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.28 <0.9.0; -import {ConfigPeg} from "./ConfigPeg.sol"; +import {ConfigPeg} from "@harbor-script/config/pegs/ConfigPeg.sol"; /// @notice Configuration for EUR peg. /// @dev Deployed pegged token: haEUR ("Harbor anchored EUR") diff --git a/script/config/pegs/ConfigPeg_GOLD.sol b/script/config/pegs/ConfigPeg_GOLD.sol index 1bf9b3fa..2a8cfb5e 100644 --- a/script/config/pegs/ConfigPeg_GOLD.sol +++ b/script/config/pegs/ConfigPeg_GOLD.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.28 <0.9.0; -import {ConfigPeg} from "./ConfigPeg.sol"; +import {ConfigPeg} from "@harbor-script/config/pegs/ConfigPeg.sol"; /// @notice Configuration for GOLD peg. /// @dev Deployed pegged token: haGOLD ("Harbor anchored GOLD") diff --git a/script/config/pegs/ConfigPeg_MCAP.sol b/script/config/pegs/ConfigPeg_MCAP.sol index 76e1dfc8..252d8f52 100644 --- a/script/config/pegs/ConfigPeg_MCAP.sol +++ b/script/config/pegs/ConfigPeg_MCAP.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.28 <0.9.0; -import {ConfigPeg} from "./ConfigPeg.sol"; +import {ConfigPeg} from "@harbor-script/config/pegs/ConfigPeg.sol"; /// @notice Configuration for MCAP peg. /// @dev Deployed pegged token: haMCAP ("Harbor anchored MCAP") diff --git a/script/config/pegs/ConfigPeg_SILVER.sol b/script/config/pegs/ConfigPeg_SILVER.sol index 3104eec8..e688c665 100644 --- a/script/config/pegs/ConfigPeg_SILVER.sol +++ b/script/config/pegs/ConfigPeg_SILVER.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.28 <0.9.0; -import {ConfigPeg} from "./ConfigPeg.sol"; +import {ConfigPeg} from "@harbor-script/config/pegs/ConfigPeg.sol"; /// @notice Configuration for SILVER peg. /// @dev Deployed pegged token: haSILVER ("Harbor anchored SILVER") diff --git a/script/config/stabilitypool/ConfigStabilityPoolManagerCommon.sol b/script/config/stabilitypool/ConfigStabilityPoolManagerCommon.sol index 74ec640f..c84f289d 100644 --- a/script/config/stabilitypool/ConfigStabilityPoolManagerCommon.sol +++ b/script/config/stabilitypool/ConfigStabilityPoolManagerCommon.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.28 <0.9.0; -import {ConfigStabilityPoolManager} from "./ConfigStabilityPoolManager.sol"; +import {ConfigStabilityPoolManager} from "@harbor-script/config/stabilitypool/ConfigStabilityPoolManager.sol"; import {HarborDeployer} from "@harbor-script/src/HarborDeployer.sol"; /// @notice Shared stability pool manager fee receiver and parameter defaults. diff --git a/script/config/volatility/ConfigPriceVolatility_105.sol b/script/config/volatility/ConfigPriceVolatility_105.sol index 12c87632..7d1cbda1 100644 --- a/script/config/volatility/ConfigPriceVolatility_105.sol +++ b/script/config/volatility/ConfigPriceVolatility_105.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {IMinter} from "@harbor/interfaces/IMinter.sol"; -import {ConfigPriceVolatilityBase} from "./ConfigPriceVolatilityBase.sol"; +import {ConfigPriceVolatilityBase} from "@harbor-script/config/volatility/ConfigPriceVolatilityBase.sol"; /// @notice Volatility configuration for 105% rebalance threshold markets (Month 1 fees). /// @dev Higher redeem leveraged fees for initial month after launch. diff --git a/script/config/volatility/ConfigPriceVolatility_105_stable.sol b/script/config/volatility/ConfigPriceVolatility_105_stable.sol index c7ced6cd..c277eb4a 100644 --- a/script/config/volatility/ConfigPriceVolatility_105_stable.sol +++ b/script/config/volatility/ConfigPriceVolatility_105_stable.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {IMinter} from "@harbor/interfaces/IMinter.sol"; -import {ConfigPriceVolatilityBase} from "./ConfigPriceVolatilityBase.sol"; +import {ConfigPriceVolatilityBase} from "@harbor-script/config/volatility/ConfigPriceVolatilityBase.sol"; /// @notice Volatility configuration for 105% rebalance threshold markets. contract ConfigPriceVolatility_105_stable is ConfigPriceVolatilityBase { diff --git a/script/config/volatility/ConfigPriceVolatility_115.sol b/script/config/volatility/ConfigPriceVolatility_115.sol index 59099e93..2198388b 100644 --- a/script/config/volatility/ConfigPriceVolatility_115.sol +++ b/script/config/volatility/ConfigPriceVolatility_115.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {IMinter} from "@harbor/interfaces/IMinter.sol"; -import {ConfigPriceVolatilityBase} from "./ConfigPriceVolatilityBase.sol"; +import {ConfigPriceVolatilityBase} from "@harbor-script/config/volatility/ConfigPriceVolatilityBase.sol"; /// @notice Volatility configuration for 115% rebalance threshold markets (Month 1 fees). /// @dev Higher redeem leveraged fees for initial month after launch. diff --git a/script/config/volatility/ConfigPriceVolatility_115_stable.sol b/script/config/volatility/ConfigPriceVolatility_115_stable.sol index 44fef451..078fc6b6 100644 --- a/script/config/volatility/ConfigPriceVolatility_115_stable.sol +++ b/script/config/volatility/ConfigPriceVolatility_115_stable.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {IMinter} from "@harbor/interfaces/IMinter.sol"; -import {ConfigPriceVolatilityBase} from "./ConfigPriceVolatilityBase.sol"; +import {ConfigPriceVolatilityBase} from "@harbor-script/config/volatility/ConfigPriceVolatilityBase.sol"; /// @notice Volatility configuration for 115% rebalance threshold markets. contract ConfigPriceVolatility_115_stable is ConfigPriceVolatilityBase { diff --git a/script/config/volatility/ConfigPriceVolatility_125.sol b/script/config/volatility/ConfigPriceVolatility_125.sol index 1b115aeb..1d9d5f7f 100644 --- a/script/config/volatility/ConfigPriceVolatility_125.sol +++ b/script/config/volatility/ConfigPriceVolatility_125.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {IMinter} from "@harbor/interfaces/IMinter.sol"; -import {ConfigPriceVolatilityBase} from "./ConfigPriceVolatilityBase.sol"; +import {ConfigPriceVolatilityBase} from "@harbor-script/config/volatility/ConfigPriceVolatilityBase.sol"; /// @notice Volatility configuration for 125% rebalance threshold markets (Month 1 fees). /// @dev Higher redeem leveraged fees for initial month after launch. diff --git a/script/config/volatility/ConfigPriceVolatility_125_stable.sol b/script/config/volatility/ConfigPriceVolatility_125_stable.sol index e8b7190b..0012ee6f 100644 --- a/script/config/volatility/ConfigPriceVolatility_125_stable.sol +++ b/script/config/volatility/ConfigPriceVolatility_125_stable.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {IMinter} from "@harbor/interfaces/IMinter.sol"; -import {ConfigPriceVolatilityBase} from "./ConfigPriceVolatilityBase.sol"; +import {ConfigPriceVolatilityBase} from "@harbor-script/config/volatility/ConfigPriceVolatilityBase.sol"; /// @notice Volatility configuration for 125% rebalance threshold markets. contract ConfigPriceVolatility_125_stable is ConfigPriceVolatilityBase { diff --git a/script/config/volatility/ConfigPriceVolatility_130.sol b/script/config/volatility/ConfigPriceVolatility_130.sol index 7325b34c..645227c9 100644 --- a/script/config/volatility/ConfigPriceVolatility_130.sol +++ b/script/config/volatility/ConfigPriceVolatility_130.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {IMinter} from "@harbor/interfaces/IMinter.sol"; -import {ConfigPriceVolatilityBase} from "./ConfigPriceVolatilityBase.sol"; +import {ConfigPriceVolatilityBase} from "@harbor-script/config/volatility/ConfigPriceVolatilityBase.sol"; /// @notice Volatility configuration for 130% rebalance threshold markets (Month 1 fees). /// @dev Higher redeem leveraged fees for initial month after launch. diff --git a/script/config/volatility/ConfigPriceVolatility_130_stable.sol b/script/config/volatility/ConfigPriceVolatility_130_stable.sol index 8d3a92b0..f5838059 100644 --- a/script/config/volatility/ConfigPriceVolatility_130_stable.sol +++ b/script/config/volatility/ConfigPriceVolatility_130_stable.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {IMinter} from "@harbor/interfaces/IMinter.sol"; -import {ConfigPriceVolatilityBase} from "./ConfigPriceVolatilityBase.sol"; +import {ConfigPriceVolatilityBase} from "@harbor-script/config/volatility/ConfigPriceVolatilityBase.sol"; /// @notice Volatility configuration for 130% rebalance threshold markets. contract ConfigPriceVolatility_130_stable is ConfigPriceVolatilityBase { diff --git a/script/src/Deploy_BTC_Minter.sol b/script/src/Deploy_BTC_Minter.sol index e44193ae..e4ae345f 100644 --- a/script/src/Deploy_BTC_Minter.sol +++ b/script/src/Deploy_BTC_Minter.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {console2 as console} from "forge-std/console2.sol"; -import {MinterDeployer} from "./MinterDeployer.sol"; +import {MinterDeployer} from "@harbor-script/src/MinterDeployer.sol"; import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; import {ConfigPeg} from "@harbor-script/config/pegs/ConfigPeg.sol"; import {ConfigPeg_BTC} from "@harbor-script/config/pegs/ConfigPeg_BTC.sol"; diff --git a/script/src/Deploy_ETH_Minter.sol b/script/src/Deploy_ETH_Minter.sol index cbaeced8..9dc26b46 100644 --- a/script/src/Deploy_ETH_Minter.sol +++ b/script/src/Deploy_ETH_Minter.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {console2 as console} from "forge-std/console2.sol"; -import {MinterDeployer} from "./MinterDeployer.sol"; +import {MinterDeployer} from "@harbor-script/src/MinterDeployer.sol"; import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; import {ConfigPeg} from "@harbor-script/config/pegs/ConfigPeg.sol"; import {ConfigPeg_ETH} from "@harbor-script/config/pegs/ConfigPeg_ETH.sol"; diff --git a/script/src/Deploy_EUR_Minter.sol b/script/src/Deploy_EUR_Minter.sol index 3fa76755..a9d2ceda 100644 --- a/script/src/Deploy_EUR_Minter.sol +++ b/script/src/Deploy_EUR_Minter.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {console2 as console} from "forge-std/console2.sol"; -import {MinterDeployer} from "./MinterDeployer.sol"; +import {MinterDeployer} from "@harbor-script/src/MinterDeployer.sol"; import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; import {ConfigPeg} from "@harbor-script/config/pegs/ConfigPeg.sol"; import {ConfigPeg_EUR} from "@harbor-script/config/pegs/ConfigPeg_EUR.sol"; diff --git a/script/src/Deploy_GOLD_Minter.sol b/script/src/Deploy_GOLD_Minter.sol index 688320dd..fdb5c310 100644 --- a/script/src/Deploy_GOLD_Minter.sol +++ b/script/src/Deploy_GOLD_Minter.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {console2 as console} from "forge-std/console2.sol"; -import {MinterDeployer} from "./MinterDeployer.sol"; +import {MinterDeployer} from "@harbor-script/src/MinterDeployer.sol"; import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; import {ConfigPeg} from "@harbor-script/config/pegs/ConfigPeg.sol"; import {ConfigPeg_GOLD} from "@harbor-script/config/pegs/ConfigPeg_GOLD.sol"; diff --git a/script/src/Deploy_MCAP_Minter.sol b/script/src/Deploy_MCAP_Minter.sol index d6fe3c24..12b629e2 100644 --- a/script/src/Deploy_MCAP_Minter.sol +++ b/script/src/Deploy_MCAP_Minter.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {console2 as console} from "forge-std/console2.sol"; -import {MinterDeployer} from "./MinterDeployer.sol"; +import {MinterDeployer} from "@harbor-script/src/MinterDeployer.sol"; import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; import {ConfigPeg} from "@harbor-script/config/pegs/ConfigPeg.sol"; import {ConfigPeg_MCAP} from "@harbor-script/config/pegs/ConfigPeg_MCAP.sol"; diff --git a/script/src/Deploy_SILVER_Minter.sol b/script/src/Deploy_SILVER_Minter.sol index bac36246..3a535ae8 100644 --- a/script/src/Deploy_SILVER_Minter.sol +++ b/script/src/Deploy_SILVER_Minter.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; import {console2 as console} from "forge-std/console2.sol"; -import {MinterDeployer} from "./MinterDeployer.sol"; +import {MinterDeployer} from "@harbor-script/src/MinterDeployer.sol"; import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; import {ConfigPeg} from "@harbor-script/config/pegs/ConfigPeg.sol"; import {ConfigPeg_SILVER} from "@harbor-script/config/pegs/ConfigPeg_SILVER.sol"; diff --git a/script/src/MinterDeployer.sol b/script/src/MinterDeployer.sol index 12fd93cb..b1ad49a6 100644 --- a/script/src/MinterDeployer.sol +++ b/script/src/MinterDeployer.sol @@ -3,12 +3,12 @@ pragma solidity >=0.8.28 <0.9.0; import {console2 as console} from "forge-std/console2.sol"; import {LibString} from "@solady/utils/LibString.sol"; -import {PeggedToken} from "./contracts/PeggedToken.sol"; -import {LeveragedToken} from "./contracts/LeveragedToken.sol"; -import {Minter} from "./contracts/Minter.sol"; -import {StabilityPool} from "./contracts/StabilityPool.sol"; -import {StabilityPoolManager} from "./contracts/StabilityPoolManager.sol"; -import {Genesis} from "./contracts/Genesis.sol"; +import {PeggedToken} from "@harbor-script/src/contracts/PeggedToken.sol"; +import {LeveragedToken} from "@harbor-script/src/contracts/LeveragedToken.sol"; +import {Minter} from "@harbor-script/src/contracts/Minter.sol"; +import {StabilityPool} from "@harbor-script/src/contracts/StabilityPool.sol"; +import {StabilityPoolManager} from "@harbor-script/src/contracts/StabilityPoolManager.sol"; +import {Genesis} from "@harbor-script/src/contracts/Genesis.sol"; import {HarborDeployer} from "@harbor-script/src/HarborDeployer.sol"; import {DeploymentState} from "@bao-script/deployment/DeploymentState.sol"; import {DeploymentTypes} from "@bao-script/deployment/DeploymentTypes.sol"; diff --git a/src/reward/distributor/LinearMultipleRewardDistributor_v3.sol b/src/reward/distributor/LinearMultipleRewardDistributor_v3.sol index 5ef2dc02..673b8f0e 100644 --- a/src/reward/distributor/LinearMultipleRewardDistributor_v3.sol +++ b/src/reward/distributor/LinearMultipleRewardDistributor_v3.sol @@ -11,7 +11,7 @@ import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Ini import {HarborOwnableRoles} from "@bao/HarborOwnableRoles.sol"; import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; -import {LinearReward} from "./LinearReward.sol"; +import {LinearReward} from "@harbor/reward/distributor/LinearReward.sol"; // solhint-disable no-empty-blocks // solhint-disable not-rely-on-time diff --git a/test/deployment/RebalanceFairnessScan.t.sol b/test/deployment/RebalanceFairnessScan.t.sol index f6161b07..0f8b9b49 100644 --- a/test/deployment/RebalanceFairnessScan.t.sol +++ b/test/deployment/RebalanceFairnessScan.t.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.28 <0.9.0; -import {RebalanceFairnessSetUp} from "./RebalanceFairness.t.sol"; +import {RebalanceFairnessSetUp} from "@harbor-test/deployment/RebalanceFairness.t.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IMinter} from "@harbor/interfaces/IMinter.sol"; @@ -297,7 +297,7 @@ contract RebalanceFairnessScan is RebalanceFairnessSetUp { for (uint256 p = 0; p < priceDropPctValues.length; p++) { uint256 priceDropPct = priceDropPctValues[p]; - uint256 snap = vm.snapshot(); + uint256 snap = vm.snapshotState(); uint256 liquidFracE18 = _setupToPostRebalance(priceDropPct, levPct); @@ -352,7 +352,7 @@ contract RebalanceFairnessScan is RebalanceFairnessSetUp { ); } - vm.revertTo(snap); + vm.revertToState(snap); } } @@ -500,7 +500,7 @@ contract RebalanceFairnessScan is RebalanceFairnessSetUp { for (uint256 f = 0; f < feePctValues.length; f++) { uint256 feePct = feePctValues[f]; - uint256 snap = vm.snapshot(); + uint256 snap = vm.snapshotState(); uint256 liquidFracE18 = _setupToPostRebalance(DESIGN_PRICE_DROP, DESIGN_LEV_PCT); require(liquidFracE18 > 0, "design case must trigger rebalance"); @@ -542,7 +542,7 @@ contract RebalanceFairnessScan is RebalanceFairnessSetUp { ) ); - vm.revertTo(snap); + vm.revertToState(snap); } _writeFeeGnuplot(); @@ -799,7 +799,7 @@ contract RebalanceFairnessScan is RebalanceFairnessSetUp { for (uint256 f = 0; f < feePctValues.length; f++) { uint256 feePct = feePctValues[f]; - uint256 snap = vm.snapshot(); + uint256 snap = vm.snapshotState(); uint256 liquidFracE18 = _setupToPostRebalance(DESIGN_PRICE_DROP, DESIGN_LEV_PCT); require(liquidFracE18 > 0, "design case must trigger rebalance"); @@ -852,7 +852,7 @@ contract RebalanceFairnessScan is RebalanceFairnessSetUp { ); } - vm.revertTo(snap); + vm.revertToState(snap); } _writeTimelineGnuplot(); @@ -876,7 +876,7 @@ contract RebalanceFairnessScan is RebalanceFairnessSetUp { // In Scenario A there is no dodge, so Bob stays in the pool through the rebalance. // We measure Bob's haXXX-eq at week 12 as the "stayed" reference. // The break-even fee is the minimum fee that makes Scenario B Bob's haXXX-eq ≤ Scenario A Bob's. - uint256 snap0 = vm.snapshot(); + uint256 snap0 = vm.snapshotState(); WeeklyResult memory baseline; { uint256 each = 100 ether; @@ -903,7 +903,7 @@ contract RebalanceFairnessScan is RebalanceFairnessSetUp { _deposit(stabilityPoolLeveraged, george, each); baseline = _runWeeks(TOTAL_WEEKS); } - vm.revertTo(snap0); + vm.revertToState(snap0); console2.log( string.concat( @@ -971,7 +971,7 @@ contract RebalanceFairnessScan is RebalanceFairnessSetUp { for (uint256 i = 0; i < 20; i++) { // 20 iterations → precision < 0.01 bp uint256 mid = (lo + hi) / 2; - uint256 snap = vm.snapshot(); + uint256 snap = vm.snapshotState(); _setupToPostRebalance(DESIGN_PRICE_DROP, DESIGN_LEV_PCT); // _applyFeeAndRedeposit takes fee in whole %, but we need bp precision. @@ -997,7 +997,7 @@ contract RebalanceFairnessScan is RebalanceFairnessSetUp { hi = mid; // fee sufficient or overshooting } - vm.revertTo(snap); + vm.revertToState(snap); } feeBps = hi; // smallest fee that makes dodging unprofitable } From 15d0cefd3354161818eb86597cdef5f560472619 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Wed, 29 Apr 2026 20:50:33 +0100 Subject: [PATCH 074/232] fix the verify-audit run --- .github/workflows/CI-test-foundry-stable.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/CI-test-foundry-stable.yml b/.github/workflows/CI-test-foundry-stable.yml index 8ae1a20d..f56d3aa9 100644 --- a/.github/workflows/CI-test-foundry-stable.yml +++ b/.github/workflows/CI-test-foundry-stable.yml @@ -37,10 +37,6 @@ jobs: submodules: recursive fetch-depth: 0 - - name: Verify audited sources unchanged - shell: bash - run: lib/bao-base/bin/verify-audit - - name: Run Bao-base CI actions uses: ./lib/bao-base/.github/actions/test-foundry with: From 8ab7c3fe6cef65850692d03d97a90e156a2665e9 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Thu, 30 Apr 2026 15:18:22 +0100 Subject: [PATCH 075/232] stability pool manager emits on failure identifying the autocompounder fix slither runs --- .gitmodules | 7 +++-- foundry.lock | 5 ++- lib/bao-base | 2 +- package.json | 2 +- regression/coverage.txt | 4 +-- regression/sizes.txt | 2 +- src/interfaces/IStabilityPoolManager_v2.sol | 23 +++++++++----- src/minter/StabilityPoolManager_v2.sol | 35 ++++++++++++--------- 8 files changed, 48 insertions(+), 32 deletions(-) diff --git a/.gitmodules b/.gitmodules index 25fd9207..e970266c 100644 --- a/.gitmodules +++ b/.gitmodules @@ -10,9 +10,10 @@ [submodule "lib/forge-std"] path = lib/forge-std url = https://github.com/foundry-rs/forge-std -[submodule "lib/bao-base"] - path = lib/bao-base - url = https://github.com/baofinance/bao-base [submodule "lib/chainlink-brownie-contracts"] path = lib/chainlink-brownie-contracts url = https://github.com/smartcontractkit/chainlink-brownie-contracts +[submodule "lib/bao-base"] + path = lib/bao-base + url = https://github.com/baofinance/bao-base + branch = main diff --git a/foundry.lock b/foundry.lock index 1420e67a..963c181f 100644 --- a/foundry.lock +++ b/foundry.lock @@ -1,6 +1,9 @@ { "lib/bao-base": { - "rev": "b98e798a7340cd5df167bb47a003172b17a26955" + "branch": { + "name": "main", + "rev": "85e6bbce7b499a6d27ef94f347090f72fa79177b" + } }, "lib/chainlink-brownie-contracts": { "rev": "5cb41fbc9b525338b6098da5ea7dd0b7e92f89e4" diff --git a/lib/bao-base b/lib/bao-base index e69cf27c..85e6bbce 160000 --- a/lib/bao-base +++ b/lib/bao-base @@ -1 +1 @@ -Subproject commit e69cf27cce81d4e2902bad1776edfdfeec4de553 +Subproject commit 85e6bbce7b499a6d27ef94f347090f72fa79177b diff --git a/package.json b/package.json index 6778f7ec..6001ac6c 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "gas": "./lib/bao-base/run regression-of gas", "coverage": "./lib/bao-base/run regression-of coverage", "wake": "uv run wake detect all src", - "slither": "./lib/bao-base/run slither --filter-paths 'script/verify'", + "slither": "./lib/bao-base/run slither", "verify-audit": "./lib/bao-base/run verify-audit", "validate": "./lib/bao-base/run validate", "script": "forge script --force --ffi", diff --git a/regression/coverage.txt b/regression/coverage.txt index da8f8e04..ad62353d 100644 --- a/regression/coverage.txt +++ b/regression/coverage.txt @@ -45,7 +45,7 @@ | src/minter/Minter_v3.sol | X 99% (609/617) | X 99% (660/667) | X 93% (98/105) | X 99% (70/71) | | src/minter/ReservePool_v1.sol | X 94% (15/16) | ✓ 100% (15/15) | ✓ 100% (3/3) | X 80% (4/5) | | src/minter/StabilityPoolManager_v1.sol | X 0% (0/166) | X 0% (0/177) | X 0% (0/21) | X 0% (0/24) | -| src/minter/StabilityPoolManager_v2.sol | X 93% (175/189) | X 92% (188/204) | X 71% (20/28) | X 93% (25/27) | +| src/minter/StabilityPoolManager_v2.sol | X 92% (175/191) | X 91% (188/206) | X 67% (20/30) | X 93% (25/27) | | src/minter/StabilityPool_v1.sol | X 0% (0/203) | X 0% (0/223) | X 0% (0/33) | X 0% (0/22) | | src/minter/StabilityPool_v2.sol | X 61% (122/199) | X 58% (127/219) | X 19% (6/31) | X 73% (16/22) | | src/minter/StabilityPool_v3.sol | ✓ 100% (234/234) | ✓ 100% (255/255) | ✓ 100% (33/33) | ✓ 100% (29/29) | @@ -65,4 +65,4 @@ | src/util/ERC20MetadataLib_v1.sol | ✓ 100% (24/24) | ✓ 100% (22/22) | ✓ 100% (4/4) | ✓ 100% (4/4) | | src/util/FmtLib.sol | X 79% (15/19) | X 73% (19/26) | X 50% (2/4) | ✓ 100% (1/1) | | src/util/WordCodec.sol | ✓ 100% (15/15) | ✓ 100% (14/14) | ✓ 100% (0/0) | ✓ 100% (4/4) | -| Total | X 55% (4466/8065) | X 54% (4730/8725) | X 43% (392/920) | X 55% (647/1166) | +| Total | X 55% (4466/8067) | X 54% (4730/8727) | X 43% (392/922) | X 55% (647/1166) | diff --git a/regression/sizes.txt b/regression/sizes.txt index 38d79c63..07ae02a9 100644 --- a/regression/sizes.txt +++ b/regression/sizes.txt @@ -45,7 +45,7 @@ | PriceOracle_v1 | 1,958 | 22,618 | 2,010 | 411,700 | 41.17 | | ReservePool_v1 | 4,760 | 19,816 | 5,009 | 1,002,090 | 100.21 | | StabilityPoolManager_v1 | 11,209 | 13,367 | 13,057 | 2,372,370 | 237.24 | -| StabilityPoolManager_v2 | 12,234 | 12,342 | 14,110 | 2,587,900 | 258.79 | +| StabilityPoolManager_v2 | 12,467 | 12,109 | 14,343 | 2,636,830 | 263.68 | | StabilityPool_v1 | 21,092 | 3,484 | 23,085 | 4,449,250 | 444.93 | | StabilityPool_v2 | 20,711 | 3,865 | 22,579 | 4,367,990 | 436.80 | | StabilityPool_v3 | 23,412 | 1,164 | 25,791 | 4,940,310 | 494.03 | diff --git a/src/interfaces/IStabilityPoolManager_v2.sol b/src/interfaces/IStabilityPoolManager_v2.sol index d02efb0c..f205a6e2 100644 --- a/src/interfaces/IStabilityPoolManager_v2.sol +++ b/src/interfaces/IStabilityPoolManager_v2.sol @@ -11,9 +11,16 @@ interface IStabilityPoolManager_v2 is IStabilityPoolManager { error InvalidHarvestRatioSum(uint256 bountyRatio, uint256 cutRatio); /// @notice Emitted when an auto-compounder is registered or unregistered for a stability pool. - /// @param sp The stability pool address. - /// @param ac The auto-compounder address (address(0) = unregistered). - event AutoCompounderSet(address indexed sp, address indexed ac); + /// @param stabilityPool The stability pool address. + /// @param autoCompounder The auto-compounder address (address(0) = unregistered). + event AutoCompounderSet(address indexed stabilityPool, address indexed autoCompounder); + + /// @notice Emitted when compound() on a registered auto-compounder fails during harvest or + /// rebalance. The failure is non-fatal — harvest/rebalance still completes — but + /// rewards remain unclaimed until the next successful compound(). + /// @param autoCompounder The auto-compounder whose compound() call reverted. + /// @param reason Raw revert bytes (empty if the revert carried no data). + event CompoundFailed(address indexed autoCompounder, bytes reason); /*////////////////////////////////////////////////////////////// PUBLIC READ FUNCTIONS @@ -24,12 +31,12 @@ interface IStabilityPoolManager_v2 is IStabilityPoolManager { /// @notice Register or unregister an auto-compounder for a stability pool. /// @dev Only one auto-compounder per stability pool. Pass address(0) to unregister. /// The stability pool must be one of the two registered pools. - /// @param sp The stability pool address. - /// @param ac The auto-compounder address, or address(0) to remove. - function setAutoCompounder(address sp, address ac) external; + /// @param stabilityPool The stability pool address. + /// @param autoCompounder_ The auto-compounder address, or address(0) to remove. + function setAutoCompounder(address stabilityPool, address autoCompounder_) external; /// @notice Get the auto-compounder registered for a stability pool. - /// @param sp The stability pool address. + /// @param stabilityPool The stability pool address. /// @return The registered auto-compounder, or address(0) if none. - function autoCompounder(address sp) external view returns (address); + function autoCompounder(address stabilityPool) external view returns (address); } diff --git a/src/minter/StabilityPoolManager_v2.sol b/src/minter/StabilityPoolManager_v2.sol index 6a140c31..d2a4ef33 100644 --- a/src/minter/StabilityPoolManager_v2.sol +++ b/src/minter/StabilityPoolManager_v2.sol @@ -287,13 +287,13 @@ contract StabilityPoolManager_v2 is } /// @inheritdoc IStabilityPoolManager_v2 - function setAutoCompounder(address sp, address ac) external override onlyOwner { - if (sp != _STABILITY_POOL_COLLATERAL && sp != _STABILITY_POOL_LEVERAGED) { - revert InvalidStabilityPool(sp); + function setAutoCompounder(address stabilityPool, address autoCompounder_) external override onlyOwner { + if (stabilityPool != _STABILITY_POOL_COLLATERAL && stabilityPool != _STABILITY_POOL_LEVERAGED) { + revert InvalidStabilityPool(stabilityPool); } StabilityPoolManagerStorage storage $ = _getStabilityPoolManagerStorage(); - $.autoCompounder[sp] = ac; - emit AutoCompounderSet(sp, ac); + $.autoCompounder[stabilityPool] = autoCompounder_; + emit AutoCompounderSet(stabilityPool, autoCompounder_); } /************************* @@ -311,23 +311,27 @@ contract StabilityPoolManager_v2 is } /// @dev Trigger compound() on any registered auto-compounder for each stability pool. - /// Failures (including NothingToCompound) are silently swallowed — compound is an - /// optional optimisation step and must not block harvest/rebalance. + /// Failures (including NothingToCompound) are non-fatal — harvest/rebalance still + /// completes. A CompoundFailed event is emitted so off-chain monitoring can detect + /// and investigate failures. function _compoundRegistered() private { StabilityPoolManagerStorage storage $ = _getStabilityPoolManagerStorage(); - address acColl = $.autoCompounder[_STABILITY_POOL_COLLATERAL]; - if (acColl != address(0)) { - // solhint-disable-next-line no-empty-blocks - try IAutoCompounder(acColl).compound() {} catch {} + address collateralAutoCompounder = $.autoCompounder[_STABILITY_POOL_COLLATERAL]; + address leveragedAutoCompounder = $.autoCompounder[_STABILITY_POOL_LEVERAGED]; + if (collateralAutoCompounder != address(0)) { + try IAutoCompounder(collateralAutoCompounder).compound() {} catch (bytes memory reason) { + emit CompoundFailed(collateralAutoCompounder, reason); + } } - address acLev = $.autoCompounder[_STABILITY_POOL_LEVERAGED]; - if (acLev != address(0)) { - // solhint-disable-next-line no-empty-blocks - try IAutoCompounder(acLev).compound() {} catch {} + if (leveragedAutoCompounder != address(0)) { + try IAutoCompounder(leveragedAutoCompounder).compound() {} catch (bytes memory reason) { + emit CompoundFailed(leveragedAutoCompounder, reason); + } } } /// @inheritdoc IStabilityPoolManager + // slither-disable-next-line reentrancy-no-eth function rebalance( address bountyReceiver, uint256 minPeggedLiquidated @@ -452,6 +456,7 @@ contract StabilityPoolManager_v2 is } /// @inheritdoc IStabilityPoolManager + // slither-disable-next-line reentrancy-no-eth function harvest( address bountyReceiver, uint256 minBounty From 7c8e0aaf681b1417664d38759064c8780d249b83 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Thu, 30 Apr 2026 17:53:14 +0100 Subject: [PATCH 076/232] fix gas --- foundry.toml | 2 +- lib/bao-base | 2 +- regression/gas.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/foundry.toml b/foundry.toml index 7ae8cb33..382dcdcc 100644 --- a/foundry.toml +++ b/foundry.toml @@ -43,7 +43,7 @@ remappings = [ "test/=test/", ] -fuzz.gas_report_samples = 64 # gas report doesn't need so many test cases + # gas_reports_ignore takes contract names, not paths — use gas_reports whitelist instead gas_reports = [ "Config_v2", diff --git a/lib/bao-base b/lib/bao-base index 85e6bbce..9bcaeaf6 160000 --- a/lib/bao-base +++ b/lib/bao-base @@ -1 +1 @@ -Subproject commit 85e6bbce7b499a6d27ef94f347090f72fa79177b +Subproject commit 9bcaeaf610c08a96e71cee717216de33ffa25b06 diff --git a/regression/gas.txt b/regression/gas.txt index 91ab7381..b2a4dace 100644 --- a/regression/gas.txt +++ b/regression/gas.txt @@ -46,7 +46,7 @@ src/minter/Minter_v3.sol:Minter_v3 | mintPeggedToken(uint256,address,uint256) | 1.911e+05 | | mintPeggedToken(uint256,address,uint256,uint256) | 1.251e+05 | | mintPeggedTokenDryRun(uint256) | 6.401e+04 | -| mintPeggedTokenDryRun(uint256,uint256) | 4.556e+04 | +| mintPeggedTokenDryRun(uint256,uint256) | 3.644e+04 | | mintPeggedTokenIncentiveRatio | 3.012e+04 | | owner | 2.402e+03 | | peggedTokenBalance | 2.409e+03 | From 4c3c44297ceff895f3f93ebb4f1f3d7d7f3ce998 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Thu, 30 Apr 2026 21:38:13 +0100 Subject: [PATCH 077/232] macOs github actions reliability --- lib/bao-base | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/bao-base b/lib/bao-base index 9bcaeaf6..1a331e3e 160000 --- a/lib/bao-base +++ b/lib/bao-base @@ -1 +1 @@ -Subproject commit 9bcaeaf610c08a96e71cee717216de33ffa25b06 +Subproject commit 1a331e3e472bf03f72d5d22b6fc67315adc3b0d8 From 3b55c942e7ab6b2c66d3111b778624c723fe356e Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Thu, 30 Apr 2026 22:08:48 +0100 Subject: [PATCH 078/232] simplified converage config --- foundry.toml | 5 ----- lib/bao-base | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/foundry.toml b/foundry.toml index 382dcdcc..bf33fcad 100644 --- a/foundry.toml +++ b/foundry.toml @@ -69,11 +69,6 @@ gas_limit = "18446744073709551615" exclude_lints = ["mixed-case-function", "mixed-case-variable"] severity = ["gas"] - -# coverage generates instrumentation warnings, so disable deny for those -[profile.coverage] -deny = "never" - # slither building profile - removes vyper [profile.novyper] skip = ["**/*.vy"] diff --git a/lib/bao-base b/lib/bao-base index 1a331e3e..32fefdbc 160000 --- a/lib/bao-base +++ b/lib/bao-base @@ -1 +1 @@ -Subproject commit 1a331e3e472bf03f72d5d22b6fc67315adc3b0d8 +Subproject commit 32fefdbc3c3ddf05ec050d2cbd7d2c5e4f76b846 From fc993655b4e4dd9cd64e77418ffcee01479d6894 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Fri, 1 May 2026 07:35:51 +0100 Subject: [PATCH 079/232] update dependencies --- foundry.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foundry.lock b/foundry.lock index 963c181f..f9ef1f69 100644 --- a/foundry.lock +++ b/foundry.lock @@ -2,7 +2,7 @@ "lib/bao-base": { "branch": { "name": "main", - "rev": "85e6bbce7b499a6d27ef94f347090f72fa79177b" + "rev": "32fefdbc3c3ddf05ec050d2cbd7d2c5e4f76b846" } }, "lib/chainlink-brownie-contracts": { From c539e8d71a27f78d4e7219211809722c74e39344 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Fri, 1 May 2026 11:55:49 +0100 Subject: [PATCH 080/232] update bao-base --- foundry.lock | 2 +- lib/bao-base | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/foundry.lock b/foundry.lock index f9ef1f69..be502782 100644 --- a/foundry.lock +++ b/foundry.lock @@ -2,7 +2,7 @@ "lib/bao-base": { "branch": { "name": "main", - "rev": "32fefdbc3c3ddf05ec050d2cbd7d2c5e4f76b846" + "rev": "d63b25e0a8be6864958dea38cbb67ef04f95527a" } }, "lib/chainlink-brownie-contracts": { diff --git a/lib/bao-base b/lib/bao-base index 32fefdbc..d63b25e0 160000 --- a/lib/bao-base +++ b/lib/bao-base @@ -1 +1 @@ -Subproject commit 32fefdbc3c3ddf05ec050d2cbd7d2c5e4f76b846 +Subproject commit d63b25e0a8be6864958dea38cbb67ef04f95527a From 5a0cb8807354ea4cf36e77ffd0dc822220539b82 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Fri, 1 May 2026 12:29:48 +0100 Subject: [PATCH 081/232] update bao-base --- foundry.lock | 2 +- lib/bao-base | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/foundry.lock b/foundry.lock index be502782..232c23fb 100644 --- a/foundry.lock +++ b/foundry.lock @@ -2,7 +2,7 @@ "lib/bao-base": { "branch": { "name": "main", - "rev": "d63b25e0a8be6864958dea38cbb67ef04f95527a" + "rev": "280f997251245d9e0d32651a9f79d2212362a705" } }, "lib/chainlink-brownie-contracts": { diff --git a/lib/bao-base b/lib/bao-base index d63b25e0..280f9972 160000 --- a/lib/bao-base +++ b/lib/bao-base @@ -1 +1 @@ -Subproject commit d63b25e0a8be6864958dea38cbb67ef04f95527a +Subproject commit 280f997251245d9e0d32651a9f79d2212362a705 From 559ea7b98b497a1fc6ff8bf9d6f6928888fedd27 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Sat, 2 May 2026 12:49:16 +0100 Subject: [PATCH 082/232] update bao-base: node 25 and corepack --- foundry.lock | 2 +- lib/bao-base | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/foundry.lock b/foundry.lock index 232c23fb..437064d7 100644 --- a/foundry.lock +++ b/foundry.lock @@ -2,7 +2,7 @@ "lib/bao-base": { "branch": { "name": "main", - "rev": "280f997251245d9e0d32651a9f79d2212362a705" + "rev": "2a9a71159a9df956f1ed675639eac8a41697049f" } }, "lib/chainlink-brownie-contracts": { diff --git a/lib/bao-base b/lib/bao-base index 280f9972..2a9a7115 160000 --- a/lib/bao-base +++ b/lib/bao-base @@ -1 +1 @@ -Subproject commit 280f997251245d9e0d32651a9f79d2212362a705 +Subproject commit 2a9a71159a9df956f1ed675639eac8a41697049f From e5169bdd1c40f7ba9dc49657eb37098194ca7048 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Sat, 2 May 2026 13:49:10 +0100 Subject: [PATCH 083/232] fixed linter errors and update to checkout v6 --- .github/workflows/CI-test-foundry-stable.yml | 2 +- foundry.lock | 2 +- lib/bao-base | 2 +- src/minter/StabilityPoolManager_v2.sol | 2 ++ 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/CI-test-foundry-stable.yml b/.github/workflows/CI-test-foundry-stable.yml index f56d3aa9..3aed467f 100644 --- a/.github/workflows/CI-test-foundry-stable.yml +++ b/.github/workflows/CI-test-foundry-stable.yml @@ -32,7 +32,7 @@ jobs: runs-on: ${{ matrix.os }} steps: - name: Checkout repository with submodules - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: submodules: recursive fetch-depth: 0 diff --git a/foundry.lock b/foundry.lock index 437064d7..93659671 100644 --- a/foundry.lock +++ b/foundry.lock @@ -2,7 +2,7 @@ "lib/bao-base": { "branch": { "name": "main", - "rev": "2a9a71159a9df956f1ed675639eac8a41697049f" + "rev": "1d02693313011486e2b09619b32be4523aa270b8" } }, "lib/chainlink-brownie-contracts": { diff --git a/lib/bao-base b/lib/bao-base index 2a9a7115..1d026933 160000 --- a/lib/bao-base +++ b/lib/bao-base @@ -1 +1 @@ -Subproject commit 2a9a71159a9df956f1ed675639eac8a41697049f +Subproject commit 1d02693313011486e2b09619b32be4523aa270b8 diff --git a/src/minter/StabilityPoolManager_v2.sol b/src/minter/StabilityPoolManager_v2.sol index d2a4ef33..e3db81ca 100644 --- a/src/minter/StabilityPoolManager_v2.sol +++ b/src/minter/StabilityPoolManager_v2.sol @@ -319,11 +319,13 @@ contract StabilityPoolManager_v2 is address collateralAutoCompounder = $.autoCompounder[_STABILITY_POOL_COLLATERAL]; address leveragedAutoCompounder = $.autoCompounder[_STABILITY_POOL_LEVERAGED]; if (collateralAutoCompounder != address(0)) { + // solhint-disable-next-line no-empty-blocks try IAutoCompounder(collateralAutoCompounder).compound() {} catch (bytes memory reason) { emit CompoundFailed(collateralAutoCompounder, reason); } } if (leveragedAutoCompounder != address(0)) { + // solhint-disable-next-line no-empty-blocks try IAutoCompounder(leveragedAutoCompounder).compound() {} catch (bytes memory reason) { emit CompoundFailed(leveragedAutoCompounder, reason); } From ef96419d6212a4c9d463e0a64304c6fdbc4263d5 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Thu, 21 May 2026 14:53:19 +0100 Subject: [PATCH 084/232] updated bao-base --- lib/bao-base | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/bao-base b/lib/bao-base index 1d026933..45cd2657 160000 --- a/lib/bao-base +++ b/lib/bao-base @@ -1 +1 @@ -Subproject commit 1d02693313011486e2b09619b32be4523aa270b8 +Subproject commit 45cd26574f1cd04a1c20e64b0cc5592f0fad51ad From 5b6584e97bc87ae8d4b6738b91fb2c27bf90758a Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Fri, 22 May 2026 21:43:11 +0100 Subject: [PATCH 085/232] make deployment scripts more usable in dependant repos --- regression/sizes.txt | 2 +- script/Deploy_Minter_v2_mainnet.s.sol | 9 +--- script/Deploy_StabilityPool_v3_mainnet.s.sol | 7 +-- script/config/ConfigBase.sol | 1 + script/config/IHarborConfig.sol | 46 +++++++++++++++++++ .../ConfigCollateral_fxUSD_mainnet.sol | 2 +- .../ConfigCollateral_stETH_mainnet.sol | 2 +- .../volatility/ConfigPriceVolatility_105.sol | 2 +- .../ConfigPriceVolatility_105_stable.sol | 2 +- .../volatility/ConfigPriceVolatility_115.sol | 2 +- .../ConfigPriceVolatility_115_stable.sol | 2 +- .../volatility/ConfigPriceVolatility_125.sol | 2 +- .../ConfigPriceVolatility_125_stable.sol | 2 +- .../volatility/ConfigPriceVolatility_130.sol | 2 +- .../ConfigPriceVolatility_130_stable.sol | 2 +- script/src/MinterDeployer.sol | 34 ++++---------- 16 files changed, 70 insertions(+), 49 deletions(-) create mode 100644 script/config/IHarborConfig.sol diff --git a/regression/sizes.txt b/regression/sizes.txt index 07ae02a9..d66cff34 100644 --- a/regression/sizes.txt +++ b/regression/sizes.txt @@ -41,7 +41,7 @@ | MinterMarketConfigLib | 85 | 24,491 | 135 | 18,350 | 1.84 | | Minter_v1 | 23,681 | 895 | 25,515 | 4,991,350 | 499.14 | | Minter_v2 | 23,675 | 901 | 25,509 | 4,990,090 | 499.01 | -| Minter_v3 | 24,218 | 358 | 26,045 | 5,104,050 | 510.41 | +| Minter_v3 | 24,231 | 345 | 26,058 | 5,106,780 | 510.68 | | PriceOracle_v1 | 1,958 | 22,618 | 2,010 | 411,700 | 41.17 | | ReservePool_v1 | 4,760 | 19,816 | 5,009 | 1,002,090 | 100.21 | | StabilityPoolManager_v1 | 11,209 | 13,367 | 13,057 | 2,372,370 | 237.24 | diff --git a/script/Deploy_Minter_v2_mainnet.s.sol b/script/Deploy_Minter_v2_mainnet.s.sol index 073ff8fa..8346214d 100644 --- a/script/Deploy_Minter_v2_mainnet.s.sol +++ b/script/Deploy_Minter_v2_mainnet.s.sol @@ -18,15 +18,10 @@ import {Deploy_SILVER_Minter} from "@harbor-script/src/Deploy_SILVER_Minter.sol" import {Script} from "forge-std/Script.sol"; import {HarborDeployer} from "@harbor-script/src/HarborDeployer.sol"; +import {IHarborConfig} from "@harbor-script/config/IHarborConfig.sol"; import {console2} from "forge-std/console2.sol"; -// TODO: put this in a file and have everything share it (or break it up or something) -interface IFullMinterConfig { - function wrappedCollateralToken() external view returns (address); - function peg() external view returns (string memory); -} - /// @notice Deploy Minter v2 implementations and queue upgrade transactions for all minters. /// @dev Broadcasts implementation deployments, then queues UUPS upgrade calls as a Safe batch. /// Run via: script/run-script Deploy_Minter_v2_mainnet --salt harbor_v1 --network mainnet --broadcast @@ -44,7 +39,7 @@ contract Deploy_Minter_v2_mainnet is function _doOneMinter(DeploymentTypes.State memory state, Config_MinterMarket[] memory markets) internal { for (uint i = 0; i < markets.length; i++) { string memory marketKey = MinterMarketConfigLib.salt(markets[i]); - IFullMinterConfig cfg = IFullMinterConfig(address(markets[i])); + IHarborConfig cfg = IHarborConfig(address(markets[i])); address wrappedCollateral = cfg.wrappedCollateralToken(); address peggedToken = _predictAddress(_key(cfg.peg(), "pegged")); address leveragedToken = _predictAddress(_key(marketKey, "leveraged")); diff --git a/script/Deploy_StabilityPool_v3_mainnet.s.sol b/script/Deploy_StabilityPool_v3_mainnet.s.sol index f8c4adda..7ed0ebe7 100644 --- a/script/Deploy_StabilityPool_v3_mainnet.s.sol +++ b/script/Deploy_StabilityPool_v3_mainnet.s.sol @@ -19,10 +19,7 @@ import {Deploy_SILVER_Minter} from "@harbor-script/src/Deploy_SILVER_Minter.sol" import {Script} from "forge-std/Script.sol"; import {HarborDeployer} from "@harbor-script/src/HarborDeployer.sol"; - -interface IFullMinterConfig { - function wrappedCollateralToken() external view returns (address); -} +import {IHarborConfig} from "@harbor-script/config/IHarborConfig.sol"; /// @notice Deploy StabilityPool_v3 implementations and queue upgrade transactions for all pools. /// @dev Prerequisite: Remediate_Accumulators must have been executed first to force-migrate @@ -45,7 +42,7 @@ contract Deploy_StabilityPool_v3_mainnet is string memory marketKey = MinterMarketConfigLib.salt(markets[i]); address minter = _predictAddress(_key(marketKey, "minter")); address leveragedToken = _predictAddress(_key(marketKey, "leveraged")); - address collateralToken = IFullMinterConfig(address(markets[i])).wrappedCollateralToken(); + address collateralToken = IHarborConfig(address(markets[i])).wrappedCollateralToken(); address implLeveraged = deployStabilityPoolImplementation( StabilityPoolLeveraged, diff --git a/script/config/ConfigBase.sol b/script/config/ConfigBase.sol index f078e7d3..3cf0afca 100644 --- a/script/config/ConfigBase.sol +++ b/script/config/ConfigBase.sol @@ -20,6 +20,7 @@ interface IMarketConfig { /// @dev Provides type safety for minter market config parameters. /// Concrete configs must provide peg() and collateral() methods via inherited components. /// This contract doesn't declare the methods to avoid diamond inheritance conflicts. +/// Concrete configs implement IHarborConfig — see script/config/IHarborConfig.sol. abstract contract Config_MinterMarket { // Methods provided by ConfigPeg_* and ConfigCollateral_* components via inheritance } diff --git a/script/config/IHarborConfig.sol b/script/config/IHarborConfig.sol new file mode 100644 index 00000000..d3331be4 --- /dev/null +++ b/script/config/IHarborConfig.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.28 <0.9.0; + +import {IMinter} from "@harbor/interfaces/IMinter.sol"; + +/// @notice Canonical interface for Harbor market configuration contracts. +/// @dev All concrete market configs (e.g. ConfigMarket_ETH_fxUSD_mainnet) implement this interface +/// via their mixin inheritance chain. Scripts cast Config_MinterMarket to IHarborConfig to call +/// config functions across the full mixin hierarchy. +/// +/// Mutability rules: +/// - `wrappedCollateralToken()` and `minterConfig()` are `view` (not `pure`) so test subclasses +/// can return immutable constructor arguments. +/// - SP/SPM functions are `pure` — they return compile-time constants, never constructor state. +interface IHarborConfig { + // ========== MARKET IDENTITY ========== + + function peg() external view returns (string memory); + function collateral() external view returns (string memory); + + // ========== COLLATERAL ========== + + function wrappedCollateralToken() external view returns (address); + + // ========== MINTER ========== + + /// @dev Declared `view` (not `pure`) so test subclasses returning immutable addresses compile. + function minterConfig() external view returns (IMinter.Config memory); + + // ========== PEG ========== + + function minTotalSupply() external view returns (uint256); + + // ========== STABILITY POOL ========== + + function stabilityPoolWithdrawalDelay() external pure returns (uint256); + function stabilityPoolWithdrawalPeriod() external pure returns (uint256); + function stabilityPoolEarlyWithdrawalFeeRatio() external pure returns (uint256); + + // ========== STABILITY POOL MANAGER ========== + + function rebalanceThreshold() external pure returns (uint256); + function rebalanceBountyRatio() external pure returns (uint256); + function harvestBountyRatio() external pure returns (uint256); + function harvestCutRatio() external pure returns (uint256); +} diff --git a/script/config/collaterals/ConfigCollateral_fxUSD_mainnet.sol b/script/config/collaterals/ConfigCollateral_fxUSD_mainnet.sol index 56221ec4..e9e48eff 100644 --- a/script/config/collaterals/ConfigCollateral_fxUSD_mainnet.sol +++ b/script/config/collaterals/ConfigCollateral_fxUSD_mainnet.sol @@ -10,7 +10,7 @@ abstract contract ConfigCollateral_fxUSD_mainnet is ConfigChain_mainnet { return fxUSD(); } - function wrappedCollateralToken() public pure virtual returns (address) { + function wrappedCollateralToken() public view virtual returns (address) { return fxSAVE(); } diff --git a/script/config/collaterals/ConfigCollateral_stETH_mainnet.sol b/script/config/collaterals/ConfigCollateral_stETH_mainnet.sol index d6faff63..386135d4 100644 --- a/script/config/collaterals/ConfigCollateral_stETH_mainnet.sol +++ b/script/config/collaterals/ConfigCollateral_stETH_mainnet.sol @@ -10,7 +10,7 @@ abstract contract ConfigCollateral_stETH_mainnet is ConfigChain_mainnet { return stETH(); } - function wrappedCollateralToken() public pure virtual returns (address) { + function wrappedCollateralToken() public view virtual returns (address) { return wstETH(); } diff --git a/script/config/volatility/ConfigPriceVolatility_105.sol b/script/config/volatility/ConfigPriceVolatility_105.sol index 7d1cbda1..7b058f23 100644 --- a/script/config/volatility/ConfigPriceVolatility_105.sol +++ b/script/config/volatility/ConfigPriceVolatility_105.sol @@ -11,7 +11,7 @@ contract ConfigPriceVolatility_105 is ConfigPriceVolatilityBase { return 1.05e18; } - function minterConfig() public pure override returns (IMinter.Config memory) { + function minterConfig() public pure virtual override returns (IMinter.Config memory) { uint256[] memory mintPeggedBounds = new uint256[](6); mintPeggedBounds[0] = 1.06e18; mintPeggedBounds[1] = 1.15e18; diff --git a/script/config/volatility/ConfigPriceVolatility_105_stable.sol b/script/config/volatility/ConfigPriceVolatility_105_stable.sol index c277eb4a..3553b410 100644 --- a/script/config/volatility/ConfigPriceVolatility_105_stable.sol +++ b/script/config/volatility/ConfigPriceVolatility_105_stable.sol @@ -10,7 +10,7 @@ contract ConfigPriceVolatility_105_stable is ConfigPriceVolatilityBase { return 1.05e18; } - function minterConfig() public pure override returns (IMinter.Config memory) { + function minterConfig() public pure virtual override returns (IMinter.Config memory) { uint256[] memory mintPeggedBounds = new uint256[](6); mintPeggedBounds[0] = 1.06e18; mintPeggedBounds[1] = 1.15e18; diff --git a/script/config/volatility/ConfigPriceVolatility_115.sol b/script/config/volatility/ConfigPriceVolatility_115.sol index 2198388b..d5b7438b 100644 --- a/script/config/volatility/ConfigPriceVolatility_115.sol +++ b/script/config/volatility/ConfigPriceVolatility_115.sol @@ -11,7 +11,7 @@ contract ConfigPriceVolatility_115 is ConfigPriceVolatilityBase { return 1.15e18; } - function minterConfig() public pure override returns (IMinter.Config memory) { + function minterConfig() public pure virtual override returns (IMinter.Config memory) { uint256[] memory mintPeggedBounds = new uint256[](6); mintPeggedBounds[0] = 1.16e18; mintPeggedBounds[1] = 1.25e18; diff --git a/script/config/volatility/ConfigPriceVolatility_115_stable.sol b/script/config/volatility/ConfigPriceVolatility_115_stable.sol index 078fc6b6..ef16672a 100644 --- a/script/config/volatility/ConfigPriceVolatility_115_stable.sol +++ b/script/config/volatility/ConfigPriceVolatility_115_stable.sol @@ -10,7 +10,7 @@ contract ConfigPriceVolatility_115_stable is ConfigPriceVolatilityBase { return 1.15e18; } - function minterConfig() public pure override returns (IMinter.Config memory) { + function minterConfig() public pure virtual override returns (IMinter.Config memory) { uint256[] memory mintPeggedBounds = new uint256[](6); mintPeggedBounds[0] = 1.16e18; mintPeggedBounds[1] = 1.25e18; diff --git a/script/config/volatility/ConfigPriceVolatility_125.sol b/script/config/volatility/ConfigPriceVolatility_125.sol index 1d9d5f7f..6a35b083 100644 --- a/script/config/volatility/ConfigPriceVolatility_125.sol +++ b/script/config/volatility/ConfigPriceVolatility_125.sol @@ -11,7 +11,7 @@ contract ConfigPriceVolatility_125 is ConfigPriceVolatilityBase { return 1.25e18; } - function minterConfig() public pure override returns (IMinter.Config memory) { + function minterConfig() public pure virtual override returns (IMinter.Config memory) { uint256[] memory mintPeggedBounds = new uint256[](6); mintPeggedBounds[0] = 1.26e18; mintPeggedBounds[1] = 1.35e18; diff --git a/script/config/volatility/ConfigPriceVolatility_125_stable.sol b/script/config/volatility/ConfigPriceVolatility_125_stable.sol index 0012ee6f..32493347 100644 --- a/script/config/volatility/ConfigPriceVolatility_125_stable.sol +++ b/script/config/volatility/ConfigPriceVolatility_125_stable.sol @@ -10,7 +10,7 @@ contract ConfigPriceVolatility_125_stable is ConfigPriceVolatilityBase { return 1.25e18; } - function minterConfig() public pure override returns (IMinter.Config memory) { + function minterConfig() public pure virtual override returns (IMinter.Config memory) { uint256[] memory mintPeggedBounds = new uint256[](6); mintPeggedBounds[0] = 1.26e18; mintPeggedBounds[1] = 1.35e18; diff --git a/script/config/volatility/ConfigPriceVolatility_130.sol b/script/config/volatility/ConfigPriceVolatility_130.sol index 645227c9..335356b8 100644 --- a/script/config/volatility/ConfigPriceVolatility_130.sol +++ b/script/config/volatility/ConfigPriceVolatility_130.sol @@ -11,7 +11,7 @@ contract ConfigPriceVolatility_130 is ConfigPriceVolatilityBase { return 1.30e18; } - function minterConfig() public pure override returns (IMinter.Config memory) { + function minterConfig() public pure virtual override returns (IMinter.Config memory) { uint256[] memory mintPeggedBounds = new uint256[](6); mintPeggedBounds[0] = 1.31e18; mintPeggedBounds[1] = 1.40e18; diff --git a/script/config/volatility/ConfigPriceVolatility_130_stable.sol b/script/config/volatility/ConfigPriceVolatility_130_stable.sol index f5838059..27cd2c14 100644 --- a/script/config/volatility/ConfigPriceVolatility_130_stable.sol +++ b/script/config/volatility/ConfigPriceVolatility_130_stable.sol @@ -10,7 +10,7 @@ contract ConfigPriceVolatility_130_stable is ConfigPriceVolatilityBase { return 1.30e18; } - function minterConfig() public pure override returns (IMinter.Config memory) { + function minterConfig() public pure virtual override returns (IMinter.Config memory) { uint256[] memory mintPeggedBounds = new uint256[](6); mintPeggedBounds[0] = 1.31e18; mintPeggedBounds[1] = 1.40e18; diff --git a/script/src/MinterDeployer.sol b/script/src/MinterDeployer.sol index b1ad49a6..cd3bc8db 100644 --- a/script/src/MinterDeployer.sol +++ b/script/src/MinterDeployer.sol @@ -16,25 +16,7 @@ import {ConfigPeg} from "@harbor-script/config/pegs/ConfigPeg.sol"; import {Config_MinterMarket, MinterMarketConfigLib} from "@harbor-script/config/ConfigBase.sol"; import {IMinter} from "@harbor/interfaces/IMinter.sol"; import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; - -/// @notice Extended market config interface with methods from collateral and chain configs. -interface IFullMinterConfig { - function peg() external view returns (string memory); - function collateral() external view returns (string memory); - function wrappedCollateralToken() external view returns (address); - function minterConfig() external pure returns (IMinter.Config memory); - // Peg config - function minTotalSupply() external view returns (uint256); - // Stability pool config - function stabilityPoolWithdrawalDelay() external pure returns (uint256); - function stabilityPoolWithdrawalPeriod() external pure returns (uint256); - function stabilityPoolEarlyWithdrawalFeeRatio() external pure returns (uint256); - // StabilityPoolManager config (rebalanceThreshold comes from volatility config) - function rebalanceThreshold() external pure returns (uint256); - function rebalanceBountyRatio() external pure returns (uint256); - function harvestBountyRatio() external pure returns (uint256); - function harvestCutRatio() external pure returns (uint256); -} +import {IHarborConfig} from "@harbor-script/config/IHarborConfig.sol"; /// @notice Shared functionality for all minter deployment contracts. /// @dev Provides common infrastructure and deployment primitives. @@ -140,7 +122,7 @@ abstract contract MinterDeployer is PeggedToken, LeveragedToken, Minter, Stabili /// @param state Deployment state (modified in place). /// @param market Market configuration. function _deployMinterInfrastructure(DeploymentTypes.State memory state, Config_MinterMarket market) private { - IFullMinterConfig cfg = IFullMinterConfig(address(market)); + IHarborConfig cfg = IHarborConfig(address(market)); string memory marketKey = MinterMarketConfigLib.salt(market); console.log(""); @@ -175,7 +157,7 @@ abstract contract MinterDeployer is PeggedToken, LeveragedToken, Minter, Stabili function _deployMinter( DeploymentTypes.State memory stateData, - IFullMinterConfig cfg, + IHarborConfig cfg, string memory marketKey ) internal { address wrappedCollateral = cfg.wrappedCollateralToken(); @@ -187,7 +169,7 @@ abstract contract MinterDeployer is PeggedToken, LeveragedToken, Minter, Stabili function _deployStabilityPools( DeploymentTypes.State memory stateData, - IFullMinterConfig cfg, + IHarborConfig cfg, string memory marketKey ) internal { address minter = _predictAddress(_key(marketKey, "minter")); @@ -209,7 +191,7 @@ abstract contract MinterDeployer is PeggedToken, LeveragedToken, Minter, Stabili ); } - function _registerRewardTokens(IFullMinterConfig cfg, string memory marketKey) internal { + function _registerRewardTokens(IHarborConfig cfg, string memory marketKey) internal { address spCollateral = _predictAddress(_key(marketKey, StabilityPoolCollateral)); address spLeveraged = _predictAddress(_key(marketKey, StabilityPoolLeveraged)); address wrappedCollateral = cfg.wrappedCollateralToken(); @@ -225,7 +207,7 @@ abstract contract MinterDeployer is PeggedToken, LeveragedToken, Minter, Stabili function _deployStabilityPoolManager( DeploymentTypes.State memory stateData, - IFullMinterConfig, + IHarborConfig, string memory marketKey ) internal { address minter = _predictAddress(_key(marketKey, "minter")); @@ -237,7 +219,7 @@ abstract contract MinterDeployer is PeggedToken, LeveragedToken, Minter, Stabili function _deployGenesis( DeploymentTypes.State memory stateData, - IFullMinterConfig cfg, + IHarborConfig cfg, string memory marketKey ) internal { cfg; @@ -246,7 +228,7 @@ abstract contract MinterDeployer is PeggedToken, LeveragedToken, Minter, Stabili } function _configureMinter(Config_MinterMarket market, string memory marketKey) internal { - IFullMinterConfig cfg = IFullMinterConfig(address(market)); + IHarborConfig cfg = IHarborConfig(address(market)); address minter = _predictAddress(_key(marketKey, "minter")); address priceOracle = _predictAddress(MinterMarketConfigLib.priceOracleKey(market)); From 5b047be2f9df56fa2e9156ecad39d10e33d7d1d6 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Fri, 22 May 2026 21:43:43 +0100 Subject: [PATCH 086/232] dryrun with cap fix for minter with zero fee band --- regression/coverage.txt | 4 ++-- src/minter/Minter_v3.sol | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/regression/coverage.txt b/regression/coverage.txt index ad62353d..f5fff6c2 100644 --- a/regression/coverage.txt +++ b/regression/coverage.txt @@ -42,7 +42,7 @@ | src/minter/Genesis_v1.sol | X 99% (88/89) | ✓ 100% (88/88) | ✓ 100% (14/14) | X 92% (11/12) | | src/minter/Minter_v1.sol | X 0% (0/601) | X 0% (0/649) | X 0% (0/102) | X 0% (0/68) | | src/minter/Minter_v2.sol | X 0% (0/601) | X 0% (0/649) | X 0% (0/102) | X 0% (0/68) | -| src/minter/Minter_v3.sol | X 99% (609/617) | X 99% (660/667) | X 93% (98/105) | X 99% (70/71) | +| src/minter/Minter_v3.sol | X 99% (609/617) | X 99% (662/669) | X 93% (98/105) | X 99% (70/71) | | src/minter/ReservePool_v1.sol | X 94% (15/16) | ✓ 100% (15/15) | ✓ 100% (3/3) | X 80% (4/5) | | src/minter/StabilityPoolManager_v1.sol | X 0% (0/166) | X 0% (0/177) | X 0% (0/21) | X 0% (0/24) | | src/minter/StabilityPoolManager_v2.sol | X 92% (175/191) | X 91% (188/206) | X 67% (20/30) | X 93% (25/27) | @@ -65,4 +65,4 @@ | src/util/ERC20MetadataLib_v1.sol | ✓ 100% (24/24) | ✓ 100% (22/22) | ✓ 100% (4/4) | ✓ 100% (4/4) | | src/util/FmtLib.sol | X 79% (15/19) | X 73% (19/26) | X 50% (2/4) | ✓ 100% (1/1) | | src/util/WordCodec.sol | ✓ 100% (15/15) | ✓ 100% (14/14) | ✓ 100% (0/0) | ✓ 100% (4/4) | -| Total | X 55% (4466/8067) | X 54% (4730/8727) | X 43% (392/922) | X 55% (647/1166) | +| Total | X 55% (4466/8067) | X 54% (4732/8729) | X 43% (392/922) | X 55% (647/1166) | diff --git a/src/minter/Minter_v3.sol b/src/minter/Minter_v3.sol index 548ae2e4..3f1d1c0a 100644 --- a/src/minter/Minter_v3.sol +++ b/src/minter/Minter_v3.sol @@ -1360,8 +1360,9 @@ contract Minter_v3 is ); collateralInBandE36 = Math.min(w.underlyingCollateralInLeftE36, collateralInBandE36); } - // Cap collateral to stay within fee budget (skip when uncapped) - if (maxFeeE36 != type(uint256).max) { + // Cap collateral to stay within fee budget (skip when uncapped or zero-fee band) + // bandFeeRatio == 0 means no fee, so no budget constraint can apply. + if (maxFeeE36 != type(uint256).max && bandFeeRatio > 0) { uint256 remainingFeeE36 = maxFeeE36 - w.underlyingFeeE36; uint256 maxCollateralForFeeE36 = Math.mulDiv(remainingFeeE36, 1 ether, bandFeeRatio); if (collateralInBandE36 > maxCollateralForFeeE36) { From 7f702bd958f076d23364259902b7ed069947be98 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Sat, 23 May 2026 07:17:38 +0100 Subject: [PATCH 087/232] Improved mocking of price oracle --- script/src/MinterDeployer.sol | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/script/src/MinterDeployer.sol b/script/src/MinterDeployer.sol index cd3bc8db..d8ceac8a 100644 --- a/script/src/MinterDeployer.sol +++ b/script/src/MinterDeployer.sol @@ -227,10 +227,17 @@ abstract contract MinterDeployer is PeggedToken, LeveragedToken, Minter, Stabili deployGenesis(stateData, marketKey, minter); } + /// @notice Resolve the wrapped price oracle address for a market. + /// @dev Default: predicts the CREATE3 address of the pre-deployed oracle from harbor-price-aggregators. + /// Override in test setups to deploy a mock oracle instead. + function _resolveWrappedPriceOracle(string memory key) internal virtual returns (address) { + return _predictAddress(key); + } + function _configureMinter(Config_MinterMarket market, string memory marketKey) internal { IHarborConfig cfg = IHarborConfig(address(market)); address minter = _predictAddress(_key(marketKey, "minter")); - address priceOracle = _predictAddress(MinterMarketConfigLib.priceOracleKey(market)); + address priceOracle = _resolveWrappedPriceOracle(MinterMarketConfigLib.priceOracleKey(market)); // Update minter configuration (incentive ratios) IMinter(minter).updateConfig(cfg.minterConfig()); From 2b3dc1e2e2bdd9fa5974396077698e9e33ed0d31 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Wed, 27 May 2026 14:16:58 +0100 Subject: [PATCH 088/232] added stability pool withdraw fee calculations to Minter --- foundry.lock | 2 +- regression/sizes.txt | 2 +- .../MainnetForkUpgradeTest.t.sol | 0 .../MainnetUpgradeTest.t.sol | 229 ++++++++++++++ .../for docs see sp-v2-upgrade docs | 0 .../linear-reward-underflow.md | 46 +++ .../run-upgrade-test-StabilityPool_v2 | 136 ++++++++ .../upgrade-StabilityPool_v2.md | 192 ++++++++++++ src/interfaces/IMinter_v3.sol | 33 +- src/minter/Minter_v3.sol | 291 ++++++++++-------- test/deployment/MinterPeggedIncentives.t.sol | 201 ++++++++++++ 11 files changed, 993 insertions(+), 139 deletions(-) rename script/{verify/minter-v2-upgrade => test}/MainnetForkUpgradeTest.t.sol (100%) create mode 100644 script/verify/sp-v2-upgrade-prep-for-v3/MainnetUpgradeTest.t.sol create mode 100644 script/verify/sp-v2-upgrade-prep-for-v3/for docs see sp-v2-upgrade docs create mode 100644 script/verify/sp-v2-upgrade-prep-for-v3/linear-reward-underflow.md create mode 100755 script/verify/sp-v2-upgrade-prep-for-v3/run-upgrade-test-StabilityPool_v2 create mode 100644 script/verify/sp-v2-upgrade-prep-for-v3/upgrade-StabilityPool_v2.md create mode 100644 test/deployment/MinterPeggedIncentives.t.sol diff --git a/foundry.lock b/foundry.lock index 93659671..525da884 100644 --- a/foundry.lock +++ b/foundry.lock @@ -2,7 +2,7 @@ "lib/bao-base": { "branch": { "name": "main", - "rev": "1d02693313011486e2b09619b32be4523aa270b8" + "rev": "45cd26574f1cd04a1c20e64b0cc5592f0fad51ad" } }, "lib/chainlink-brownie-contracts": { diff --git a/regression/sizes.txt b/regression/sizes.txt index d66cff34..f4aa12a2 100644 --- a/regression/sizes.txt +++ b/regression/sizes.txt @@ -41,7 +41,7 @@ | MinterMarketConfigLib | 85 | 24,491 | 135 | 18,350 | 1.84 | | Minter_v1 | 23,681 | 895 | 25,515 | 4,991,350 | 499.14 | | Minter_v2 | 23,675 | 901 | 25,509 | 4,990,090 | 499.01 | -| Minter_v3 | 24,231 | 345 | 26,058 | 5,106,780 | 510.68 | +| Minter_v3 | 24,515 | 61 | 26,342 | 5,166,420 | 516.64 | | PriceOracle_v1 | 1,958 | 22,618 | 2,010 | 411,700 | 41.17 | | ReservePool_v1 | 4,760 | 19,816 | 5,009 | 1,002,090 | 100.21 | | StabilityPoolManager_v1 | 11,209 | 13,367 | 13,057 | 2,372,370 | 237.24 | diff --git a/script/verify/minter-v2-upgrade/MainnetForkUpgradeTest.t.sol b/script/test/MainnetForkUpgradeTest.t.sol similarity index 100% rename from script/verify/minter-v2-upgrade/MainnetForkUpgradeTest.t.sol rename to script/test/MainnetForkUpgradeTest.t.sol diff --git a/script/verify/sp-v2-upgrade-prep-for-v3/MainnetUpgradeTest.t.sol b/script/verify/sp-v2-upgrade-prep-for-v3/MainnetUpgradeTest.t.sol new file mode 100644 index 00000000..bbedab84 --- /dev/null +++ b/script/verify/sp-v2-upgrade-prep-for-v3/MainnetUpgradeTest.t.sol @@ -0,0 +1,229 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.28 <0.9.0; + +import "forge-std/Test.sol"; +import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; +import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {StabilityPool_v2} from "@harbor/minter/StabilityPool_v2.sol"; +import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; +import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; + +/// @title Mainnet Upgrade Test +/// @notice Comprehensive test that: +/// 1. Replicates all three user-visible issues on mainnet (deposit, depositReward, withdrawal) +/// 2. Deploys fixed implementation +/// 3. Upgrades the proxy +/// 4. Verifies all issues are resolved +contract MainnetUpgradeTest is Test { + uint constant PASS = 0; + uint constant FAIL = 1; + + address constant STABILITY_POOL = 0x9e56F1E1E80EBf165A1dAa99F9787B41cD5bFE40; + address constant MINTER = 0x33e32ff4d0677862fa31582CC654a25b9b1e4888; // Real mainnet minter + address constant REWARD_COLLATERAL = 0x7743e50F534a7f9F1791DdE7dCD89F7783Eefc39; // fxSAVE + address constant REWARD_SAIL = 0x9567c243F647f9Ac37efb7Fc26BD9551Dce0BE1B; // hsBTC-fxUSD + address constant ANCHORED = 0x25bA4A826E1A1346dcA2Ab530831dbFF9C08bEA7; // haBTC + uint256 constant FORK_BLOCK = 25186513; + uint256 constant LATER_BLOCK = 25186514; + + string mainnet = vm.rpcUrl("mainnet"); + + // Known addresses that might have the pegged token for testing + address userWithTokens; + address rewardDepositor; + address userWithBalance; // Existing user with balance for withdrawal test + + function parseError(bytes memory lowLevelData) internal pure { + // Check if it's an arithmetic underflow (panic code 0x11) + if (lowLevelData.length >= 36) { + bytes4 selector = bytes4(lowLevelData); + if (selector == 0x4e487b71) { + // Panic selector + uint256 panicCode; + assembly { + panicCode := mload(add(lowLevelData, 36)) + } + if (panicCode == 0x11) { + console.log("*** User deposit fails with Panic 0x11 (Arithmetic Underflow) ***"); + } else { + console.log("Unexpected panic code:", panicCode); + } + } else { + console.log("Unexpected error type"); + } + } + } + + function _doFailingTransactions(uint expect) internal { + // Try to deposit - this should fail with arithmetic underflow + // Deal some tokens to our test user + console.log("Attempting user deposit..."); + deal(ANCHORED, userWithTokens, 1000 ether); + + vm.startPrank(userWithTokens); + IERC20(ANCHORED).approve(STABILITY_POOL, type(uint256).max); + + try IStabilityPool(STABILITY_POOL).deposit(100 ether, userWithTokens, 0) { + console.log("ERROR: deposit succeeded when it should have failed!"); + assertEq(expect, PASS, "Deposit should have failed"); + } catch (bytes memory lowLevelData) { + parseError(lowLevelData); + assertEq(expect, FAIL, "expected deposit to succeed"); + } + vm.stopPrank(); + + vm.startPrank(rewardDepositor); + + address[] memory activeTokens = IMultipleRewardDistributor(STABILITY_POOL).activeRewardTokens(); + for (uint256 i = 0; i < activeTokens.length; i++) { + address tokenToDeposit = activeTokens[i]; + + console.log("Attempting depositReward for token:", tokenToDeposit); + + // Deal some reward tokens to our depositor + deal(tokenToDeposit, rewardDepositor, 1000 ether); + + IERC20(tokenToDeposit).approve(STABILITY_POOL, type(uint256).max); + + // Try to deposit rewards - this should fail with arithmetic underflow + try IMultipleRewardDistributor(STABILITY_POOL).depositReward(tokenToDeposit, 100 ether) { + assertEq(expect, PASS, "depositReward should have failed"); + } catch (bytes memory lowLevelData) { + parseError(lowLevelData); + assertEq(expect, FAIL, "expected depositReward to succeed"); + } + } + vm.stopPrank(); + + // Try to withdraw - this should also fail with arithmetic underflow before upgrade + // Testing with real mainnet user who had failed withdrawal tx: + // 0xacbb222f01fa442075187334a42eaefc6ebd03411b18635bbbf8e93cde54c205 + if (userWithBalance != address(0)) { + console.log("Attempting user withdrawal..."); + console.log("User address:", userWithBalance); + uint256 balance = IStabilityPool(STABILITY_POOL).assetBalanceOf(userWithBalance); + console.log("User balance:", balance); + + if (balance > 0) { + uint256 withdrawAmount = balance / 2; // Withdraw half + vm.startPrank(userWithBalance); + + try IStabilityPool(STABILITY_POOL).withdraw(withdrawAmount, userWithBalance, 0) { + console.log("Withdrawal succeeded"); + assertEq(expect, PASS, "Withdrawal should have failed"); + } catch (bytes memory lowLevelData) { + parseError(lowLevelData); + assertEq(expect, FAIL, "expected withdrawal to succeed"); + } + vm.stopPrank(); + } else { + console.log("User has no balance to withdraw"); + } + } + } + + function setUp() public { + // Fork mainnet at the problematic block + vm.createSelectFork(mainnet, FORK_BLOCK); + + // Find the reward depositor by checking who has the REWARD_DEPOSITOR_ROLE + // For now, we'll use a test address and deal tokens to it + userWithTokens = makeAddr("user"); + rewardDepositor = makeAddr("rewardDepositor"); + + // Find a user with existing balance for withdrawal test + // We need to find an actual depositor from mainnet state + userWithBalance = _findUserWithBalance(); + } + + /// @notice Helper to find a user with existing balance in the stability pool + /// @dev Returns a known depositor who had a failed withdrawal transaction + /// @dev Transaction: 0xacbb222f01fa442075187334a42eaefc6ebd03411b18635bbbf8e93cde54c205 + function _findUserWithBalance() internal pure returns (address) { + // Known depositor with failed withdrawal + return 0xb9ab9578a34a05c86124c399735fdE44dEc80E7F; + } + + /// @notice Test 1: Demonstrate that deposit, depositReward, and withdrawal all fail on mainnet + function test_1_AllOperationsFail_OnMainnet() public { + console.log("=== TEST 1: All Operations Fail on Mainnet ==="); + console.log("Block:", FORK_BLOCK); + console.log("StabilityPool:", STABILITY_POOL); + console.log(""); + + // Show the problematic token state + address[] memory activeTokens = IMultipleRewardDistributor(STABILITY_POOL).activeRewardTokens(); + console.log("Active reward tokens:", activeTokens.length); + + for (uint256 i = 0; i < activeTokens.length; i++) { + address token = activeTokens[i]; + console.log("activeToken[%s]", i, token); + + (uint256 lastUpdate, uint256 finishAt, uint256 rate, ) = IMultipleRewardDistributor(STABILITY_POOL) + .rewardData(token); + if (finishAt == 0 && lastUpdate > 0) { + console.log(""); + console.log("Found problematic token at index:", i); + console.log("Token address:", token); + console.log(" lastUpdate:", lastUpdate); + console.log(" finishAt:", finishAt); + console.log(" rate:", rate); + console.log("*** This token will cause underflow! ***"); + } + } + + _doFailingTransactions(FAIL); + } + + /// @notice Test 2: Verify issues persist at latest block + function test_2_IssuesPersist_AtLatestBlock() public { + console.log("=== TEST 2: Issues Persist at Latest Block ==="); + console.log(""); + + // Fork to the latest block + vm.createSelectFork(mainnet, LATER_BLOCK); + console.log("Forked to latest block:", block.number); + _doFailingTransactions(FAIL); + } + + /// @notice Test 5: Verify all operations work after upgrade + function test_3_AllOperationsWork_AfterUpgrade() public { + console.log("=== TEST 3: All Operations Work After Upgrade ==="); + console.log(""); + + // First do the upgrade (reusing logic from test 4) + vm.createSelectFork(mainnet, FORK_BLOCK); + + StabilityPool_v2 currentProxy = StabilityPool_v2(STABILITY_POOL); + + // Read immutable variables from the current contract (these are in the implementation bytecode) + address liquidationToken = currentProxy.LIQUIDATION_TOKEN(); + (uint64 startDelay, uint64 endWindow) = currentProxy.getWithdrawalWindow(); + uint256 minTotalAssetSupply = currentProxy.MIN_TOTAL_ASSET_SUPPLY(); + console2.log("minTotalAssetSupply = %s", minTotalAssetSupply); + + // Deploy new implementation with the same parameters + StabilityPool_v2 newImplementation = new StabilityPool_v2( + MINTER, + liquidationToken, + startDelay, + endWindow, + minTotalAssetSupply + ); + + address proxyOwner = IBaoOwnable(STABILITY_POOL).owner(); + vm.startPrank(proxyOwner); + StabilityPool_v2(STABILITY_POOL).upgradeToAndCall(address(newImplementation), ""); + + // Grant REWARD_DEPOSITOR_ROLE to our test depositor so depositReward can succeed + uint256 depositorRole = IMultipleRewardDistributor(STABILITY_POOL).REWARD_DEPOSITOR_ROLE(); + IBaoRoles(STABILITY_POOL).grantRoles(rewardDepositor, depositorRole); + vm.stopPrank(); + + console.log("Upgrade completed. Testing user deposit..."); + console.log(""); + + _doFailingTransactions(PASS); + } +} diff --git a/script/verify/sp-v2-upgrade-prep-for-v3/for docs see sp-v2-upgrade docs b/script/verify/sp-v2-upgrade-prep-for-v3/for docs see sp-v2-upgrade docs new file mode 100644 index 00000000..e69de29b diff --git a/script/verify/sp-v2-upgrade-prep-for-v3/linear-reward-underflow.md b/script/verify/sp-v2-upgrade-prep-for-v3/linear-reward-underflow.md new file mode 100644 index 00000000..028e117d --- /dev/null +++ b/script/verify/sp-v2-upgrade-prep-for-v3/linear-reward-underflow.md @@ -0,0 +1,46 @@ +# LinearReward Arithmetic Underflow + +## Executive Summary + +**Status**: Bug confirmed on mainnet at block 24404265. +**Contract**: `0x9e56F1E1E80EBf165A1dAa99F9787B41cD5bFE40` (StabilityPool) +**Impact**: Deposits completely blocked -- users cannot deposit into the pool. + +## Root Cause + +Reward token `0x9567c243F647f9Ac37efb7Fc26BD9551Dce0BE1B` was registered as an active reward token but never received any reward deposits. The `_distributePendingReward()` function updates `lastUpdate` for ALL active tokens on every deposit, but `finishAt` is only set by `increase()`, which is only called for the token actually receiving rewards. This results in: + +``` +lastUpdate: 1769846711 (valid timestamp) +finishAt: 0 (never set) +rate: 0 +queued: 0 +``` + +### The Bug in LinearReward.sol + +In `increase()`, the `else` branch (entered when `block.timestamp < finishAt`) performs unsafe subtractions: + +**Line 48** -- `finishAt - periodLength` underflows when `finishAt < periodLength` (e.g., 0 < 1209600) + +**Line 52** -- `finishAt - lastUpdate` underflows when `finishAt < lastUpdate` (e.g., 0 < 1769846711) + +When any user calls `deposit()`, `_distributePendingReward()` loops through all active tokens and calls `increase()`. The underflow causes Panic 0x11 and the entire transaction reverts. + +## The Fix + +Safe subtractions at all affected lines: + +```solidity +// Line 48-50 +uint256 periodStart = _data.finishAt >= _periodLength ? _data.finishAt - _periodLength : 0; +uint256 _elapsed = block.timestamp >= periodStart ? block.timestamp - periodStart : 0; + +// Line 52-54 +uint256 timeSinceLastUpdate = _data.finishAt >= _data.lastUpdate ? _data.finishAt - _data.lastUpdate : 0; +_amount = _amount + uint256(_data.rate) * timeSinceLastUpdate; +``` + +## See Also + +- [finishAt = 0 Root Cause Investigation](finishat-zero.md) -- detailed investigation of how the state arose diff --git a/script/verify/sp-v2-upgrade-prep-for-v3/run-upgrade-test-StabilityPool_v2 b/script/verify/sp-v2-upgrade-prep-for-v3/run-upgrade-test-StabilityPool_v2 new file mode 100755 index 00000000..418976d6 --- /dev/null +++ b/script/verify/sp-v2-upgrade-prep-for-v3/run-upgrade-test-StabilityPool_v2 @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CWD=$(pwd) + +echo "Running from $CWD" +echo "Script is in $SCRIPT_DIR" +echo "files generated in $CWD/tmp" + +# ── Configuration ────────────────────────────────────────────────────────────── + +BLOCK=25186514 # just before the actual deploy and upgrade +POOL_FILTER="${POOL_FILTER:-}" +FORGE_VERBOSITY="${FORGE_VERBOSITY:--vvv}" + +while [[ $# -gt 0 ]]; do + case "$1" in + --include) + POOL_FILTER="$2" + shift 2 + ;; + -h | --help) + echo "Usage: $(basename "$0") [--include ]" + echo "" + echo "Options:" + echo " --include Substring filter on pool labels (e.g., BTC, _col, GOLD_fxUSD_lev)" + echo "" + echo "Environment variables:" + echo " POOL_FILTER Same as --include (--include takes precedence)" + echo " FORGE_VERBOSITY Forge verbosity flag (default: -vvv)" + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + echo "Usage: $(basename "$0") [--include ]" >&2 + exit 1 + ;; + esac +done + +# ── Helpers ──────────────────────────────────────────────────────────────────── + +prompt() { + local msg="$1" + echo "" + echo "──────────────────────────────────────────────────────────────" + echo "$msg" + echo "──────────────────────────────────────────────────────────────" + read -rn1 -p "Ready? [Y/n] " response + echo "" + if [[ "$response" =~ ^[Nn]$ ]]; then + echo "Aborted." + exit 1 + fi +} + +run_capture() { + local version="$1" + local env_args=( + START_TIMESTAMP="$START_TIMESTAMP" + VERSION="$version" + ) + if [[ -n "$POOL_FILTER" ]]; then + env_args+=(POOL_FILTER="$POOL_FILTER") + fi + + echo "" + echo "Running $version capture..." + env "${env_args[@]}" forge test \ + --match-path script/test/MainnetForkUpgradeTest.t.sol \ + --fork-url local $FORGE_VERBOSITY + echo "" + echo "$version capture complete. Files in tmp/$version/" +} + +# ── Main ─────────────────────────────────────────────────────────────────────── + +echo "======================================================================" +echo " StabilityPool v2 storage state change Verification" +echo "======================================================================" +echo "" +echo "Fork block: $BLOCK" +if [[ -n "$POOL_FILTER" ]]; then + echo "Pool filter: $POOL_FILTER" +fi + +# Compute START_TIMESTAMP from the fork block +echo "" +echo "Computing START_TIMESTAMP from block $BLOCK..." +TS=$(cast block --rpc-url mainnet "$BLOCK" -f timestamp) +START_TIMESTAMP=$((TS + 12000)) +echo "Fork block timestamp: $TS" +echo "START_TIMESTAMP: $START_TIMESTAMP (+12000s buffer)" + +# ── Step 1: v1 capture ──────────────────────────────────────────────────────── + +prompt "Start anvil fork: script/anvil --block $BLOCK" + +run_capture v1 + +# ── Step 2: Deploy upgrade ──────────────────────────────────────────────────── + +prompt "Cycle anvil (Ctrl+C, then: script/anvil --block $BLOCK)" + +echo "" +echo "NOT Deploying StabilityPool v2 upgrade..." +# ./script/run-script Deploy_StabilityPool_v2_mainnet --network mainnet --salt harbor_v1 --broadcast --local + +# ── Step 3: v2 capture (same anvil instance) ────────────────────────────────── + +run_capture v2 + +# ── Step 4: Compare ────────────────────────────────────────────────────────── + +echo "" +echo "======================================================================" +echo " Comparison" +echo "======================================================================" +echo "" + +if command -v meld >/dev/null 2>&1; then + echo "Opening meld (v1 vs v2)..." + meld tmp/v1 tmp/v2 +else + echo "meld not found -- using diff:" + echo "" + echo "--- Pre-interaction (should be identical) ---" + diff -ru tmp/v1/pre tmp/v2/pre || true + echo "" + echo "--- Post-interaction (broken pools now work) ---" + diff -ru tmp/v1/post tmp/v2/post || true +fi + +echo "" +echo "Done. See script/test/upgrade-StabilityPool_v2.md for expected differences." diff --git a/script/verify/sp-v2-upgrade-prep-for-v3/upgrade-StabilityPool_v2.md b/script/verify/sp-v2-upgrade-prep-for-v3/upgrade-StabilityPool_v2.md new file mode 100644 index 00000000..92ee73b7 --- /dev/null +++ b/script/verify/sp-v2-upgrade-prep-for-v3/upgrade-StabilityPool_v2.md @@ -0,0 +1,192 @@ +# StabilityPool v2 Upgrade Verification + +These tests verify that upgrading StabilityPool v1 to v2 preserves all on-chain +state and produces identical behavior. They run against a local anvil fork and +are NOT part of CI -- run them manually during the upgrade deployment workflow. + +## Output + +Each run produces one JSON file per pool in two directories: + +``` +tmp/{version}/pre/{label}.json -- state snapshot before any interactions +tmp/{version}/post/{label}.json -- interaction results + state snapshot after +``` + +## Quick start (automated) + +The `run-upgrade-test-StabilityPool_v2` script orchestrates the full workflow +(fork block: 24433566). It prompts you to start and stop anvil manually between steps: + +```bash +script/test/run-upgrade-test-StabilityPool_v2 +``` + +It will: +1. Compute `START_TIMESTAMP` from the fork block +2. Prompt you to start anvil, then capture v1 state +3. Prompt you to restart anvil, then deploy the upgrade and capture v2 state +4. Open meld (or diff) to compare the results + +Optional environment variables: + +```bash +# Only test BTC pools +POOL_FILTER=BTC script/test/run-upgrade-test-StabilityPool_v2 + +# Less verbose forge output +FORGE_VERBOSITY=-vv script/test/run-upgrade-test-StabilityPool_v2 +``` + +## Manual workflow + +### 1. Start anvil fork and capture v1 state + +```bash +script/anvil --block 24433566 # just before the SP v2 upgrade +``` + +Compute normalized starting timestamp (use same value for both runs): + +```bash +BLOCK=24433566 +TS=$(cast block --rpc-url local $BLOCK -f timestamp) +export START_TIMESTAMP=$((TS + 12000)) +``` + +In a separate terminal: + +```bash +START_TIMESTAMP=$START_TIMESTAMP VERSION=v1 forge test \ + --match-path script/test/MainnetForkUpgradeTest.t.sol \ + --fork-url local -vvv +``` + +Output: `tmp/v1/pre/*.json` and `tmp/v1/post/*.json` + +Stop anvil (Ctrl+C). + +### 2. Start fresh anvil fork and deploy the upgrade + +```bash +script/anvil --block 24433566 +``` + +Deploy the upgrade against the local fork: + +```bash +./script/run-script Deploy_StabilityPool_v2_mainnet --network mainnet --salt harbor_v1 --broadcast --local +``` + +### 3. Capture v2 state (same anvil instance, post-upgrade) + +```bash +START_TIMESTAMP=$START_TIMESTAMP VERSION=v2 forge test \ + --match-path script/test/MainnetForkUpgradeTest.t.sol \ + --fork-url local -vvv +``` + +Output: `tmp/v2/pre/*.json` and `tmp/v2/post/*.json` + +Stop anvil. + +### 4. Compare + +Using meld (recommended -- shows all pools side by side): + +```bash +# State before interactions -- should be identical +meld tmp/v1/pre tmp/v2/pre + +# Interactions + post-interaction state -- broken pools now work +meld tmp/v1/post tmp/v2/post +``` + +Using diff: + +```bash +diff -ru tmp/v1/pre tmp/v2/pre +diff -ru tmp/v1/post tmp/v2/post +``` + +Single pool with sorted keys: + +```bash +jq --sort-keys . tmp/v1/pre/BTC_fxUSD_col.json > /tmp/v1.json +jq --sort-keys . tmp/v2/pre/BTC_fxUSD_col.json > /tmp/v2.json +diff --color /tmp/v1.json /tmp/v2.json +``` + +## Environment variables + +| Variable | Default | Description | +| ----------------- | -------------- | ---------------------------------------------------------- | +| `VERSION` | `v1` | Labels the output directories (`v1` or `v2`) | +| `POOL_FILTER` | (none) | Substring filter on pool labels -- only matching pools run | +| `START_TIMESTAMP` | (current) | Normalize to this timestamp (use same value for v1/v2) | + +`START_TIMESTAMP` eliminates diffs caused by the v2 deployment adding blocks +(and therefore advancing `block.timestamp`) on anvil. The test rolls one block +forward then warps to the target timestamp. Pick a value above the fork block's +timestamp and pass the same one to both runs: + +```bash +BLOCK=24433566 +TS=$(cast block --rpc-url mainnet $BLOCK -f timestamp) +START_TIMESTAMP=$((TS + 12000)) VERSION=v1 forge test ... +``` + +`POOL_FILTER` examples: + +```bash +# Only BTC pools +POOL_FILTER=BTC VERSION=v1 forge test ... + +# Only collateral pools +POOL_FILTER=_col VERSION=v1 forge test ... + +# Single pool +POOL_FILTER=GOLD_fxUSD_lev VERSION=v1 forge test ... +``` + +Pool labels: `BTC_fxUSD_col`, `BTC_fxUSD_lev`, `BTC_stETH_col`, `BTC_stETH_lev`, +`ETH_fxUSD_col`, `ETH_fxUSD_lev`, `EUR_fxUSD_col`, `EUR_fxUSD_lev`, +`EUR_stETH_col`, `EUR_stETH_lev`, `GOLD_fxUSD_col`, `GOLD_fxUSD_lev`, +`GOLD_stETH_col`, `GOLD_stETH_lev`, `MCAP_fxUSD_col`, `MCAP_fxUSD_lev`, +`MCAP_stETH_col`, `MCAP_stETH_lev`, `SILVER_fxUSD_col`, `SILVER_fxUSD_lev`, +`SILVER_stETH_col`, `SILVER_stETH_lev` + +## Expected differences + +### Pre files (`v1/pre/*.json` vs `v2/pre/*.json`) + +| Key | v1 | v2 | Meaning | +| --------------------------------- | --------- | -------- | ------------------------------------------------ | +| `version` | `"v1"` | `"v2"` | Test metadata | +| `state_reward_*_pendingRewards_ok` | `"false"` | `"true"` | pendingRewards no longer reverts on broken pools | + +Everything else should be **identical** -- proves the upgrade preserves all state. + +### Post files (`v1/post/*.json` vs `v2/post/*.json`) + +| Key | v1 | v2 | Meaning | +| ------------------------------------- | --------- | -------- | ------------------------------------------------------------ | +| `version` | `"v1"` | `"v2"` | Test metadata | +| `interact_deposit_success` | `"false"` | `"true"` | Broken pools now accept deposits | +| `interact_depositReward_success` | `"false"` | `"true"` | Broken pools now accept rewards | +| `interact_withdraw_success` | `"false"` | `"true"` | Broken pools now allow withdrawals | +| post-interaction state | differs | differs | State for newly-fixed pools reflects successful interactions | + +## Adding depositors + +The test currently has one hardcoded depositor. To discover more: + +```bash +TOPIC0=$(cast sig-event "Deposit(address indexed,address indexed,uint256)") +POOL=0x9e56F1E1E80EBf165A1dAa99F9787B41cD5bFE40 +cast logs --rpc-url mainnet --address $POOL \ + --from-block 0 --to-block 24404265 $TOPIC0 \ + | jq -r '.[].topics[1]' | sort -u +``` + +Add discovered addresses to the `poolDepositors` mapping in `setUp()`. diff --git a/src/interfaces/IMinter_v3.sol b/src/interfaces/IMinter_v3.sol index 52e68f32..f7b8450a 100644 --- a/src/interfaces/IMinter_v3.sol +++ b/src/interfaces/IMinter_v3.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; -/// @notice Minter v3 extensions: fee-capped minting. +/// @notice Minter v3 extensions: fee-capped minting and absolute-amount fee queries in pegged space. // solhint-disable-next-line contract-name-capwords interface IMinter_v3 { /// @notice Mint pegged tokens with a fee cap. Stops minting when cumulative fee would exceed maxFeeRatio. @@ -37,4 +37,35 @@ interface IMinter_v3 { uint256 price, uint256 rate ); + + /// @notice Returns the absolute mint fee and uncapped redeem bonus for a given pegged amount, both + /// expressed in pegged token units at current oracle prices. + /// + /// Intended for contract-to-contract callers (e.g. SP_v3 withdrawal fee, HY mechanism C) that need + /// the fee as an amount (not a ratio) for a specific withdrawal size. Does NOT handle + /// type(uint256).max as "all tokens" — callers must supply the actual amount. + /// + /// @param peggedIn The pegged token amount being evaluated (in pegged base units, 1e18-scaled). + /// @return mintFee The absolute mint fee in pegged units that the Minter would charge for minting + /// the collateral-equivalent of `peggedIn`. Floored at zero. Used by SP_v3 as one component + /// of the CR-based withdrawal fee. + /// @return peggedNotMinted The portion of `peggedIn` that falls in the disallow band (unmintable). + /// Zero when CR is high enough that the full amount is mintable. + /// @return mintMaxFeeRatio The highest configured fee ratio across all non-disallow mint bands + /// (1e18-scaled). Used by callers to cap or scale the fee. + /// @return redeemPeggedUncappedBonus The absolute uncapped redeem bonus in pegged units that the + /// system would pay from the reserve pool for redeeming `peggedIn` at the current CR. The + /// reserve pool is treated as unlimited (theoretical, not capped by actual balance). Floored + /// at zero — positive only when CR is stressed enough to offer a redemption discount. + function peggedIncentivesByPegged( + uint256 peggedIn + ) + external + view + returns ( + uint256 mintFee, + uint256 peggedNotMinted, + uint256 mintMaxFeeRatio, + uint256 redeemPeggedUncappedBonus + ); } diff --git a/src/minter/Minter_v3.sol b/src/minter/Minter_v3.sol index 3f1d1c0a..1eef478c 100644 --- a/src/minter/Minter_v3.sol +++ b/src/minter/Minter_v3.sol @@ -307,30 +307,26 @@ contract Minter_v3 is /// @inheritdoc IMinter function collateralRatio() external view override returns (uint256 collateralRatio_) { MinterStorage storage $ = _getMinterStorage(); - collateralRatio_ = _collateralRatio( - $.underlyingCollateral, - _fetchMid($.priceOracle).price, - $.peggedTokenBalance - ); + (uint256 price, ) = _fetchMid($.priceOracle); + collateralRatio_ = _collateralRatio($.underlyingCollateral, price, $.peggedTokenBalance); } /// @inheritdoc IMinter function leverageRatio() external view override returns (uint256 ratio) { MinterStorage storage $ = _getMinterStorage(); - // slither-disable-next-line unused-return we don't need the leveraged value here - OracleData memory oracle = _fetchMid($.priceOracle); - ratio = _leverageRatio($.peggedTokenBalance, $.underlyingCollateral, oracle.price); + (uint256 price, ) = _fetchMid($.priceOracle); + ratio = _leverageRatio($.peggedTokenBalance, $.underlyingCollateral, price); } /// @inheritdoc IMinter function leveragedTokenPrice() external view override returns (uint256 nav) { MinterStorage storage $ = _getMinterStorage(); - OracleData memory oracle = _fetchMid($.priceOracle); + (uint256 price, ) = _fetchMid($.priceOracle); (uint256 collateralValueE36, uint256 peggedValueE36) = _tokenValuesE36( $.peggedTokenBalance, $.underlyingCollateral, - oracle.price + price ); nav = _leveragedTokenPriceE36(collateralValueE36, peggedValueE36, _leveragedTokenBalance()) / 1 ether; } @@ -355,8 +351,8 @@ contract Minter_v3 is if (peggedTokenBalance_ == 0) { nav = 1 ether; } else { - OracleData memory oracle = _fetchMid($.priceOracle); - (, uint256 peggedValueE36) = _tokenValuesE36(peggedTokenBalance_, $.underlyingCollateral, oracle.price); + (uint256 price, ) = _fetchMid($.priceOracle); + (, uint256 peggedValueE36) = _tokenValuesE36(peggedTokenBalance_, $.underlyingCollateral, price); nav = peggedValueE36 / peggedTokenBalance_; } } @@ -367,21 +363,21 @@ contract Minter_v3 is ) external view returns (uint256 peggedForCollateral, uint256 peggedForLeveraged) { // TODO: add a check for no pegged tokens MinterStorage storage $ = _getMinterStorage(); - OracleData memory oracle = _fetchMax($.priceOracle); + (uint256 price, ) = _fetchMax($.priceOracle); uint256 collateralTokenBalance_ = $.underlyingCollateral; uint256 peggedTokenBalance_ = $.peggedTokenBalance; - uint256 currentCollateralRatio = _collateralRatio(collateralTokenBalance_, oracle.price, peggedTokenBalance_); + uint256 currentCollateralRatio = _collateralRatio(collateralTokenBalance_, price, peggedTokenBalance_); if (targetCollateralRatio > currentCollateralRatio) { if (currentCollateralRatio < 1 ether) { // we're depegged, so all we can do is redeem them all peggedForCollateral = peggedTokenBalance_; } else { peggedForCollateral = - (targetCollateralRatio * peggedTokenBalance_ - collateralTokenBalance_ * oracle.price) / + (targetCollateralRatio * peggedTokenBalance_ - collateralTokenBalance_ * price) / (targetCollateralRatio - 1 ether); } peggedForLeveraged = - peggedTokenBalance_ - Math.mulDiv(collateralTokenBalance_, oracle.price, targetCollateralRatio); + peggedTokenBalance_ - Math.mulDiv(collateralTokenBalance_, price, targetCollateralRatio); } else { peggedForCollateral = 0; peggedForLeveraged = 0; @@ -394,13 +390,13 @@ contract Minter_v3 is // solhint-disable-next-line explicit-types function _lookupIncentiveRatio(uint action) internal view returns (int256 incentiveRatio) { MinterStorage storage $ = _getMinterStorage(); - OracleData memory oracle = _fetchMid($.priceOracle); + (uint256 price, ) = _fetchMid($.priceOracle); uint256 collateralTokenBalance_ = $.underlyingCollateral; uint256 peggedTokenBalance_ = $.peggedTokenBalance; ConfigIncentiveLib.ActionIncentive memory config_ = $.incentiveConfig[action]; // solhint-disable-next-line explicit-types - uint band = _findBand(config_, collateralTokenBalance_, oracle.price, peggedTokenBalance_, false); + uint band = _findBand(config_, collateralTokenBalance_, price, peggedTokenBalance_, false); incentiveRatio = ConfigIncentiveLib._incentiveRatio(config_, band); } @@ -470,17 +466,15 @@ contract Minter_v3 is { wrappedCollateralIn = Token.allOfQuiet(_msgSender(), WRAPPED_COLLATERAL_TOKEN, wrappedCollateralIn); MinterStorage storage $ = _getMinterStorage(); - OracleData memory oracle = _fetchMid($.priceOracle); - price = oracle.price; - rate = oracle.rate; + (price, rate) = _fetchMid($.priceOracle); uint256 maxFeeE36 = maxFeeRatio == type(uint256).max ? type(uint256).max - : Math.mulDiv(wrappedCollateralIn, maxFeeRatio, 1 ether) * oracle.rate; + : Math.mulDiv(wrappedCollateralIn, maxFeeRatio, 1 ether) * rate; uint256 underlyingCollateralAdded; (wrappedFee, peggedMinted, wrappedCollateralUsed, underlyingCollateralAdded) = _mintPeggedAdjustments( $.incentiveConfig[Config_v2.MINT_PEGGED], wrappedCollateralIn, - CollateralRatioData($.underlyingCollateral, oracle.price, oracle.rate, $.peggedTokenBalance), + CollateralRatioData($.underlyingCollateral, price, rate, $.peggedTokenBalance), maxFeeE36 ); // slither-disable-next-line incorrect-equality @@ -509,9 +503,7 @@ contract Minter_v3 is MinterStorage storage $ = _getMinterStorage(); uint256 peggedTokenBalance_ = $.peggedTokenBalance; peggedIn = _redeemableQuiet(peggedIn, peggedTokenBalance_); - OracleData memory oracle = _fetchMid($.priceOracle); - price = oracle.price; - rate = oracle.rate; + (price, rate) = _fetchMid($.priceOracle); peggedRedeemed = peggedIn; uint256 peggedPriceE36; (wrappedFee, wrappedDiscount, wrappedCollateralReturned, , peggedPriceE36) = _redeemPeggedAdjustments( @@ -538,6 +530,66 @@ contract Minter_v3 is } } + /// @inheritdoc IMinter_v3 + function peggedIncentivesByPegged( + uint256 peggedIn + ) + external + view + returns (uint256 mintFee, uint256 peggedNotMinted, uint256 mintMaxFeeRatio, uint256 redeemPeggedUncappedBonus) + { + MinterStorage storage $ = _getMinterStorage(); + (uint256 price, uint256 rate) = _fetchMid($.priceOracle); + uint256 peggedTokenPriceE36; + + // do the redeem part first to do get the pegged price + { + uint256 peggedTokenBalance_ = $.peggedTokenBalance; + uint256 wrappedDiscount; + (, wrappedDiscount, , , peggedTokenPriceE36) = _redeemPeggedAdjustments( + $.incentiveConfig[Config_v2.REDEEM_PEGGED], + _redeemableQuiet(peggedIn, peggedTokenBalance_), + CollateralRatioData($.underlyingCollateral, price, rate, peggedTokenBalance_), + type(uint256).max // uncapped: theoretical maximum discount (reserve pool not limiting) + ); + redeemPeggedUncappedBonus = Math.mulDiv(wrappedDiscount * rate, price, peggedTokenPriceE36); + } + // do the mint part + ConfigIncentiveLib.ActionIncentive memory config_ = $.incentiveConfig[Config_v2.MINT_PEGGED]; + + // Convert peggedIn to approximate wrapped collateral using oracle mid price. + // 1 wrapped collateral ≈ price × rate / 1e18 pegged, so: + // wrappedCollateralIn ≈ peggedIn × 1e18 / (price × rate / 1e18) + uint256 priceRateE36 = price * rate; + uint256 wrappedCollateralIn = Math.mulDiv(peggedIn, 1e36, priceRateE36); + + uint256 wrappedFee; + uint256 wrappedCollateralUsed; + (wrappedFee, , wrappedCollateralUsed, ) = _mintPeggedAdjustments( + config_, + wrappedCollateralIn, + CollateralRatioData($.underlyingCollateral, price, rate, $.peggedTokenBalance), + type(uint256).max + ); + + mintFee = Math.mulDiv(wrappedFee, priceRateE36, peggedTokenPriceE36); + peggedNotMinted = peggedIn - Math.mulDiv(wrappedCollateralUsed, priceRateE36, peggedTokenPriceE36); + + // Scan all configured bands for mintMaxFeeRatio (highest non-disallow fee). + // All mint-pegged rates are in [0, 1) enforced by config validation. + // solhint-disable-next-line explicit-types + uint bandCount = ConfigIncentiveLib._collateralRatioBandCount(config_); + // solhint-disable-next-line explicit-types + // mintMaxFeeRatio = 0; not needed due to default value being 0 + for (uint i = 0; i < bandCount; i++) { + int256 bandRatio = ConfigIncentiveLib._incentiveRatio(config_, i); + if (bandRatio == 1 ether) { + continue; // disallow band — skip + } + mintMaxFeeRatio = Math.max(uint256(bandRatio), mintMaxFeeRatio); // safe: mint-pegged enforces [0, 1) + } + } + /// @inheritdoc IMinter function mintLeveragedTokenDryRun( uint256 wrappedCollateralIn @@ -556,13 +608,12 @@ contract Minter_v3 is { wrappedCollateralIn = Token.allOfQuiet(_msgSender(), WRAPPED_COLLATERAL_TOKEN, wrappedCollateralIn); MinterStorage storage $ = _getMinterStorage(); - OracleData memory oracle = _fetchMid($.priceOracle); - price = oracle.price; - rate = oracle.rate; + (price, rate) = _fetchMid($.priceOracle); + (wrappedFee, wrappedDiscount, leveragedMinted, wrappedCollateralUsed, ) = _mintLeveragedAdjustments( $.incentiveConfig[Config_v2.MINT_LEVERAGED], wrappedCollateralIn, - CollateralRatioData($.underlyingCollateral, oracle.price, oracle.rate, $.peggedTokenBalance), + CollateralRatioData($.underlyingCollateral, price, rate, $.peggedTokenBalance), IERC20(WRAPPED_COLLATERAL_TOKEN).balanceOf($.reservePool) ); // slither-disable-next-line incorrect-equality @@ -601,9 +652,8 @@ contract Minter_v3 is MinterStorage storage $ = _getMinterStorage(); uint256 leveragedTokenBalance_ = _leveragedTokenBalance(); leveragedIn = _redeemableQuiet(leveragedIn, leveragedTokenBalance_); - OracleData memory oracle = _fetchMid($.priceOracle); - price = oracle.price; - rate = oracle.rate; + (price, rate) = _fetchMid($.priceOracle); + (wrappedFee, leveragedRedeemed, wrappedCollateralReturned, ) = _redeemLeveragedAdjustments( $.incentiveConfig[Config_v2.REDEEM_LEVERAGED], leveragedIn, @@ -619,8 +669,8 @@ contract Minter_v3 is /// @inheritdoc IMinter function harvestable() external view returns (uint256 wrappedAmount) { MinterStorage storage $ = _getMinterStorage(); - uint256 rate = _fetchMid($.priceOracle).rate; - wrappedAmount = 0; + (, uint256 rate) = _fetchMid($.priceOracle); + // wrappedAmount = 0; not needed due to 0 default value if (rate > 0) { uint256 balance = IERC20(WRAPPED_COLLATERAL_TOKEN).balanceOf(address(this)); uint256 value = Math.mulDiv($.underlyingCollateral, 1 ether, rate); @@ -637,8 +687,8 @@ contract Minter_v3 is MinterStorage storage $ = _getMinterStorage(); uint256 underlying = $.underlyingCollateral; uint256 wrapped = IERC20(WRAPPED_COLLATERAL_TOKEN).balanceOf(address(this)); - OracleData memory oracle = _fetchMid($.priceOracle); - wrapped = Math.mulDiv(wrapped, oracle.rate, 1 ether); + (, uint256 rate) = _fetchMid($.priceOracle); + wrapped = Math.mulDiv(wrapped, rate, 1 ether); emit Reset(underlying, wrapped); $.underlyingCollateral = wrapped; } @@ -711,7 +761,8 @@ contract Minter_v3 is uint256 maxFeeRatio ) internal returns (uint256 peggedOut, uint256 wrappedCollateralUsed) { MinterStorage storage $ = _getMinterStorage(); - OracleData memory oracle = _fetchMid($.priceOracle); + (uint256 price, uint256 rate) = _fetchMid($.priceOracle); + wrappedCollateralIn = Token.allOf(_msgSender(), WRAPPED_COLLATERAL_TOKEN, wrappedCollateralIn); uint256 peggedTokenBalance_ = $.peggedTokenBalance; @@ -719,14 +770,14 @@ contract Minter_v3 is uint256 maxFeeE36 = maxFeeRatio == type(uint256).max ? type(uint256).max - : Math.mulDiv(wrappedCollateralIn, maxFeeRatio, 1 ether) * oracle.rate; + : Math.mulDiv(wrappedCollateralIn, maxFeeRatio, 1 ether) * rate; uint256 wrappedFee; uint256 underlyingCollateralAdded; (wrappedFee, peggedOut, wrappedCollateralUsed, underlyingCollateralAdded) = _mintPeggedAdjustments( $.incentiveConfig[Config_v2.MINT_PEGGED], wrappedCollateralIn, - CollateralRatioData(underlyingCollateral_, oracle.price, oracle.rate, peggedTokenBalance_), + CollateralRatioData(underlyingCollateral_, price, rate, peggedTokenBalance_), maxFeeE36 ); @@ -773,8 +824,8 @@ contract Minter_v3 is uint256 peggedTokenBalance_ = $.peggedTokenBalance; peggedIn = Token.allOf(_msgSender(), PEGGED_TOKEN, peggedIn); peggedIn = _redeemable(PEGGED_TOKEN, peggedIn, peggedTokenBalance_); + (uint256 price, uint256 rate) = _fetchMax($.priceOracle); - OracleData memory oracle = _fetchMax($.priceOracle); uint256 underlyingCollateral_ = $.underlyingCollateral; address reservePool_ = $.reservePool; @@ -784,7 +835,7 @@ contract Minter_v3 is (wrappedFee, wrappedDiscount, wrappedCollateralOut, underlyingCollateralRemoved, ) = _redeemPeggedAdjustments( $.incentiveConfig[Config_v2.REDEEM_PEGGED], peggedIn, - CollateralRatioData(underlyingCollateral_, oracle.price, oracle.rate, peggedTokenBalance_), + CollateralRatioData(underlyingCollateral_, price, rate, peggedTokenBalance_), IERC20(WRAPPED_COLLATERAL_TOKEN).balanceOf(reservePool_) ); // make sure it meets the minimum requirements @@ -815,7 +866,9 @@ contract Minter_v3 is } // redeem pegged tokens and send the remainder of the collateral - _redeemPeggedToken(peggedIn, wrappedCollateralOut, receiver); + emit RedeemPeggedToken(_msgSender(), receiver, peggedIn, wrappedCollateralOut, 0); + _burnPeggedToken(peggedIn); + IERC20(WRAPPED_COLLATERAL_TOKEN).safeTransfer(receiver, wrappedCollateralOut); // update our records $.peggedTokenBalance = peggedTokenBalance_ - peggedIn; @@ -831,12 +884,16 @@ contract Minter_v3 is MinterStorage storage $ = _getMinterStorage(); wrappedCollateralIn = Token.allOf(_msgSender(), WRAPPED_COLLATERAL_TOKEN, wrappedCollateralIn); - OracleData memory oracle = _fetchMid($.priceOracle); + CollateralRatioData memory crData; + { + (uint256 price, uint256 rate) = _fetchMid($.priceOracle); + crData = CollateralRatioData($.underlyingCollateral, price, rate, $.peggedTokenBalance); + } uint256 wrappedFee; uint256 wrappedDiscount; - uint256 underlyingCollateral_ = $.underlyingCollateral; uint256 underlyingCollateralAdded; address reservePool_ = $.reservePool; + ( wrappedFee, wrappedDiscount, @@ -846,9 +903,10 @@ contract Minter_v3 is ) = _mintLeveragedAdjustments( $.incentiveConfig[Config_v2.MINT_LEVERAGED], wrappedCollateralIn, - CollateralRatioData(underlyingCollateral_, oracle.price, oracle.rate, $.peggedTokenBalance), + crData, IERC20(WRAPPED_COLLATERAL_TOKEN).balanceOf(reservePool_) ); + if (wrappedDiscount > 0) { // it's a discount, so collect the extra collateral, if available // wake-disable-next-line reentrancy // reservePool is trusted @@ -872,7 +930,7 @@ contract Minter_v3 is IERC20(WRAPPED_COLLATERAL_TOKEN).safeTransfer($.feeReceiver, wrappedFee); } // update our records - $.underlyingCollateral = underlyingCollateral_ + underlyingCollateralAdded; + $.underlyingCollateral = crData.underlyingCollateral + underlyingCollateralAdded; } /// @inheritdoc IMinter @@ -886,7 +944,7 @@ contract Minter_v3 is uint256 leveragedTokenBalance_ = _leveragedTokenBalance(); leveragedIn = _redeemable(LEVERAGED_TOKEN, leveragedIn, leveragedTokenBalance_); - OracleData memory oracle = _fetchMin($.priceOracle); + (uint256 price, uint256 rate) = _fetchMin($.priceOracle); uint256 underlyingCollateral_ = $.underlyingCollateral; @@ -895,7 +953,7 @@ contract Minter_v3 is (wrappedFee, leveragedIn, wrappedCollateralOut, underlyingCollateralOut) = _redeemLeveragedAdjustments( $.incentiveConfig[Config_v2.REDEEM_LEVERAGED], leveragedIn, - CollateralRatioData(underlyingCollateral_, oracle.price, oracle.rate, $.peggedTokenBalance), + CollateralRatioData(underlyingCollateral_, price, rate, $.peggedTokenBalance), leveragedTokenBalance_ ); // slither-disable-next-line incorrect-equality @@ -930,19 +988,18 @@ contract Minter_v3 is address receiver ) external override onlyRoles(ZERO_FEE_ROLE) nonReentrant returns (uint256 peggedOut) { MinterStorage storage $ = _getMinterStorage(); - OracleData memory oracle = _fetchMid($.priceOracle); - uint256 underlyingCollateralInE36 = wrappedCollateralIn * oracle.rate; + (uint256 price, uint256 rate) = _fetchMid($.priceOracle); + uint256 underlyingCollateralInE36 = wrappedCollateralIn * rate; uint256 peggedTokenBalance_ = $.peggedTokenBalance; uint256 underlyingCollateral_ = $.underlyingCollateral; - - // transfer and mint peggedOut = Math.mulDiv( underlyingCollateralInE36, - oracle.price, - _peggedTokenPriceE36(peggedTokenBalance_, underlyingCollateral_, oracle.price) + price, + _peggedTokenPriceE36(peggedTokenBalance_, underlyingCollateral_, price) ); + // transfer and mint _mintPeggedToken(wrappedCollateralIn, peggedOut, receiver); // update our records @@ -968,7 +1025,8 @@ contract Minter_v3 is ); } - OracleData memory oracle = _fetchMax($.priceOracle); + (uint256 price, uint256 rate) = _fetchMax($.priceOracle); + // Snapshot original state so both paths price against the same pre-burn balances, // consistent with how redeemPeggedForCollateralRatio computed the amounts. uint256 underlyingCollateral_ = $.underlyingCollateral; @@ -976,23 +1034,33 @@ contract Minter_v3 is if (peggedForCollateral > 0) { uint256 underlyingCollateralOutE36 = Math.mulDiv( peggedForCollateral, - _peggedTokenPriceE36(peggedTokenBalance_, underlyingCollateral_, oracle.price), - oracle.price + _peggedTokenPriceE36(peggedTokenBalance_, underlyingCollateral_, price), + price ); - wrappedCollateralOut = underlyingCollateralOutE36 / oracle.rate; + wrappedCollateralOut = underlyingCollateralOutE36 / rate; // return the collateral IERC20(WRAPPED_COLLATERAL_TOKEN).safeTransfer(receiver, wrappedCollateralOut); $.underlyingCollateral = underlyingCollateral_ - underlyingCollateralOutE36 / 1 ether; } if (peggedForLeveraged > 0) { - leveragedOut = _leveragedTokensForPegged( - peggedForLeveraged, - _leveragedTokenBalance(), - peggedTokenBalance_, - underlyingCollateral_, - oracle.price - ); + // we use leverage ratio for this calculation as it is capped + uint256 leveragedTokenBalance_ = _leveragedTokenBalance(); + if (leveragedTokenBalance_ > 0) { + uint256 leverageRatio_ = _leverageRatio(peggedTokenBalance_, underlyingCollateral_, price); + // slither-disable-next-line incorrect-equality + if (leverageRatio_ == _LEVERAGE_RATIO_CAP) { + leveragedOut = Math.mulDiv(peggedForLeveraged, _LEVERAGE_RATIO_CAP, 1 ether); + } else { + leveragedOut = Math.mulDiv( + peggedForLeveraged * leveragedTokenBalance_, + leverageRatio_, + underlyingCollateral_ * price + ); + } + } else { + leveragedOut = peggedForLeveraged; // TODO: the third place initial price of 1 ether is assumed + } // mint the tokens to the receiver // wake-disable-next-line reentrancy IMintable(LEVERAGED_TOKEN).mint(receiver, leveragedOut); @@ -1020,21 +1088,22 @@ contract Minter_v3 is ) external override onlyRoles(ZERO_FEE_ROLE) nonReentrant returns (uint256 leveragedOut) { MinterStorage storage $ = _getMinterStorage(); // how much collateral to use - OracleData memory oracle = _fetchMid($.priceOracle); + (uint256 price, uint256 rate) = _fetchMid($.priceOracle); + (uint256 collateralValueE36, uint256 peggedValueE36) = _tokenValuesE36( $.peggedTokenBalance, $.underlyingCollateral, - oracle.price + price ); - uint256 underlyingCollateralInE36 = wrappedCollateralIn * oracle.rate; + uint256 underlyingCollateralInE36 = wrappedCollateralIn * rate; uint256 leveragedTokenBalance_ = _leveragedTokenBalance(); if (leveragedTokenBalance_ > 0) { leveragedOut = - (underlyingCollateralInE36 * oracle.price) / + (underlyingCollateralInE36 * price) / _leveragedTokenPriceE36(collateralValueE36, peggedValueE36, leveragedTokenBalance_); } else { leveragedOut = collateralValueE36; // First term - leveragedOut += Math.mulDiv(underlyingCollateralInE36, oracle.price, 1e18); // Second term + leveragedOut += Math.mulDiv(underlyingCollateralInE36, price, 1e18); // Second term leveragedOut -= $.peggedTokenBalance * 1e18; leveragedOut /= 1e18; } @@ -1056,27 +1125,27 @@ contract Minter_v3 is uint256 leveragedTokenBalance_ = _leveragedTokenBalance(); leveragedIn = _redeemable(LEVERAGED_TOKEN, leveragedIn, leveragedTokenBalance_); - OracleData memory oracle = _fetchMin($.priceOracle); + (uint256 price, uint256 rate) = _fetchMin($.priceOracle); (uint256 collateralValueE36, uint256 peggedValueE36) = _tokenValuesE36( $.peggedTokenBalance, $.underlyingCollateral, - oracle.price + price ); if (collateralValueE36 <= peggedValueE36) { collateralOut = 0; } else { uint256 underlyingCollateralOutE36; if (leveragedTokenBalance_ == 0) { - underlyingCollateralOutE36 = leveragedIn * oracle.price; + underlyingCollateralOutE36 = leveragedIn * price; } else { underlyingCollateralOutE36 = Math.mulDiv( leveragedIn * 1 ether, collateralValueE36 - peggedValueE36, - oracle.price * leveragedTokenBalance_ + price * leveragedTokenBalance_ ); } - collateralOut = underlyingCollateralOutE36 / oracle.rate; + collateralOut = underlyingCollateralOutE36 / rate; _redeemLeveragedToken(leveragedIn, collateralOut, receiver); @@ -1168,24 +1237,6 @@ contract Minter_v3 is } // no need to check for others because the constructor does this } - /// @notice Perform the transfers and event emissions for redeeming pegged tokens - /// Fees and discounts transfers and event emissions are not handled here. - /// @dev no checks for zeros values are performed. - /// @param peggedIn The amount of pegged tokens to be taken from the sender. - /// @param wrappedCollateralOut The amount of collateral to be transferred to the `receiver`. - /// @param receiver The address of the receiver. - - function _redeemPeggedToken(uint256 peggedIn, uint256 wrappedCollateralOut, address receiver) private { - // tell the world - emit RedeemPeggedToken(_msgSender(), receiver, peggedIn, wrappedCollateralOut, 0); - - // burn the tokens from the sender - deal with the different burn signatures for ERC20 contracts - _burnPeggedToken(peggedIn); - - // return the collateral - IERC20(WRAPPED_COLLATERAL_TOKEN).safeTransfer(receiver, wrappedCollateralOut); - } - /// @notice Perform the transfers and event emissions for minting leveraged tokens /// Fees and discounts transfers and event emissions are not handled here. /// @dev no checks for zeros values are performed. @@ -1320,7 +1371,7 @@ contract Minter_v3 is // (note we treat the disallow band as any other here, except that it is the terminal band) MintPeggedWorkspace memory w; w.band = _findBand(config_, cr.underlyingCollateral, cr.price, cr.peggedTokenBalance, false); - w.peggedTokenPriceE36 = _peggedTokenPriceE36(cr.peggedTokenBalance, cr.underlyingCollateral, cr.price); + uint256 peggedTokenPriceE36 = _peggedTokenPriceE36(cr.peggedTokenBalance, cr.underlyingCollateral, cr.price); w.underlyingCollateralInLeftE36 = wrappedCollateralIn * cr.rate; // scaled to 1e36 w.underlyingCollateralHeldE36 = cr.underlyingCollateral * 1 ether; // scaled to 1e36 @@ -1382,7 +1433,7 @@ contract Minter_v3 is uint256 peggedMintedInBandE36 = Math.mulDiv( collateralAddedInBandE36, cr.price * 1 ether, - w.peggedTokenPriceE36 + peggedTokenPriceE36 ); w.mintedE36 += peggedMintedInBandE36; @@ -1434,6 +1485,7 @@ contract Minter_v3 is /// @return wrappedDiscount the discount given in wrapped collateral tokens. /// @return wrappedCollateralReturned the wrapped collateral returned to the receiver in exchange for the 'peggedRedeemed' /// @return underlyingCollateralRemoved the collateral removed from the balance to return the peggedIn. + /// @return peggedPriceE36 the price of pegged token (takes into account the pegged token depegging) function _redeemPeggedAdjustments( ConfigIncentiveLib.ActionIncentive memory config_, @@ -1931,33 +1983,6 @@ contract Minter_v3 is } } - function _leveragedTokensForPegged( - uint256 peggedIn, - uint256 leveragedTokenBalance_, - uint256 peggedTokenBalance_, - uint256 collateralTokenBalance_, - uint256 collateralPrice - ) private pure returns (uint256 leveragedTokens) { - // we use leverage ratio for this calculation as it is capped - if (leveragedTokenBalance_ > 0) { - uint256 leverageRatio_ = _leverageRatio(peggedTokenBalance_, collateralTokenBalance_, collateralPrice); - // slither-disable-next-line incorrect-equality - if (leverageRatio_ == _LEVERAGE_RATIO_CAP) { - // cap the amount returned - leveragedTokens = Math.mulDiv(peggedIn, _LEVERAGE_RATIO_CAP, 1 ether); - } else { - // Convert using leverage ratio approach as this is only called in a rebalance context - leveragedTokens = Math.mulDiv( - peggedIn * leveragedTokenBalance_, - leverageRatio_, - collateralTokenBalance_ * collateralPrice - ); - } - } else { - leveragedTokens = peggedIn; // TODO: the third place initial price of 1 ether is assumed - } - } - /// @notice Calculates the raw collateral ratio without any flooring. /// @dev This returns the actual mathematical ratio (collateralValue / peggedValue) which may be < 1 in depegged scenarios. /// Semantics: @@ -2001,35 +2026,29 @@ contract Minter_v3 is // fetching collateral price in terms of the pegged tokens // ------------------------------------------------------- - struct OracleData { - uint256 price; - uint256 rate; - } - /// @notice Returns the safe price for the collateral token. /// @dev Checks safe price non-zero. - function _fetchMid(address priceOracle_) private view returns (OracleData memory) { + function _fetchMid(address priceOracle_) private view returns (uint256 price, uint256 rate) { (uint256 minPrice, uint256 maxPrice, uint256 minRate, uint256 maxRate) = IWrappedPriceOracle(priceOracle_) .latestAnswer(); - return OracleData(_round(minPrice + maxPrice, 2), _round(minRate + maxRate, 2)); + price = _round(minPrice + maxPrice, 2); + rate = _round(minRate + maxRate, 2); } /// @notice Returns the min price for the collateral token. /// If the safe price is valid it is returned, else the min price. /// @dev Checks the returned price is non-zero. - function _fetchMin(address priceOracle_) private view returns (OracleData memory) { + function _fetchMin(address priceOracle_) private view returns (uint256 price, uint256 rate) { // slither-disable-next-line unused-return - (uint256 minPrice, , uint256 minRate, ) = IWrappedPriceOracle(priceOracle_).latestAnswer(); - return OracleData(minPrice, minRate); + (price, , rate, ) = IWrappedPriceOracle(priceOracle_).latestAnswer(); } /// @notice Returns the max price for the collateral token. /// If the safe price is valid it is returned, else the max price. /// @dev Checks the returned price is non-zero. - function _fetchMax(address priceOracle_) private view returns (OracleData memory) { + function _fetchMax(address priceOracle_) private view returns (uint256 price, uint256 rate) { // slither-disable-next-line unused-return - (, uint256 maxPrice, , uint256 maxRate) = IWrappedPriceOracle(priceOracle_).latestAnswer(); - return OracleData(maxPrice, maxRate); + (, price, , rate) = IWrappedPriceOracle(priceOracle_).latestAnswer(); } // Harvesting support diff --git a/test/deployment/MinterPeggedIncentives.t.sol b/test/deployment/MinterPeggedIncentives.t.sol new file mode 100644 index 00000000..f2720ca2 --- /dev/null +++ b/test/deployment/MinterPeggedIncentives.t.sol @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.28 <0.9.0; + +import {MinterCappedMintSetUp} from "@harbor-test/deployment/MinterCappedMint.t.sol"; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IMinter} from "@harbor/interfaces/IMinter.sol"; +import {IMinter_v3} from "@harbor/interfaces/IMinter_v3.sol"; + +/// @title MinterPeggedIncentivesTest +/// @notice Tests for Minter_v3.peggedIncentivesByPegged — returns absolute mint fee, +/// unmintable amount, the max configured mint fee ratio, and uncapped redeem bonus, +/// all expressed in pegged token units. +/// +/// Test oracle: price = rate = 1e18 (set by MockWrappedPriceOracle in setUp). +/// At parity the unit conversions are identity: 1 wCOL == 1 pegged. +/// +/// Fee config (ConfigPriceVolatility_130_stable, ETH::fxUSD market): +/// mintPegged bands (CR upper bounds): +/// CR < 1.31: 1e18 → disallow +/// 1.31–1.40: 2e16 → 2% (highest non-disallow band) +/// 1.40–1.50: 1e16 → 1% +/// 1.50–1.60: 0.75e16 +/// 1.60–1.70: 0.5e16 +/// 1.70–1.80: 0.33e16 +/// CR ≥ 1.80: 0.25e16 ← active after _bootstrapCollateralRatio (CR = 2.0) +/// +/// redeemPegged bands: +/// CR < 1.00: -1e16 → 1% bonus (most stressed) +/// 1.00–1.10: -0.75e16 → 0.75% bonus +/// 1.10–1.29: -0.3e16 → 0.3% bonus ← active after _lowerCRToBonus (CR = 1.25) +/// 1.29–1.40: 0 +/// CR ≥ 1.40: positive (fee) ← active after _bootstrapCollateralRatio (CR = 2.0) +contract MinterPeggedIncentivesTest is MinterCappedMintSetUp { + /// @dev Lower CR from 2.0 to 1.25 using zero-fee minting. + /// Pre-condition: _bootstrapCollateralRatio() already called. + /// Post: underlyingCollateral = 2500, peggedBalance = 2000, CR = 1.25. + /// + /// Derivation: (1000 + X) / (500 + X) = 1.25 → X = 1500. + function _lowerCRToBonus() internal { + deal(wrappedCollateral, address(this), 1500 ether); + IERC20(wrappedCollateral).approve(minter, 1500 ether); + IMinter(minter).freeMintPeggedToken(1500 ether, address(this)); + } + + // ═══════════════════════════════════════════════════════════════ + // Zero input + // ═══════════════════════════════════════════════════════════════ + + function test_peggedIncentives_zeroInput() public { + // At peggedIn = 0, no mint or redeem action occurs. + // mintMaxFeeRatio is independent of input — it scans all configured bands. + _bootstrapCollateralRatio(); + + (uint256 mintFee, uint256 peggedNotMinted, uint256 mintMaxFeeRatio, uint256 redeemBonus) = + IMinter_v3(minter).peggedIncentivesByPegged(0); + + assertEq(mintFee, 0, "mintFee zero at zero input"); + assertEq(peggedNotMinted, 0, "peggedNotMinted zero at zero input"); + assertGt(mintMaxFeeRatio, 0, "mintMaxFeeRatio is config-derived, non-zero regardless of input"); + assertEq(redeemBonus, 0, "redeemBonus zero at zero input"); + } + + // ═══════════════════════════════════════════════════════════════ + // Mint fee — positive at bootstrap CR (= 2.0) + // ═══════════════════════════════════════════════════════════════ + + function test_peggedIncentives_mintFee_positive_atBootstrapCR() public { + // CR = 2.0 falls in the 0.25% mint band. The function must return mintFee > 0. + // No part of peggedIn falls in the disallow band, so peggedNotMinted = 0. + _bootstrapCollateralRatio(); + + (uint256 mintFee, uint256 peggedNotMinted, , ) = IMinter_v3(minter).peggedIncentivesByPegged(100 ether); + + assertGt(mintFee, 0, "mintFee > 0 at CR=2.0 (0.25% fee band)"); + assertEq(peggedNotMinted, 0, "no disallow portion at CR=2.0"); + } + + // ═══════════════════════════════════════════════════════════════ + // Cross-check mintFee against mintPeggedTokenDryRun at parity + // ═══════════════════════════════════════════════════════════════ + + function test_peggedIncentives_mintFee_matchesDryRun_atParity() public { + // At price = rate = 1e18: priceRateE36 = 1e36 and peggedTokenPriceE36 = 1e36. + // Unit conversions in peggedIncentivesByPegged are therefore identity: + // wrappedCollateralIn = peggedIn + // mintFee = wrappedFee × 1e36 / 1e36 = wrappedFee + // So mintFee from peggedIncentivesByPegged(N) must equal the wrappedFee + // returned by mintPeggedTokenDryRun(N) (both use type(uint256).max fee cap). + _bootstrapCollateralRatio(); + + uint256 amount = 100 ether; + (, uint256 dryRunFee, , , , ) = IMinter(minter).mintPeggedTokenDryRun(amount); + (uint256 mintFee, , , ) = IMinter_v3(minter).peggedIncentivesByPegged(amount); + + assertEq(mintFee, dryRunFee, "mintFee == wrappedFee from mintPeggedTokenDryRun at price=rate=1"); + } + + // ═══════════════════════════════════════════════════════════════ + // Redeem bonus — none at healthy CR + // ═══════════════════════════════════════════════════════════════ + + function test_peggedIncentives_redeemBonus_zero_atBootstrapCR() public { + // At CR = 2.0, the redeemPegged band is +0.5e16 (a fee, not a bonus). + // redeemPeggedUncappedBonus is floored at zero — must return 0. + _bootstrapCollateralRatio(); + + (, , , uint256 redeemBonus) = IMinter_v3(minter).peggedIncentivesByPegged(100 ether); + + assertEq(redeemBonus, 0, "no redeem bonus at CR=2.0 (positive redeemPegged band)"); + } + + // ═══════════════════════════════════════════════════════════════ + // Redeem bonus — positive when CR is stressed (< 1.29) + // ═══════════════════════════════════════════════════════════════ + + function test_peggedIncentives_redeemBonus_positive_atLowCR() public { + // At CR = 1.25 the redeemPegged band is -0.3e16 (a 0.3% discount). + // The system offers a bonus to incentivise redemptions that restore CR. + // redeemPeggedUncappedBonus must be > 0. + _bootstrapCollateralRatio(); + _lowerCRToBonus(); // CR → 1.25 + + (, , , uint256 redeemBonus) = IMinter_v3(minter).peggedIncentivesByPegged(100 ether); + + assertGt(redeemBonus, 0, "redeemBonus > 0 at CR=1.25 (negative redeemPegged band)"); + } + + // ═══════════════════════════════════════════════════════════════ + // Disallow band: all input is unmintable, mintFee = 0 + // ═══════════════════════════════════════════════════════════════ + + function test_peggedIncentives_disallowBand_peggedNotMinted() public { + // At CR = 1.25 (< 1.31), the mintPegged band is 1e18 (disallow). + // _mintPeggedAdjustments returns wrappedCollateralUsed = 0, so: + // peggedNotMinted = peggedIn (nothing mintable) + // mintFee = 0 (no fee — collateral fully blocked) + _bootstrapCollateralRatio(); + _lowerCRToBonus(); // CR → 1.25 + + uint256 peggedIn = 100 ether; + (uint256 mintFee, uint256 peggedNotMinted, , ) = IMinter_v3(minter).peggedIncentivesByPegged(peggedIn); + + assertEq(mintFee, 0, "mintFee = 0 in disallow band"); + assertEq(peggedNotMinted, peggedIn, "peggedNotMinted = peggedIn in disallow band"); + } + + // ═══════════════════════════════════════════════════════════════ + // mintMaxFeeRatio = highest non-disallow band rate in the config + // ═══════════════════════════════════════════════════════════════ + + function test_peggedIncentives_mintMaxFeeRatio_isMaxBandRate() public { + // ConfigPriceVolatility_130_stable has 7 mintPegged bands. + // The highest non-disallow rate is 2e16 (2%, the 1.31–1.40 band). + // mintMaxFeeRatio must reflect this regardless of current CR or input amount. + _bootstrapCollateralRatio(); + + (, , uint256 mintMaxFeeRatio, ) = IMinter_v3(minter).peggedIncentivesByPegged(1 ether); + + assertEq(mintMaxFeeRatio, 2e16, "mintMaxFeeRatio = 2e16 (highest non-disallow band)"); + } + + // ═══════════════════════════════════════════════════════════════ + // Fee scales linearly within a single band + // ═══════════════════════════════════════════════════════════════ + + function test_peggedIncentives_mintFee_scalesProportionally() public { + // At CR = 2.0, both 10 ether and 20 ether fall entirely within the 0.25% band + // (deposit does not push CR below 1.80). The fee formula is linear in this case: + // mintFee(2×A) == 2×mintFee(A) + _bootstrapCollateralRatio(); // CR = 2.0, band ≥ 1.80 → 0.25% fee + + uint256 amount = 10 ether; + (uint256 feeOnce, , , ) = IMinter_v3(minter).peggedIncentivesByPegged(amount); + (uint256 feeDouble, , , ) = IMinter_v3(minter).peggedIncentivesByPegged(amount * 2); + + assertEq(feeDouble, feeOnce * 2, "mintFee scales linearly within a single fee band"); + } + + // ═══════════════════════════════════════════════════════════════ + // mintFee and redeemBonus are mutually exclusive by config + // ═══════════════════════════════════════════════════════════════ + + function test_peggedIncentives_mintFeeAndRedeemBonus_mutuallyExclusive() public { + // ConfigPriceVolatility_130_stable: + // mintFee > 0 requires CR ≥ 1.31 (first non-disallow mint band) + // redeemBonus > 0 requires CR < 1.29 (first bonus redeem band) + // These ranges do not overlap, so both cannot be true simultaneously. + _bootstrapCollateralRatio(); // CR = 2.0 + + (uint256 mintFeeHigh, , , uint256 redeemBonusHigh) = IMinter_v3(minter).peggedIncentivesByPegged(100 ether); + assertGt(mintFeeHigh, 0, "at CR=2.0: mintFee > 0"); + assertEq(redeemBonusHigh, 0, "at CR=2.0: redeemBonus = 0"); + + _lowerCRToBonus(); // CR → 1.25 + + (uint256 mintFeeLow, , , uint256 redeemBonusLow) = IMinter_v3(minter).peggedIncentivesByPegged(100 ether); + assertEq(mintFeeLow, 0, "at CR=1.25: mintFee = 0 (disallow band)"); + assertGt(redeemBonusLow, 0, "at CR=1.25: redeemBonus > 0"); + } +} From d4cd2e1bf1ad0e2bfc4993dc7be1cac09f5dcba9 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Wed, 27 May 2026 17:22:49 +0100 Subject: [PATCH 089/232] sp_v2 data migration, first draft --- ...igrate_StabilityPool_v2_Data_mainnet.s.sol | 207 +++++++ .../MigrateBalancesTest.t.sol | 206 +++++++ .../MigrateCaptureTest.t.sol | 565 ++++++++++++++++++ .../sp-v2-data-prep-for-v3/collect-sp-holders | 187 ++++++ .../run-migrate-StabilityPool_v2-data | 127 ++++ .../run-upgrade-test-StabilityPool_v2 | 0 .../MainnetUpgradeTest.t.sol | 229 ------- .../for docs see sp-v2-upgrade docs | 0 .../linear-reward-underflow.md | 46 -- .../upgrade-StabilityPool_v2.md | 192 ------ 10 files changed, 1292 insertions(+), 467 deletions(-) create mode 100644 script/Migrate_StabilityPool_v2_Data_mainnet.s.sol create mode 100644 script/verify/sp-v2-data-prep-for-v3/MigrateBalancesTest.t.sol create mode 100644 script/verify/sp-v2-data-prep-for-v3/MigrateCaptureTest.t.sol create mode 100755 script/verify/sp-v2-data-prep-for-v3/collect-sp-holders create mode 100755 script/verify/sp-v2-data-prep-for-v3/run-migrate-StabilityPool_v2-data rename script/verify/{sp-v2-upgrade-prep-for-v3 => sp-v2-data-prep-for-v3}/run-upgrade-test-StabilityPool_v2 (100%) delete mode 100644 script/verify/sp-v2-upgrade-prep-for-v3/MainnetUpgradeTest.t.sol delete mode 100644 script/verify/sp-v2-upgrade-prep-for-v3/for docs see sp-v2-upgrade docs delete mode 100644 script/verify/sp-v2-upgrade-prep-for-v3/linear-reward-underflow.md delete mode 100644 script/verify/sp-v2-upgrade-prep-for-v3/upgrade-StabilityPool_v2.md diff --git a/script/Migrate_StabilityPool_v2_Data_mainnet.s.sol b/script/Migrate_StabilityPool_v2_Data_mainnet.s.sol new file mode 100644 index 00000000..cc441df6 --- /dev/null +++ b/script/Migrate_StabilityPool_v2_Data_mainnet.s.sol @@ -0,0 +1,207 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.28 <0.9.0; + +import {console2 as console} from "forge-std/console2.sol"; +import {LibString} from "@solady/utils/LibString.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; + +import {Config_MinterMarket, MinterMarketConfigLib} from "@harbor-script/config/ConfigBase.sol"; +import {ForceMigrateAccumulator_v1} from "@harbor-script/verify/sp-v3-migration/ForceMigrateAccumulator_v1.sol"; +import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; + +import {Deploy_BTC_Minter} from "@harbor-script/src/Deploy_BTC_Minter.sol"; +import {Deploy_ETH_Minter} from "@harbor-script/src/Deploy_ETH_Minter.sol"; +import {Deploy_EUR_Minter} from "@harbor-script/src/Deploy_EUR_Minter.sol"; +import {Deploy_GOLD_Minter} from "@harbor-script/src/Deploy_GOLD_Minter.sol"; +import {Deploy_MCAP_Minter} from "@harbor-script/src/Deploy_MCAP_Minter.sol"; +import {Deploy_SILVER_Minter} from "@harbor-script/src/Deploy_SILVER_Minter.sol"; + +import {Script} from "forge-std/Script.sol"; + +/// @notice Force-migrate accumulator storage from the legacy V1 (uint192 integral) +/// format to the V2 (uint256 integral) format for all stability pools, then restore +/// each pool to its current StabilityPool_v2 implementation. +/// +/// This is the "prep for v3" step: once every remaining user is in V2 format, the +/// V1 read-fallback in the accumulator is dead code and a later StabilityPool_v3 +/// upgrade can remove it safely. The proxy ends on v2 here — no v3 upgrade. +/// +/// Per pool, the Safe batch contains 3 atomic transactions: +/// 1. Upgrade proxy -> ForceMigrateAccumulator_v1 (pauses the pool) +/// 2. remediate(tokens, holders) -> pure copy of V1 snapshot data into V2 +/// 3. Restore proxy -> the StabilityPool_v2 implementation it had before +/// +/// Holders are read at runtime from per-pool files produced by +/// `script/verify/sp-v2-upgrade-prep-for-v3/collect-sp-holders` (UserDepositChange +/// logs). Pools with no holder file, or an empty one, are skipped. +/// +/// Run via: +/// script/run-script Migrate_StabilityPool_v2_Data_mainnet --salt harbor_v1 --network mainnet --broadcast --local +contract Migrate_StabilityPool_v2_Data_mainnet is + Script, + Deploy_BTC_Minter, + Deploy_ETH_Minter, + Deploy_EUR_Minter, + Deploy_GOLD_Minter, + Deploy_MCAP_Minter, + Deploy_SILVER_Minter +{ + using LibString for address; + + // StabilityPoolCollateral / StabilityPoolLeveraged inherited from the StabilityPool deployment helper. + + /// @dev ERC1967 implementation slot. + bytes32 internal constant IMPL_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; + + /// @dev Directory of per-pool holder files (one checksummed address per line, '#' comments). + string internal constant HOLDERS_DIR = "script/verify/sp-v2-upgrade-prep-for-v3/holders/"; + + /// @dev Deployed once, shared across all pools (no constructor params, deterministic bytecode). + address internal migImpl; + + function _doOneMinter(Config_MinterMarket[] memory markets) internal { + for (uint256 i = 0; i < markets.length; i++) { + string memory marketKey = MinterMarketConfigLib.salt(markets[i]); + _migratePool(marketKey, StabilityPoolCollateral); + _migratePool(marketKey, StabilityPoolLeveraged); + } + } + + function _migratePool(string memory marketKey, string memory spType) internal { + string memory key = _key(marketKey, spType); + address pool = _predictAddress(key); + + // Read current implementation (restored after remediation). + address currentImpl = address(uint160(uint256(vm.load(pool, IMPL_SLOT)))); + require(currentImpl.code.length != 0, string.concat("no impl for ", _saltString(key))); + + // Read reward tokens before the upgrade (the pauser fallback reverts all other calls). + // Include BOTH active and historical: _checkpoint snapshots both, so a user can carry V1 + // data for a no-longer-active token. remediate() skips tokens with no V1 data, so passing + // historical tokens is harmless and makes the migration complete (safe to remove the + // fallback in a later v3 upgrade). + address[] memory tokens = _concat( + IMultipleRewardDistributor(pool).activeRewardTokens(), + IMultipleRewardDistributor(pool).historicalRewardTokens() + ); + + // Holders for this pool, from the generated file. + address[] memory holders = _readHolders(marketKey, spType); + + if (holders.length == 0) { + console.log(" > %s: no holders, skipping", _saltString(key)); + return; + } + + console.log(" > %s: %d holders, %d tokens", _saltString(key), holders.length, tokens.length); + + // 1. Upgrade to the migration contract. + queue( + key, + abi.encodeCall(UUPSUpgradeable.upgradeToAndCall, (migImpl, "")), + "upgrade to ForceMigrateAccumulator_v1" + ); + + // 2. Remediate: copy V1 -> V2 for every holder/token pair. + queue( + pool, + abi.encodeCall(ForceMigrateAccumulator_v1.remediate, (tokens, holders)), + string.concat("remediate ", _saltString(key)) + ); + + // 3. Restore the original (StabilityPool_v2) implementation. + queue( + key, + abi.encodeCall(UUPSUpgradeable.upgradeToAndCall, (currentImpl, "")), + string.concat("restore to ", currentImpl.toHexString()) + ); + } + + /// @dev Read a pool's holder list from HOLDERS_DIR/::.txt. + /// Lines starting with '#' are comments; every other non-empty line is an address. + /// Returns an empty array when the file is absent (pool skipped by the caller). + function _readHolders(string memory marketKey, string memory spType) internal returns (address[] memory holders) { + string memory path = string.concat(HOLDERS_DIR, marketKey, "::", spType, ".txt"); + if (!vm.isFile(path)) { + return new address[](0); + } + + // First pass: count address lines. + uint256 count = 0; + while (true) { + string memory line = vm.readLine(path); + if (bytes(line).length == 0) { + break; + } + if (_isAddressLine(line)) { + count++; + } + } + vm.closeFile(path); + + // Second pass: parse them. + holders = new address[](count); + uint256 idx = 0; + while (true) { + string memory line = vm.readLine(path); + if (bytes(line).length == 0) { + break; + } + if (_isAddressLine(line)) { + holders[idx] = vm.parseAddress(line); + idx++; + } + } + vm.closeFile(path); + } + + /// @dev True for a non-comment, non-empty line (an address). '#' (0x23) marks a comment. + function _isAddressLine(string memory line) internal pure returns (bool) { + bytes memory b = bytes(line); + if (b.length == 0) { + return false; + } + if (b[0] == 0x23) { + return false; + } + return true; + } + + /// @dev Concatenate two address arrays. + function _concat(address[] memory a, address[] memory b) internal pure returns (address[] memory out) { + out = new address[](a.length + b.length); + for (uint256 i = 0; i < a.length; i++) { + out[i] = a[i]; + } + for (uint256 i = 0; i < b.length; i++) { + out[a.length + i] = b[i]; + } + } + + function build() internal override { + Config_MinterMarket[] memory markets; + + vm.startBroadcast(); + migImpl = address(new ForceMigrateAccumulator_v1()); + console.log(" Migration impl: %s", migImpl); + vm.stopBroadcast(); + + (, markets) = createBTCMintersConfig(); + _doOneMinter(markets); + + (, markets) = createETHMintersConfig(); + _doOneMinter(markets); + + (, markets) = createEURMintersConfig(); + _doOneMinter(markets); + + (, markets) = createGOLDMintersConfig(); + _doOneMinter(markets); + + (, markets) = createMCAPMintersConfig(); + _doOneMinter(markets); + + (, markets) = createSILVERMintersConfig(); + _doOneMinter(markets); + } +} diff --git a/script/verify/sp-v2-data-prep-for-v3/MigrateBalancesTest.t.sol b/script/verify/sp-v2-data-prep-for-v3/MigrateBalancesTest.t.sol new file mode 100644 index 00000000..8e6b2155 --- /dev/null +++ b/script/verify/sp-v2-data-prep-for-v3/MigrateBalancesTest.t.sol @@ -0,0 +1,206 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.28 <0.9.0; + +import {Test} from "forge-std/Test.sol"; +import {console2 as console} from "forge-std/console2.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; +import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; +import {ForceMigrateAccumulator_v1} from "@harbor-script/verify/sp-v3-migration/ForceMigrateAccumulator_v1.sol"; + +import {Config_MinterMarket, MinterMarketConfigLib} from "@harbor-script/config/ConfigBase.sol"; +import {Deploy_BTC_Minter} from "@harbor-script/src/Deploy_BTC_Minter.sol"; +import {Deploy_ETH_Minter} from "@harbor-script/src/Deploy_ETH_Minter.sol"; +import {Deploy_EUR_Minter} from "@harbor-script/src/Deploy_EUR_Minter.sol"; +import {Deploy_GOLD_Minter} from "@harbor-script/src/Deploy_GOLD_Minter.sol"; +import {Deploy_MCAP_Minter} from "@harbor-script/src/Deploy_MCAP_Minter.sol"; +import {Deploy_SILVER_Minter} from "@harbor-script/src/Deploy_SILVER_Minter.sol"; + +/// @title MigrateBalancesTest — Prong B of the force-migrate verification +/// @notice White-box storage check, complementing the black-box before/after diff (Prong A). +/// For every pool, upgrades the proxy to ForceMigrateAccumulator_v1, reads each holder/token's +/// raw (V1, V2) integral via balances(), runs remediate(), and asserts the copy is correct: +/// - the old (V1) slot is never modified; +/// - an unmigrated holder (V1 set, V2 empty) ends with V2 integral == V1 integral; +/// - an already-migrated holder is left untouched; +/// - a holder with no V1 data stays zero. +/// This directly verifies the storage copy that Prong A only observes through claimable/claimed. +/// +/// Pools/holders/tokens are derived exactly as Migrate_StabilityPool_v2_Data_mainnet derives +/// them (same salt keys, same holders/*.txt, active+historical reward tokens). +/// +/// Run: forge test --mc MigrateBalancesTest --fork-url local -vv (against a mainnet fork) +contract MigrateBalancesTest is + Test, + Deploy_BTC_Minter, + Deploy_ETH_Minter, + Deploy_EUR_Minter, + Deploy_GOLD_Minter, + Deploy_MCAP_Minter, + Deploy_SILVER_Minter +{ + string internal constant HOLDERS_DIR = "script/verify/sp-v2-upgrade-prep-for-v3/holders/"; + + address internal migImpl; + + function setUp() public { + vm.createSelectFork(vm.rpcUrl("local")); + _setSaltPrefix("harbor_v1"); + migImpl = address(new ForceMigrateAccumulator_v1()); + } + + /// @notice Migrate every pool and assert the V1->V2 copy is correct for all holders/tokens. + function test_allPoolsMigrateCorrectly() public { + Config_MinterMarket[] memory markets; + uint256 checked = 0; + + (, markets) = createBTCMintersConfig(); + checked += _checkMarkets(markets); + (, markets) = createETHMintersConfig(); + checked += _checkMarkets(markets); + (, markets) = createEURMintersConfig(); + checked += _checkMarkets(markets); + (, markets) = createGOLDMintersConfig(); + checked += _checkMarkets(markets); + (, markets) = createMCAPMintersConfig(); + checked += _checkMarkets(markets); + (, markets) = createSILVERMintersConfig(); + checked += _checkMarkets(markets); + + console.log("Checked %d pools with holders", checked); + } + + function _checkMarkets(Config_MinterMarket[] memory markets) internal returns (uint256 checked) { + for (uint256 i = 0; i < markets.length; i++) { + string memory marketKey = MinterMarketConfigLib.salt(markets[i]); + if (_checkPool(marketKey, "stabilityPoolCollateral")) { + checked++; + } + if (_checkPool(marketKey, "stabilityPoolLeveraged")) { + checked++; + } + } + } + + function _checkPool(string memory marketKey, string memory spType) internal returns (bool) { + string memory saltKey = string.concat(marketKey, "::", spType); + address pool = _predictAddress(_key(marketKey, spType)); + address[] memory holders = _readHolders(saltKey); + if (holders.length == 0) { + return false; + } + + // Reward tokens (active + historical) read before upgrade — the pauser reverts other calls. + address[] memory tokens = _concat( + IMultipleRewardDistributor(pool).activeRewardTokens(), + IMultipleRewardDistributor(pool).historicalRewardTokens() + ); + address owner = IBaoOwnable(pool).owner(); + + // 1. Upgrade to the migration contract. + vm.prank(owner); + UUPSUpgradeable(pool).upgradeToAndCall(migImpl, ""); + ForceMigrateAccumulator_v1 mig = ForceMigrateAccumulator_v1(pool); + + // 2. Snapshot raw (V1, V2) integrals before remediation. + uint256[][] memory preOld = new uint256[][](holders.length); + uint256[][] memory preNew = new uint256[][](holders.length); + for (uint256 h = 0; h < holders.length; h++) { + preOld[h] = new uint256[](tokens.length); + preNew[h] = new uint256[](tokens.length); + for (uint256 t = 0; t < tokens.length; t++) { + (preOld[h][t], preNew[h][t]) = mig.balances(holders[h], tokens[t]); + } + } + + // 3. Remediate. + vm.prank(owner); + mig.remediate(tokens, holders); + + // 4. Assert the copy is correct for every holder/token. + for (uint256 h = 0; h < holders.length; h++) { + for (uint256 t = 0; t < tokens.length; t++) { + (uint256 postOld, uint256 postNew) = mig.balances(holders[h], tokens[t]); + string memory label = string.concat( + saltKey, + " holder ", + vm.toString(holders[h]), + " token ", + vm.toString(t) + ); + + // The old (V1) slot is never modified. + assertEq(postOld, preOld[h][t], string.concat("old changed: ", label)); + + if (preNew[h][t] != 0) { + // Already migrated: V2 unchanged. + assertEq(postNew, preNew[h][t], string.concat("already-migrated changed: ", label)); + } else if (preOld[h][t] != 0) { + // Was unmigrated: V2 now equals V1 (pure copy). + assertEq(postNew, preOld[h][t], string.concat("not copied: ", label)); + } else { + // No V1 data: stays zero. + assertEq(postNew, 0, string.concat("spurious V2: ", label)); + } + } + } + + console.log(" > %s: %d holders OK", saltKey, holders.length); + return true; + } + + // ── Holder file reading (same format as collect-sp-holders output) ──────── + + function _readHolders(string memory saltKey) internal returns (address[] memory holders) { + string memory path = string.concat(HOLDERS_DIR, saltKey, ".txt"); + if (!vm.isFile(path)) { + return new address[](0); + } + uint256 count = 0; + while (true) { + string memory line = vm.readLine(path); + if (bytes(line).length == 0) { + break; + } + if (_isAddressLine(line)) { + count++; + } + } + vm.closeFile(path); + + holders = new address[](count); + uint256 idx = 0; + while (true) { + string memory line = vm.readLine(path); + if (bytes(line).length == 0) { + break; + } + if (_isAddressLine(line)) { + holders[idx] = vm.parseAddress(line); + idx++; + } + } + vm.closeFile(path); + } + + function _isAddressLine(string memory line) internal pure returns (bool) { + bytes memory b = bytes(line); + if (b.length == 0) { + return false; + } + if (b[0] == 0x23) { + return false; + } + return true; + } + + function _concat(address[] memory a, address[] memory b) internal pure returns (address[] memory out) { + out = new address[](a.length + b.length); + for (uint256 i = 0; i < a.length; i++) { + out[i] = a[i]; + } + for (uint256 i = 0; i < b.length; i++) { + out[a.length + i] = b[i]; + } + } +} diff --git a/script/verify/sp-v2-data-prep-for-v3/MigrateCaptureTest.t.sol b/script/verify/sp-v2-data-prep-for-v3/MigrateCaptureTest.t.sol new file mode 100644 index 00000000..7edfe377 --- /dev/null +++ b/script/verify/sp-v2-data-prep-for-v3/MigrateCaptureTest.t.sol @@ -0,0 +1,565 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.28 <0.9.0; + +import "forge-std/Test.sol"; +import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; +import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; +import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; +import {LibString} from "@solady/utils/LibString.sol"; + +import {Config_MinterMarket, MinterMarketConfigLib} from "@harbor-script/config/ConfigBase.sol"; +import {Deploy_BTC_Minter} from "@harbor-script/src/Deploy_BTC_Minter.sol"; +import {Deploy_ETH_Minter} from "@harbor-script/src/Deploy_ETH_Minter.sol"; +import {Deploy_EUR_Minter} from "@harbor-script/src/Deploy_EUR_Minter.sol"; +import {Deploy_GOLD_Minter} from "@harbor-script/src/Deploy_GOLD_Minter.sol"; +import {Deploy_MCAP_Minter} from "@harbor-script/src/Deploy_MCAP_Minter.sol"; +import {Deploy_SILVER_Minter} from "@harbor-script/src/Deploy_SILVER_Minter.sol"; + +/// @title MigrateCaptureTest — Prong A of the force-migrate verification +/// @notice Captures all view-function results AND interaction outcomes for every stability +/// pool, writing them to JSON. Run once before the migrate script and once after, then diff: +/// the force-migrate only reshapes internal accumulator storage, so the diff MUST be empty. +/// +/// This mirrors script/test/MainnetForkUpgradeTest.t.sol (the v1->v2 upgrade verifier) but: +/// - derives pools + minters from salt keys (no hardcoded addresses), and +/// - reads each pool's depositors from holders/.txt (collect-sp-holders output), +/// so the captured set is exactly the migrated set across all 22 pools. +/// +/// Produces per-pool files in: +/// tmp/{VERSION}/pre/{saltKey}.json -- state snapshot before any interactions +/// tmp/{VERSION}/post/{saltKey}.json -- interaction results + state snapshot after +/// +/// Run (against a local mainnet fork) with VERSION=before, then VERSION=after around the +/// Migrate_StabilityPool_v2_Data_mainnet run; pass the same START_TIMESTAMP to both. +contract MigrateCaptureTest is + Test, + Deploy_BTC_Minter, + Deploy_ETH_Minter, + Deploy_EUR_Minter, + Deploy_GOLD_Minter, + Deploy_MCAP_Minter, + Deploy_SILVER_Minter +{ + using LibString for string; + + string internal constant HOLDERS_DIR = "script/verify/sp-v2-upgrade-prep-for-v3/holders/"; + + struct PoolConfig { + address proxy; + address minter; + string label; // == saltKey, e.g. "ETH::fxUSD::stabilityPoolCollateral" + } + + /// @dev Mutable JSON object key -- unique per pool per phase (e.g., "pre_0", "post_3") + string private _jsonKey; + + PoolConfig[] pools; + // proxy => depositors (read from holders/.txt) + mapping(address => address[]) poolDepositors; + + address testUser; + + function setUp() public { + vm.createSelectFork(vm.rpcUrl("local")); + _setSaltPrefix("harbor_v1"); + + testUser = makeAddr("testUser"); + + Config_MinterMarket[] memory markets; + (, markets) = createBTCMintersConfig(); + _registerMarkets(markets); + (, markets) = createETHMintersConfig(); + _registerMarkets(markets); + (, markets) = createEURMintersConfig(); + _registerMarkets(markets); + (, markets) = createGOLDMintersConfig(); + _registerMarkets(markets); + (, markets) = createMCAPMintersConfig(); + _registerMarkets(markets); + (, markets) = createSILVERMintersConfig(); + _registerMarkets(markets); + } + + function _registerMarkets(Config_MinterMarket[] memory markets) internal { + for (uint256 i = 0; i < markets.length; i++) { + string memory marketKey = MinterMarketConfigLib.salt(markets[i]); + _registerPool(marketKey, "stabilityPoolCollateral"); + _registerPool(marketKey, "stabilityPoolLeveraged"); + } + } + + function _registerPool(string memory marketKey, string memory spType) internal { + string memory saltKey = string.concat(marketKey, "::", spType); + address proxy = _predictAddress(_key(marketKey, spType)); + address minter = _predictAddress(_key(marketKey, "minter")); + pools.push(PoolConfig(proxy, minter, saltKey)); + + address[] memory holders = _readHolders(saltKey); + for (uint256 i = 0; i < holders.length; i++) { + poolDepositors[proxy].push(holders[i]); + } + } + + // ======================================================================== + // HOLDER FILE READING + // ======================================================================== + + /// @dev Read a pool's holder list from HOLDERS_DIR/.txt; '#' lines are comments. + function _readHolders(string memory saltKey) internal returns (address[] memory holders) { + string memory path = string.concat(HOLDERS_DIR, saltKey, ".txt"); + if (!vm.isFile(path)) { + return new address[](0); + } + uint256 count = 0; + while (true) { + string memory line = vm.readLine(path); + if (bytes(line).length == 0) { + break; + } + if (_isAddressLine(line)) { + count++; + } + } + vm.closeFile(path); + + holders = new address[](count); + uint256 idx = 0; + while (true) { + string memory line = vm.readLine(path); + if (bytes(line).length == 0) { + break; + } + if (_isAddressLine(line)) { + holders[idx] = vm.parseAddress(line); + idx++; + } + } + vm.closeFile(path); + } + + function _isAddressLine(string memory line) internal pure returns (bool) { + bytes memory b = bytes(line); + if (b.length == 0) { + return false; + } + if (b[0] == 0x23) { + return false; + } + return true; + } + + // ======================================================================== + // HELPERS (called many times across pools/phases) + // ======================================================================== + + /// @dev Serialize a uint256 as a decimal string. + function _serializeUint(string memory key, uint256 value) internal { + vm.serializeString(_jsonKey, key, vm.toString(value)); + } + + /// @dev Serialize totalAssetSupply + per-user assetBalanceOf. Called 2x per pool (pre/post deposit). + function _serializeBalances(address proxy, address[] memory users, string memory prefix) internal { + _serializeUint(string.concat(prefix, "_totalAssetSupply"), IStabilityPool(proxy).totalAssetSupply()); + for (uint256 i = 0; i < users.length; i++) { + _serializeUint( + string.concat(prefix, "_user_", vm.toString(i), "_assetBalance"), + IStabilityPool(proxy).assetBalanceOf(users[i]) + ); + } + } + + /// @dev Serialize per-user per-token claimable. Called 6x per pool (before/after reward, half/full claim). + function _serializeClaimables( + address proxy, + address[] memory users, + address[] memory tokens, + string memory prefix + ) internal { + for (uint256 u = 0; u < users.length; u++) { + for (uint256 t = 0; t < tokens.length; t++) { + _serializeUint( + string.concat(prefix, "_user_", vm.toString(u), "_reward_", vm.toString(t), "_claimable"), + IMultipleRewardAccumulator(proxy).claimable(users[u], tokens[t]) + ); + } + } + } + + /// @dev Claim for each user via try/catch, recording success/failure and per-token balance deltas. + /// Called 2x per pool (half/full period). Records deltas instead of absolute balances to avoid + /// cross-pool contamination when the same user appears in multiple pools. + function _claimAllWithDeltas( + address proxy, + address[] memory users, + address[] memory tokens, + string memory prefix + ) internal { + // Snapshot balances before claims + uint256[] memory balancesBefore = new uint256[](users.length * tokens.length); + for (uint256 u = 0; u < users.length; u++) { + for (uint256 t = 0; t < tokens.length; t++) { + balancesBefore[u * tokens.length + t] = IERC20(tokens[t]).balanceOf(users[u]); + } + } + + // Claim for each user + for (uint256 i = 0; i < users.length; i++) { + vm.prank(users[i]); + try IMultipleRewardAccumulator(proxy).claim(users[i]) { + vm.serializeString(_jsonKey, string.concat(prefix, "_user_", vm.toString(i), "_success"), "true"); + } catch { + vm.serializeString(_jsonKey, string.concat(prefix, "_user_", vm.toString(i), "_success"), "false"); + } + } + + // Record balance deltas + for (uint256 u = 0; u < users.length; u++) { + for (uint256 t = 0; t < tokens.length; t++) { + _serializeUint( + string.concat(prefix, "_user_", vm.toString(u), "_reward_", vm.toString(t), "_delta"), + IERC20(tokens[t]).balanceOf(users[u]) - balancesBefore[u * tokens.length + t] + ); + } + } + } + + /// @dev Serialize reward token data for one token. Called per-token per-pool per-phase. + function _serializeRewardToken(address proxy, address token, string memory ri) internal { + vm.serializeString(_jsonKey, string.concat(ri, "_token"), vm.toString(token)); + vm.serializeString(_jsonKey, string.concat(ri, "_isActive"), "true"); + + { + (uint256 lastUpdate, uint256 finishAt, uint256 rate, uint256 queued) = IMultipleRewardDistributor(proxy) + .rewardData(token); + _serializeUint(string.concat(ri, "_lastUpdate"), lastUpdate); + _serializeUint(string.concat(ri, "_finishAt"), finishAt); + _serializeUint(string.concat(ri, "_rate"), rate); + _serializeUint(string.concat(ri, "_queued"), queued); + } + + try IMultipleRewardDistributor(proxy).pendingRewards(token) returns ( + uint256 distributable, + uint256 undistributed + ) { + vm.serializeString(_jsonKey, string.concat(ri, "_pendingRewards_ok"), "true"); + _serializeUint(string.concat(ri, "_pendingDistributable"), distributable); + _serializeUint(string.concat(ri, "_pendingUndistributed"), undistributed); + } catch { + vm.serializeString(_jsonKey, string.concat(ri, "_pendingRewards_ok"), "false"); + _serializeUint(string.concat(ri, "_pendingDistributable"), 0); + _serializeUint(string.concat(ri, "_pendingUndistributed"), 0); + } + } + + /// @dev Serialize per-user view data. Called per-user per-pool per-phase. + function _serializeUser(address proxy, address user, address[] memory activeTokens, string memory ui) internal { + vm.serializeString(_jsonKey, string.concat(ui, "_address"), vm.toString(user)); + _serializeUint(string.concat(ui, "_assetBalance"), IStabilityPool(proxy).assetBalanceOf(user)); + + { + (uint64 wStart, uint64 wEnd) = IStabilityPool(proxy).getWithdrawalRequest(user); + _serializeUint(string.concat(ui, "_withdrawalStart"), uint256(wStart)); + _serializeUint(string.concat(ui, "_withdrawalEnd"), uint256(wEnd)); + } + + vm.serializeString( + _jsonKey, + string.concat(ui, "_rewardReceiver"), + vm.toString(IMultipleRewardAccumulator(proxy).rewardReceiver(user)) + ); + + for (uint256 t = 0; t < activeTokens.length; t++) { + string memory ut = string.concat(ui, "_reward_", vm.toString(t)); + _serializeUint( + string.concat(ut, "_claimable"), + IMultipleRewardAccumulator(proxy).claimable(user, activeTokens[t]) + ); + _serializeUint( + string.concat(ut, "_claimed"), + IMultipleRewardAccumulator(proxy).claimed(user, activeTokens[t]) + ); + } + } + + /// @dev Serialize all view functions for a pool into the current JSON object. + function _serializePoolState(address proxy, address[] memory depositors, string memory prefix) internal { + vm.serializeString(_jsonKey, string.concat(prefix, "_owner"), vm.toString(IBaoOwnable(proxy).owner())); + { + IStabilityPool pool = IStabilityPool(proxy); + _serializeUint(string.concat(prefix, "_totalAssetSupply"), pool.totalAssetSupply()); + _serializeUint(string.concat(prefix, "_lastAssetLossError"), pool.lastAssetLossError()); + _serializeUint(string.concat(prefix, "_earlyWithdrawalFee"), pool.getEarlyWithdrawalFee()); + vm.serializeString(_jsonKey, string.concat(prefix, "_feeAddress"), vm.toString(pool.getFeeAddress())); + { + (uint64 startDelay, uint64 endWindow) = pool.getWithdrawalWindow(); + _serializeUint(string.concat(prefix, "_withdrawalStartDelay"), uint256(startDelay)); + _serializeUint(string.concat(prefix, "_withdrawalEndWindow"), uint256(endWindow)); + } + vm.serializeString( + _jsonKey, + string.concat(prefix, "_liquidationToken"), + vm.toString(pool.LIQUIDATION_TOKEN()) + ); + _serializeUint(string.concat(prefix, "_minTotalAssetSupply"), pool.MIN_TOTAL_ASSET_SUPPLY()); + _serializeUint(string.concat(prefix, "_minDeposit"), pool.MIN_DEPOSIT()); + vm.serializeString(_jsonKey, string.concat(prefix, "_assetToken"), vm.toString(pool.ASSET_TOKEN())); + } + + _serializeUint( + string.concat(prefix, "_rewardPeriodLength"), + uint256(IMultipleRewardDistributor(proxy).REWARD_PERIOD_LENGTH()) + ); + + { + address[] memory activeTokens = IMultipleRewardDistributor(proxy).activeRewardTokens(); + _serializeUint(string.concat(prefix, "_activeRewardTokenCount"), activeTokens.length); + for (uint256 i = 0; i < activeTokens.length; i++) { + _serializeRewardToken(proxy, activeTokens[i], string.concat(prefix, "_reward_", vm.toString(i))); + } + } + + { + address[] memory historicalTokens = IMultipleRewardDistributor(proxy).historicalRewardTokens(); + _serializeUint(string.concat(prefix, "_historicalRewardTokenCount"), historicalTokens.length); + for (uint256 i = 0; i < historicalTokens.length; i++) { + vm.serializeString( + _jsonKey, + string.concat(prefix, "_historicalRewardToken_", vm.toString(i)), + vm.toString(historicalTokens[i]) + ); + } + } + + { + address[] memory activeTokens = IMultipleRewardDistributor(proxy).activeRewardTokens(); + _serializeUint(string.concat(prefix, "_depositorCount"), depositors.length); + for (uint256 u = 0; u < depositors.length; u++) { + _serializeUser(proxy, depositors[u], activeTokens, string.concat(prefix, "_user_", vm.toString(u))); + } + } + } + + /// @dev Try all interactions on a pool with before/after snapshots. Called once per pool. + function _doInteractions(address proxy, string memory prefix) internal { + address[] memory depositors = poolDepositors[proxy]; + address[] memory allUsers = new address[](depositors.length + 1); + for (uint256 i = 0; i < depositors.length; i++) { + allUsers[i] = depositors[i]; + } + allUsers[depositors.length] = testUser; + + address[] memory activeTokens = IMultipleRewardDistributor(proxy).activeRewardTokens(); + + // 1. Pre-deposit balances + _serializeBalances(proxy, allUsers, string.concat(prefix, "_preDeposit")); + + // 2. Deposit (new user) + { + address assetToken = IStabilityPool(proxy).ASSET_TOKEN(); + deal(assetToken, testUser, 100 ether); + vm.startPrank(testUser); + IERC20(assetToken).approve(proxy, type(uint256).max); + try IStabilityPool(proxy).deposit(100 ether, testUser, 0) { + vm.serializeString(_jsonKey, string.concat(prefix, "_deposit_success"), "true"); + } catch { + vm.serializeString(_jsonKey, string.concat(prefix, "_deposit_success"), "false"); + } + vm.stopPrank(); + _serializeUint(string.concat(prefix, "_deposit_amount"), 100 ether); + } + + // 2b. Existing depositor deposit (exercises checkpoint with historical reward integral) + if (depositors.length > 0) { + address existingDepositor = depositors[0]; + address assetToken2 = IStabilityPool(proxy).ASSET_TOKEN(); + deal(assetToken2, existingDepositor, 100 ether); + vm.startPrank(existingDepositor); + IERC20(assetToken2).approve(proxy, type(uint256).max); + try IStabilityPool(proxy).deposit(100 ether, existingDepositor, 0) { + vm.serializeString(_jsonKey, string.concat(prefix, "_existingDepositorDeposit_success"), "true"); + } catch { + vm.serializeString(_jsonKey, string.concat(prefix, "_existingDepositorDeposit_success"), "false"); + } + vm.stopPrank(); + vm.serializeString( + _jsonKey, + string.concat(prefix, "_existingDepositorDeposit_user"), + vm.toString(existingDepositor) + ); + } + + // 3. Post-deposit balances + _serializeBalances(proxy, allUsers, string.concat(prefix, "_postDeposit")); + + // 4. Pre-depositReward claimables + _serializeClaimables(proxy, allUsers, activeTokens, string.concat(prefix, "_preReward")); + + // 5. Deposit reward (prank owner who already has depositor role) + { + address liquidationToken = IStabilityPool(proxy).LIQUIDATION_TOKEN(); + address owner = IBaoOwnable(proxy).owner(); + deal(liquidationToken, owner, 10 ether); + vm.startPrank(owner); + IERC20(liquidationToken).approve(proxy, type(uint256).max); + try IMultipleRewardDistributor(proxy).depositReward(liquidationToken, 10 ether) { + vm.serializeString(_jsonKey, string.concat(prefix, "_depositReward_success"), "true"); + } catch { + vm.serializeString(_jsonKey, string.concat(prefix, "_depositReward_success"), "false"); + } + vm.stopPrank(); + vm.serializeString(_jsonKey, string.concat(prefix, "_depositReward_token"), vm.toString(liquidationToken)); + _serializeUint(string.concat(prefix, "_depositReward_amount"), 10 ether); + } + + // 6. Post-depositReward claimables + _serializeClaimables(proxy, allUsers, activeTokens, string.concat(prefix, "_postReward")); + + // 7. Warp half period + uint256 halfPeriod = uint256(IMultipleRewardDistributor(proxy).REWARD_PERIOD_LENGTH()) / 2; + vm.warp(block.timestamp + halfPeriod); + _serializeUint(string.concat(prefix, "_warp_half_seconds"), halfPeriod); + + // 8-10. Half-period: claimable before, claim all (with balance deltas), claimable after + _serializeClaimables(proxy, allUsers, activeTokens, string.concat(prefix, "_half_preClaim")); + _claimAllWithDeltas(proxy, allUsers, activeTokens, string.concat(prefix, "_half_claim")); + _serializeClaimables(proxy, allUsers, activeTokens, string.concat(prefix, "_half_postClaim")); + + // 11. Warp remaining half period + vm.warp(block.timestamp + halfPeriod); + _serializeUint(string.concat(prefix, "_warp_total_seconds"), halfPeriod * 2); + + // 12-14. Full-period: claimable before, claim all (with balance deltas), claimable after + _serializeClaimables(proxy, allUsers, activeTokens, string.concat(prefix, "_full_preClaim")); + _claimAllWithDeltas(proxy, allUsers, activeTokens, string.concat(prefix, "_full_claim")); + _serializeClaimables(proxy, allUsers, activeTokens, string.concat(prefix, "_full_postClaim")); + + // 15. Assert all claimable == 0 after full period + { + bool allZero = true; + for (uint256 u = 0; u < allUsers.length && allZero; u++) { + for (uint256 t = 0; t < activeTokens.length && allZero; t++) { + if (IMultipleRewardAccumulator(proxy).claimable(allUsers[u], activeTokens[t]) > 0) { + allZero = false; + } + } + } + vm.serializeString(_jsonKey, string.concat(prefix, "_full_allClaimableZero"), allZero ? "true" : "false"); + } + + // 16. Withdraw for test user (request, warp into window, withdraw) + vm.startPrank(testUser); + try IStabilityPool(proxy).requestWithdrawal() { + vm.serializeString(_jsonKey, string.concat(prefix, "_requestWithdrawal_success"), "true"); + vm.stopPrank(); + + { + (uint64 wStart, ) = IStabilityPool(proxy).getWithdrawalRequest(testUser); + if (wStart > 0) { + vm.warp(uint256(wStart) + 1); + } + } + + uint256 withdrawAmount = IStabilityPool(proxy).assetBalanceOf(testUser) / 2; + vm.prank(testUser); + try IStabilityPool(proxy).withdraw(withdrawAmount, testUser, 0) { + vm.serializeString(_jsonKey, string.concat(prefix, "_withdraw_success"), "true"); + } catch { + vm.serializeString(_jsonKey, string.concat(prefix, "_withdraw_success"), "false"); + } + _serializeUint(string.concat(prefix, "_withdraw_amount"), withdrawAmount); + } catch { + vm.stopPrank(); + vm.serializeString(_jsonKey, string.concat(prefix, "_requestWithdrawal_success"), "false"); + vm.serializeString(_jsonKey, string.concat(prefix, "_withdraw_success"), "false"); + _serializeUint(string.concat(prefix, "_withdraw_amount"), 0); + } + } + + // ======================================================================== + // TEST FUNCTION + // ======================================================================== + + /// @notice Capture all view results and interaction outcomes to JSON files. + /// @dev Run with VERSION=before (pre-migrate) or VERSION=after (post-migrate). + function test_captureState() public { + string memory version = vm.envOr("VERSION", string("before")); + string memory poolFilter = vm.envOr("POOL_FILTER", string("")); + + // Normalize timestamp so before/after runs start from the same point (the migrate run + // advances block.timestamp, which would otherwise shift reward accrual). + { + uint256 startTimestamp = vm.envOr("START_TIMESTAMP", uint256(0)); + console.log("Current block:", block.number, "timestamp:", block.timestamp); + if (startTimestamp > 0) { + require(startTimestamp >= block.timestamp, "START_TIMESTAMP is in the past"); + vm.roll(block.number + 1); + vm.warp(startTimestamp); + } + console.log("Normalized block:", block.number, "timestamp:", block.timestamp); + } + + string memory preDir = string.concat("tmp/", version, "/pre"); + string memory postDir = string.concat("tmp/", version, "/post"); + vm.createDir(preDir, true); + vm.createDir(postDir, true); + + uint256 activeCount = 0; + + // PRE FILES: state snapshot before any interactions (one per pool) + for (uint256 i = 0; i < pools.length; i++) { + if (bytes(poolFilter).length > 0 && !pools[i].label.contains(poolFilter)) { + continue; + } + activeCount++; + + _jsonKey = string.concat("pre_", vm.toString(i)); + vm.serializeString(_jsonKey, "proxy", vm.toString(pools[i].proxy)); + vm.serializeString(_jsonKey, "minter", vm.toString(pools[i].minter)); + vm.serializeString(_jsonKey, "label", pools[i].label); + _serializePoolState(pools[i].proxy, poolDepositors[pools[i].proxy], "state"); + + vm.writeJson( + vm.serializeString(_jsonKey, "_complete", "true"), + string.concat(preDir, "/", pools[i].label, ".json") + ); + console.log("Pre:", pools[i].label); + } + + // INTERACTIONS: deposit, depositReward, warp, claim, withdraw + for (uint256 i = 0; i < pools.length; i++) { + if (bytes(poolFilter).length > 0 && !pools[i].label.contains(poolFilter)) { + continue; + } + _jsonKey = string.concat("post_", vm.toString(i)); + vm.serializeString(_jsonKey, "proxy", vm.toString(pools[i].proxy)); + vm.serializeString(_jsonKey, "minter", vm.toString(pools[i].minter)); + vm.serializeString(_jsonKey, "label", pools[i].label); + _doInteractions(pools[i].proxy, "interact"); + } + + // POST FILES: interaction results + state after interactions (one per pool) + for (uint256 i = 0; i < pools.length; i++) { + if (bytes(poolFilter).length > 0 && !pools[i].label.contains(poolFilter)) { + continue; + } + _jsonKey = string.concat("post_", vm.toString(i)); + address[] memory depositors = poolDepositors[pools[i].proxy]; + address[] memory postDepositors = new address[](depositors.length + 1); + for (uint256 j = 0; j < depositors.length; j++) { + postDepositors[j] = depositors[j]; + } + postDepositors[depositors.length] = testUser; + _serializePoolState(pools[i].proxy, postDepositors, "state"); + + vm.writeJson( + vm.serializeString(_jsonKey, "_complete", "true"), + string.concat(postDir, "/", pools[i].label, ".json") + ); + console.log("Post:", pools[i].label); + } + + console.log("Pool count:", activeCount); + } +} diff --git a/script/verify/sp-v2-data-prep-for-v3/collect-sp-holders b/script/verify/sp-v2-data-prep-for-v3/collect-sp-holders new file mode 100755 index 00000000..5f8e92df --- /dev/null +++ b/script/verify/sp-v2-data-prep-for-v3/collect-sp-holders @@ -0,0 +1,187 @@ +#!/usr/bin/env bash +# collect-sp-holders — discover every account that has ever held a position in +# each Harbor stability pool, by scanning each SP proxy's `UserDepositChange` +# event logs. +# +# Why UserDepositChange: an account gets a per-user reward snapshot exactly when +# it is checkpointed (deposit -> receiver, withdraw -> sender, liquidation -> +# account). UserDepositChange(owner, ...) fires on every one of those, so the +# distinct set of `owner` values is precisely the set of accounts that may carry +# legacy V1 accumulator storage. (Over-approximation is harmless: the on-chain +# remediate() skips any account with no V1 data.) +# +# Output: one file per pool under holders/, one checksummed address per line, +# with a comment header. These files are read at runtime by +# Migrate_StabilityPool_v2_Data_mainnet.s.sol. +# +# The RPC is resolved via foundry's `rpc_endpoints` alias (default: mainnet), +# which expands ${MAINNET_RPC_URL} from .env — this script never reads .env. +# +# Usage: +# collect-sp-holders # full scan -> holders/ +# collect-sp-holders --dry-run # print pool->address table, no RPC +# collect-sp-holders --from-block 24049000 --chunk 50000 +# collect-sp-holders --out-dir tmp/holders-rerun # for the re-run/verify facility + +set -euo pipefail +cd "$(git rev-parse --show-toplevel)" + +# ── Configuration ──────────────────────────────────────────────────────────── + +NETWORK="mainnet" +FROM_BLOCK=24049000 # first harbor mainnet deployment (same as collect-holders) +TO_BLOCK="25186514" +CHUNK=50000 # block-range chunk size for eth_getLogs (range-limit safe) +STATE_FILE="deployments/mainnet/harbor_v1.state.json" +SALT_PREFIX="harbor_v1" +OUT_DIR="script/verify/sp-v2-upgrade-prep-for-v3/holders" +DRY_RUN=false + +# CREATE3 (solady) — same constants the repo's script/cast wrapper uses. +FACTORY="0xD696E56b3A054734d4C6DCBD32E11a278b0EC458" +INIT_CODE_HASH="21c35dbe1b344a2488cf3321d6ce542f8e9f305544ff09e4993a62319a497c1f" +# topic0 = keccak("UserDepositChange(address,uint256,uint256)") +TOPIC0="0x5fa7d0e13a31540b6d42936e522173de31fa0c53aa5aff71e63c60fe02b2b50e" + +while [[ $# -gt 0 ]]; do + case "$1" in + --network) + NETWORK="$2" + shift 2 + ;; + --from-block) + FROM_BLOCK="$2" + shift 2 + ;; + --to-block) + TO_BLOCK="$2" + shift 2 + ;; + --chunk) + CHUNK="$2" + shift 2 + ;; + --out-dir) + OUT_DIR="$2" + shift 2 + ;; + --dry-run) + DRY_RUN=true + shift + ;; + -h | --help) + sed -n '2,30p' "$0" + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + exit 1 + ;; + esac +done + +# ── Helpers ────────────────────────────────────────────────────────────────── + +# Compute the CREATE3 proxy address for a full salt string (no RPC). +compute_create3_address() { + local salt_string="$1" + local salt preimage proxy_hash proxy rlp deployed_hash + salt=$(cast keccak "$salt_string") + preimage=$(cast concat-hex 0xff "$FACTORY" "$salt" "0x$INIT_CODE_HASH") + proxy_hash=$(cast keccak "$preimage") + proxy="0x${proxy_hash:26}" + rlp="0xd694${proxy#0x}01" + deployed_hash=$(cast keccak "$rlp") + echo "0x${deployed_hash:26}" +} + +# Distinct stability-pool salts (collateral + leveraged), e.g. "ETH::fxUSD::stabilityPoolCollateral". +pool_salts() { + local salts + salts=$(jq -r '.implementations[].proxy' "$STATE_FILE") + if [[ ${PIPESTATUS[0]:-$?} -ne 0 ]]; then + echo "ERROR: failed reading $STATE_FILE" >&2 + exit 1 + fi + printf '%s\n' "$salts" | grep -E 'stabilityPool(Collateral|Leveraged)$' | sort -u +} + +# Extract the indexed `owner` (topic[1]) from a `cast logs --json` array on stdin. +extract_owners() { + python3 -c ' +import json, sys +data = json.load(sys.stdin) +for log in data: + topics = log.get("topics", []) + if len(topics) >= 2: + print("0x" + topics[1][-40:]) +' +} + +# Fetch all owners for one proxy, scanning [FROM_BLOCK, latest] in chunks. +fetch_owners() { + local proxy="$1" latest="$2" + local from="$FROM_BLOCK" to logs rc + while ((from <= latest)); do + to=$((from + CHUNK - 1)) + ((to > latest)) && to="$latest" + logs=$(cast logs --rpc-url "$NETWORK" --from-block "$from" --to-block "$to" \ + "$TOPIC0" --address "$proxy" --json) + rc=$? + if [[ $rc -ne 0 ]]; then + echo "ERROR: cast logs failed for $proxy blocks $from-$to (rc=$rc)" >&2 + exit 1 + fi + printf '%s' "$logs" | extract_owners + if [[ ${PIPESTATUS[1]} -ne 0 ]]; then + echo "ERROR: failed parsing logs for $proxy blocks $from-$to" >&2 + exit 1 + fi + from=$((to + 1)) + done +} + +# ── Main ───────────────────────────────────────────────────────────────────── + +mapfile -t SALTS < <(pool_salts) +echo "# ${#SALTS[@]} stability pools from $STATE_FILE" >&2 + +if [[ "$DRY_RUN" == true ]]; then + echo "# DRY RUN — pool -> proxy address (no RPC)" >&2 + for salt in "${SALTS[@]}"; do + printf '%-44s %s\n' "$salt" "$(compute_create3_address "${SALT_PREFIX}::${salt}")" + done + exit 0 +fi + +# Resolve `latest` once so all pools scan the same upper bound. +latest_block="$TO_BLOCK" +if [[ "$TO_BLOCK" == "latest" ]]; then + latest_block=$(cast block-number --rpc-url "$NETWORK") +fi +echo "# scanning blocks $FROM_BLOCK -> $latest_block (chunk $CHUNK)" >&2 + +mkdir -p "$OUT_DIR" + +for salt in "${SALTS[@]}"; do + proxy=$(compute_create3_address "${SALT_PREFIX}::${salt}") + out_file="${OUT_DIR}/${salt}.txt" + + # Collect, dedupe, checksum. + mapfile -t owners < <(fetch_owners "$proxy" "$latest_block" | sort -uf) + + { + echo "# Stability pool holders: ${salt}" + echo "# proxy: ${proxy}" + echo "# source: UserDepositChange logs, blocks ${FROM_BLOCK}-${latest_block}" + echo "# generated: $(date -Iseconds)" + for addr in "${owners[@]}"; do + [[ -z "$addr" ]] && continue + cast to-check-sum-address "$addr" + done + } >"$out_file" + + echo " ${salt}: ${#owners[@]} holders -> ${out_file}" >&2 +done + +echo "# done -> ${OUT_DIR}/" >&2 diff --git a/script/verify/sp-v2-data-prep-for-v3/run-migrate-StabilityPool_v2-data b/script/verify/sp-v2-data-prep-for-v3/run-migrate-StabilityPool_v2-data new file mode 100755 index 00000000..ee8c9eed --- /dev/null +++ b/script/verify/sp-v2-data-prep-for-v3/run-migrate-StabilityPool_v2-data @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# run-migrate-StabilityPool_v2-data — local verification of the accumulator V1->V2 +# force-migration (prep for v3). +# +# Two-pronged verification against a single local mainnet-fork anvil session: +# +# Prong A (black-box): capture all pool/holder state + interactions BEFORE, run the +# ACTUAL migrate script (--broadcast --local persists to anvil), capture AFTER, diff. +# The migration only reshapes internal storage, so the diff MUST be empty. +# +# Prong B (white-box): MigrateBalancesTest upgrades each proxy to ForceMigrateAccumulator_v1, +# reads raw (V1, V2) integrals via balances(), remediates, and asserts the copy is correct. +# Runs in-memory on the un-migrated node, so it does not disturb anvil. +# +# forge test forks anvil IN-MEMORY (no writeback); run-script --broadcast --local PERSISTS. +# So a single anvil session works: in-memory captures/checks see the un-migrated node, the +# migrate script mutates it, then the after-capture forks the migrated node. START_TIMESTAMP +# normalizes the timestamp drift the migrate txs introduce. + +set -euo pipefail +cd "$(git rev-parse --show-toplevel)" + +# ── Configuration ──────────────────────────────────────────────────────────── +BLOCK="${BLOCK:-latest}" +SALT="${SALT:-harbor_v1}" +NETWORK="${NETWORK:-mainnet}" +POOL_FILTER="${POOL_FILTER:-}" +FORGE_VERBOSITY="${FORGE_VERBOSITY:--vv}" +CAPTURE_TEST="script/verify/sp-v2-upgrade-prep-for-v3/MigrateCaptureTest.t.sol" +BALANCES_TEST="script/verify/sp-v2-upgrade-prep-for-v3/MigrateBalancesTest.t.sol" + +while [[ $# -gt 0 ]]; do + case "$1" in + --include) + POOL_FILTER="$2" + shift 2 + ;; + --block) + BLOCK="$2" + shift 2 + ;; + -h | --help) + echo "Usage: $(basename "$0") [--block ] [--include ]" + echo "" + echo "Env: BLOCK, SALT (default harbor_v1), NETWORK (default mainnet)," + echo " POOL_FILTER, FORGE_VERBOSITY (default -vv)" + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + exit 1 + ;; + esac +done + +prompt() { + echo "" + echo "──────────────────────────────────────────────────────────────" + echo "$1" + echo "──────────────────────────────────────────────────────────────" + read -rn1 -p "Ready? [Y/n] " response + echo "" + if [[ "$response" =~ ^[Nn]$ ]]; then + echo "Aborted." + exit 1 + fi +} + +capture() { + local version="$1" + local env_args=(START_TIMESTAMP="$START_TIMESTAMP" VERSION="$version") + [[ -n "$POOL_FILTER" ]] && env_args+=(POOL_FILTER="$POOL_FILTER") + echo "" + echo "Capturing '$version' state..." + env "${env_args[@]}" forge test --match-path "$CAPTURE_TEST" --fork-url local $FORGE_VERBOSITY +} + +echo "======================================================================" +echo " StabilityPool_v2 accumulator V1->V2 force-migration verification" +echo "======================================================================" +echo " Fork block: $BLOCK" +echo " Salt: $SALT" +[[ -n "$POOL_FILTER" ]] && echo " Pool filter: $POOL_FILTER" + +# Compute START_TIMESTAMP from the fork block (shared by before/after captures). +echo "" +echo "Computing START_TIMESTAMP from block $BLOCK..." +TS=$(cast block --rpc-url "$NETWORK" "$BLOCK" -f timestamp) +START_TIMESTAMP=$((TS + 12000)) +echo " block timestamp: $TS" +echo " START_TIMESTAMP: $START_TIMESTAMP (+12000s buffer)" + +prompt "Start a fresh anvil fork: script/anvil --block $BLOCK" + +# ── Prong A: capture BEFORE (in-memory; does not mutate anvil) ──────────────── +capture before + +# ── Prong B: white-box balances() check (in-memory; un-migrated node) ───────── +echo "" +echo "Prong B: balances() white-box check..." +forge test --match-path "$BALANCES_TEST" --fork-url local $FORGE_VERBOSITY + +# ── Run the ACTUAL migrate script (persists to anvil) ───────────────────────── +echo "" +echo "Running Migrate_StabilityPool_v2_Data_mainnet (--broadcast --local)..." +./script/run-script Migrate_StabilityPool_v2_Data_mainnet --network "$NETWORK" --salt "$SALT" --broadcast --local + +# ── Prong A: capture AFTER (forks the now-migrated node) ────────────────────── +capture after + +# ── Compare (expect: NO differences) ────────────────────────────────────────── +echo "" +echo "======================================================================" +echo " Comparison — expect NO differences (migration is transparent)" +echo "======================================================================" +if command -v meld >/dev/null 2>&1; then + meld tmp/before tmp/after +else + if diff -ru tmp/before tmp/after; then + echo "" + echo "✅ PASS: before and after are identical." + else + echo "" + echo "❌ FAIL: differences found above — migration changed user-visible state." + exit 1 + fi +fi diff --git a/script/verify/sp-v2-upgrade-prep-for-v3/run-upgrade-test-StabilityPool_v2 b/script/verify/sp-v2-data-prep-for-v3/run-upgrade-test-StabilityPool_v2 similarity index 100% rename from script/verify/sp-v2-upgrade-prep-for-v3/run-upgrade-test-StabilityPool_v2 rename to script/verify/sp-v2-data-prep-for-v3/run-upgrade-test-StabilityPool_v2 diff --git a/script/verify/sp-v2-upgrade-prep-for-v3/MainnetUpgradeTest.t.sol b/script/verify/sp-v2-upgrade-prep-for-v3/MainnetUpgradeTest.t.sol deleted file mode 100644 index bbedab84..00000000 --- a/script/verify/sp-v2-upgrade-prep-for-v3/MainnetUpgradeTest.t.sol +++ /dev/null @@ -1,229 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.28 <0.9.0; - -import "forge-std/Test.sol"; -import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; -import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {StabilityPool_v2} from "@harbor/minter/StabilityPool_v2.sol"; -import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; -import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; - -/// @title Mainnet Upgrade Test -/// @notice Comprehensive test that: -/// 1. Replicates all three user-visible issues on mainnet (deposit, depositReward, withdrawal) -/// 2. Deploys fixed implementation -/// 3. Upgrades the proxy -/// 4. Verifies all issues are resolved -contract MainnetUpgradeTest is Test { - uint constant PASS = 0; - uint constant FAIL = 1; - - address constant STABILITY_POOL = 0x9e56F1E1E80EBf165A1dAa99F9787B41cD5bFE40; - address constant MINTER = 0x33e32ff4d0677862fa31582CC654a25b9b1e4888; // Real mainnet minter - address constant REWARD_COLLATERAL = 0x7743e50F534a7f9F1791DdE7dCD89F7783Eefc39; // fxSAVE - address constant REWARD_SAIL = 0x9567c243F647f9Ac37efb7Fc26BD9551Dce0BE1B; // hsBTC-fxUSD - address constant ANCHORED = 0x25bA4A826E1A1346dcA2Ab530831dbFF9C08bEA7; // haBTC - uint256 constant FORK_BLOCK = 25186513; - uint256 constant LATER_BLOCK = 25186514; - - string mainnet = vm.rpcUrl("mainnet"); - - // Known addresses that might have the pegged token for testing - address userWithTokens; - address rewardDepositor; - address userWithBalance; // Existing user with balance for withdrawal test - - function parseError(bytes memory lowLevelData) internal pure { - // Check if it's an arithmetic underflow (panic code 0x11) - if (lowLevelData.length >= 36) { - bytes4 selector = bytes4(lowLevelData); - if (selector == 0x4e487b71) { - // Panic selector - uint256 panicCode; - assembly { - panicCode := mload(add(lowLevelData, 36)) - } - if (panicCode == 0x11) { - console.log("*** User deposit fails with Panic 0x11 (Arithmetic Underflow) ***"); - } else { - console.log("Unexpected panic code:", panicCode); - } - } else { - console.log("Unexpected error type"); - } - } - } - - function _doFailingTransactions(uint expect) internal { - // Try to deposit - this should fail with arithmetic underflow - // Deal some tokens to our test user - console.log("Attempting user deposit..."); - deal(ANCHORED, userWithTokens, 1000 ether); - - vm.startPrank(userWithTokens); - IERC20(ANCHORED).approve(STABILITY_POOL, type(uint256).max); - - try IStabilityPool(STABILITY_POOL).deposit(100 ether, userWithTokens, 0) { - console.log("ERROR: deposit succeeded when it should have failed!"); - assertEq(expect, PASS, "Deposit should have failed"); - } catch (bytes memory lowLevelData) { - parseError(lowLevelData); - assertEq(expect, FAIL, "expected deposit to succeed"); - } - vm.stopPrank(); - - vm.startPrank(rewardDepositor); - - address[] memory activeTokens = IMultipleRewardDistributor(STABILITY_POOL).activeRewardTokens(); - for (uint256 i = 0; i < activeTokens.length; i++) { - address tokenToDeposit = activeTokens[i]; - - console.log("Attempting depositReward for token:", tokenToDeposit); - - // Deal some reward tokens to our depositor - deal(tokenToDeposit, rewardDepositor, 1000 ether); - - IERC20(tokenToDeposit).approve(STABILITY_POOL, type(uint256).max); - - // Try to deposit rewards - this should fail with arithmetic underflow - try IMultipleRewardDistributor(STABILITY_POOL).depositReward(tokenToDeposit, 100 ether) { - assertEq(expect, PASS, "depositReward should have failed"); - } catch (bytes memory lowLevelData) { - parseError(lowLevelData); - assertEq(expect, FAIL, "expected depositReward to succeed"); - } - } - vm.stopPrank(); - - // Try to withdraw - this should also fail with arithmetic underflow before upgrade - // Testing with real mainnet user who had failed withdrawal tx: - // 0xacbb222f01fa442075187334a42eaefc6ebd03411b18635bbbf8e93cde54c205 - if (userWithBalance != address(0)) { - console.log("Attempting user withdrawal..."); - console.log("User address:", userWithBalance); - uint256 balance = IStabilityPool(STABILITY_POOL).assetBalanceOf(userWithBalance); - console.log("User balance:", balance); - - if (balance > 0) { - uint256 withdrawAmount = balance / 2; // Withdraw half - vm.startPrank(userWithBalance); - - try IStabilityPool(STABILITY_POOL).withdraw(withdrawAmount, userWithBalance, 0) { - console.log("Withdrawal succeeded"); - assertEq(expect, PASS, "Withdrawal should have failed"); - } catch (bytes memory lowLevelData) { - parseError(lowLevelData); - assertEq(expect, FAIL, "expected withdrawal to succeed"); - } - vm.stopPrank(); - } else { - console.log("User has no balance to withdraw"); - } - } - } - - function setUp() public { - // Fork mainnet at the problematic block - vm.createSelectFork(mainnet, FORK_BLOCK); - - // Find the reward depositor by checking who has the REWARD_DEPOSITOR_ROLE - // For now, we'll use a test address and deal tokens to it - userWithTokens = makeAddr("user"); - rewardDepositor = makeAddr("rewardDepositor"); - - // Find a user with existing balance for withdrawal test - // We need to find an actual depositor from mainnet state - userWithBalance = _findUserWithBalance(); - } - - /// @notice Helper to find a user with existing balance in the stability pool - /// @dev Returns a known depositor who had a failed withdrawal transaction - /// @dev Transaction: 0xacbb222f01fa442075187334a42eaefc6ebd03411b18635bbbf8e93cde54c205 - function _findUserWithBalance() internal pure returns (address) { - // Known depositor with failed withdrawal - return 0xb9ab9578a34a05c86124c399735fdE44dEc80E7F; - } - - /// @notice Test 1: Demonstrate that deposit, depositReward, and withdrawal all fail on mainnet - function test_1_AllOperationsFail_OnMainnet() public { - console.log("=== TEST 1: All Operations Fail on Mainnet ==="); - console.log("Block:", FORK_BLOCK); - console.log("StabilityPool:", STABILITY_POOL); - console.log(""); - - // Show the problematic token state - address[] memory activeTokens = IMultipleRewardDistributor(STABILITY_POOL).activeRewardTokens(); - console.log("Active reward tokens:", activeTokens.length); - - for (uint256 i = 0; i < activeTokens.length; i++) { - address token = activeTokens[i]; - console.log("activeToken[%s]", i, token); - - (uint256 lastUpdate, uint256 finishAt, uint256 rate, ) = IMultipleRewardDistributor(STABILITY_POOL) - .rewardData(token); - if (finishAt == 0 && lastUpdate > 0) { - console.log(""); - console.log("Found problematic token at index:", i); - console.log("Token address:", token); - console.log(" lastUpdate:", lastUpdate); - console.log(" finishAt:", finishAt); - console.log(" rate:", rate); - console.log("*** This token will cause underflow! ***"); - } - } - - _doFailingTransactions(FAIL); - } - - /// @notice Test 2: Verify issues persist at latest block - function test_2_IssuesPersist_AtLatestBlock() public { - console.log("=== TEST 2: Issues Persist at Latest Block ==="); - console.log(""); - - // Fork to the latest block - vm.createSelectFork(mainnet, LATER_BLOCK); - console.log("Forked to latest block:", block.number); - _doFailingTransactions(FAIL); - } - - /// @notice Test 5: Verify all operations work after upgrade - function test_3_AllOperationsWork_AfterUpgrade() public { - console.log("=== TEST 3: All Operations Work After Upgrade ==="); - console.log(""); - - // First do the upgrade (reusing logic from test 4) - vm.createSelectFork(mainnet, FORK_BLOCK); - - StabilityPool_v2 currentProxy = StabilityPool_v2(STABILITY_POOL); - - // Read immutable variables from the current contract (these are in the implementation bytecode) - address liquidationToken = currentProxy.LIQUIDATION_TOKEN(); - (uint64 startDelay, uint64 endWindow) = currentProxy.getWithdrawalWindow(); - uint256 minTotalAssetSupply = currentProxy.MIN_TOTAL_ASSET_SUPPLY(); - console2.log("minTotalAssetSupply = %s", minTotalAssetSupply); - - // Deploy new implementation with the same parameters - StabilityPool_v2 newImplementation = new StabilityPool_v2( - MINTER, - liquidationToken, - startDelay, - endWindow, - minTotalAssetSupply - ); - - address proxyOwner = IBaoOwnable(STABILITY_POOL).owner(); - vm.startPrank(proxyOwner); - StabilityPool_v2(STABILITY_POOL).upgradeToAndCall(address(newImplementation), ""); - - // Grant REWARD_DEPOSITOR_ROLE to our test depositor so depositReward can succeed - uint256 depositorRole = IMultipleRewardDistributor(STABILITY_POOL).REWARD_DEPOSITOR_ROLE(); - IBaoRoles(STABILITY_POOL).grantRoles(rewardDepositor, depositorRole); - vm.stopPrank(); - - console.log("Upgrade completed. Testing user deposit..."); - console.log(""); - - _doFailingTransactions(PASS); - } -} diff --git a/script/verify/sp-v2-upgrade-prep-for-v3/for docs see sp-v2-upgrade docs b/script/verify/sp-v2-upgrade-prep-for-v3/for docs see sp-v2-upgrade docs deleted file mode 100644 index e69de29b..00000000 diff --git a/script/verify/sp-v2-upgrade-prep-for-v3/linear-reward-underflow.md b/script/verify/sp-v2-upgrade-prep-for-v3/linear-reward-underflow.md deleted file mode 100644 index 028e117d..00000000 --- a/script/verify/sp-v2-upgrade-prep-for-v3/linear-reward-underflow.md +++ /dev/null @@ -1,46 +0,0 @@ -# LinearReward Arithmetic Underflow - -## Executive Summary - -**Status**: Bug confirmed on mainnet at block 24404265. -**Contract**: `0x9e56F1E1E80EBf165A1dAa99F9787B41cD5bFE40` (StabilityPool) -**Impact**: Deposits completely blocked -- users cannot deposit into the pool. - -## Root Cause - -Reward token `0x9567c243F647f9Ac37efb7Fc26BD9551Dce0BE1B` was registered as an active reward token but never received any reward deposits. The `_distributePendingReward()` function updates `lastUpdate` for ALL active tokens on every deposit, but `finishAt` is only set by `increase()`, which is only called for the token actually receiving rewards. This results in: - -``` -lastUpdate: 1769846711 (valid timestamp) -finishAt: 0 (never set) -rate: 0 -queued: 0 -``` - -### The Bug in LinearReward.sol - -In `increase()`, the `else` branch (entered when `block.timestamp < finishAt`) performs unsafe subtractions: - -**Line 48** -- `finishAt - periodLength` underflows when `finishAt < periodLength` (e.g., 0 < 1209600) - -**Line 52** -- `finishAt - lastUpdate` underflows when `finishAt < lastUpdate` (e.g., 0 < 1769846711) - -When any user calls `deposit()`, `_distributePendingReward()` loops through all active tokens and calls `increase()`. The underflow causes Panic 0x11 and the entire transaction reverts. - -## The Fix - -Safe subtractions at all affected lines: - -```solidity -// Line 48-50 -uint256 periodStart = _data.finishAt >= _periodLength ? _data.finishAt - _periodLength : 0; -uint256 _elapsed = block.timestamp >= periodStart ? block.timestamp - periodStart : 0; - -// Line 52-54 -uint256 timeSinceLastUpdate = _data.finishAt >= _data.lastUpdate ? _data.finishAt - _data.lastUpdate : 0; -_amount = _amount + uint256(_data.rate) * timeSinceLastUpdate; -``` - -## See Also - -- [finishAt = 0 Root Cause Investigation](finishat-zero.md) -- detailed investigation of how the state arose diff --git a/script/verify/sp-v2-upgrade-prep-for-v3/upgrade-StabilityPool_v2.md b/script/verify/sp-v2-upgrade-prep-for-v3/upgrade-StabilityPool_v2.md deleted file mode 100644 index 92ee73b7..00000000 --- a/script/verify/sp-v2-upgrade-prep-for-v3/upgrade-StabilityPool_v2.md +++ /dev/null @@ -1,192 +0,0 @@ -# StabilityPool v2 Upgrade Verification - -These tests verify that upgrading StabilityPool v1 to v2 preserves all on-chain -state and produces identical behavior. They run against a local anvil fork and -are NOT part of CI -- run them manually during the upgrade deployment workflow. - -## Output - -Each run produces one JSON file per pool in two directories: - -``` -tmp/{version}/pre/{label}.json -- state snapshot before any interactions -tmp/{version}/post/{label}.json -- interaction results + state snapshot after -``` - -## Quick start (automated) - -The `run-upgrade-test-StabilityPool_v2` script orchestrates the full workflow -(fork block: 24433566). It prompts you to start and stop anvil manually between steps: - -```bash -script/test/run-upgrade-test-StabilityPool_v2 -``` - -It will: -1. Compute `START_TIMESTAMP` from the fork block -2. Prompt you to start anvil, then capture v1 state -3. Prompt you to restart anvil, then deploy the upgrade and capture v2 state -4. Open meld (or diff) to compare the results - -Optional environment variables: - -```bash -# Only test BTC pools -POOL_FILTER=BTC script/test/run-upgrade-test-StabilityPool_v2 - -# Less verbose forge output -FORGE_VERBOSITY=-vv script/test/run-upgrade-test-StabilityPool_v2 -``` - -## Manual workflow - -### 1. Start anvil fork and capture v1 state - -```bash -script/anvil --block 24433566 # just before the SP v2 upgrade -``` - -Compute normalized starting timestamp (use same value for both runs): - -```bash -BLOCK=24433566 -TS=$(cast block --rpc-url local $BLOCK -f timestamp) -export START_TIMESTAMP=$((TS + 12000)) -``` - -In a separate terminal: - -```bash -START_TIMESTAMP=$START_TIMESTAMP VERSION=v1 forge test \ - --match-path script/test/MainnetForkUpgradeTest.t.sol \ - --fork-url local -vvv -``` - -Output: `tmp/v1/pre/*.json` and `tmp/v1/post/*.json` - -Stop anvil (Ctrl+C). - -### 2. Start fresh anvil fork and deploy the upgrade - -```bash -script/anvil --block 24433566 -``` - -Deploy the upgrade against the local fork: - -```bash -./script/run-script Deploy_StabilityPool_v2_mainnet --network mainnet --salt harbor_v1 --broadcast --local -``` - -### 3. Capture v2 state (same anvil instance, post-upgrade) - -```bash -START_TIMESTAMP=$START_TIMESTAMP VERSION=v2 forge test \ - --match-path script/test/MainnetForkUpgradeTest.t.sol \ - --fork-url local -vvv -``` - -Output: `tmp/v2/pre/*.json` and `tmp/v2/post/*.json` - -Stop anvil. - -### 4. Compare - -Using meld (recommended -- shows all pools side by side): - -```bash -# State before interactions -- should be identical -meld tmp/v1/pre tmp/v2/pre - -# Interactions + post-interaction state -- broken pools now work -meld tmp/v1/post tmp/v2/post -``` - -Using diff: - -```bash -diff -ru tmp/v1/pre tmp/v2/pre -diff -ru tmp/v1/post tmp/v2/post -``` - -Single pool with sorted keys: - -```bash -jq --sort-keys . tmp/v1/pre/BTC_fxUSD_col.json > /tmp/v1.json -jq --sort-keys . tmp/v2/pre/BTC_fxUSD_col.json > /tmp/v2.json -diff --color /tmp/v1.json /tmp/v2.json -``` - -## Environment variables - -| Variable | Default | Description | -| ----------------- | -------------- | ---------------------------------------------------------- | -| `VERSION` | `v1` | Labels the output directories (`v1` or `v2`) | -| `POOL_FILTER` | (none) | Substring filter on pool labels -- only matching pools run | -| `START_TIMESTAMP` | (current) | Normalize to this timestamp (use same value for v1/v2) | - -`START_TIMESTAMP` eliminates diffs caused by the v2 deployment adding blocks -(and therefore advancing `block.timestamp`) on anvil. The test rolls one block -forward then warps to the target timestamp. Pick a value above the fork block's -timestamp and pass the same one to both runs: - -```bash -BLOCK=24433566 -TS=$(cast block --rpc-url mainnet $BLOCK -f timestamp) -START_TIMESTAMP=$((TS + 12000)) VERSION=v1 forge test ... -``` - -`POOL_FILTER` examples: - -```bash -# Only BTC pools -POOL_FILTER=BTC VERSION=v1 forge test ... - -# Only collateral pools -POOL_FILTER=_col VERSION=v1 forge test ... - -# Single pool -POOL_FILTER=GOLD_fxUSD_lev VERSION=v1 forge test ... -``` - -Pool labels: `BTC_fxUSD_col`, `BTC_fxUSD_lev`, `BTC_stETH_col`, `BTC_stETH_lev`, -`ETH_fxUSD_col`, `ETH_fxUSD_lev`, `EUR_fxUSD_col`, `EUR_fxUSD_lev`, -`EUR_stETH_col`, `EUR_stETH_lev`, `GOLD_fxUSD_col`, `GOLD_fxUSD_lev`, -`GOLD_stETH_col`, `GOLD_stETH_lev`, `MCAP_fxUSD_col`, `MCAP_fxUSD_lev`, -`MCAP_stETH_col`, `MCAP_stETH_lev`, `SILVER_fxUSD_col`, `SILVER_fxUSD_lev`, -`SILVER_stETH_col`, `SILVER_stETH_lev` - -## Expected differences - -### Pre files (`v1/pre/*.json` vs `v2/pre/*.json`) - -| Key | v1 | v2 | Meaning | -| --------------------------------- | --------- | -------- | ------------------------------------------------ | -| `version` | `"v1"` | `"v2"` | Test metadata | -| `state_reward_*_pendingRewards_ok` | `"false"` | `"true"` | pendingRewards no longer reverts on broken pools | - -Everything else should be **identical** -- proves the upgrade preserves all state. - -### Post files (`v1/post/*.json` vs `v2/post/*.json`) - -| Key | v1 | v2 | Meaning | -| ------------------------------------- | --------- | -------- | ------------------------------------------------------------ | -| `version` | `"v1"` | `"v2"` | Test metadata | -| `interact_deposit_success` | `"false"` | `"true"` | Broken pools now accept deposits | -| `interact_depositReward_success` | `"false"` | `"true"` | Broken pools now accept rewards | -| `interact_withdraw_success` | `"false"` | `"true"` | Broken pools now allow withdrawals | -| post-interaction state | differs | differs | State for newly-fixed pools reflects successful interactions | - -## Adding depositors - -The test currently has one hardcoded depositor. To discover more: - -```bash -TOPIC0=$(cast sig-event "Deposit(address indexed,address indexed,uint256)") -POOL=0x9e56F1E1E80EBf165A1dAa99F9787B41cD5bFE40 -cast logs --rpc-url mainnet --address $POOL \ - --from-block 0 --to-block 24404265 $TOPIC0 \ - | jq -r '.[].topics[1]' | sort -u -``` - -Add discovered addresses to the `poolDepositors` mapping in `setUp()`. From 5a180c5f4ce65cb84f2e33c3a21cf62b8d77acee Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Wed, 27 May 2026 17:24:32 +0100 Subject: [PATCH 090/232] formatting --- src/interfaces/IMinter_v3.sol | 7 +------ test/deployment/MinterPeggedIncentives.t.sol | 4 ++-- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/interfaces/IMinter_v3.sol b/src/interfaces/IMinter_v3.sol index f7b8450a..d159c3d8 100644 --- a/src/interfaces/IMinter_v3.sol +++ b/src/interfaces/IMinter_v3.sol @@ -62,10 +62,5 @@ interface IMinter_v3 { ) external view - returns ( - uint256 mintFee, - uint256 peggedNotMinted, - uint256 mintMaxFeeRatio, - uint256 redeemPeggedUncappedBonus - ); + returns (uint256 mintFee, uint256 peggedNotMinted, uint256 mintMaxFeeRatio, uint256 redeemPeggedUncappedBonus); } diff --git a/test/deployment/MinterPeggedIncentives.t.sol b/test/deployment/MinterPeggedIncentives.t.sol index f2720ca2..d249862c 100644 --- a/test/deployment/MinterPeggedIncentives.t.sol +++ b/test/deployment/MinterPeggedIncentives.t.sol @@ -52,8 +52,8 @@ contract MinterPeggedIncentivesTest is MinterCappedMintSetUp { // mintMaxFeeRatio is independent of input — it scans all configured bands. _bootstrapCollateralRatio(); - (uint256 mintFee, uint256 peggedNotMinted, uint256 mintMaxFeeRatio, uint256 redeemBonus) = - IMinter_v3(minter).peggedIncentivesByPegged(0); + (uint256 mintFee, uint256 peggedNotMinted, uint256 mintMaxFeeRatio, uint256 redeemBonus) = IMinter_v3(minter) + .peggedIncentivesByPegged(0); assertEq(mintFee, 0, "mintFee zero at zero input"); assertEq(peggedNotMinted, 0, "peggedNotMinted zero at zero input"); From 95182e605db6d82d2ca4ebe1652d6dadff82f7c3 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Fri, 29 May 2026 18:51:21 +0100 Subject: [PATCH 091/232] * removed the on-the-fly migration of the user reward data in favour of an already fully migrated assumption * removed a load of excess claiming code - we only need 2 claim types: all (UI) or selected (autocompounder) * size optimisation particularly in Minter, with some gas improvements --- regression/coverage.txt | 10 +- regression/gas.txt | 155 ++++++------ regression/sizes.txt | 2 +- .../run-upgrade-test-StabilityPool_v2 | 136 ---------- .../IMultipleRewardAccumulator_v3.sol | 55 ++++ src/minter/Minter_v3.sol | 8 +- src/minter/StabilityPool_v3.sol | 8 +- ...ultipleRewardCompoundingAccumulator_v3.sol | 237 ++++++------------ test/GraphReward.t.sol | 2 +- test/GraphsLiquidate.t.sol | 2 +- test/StabilityPoolClaimable.t.sol | 8 +- test/StabilityPoolExtras2.t.sol | 2 +- test/StabilityPoolLoss.t.sol | 2 +- test/StabilityPoolManager_v1.t.sol | 2 +- test/StabilityPoolSpec.t.sol | 4 +- test/StabilityPoolUpgradeMigration.t.sol | 24 +- test/StabilityPool_v3_ERC20.t.sol | 2 +- test/deployment/RebalanceFairness.t.sol | 2 +- test/deployment/RebalanceFairnessScan.t.sol | 6 +- test/deployment/RewardSystem.t.sol | 32 +-- ...ckMultipleRewardCompoundingAccumulator.sol | 2 +- ...ultipleRewardCompoundingAccumulator_v2.sol | 8 +- ...ultipleRewardCompoundingAccumulator_v3.sol | 10 +- .../reward/accumulator/ClaimEquivalence.t.sol | 14 +- ...MultipleRewardCompoundingAccumulator.t.sol | 39 +-- 25 files changed, 318 insertions(+), 454 deletions(-) delete mode 100755 script/verify/sp-v2-data-prep-for-v3/run-upgrade-test-StabilityPool_v2 create mode 100644 src/interfaces/IMultipleRewardAccumulator_v3.sol diff --git a/regression/coverage.txt b/regression/coverage.txt index f5fff6c2..e2579e2e 100644 --- a/regression/coverage.txt +++ b/regression/coverage.txt @@ -31,7 +31,7 @@ | script/src/Deploy_MCAP_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | | script/src/Deploy_SILVER_Minter.sol | X 0% (0/5) | X 0% (0/4) | ✓ 100% (0/0) | X 0% (0/1) | | script/src/HarborDeployer.sol | X 56% (9/16) | X 73% (8/11) | ✓ 100% (0/0) | X 20% (1/5) | -| script/src/MinterDeployer.sol | X 83% (70/84) | X 82% (81/99) | X 25% (1/4) | X 80% (8/10) | +| script/src/MinterDeployer.sol | X 84% (72/86) | X 82% (83/101) | X 25% (1/4) | X 82% (9/11) | | script/src/contracts/Genesis.sol | X 77% (10/13) | X 73% (11/15) | ✓ 100% (0/0) | X 67% (2/3) | | script/src/contracts/LeveragedToken.sol | ✓ 100% (18/18) | ✓ 100% (26/26) | ✓ 100% (0/0) | ✓ 100% (2/2) | | script/src/contracts/Minter.sol | X 62% (32/52) | X 63% (38/60) | ✓ 100% (0/0) | X 60% (6/10) | @@ -42,7 +42,7 @@ | src/minter/Genesis_v1.sol | X 99% (88/89) | ✓ 100% (88/88) | ✓ 100% (14/14) | X 92% (11/12) | | src/minter/Minter_v1.sol | X 0% (0/601) | X 0% (0/649) | X 0% (0/102) | X 0% (0/68) | | src/minter/Minter_v2.sol | X 0% (0/601) | X 0% (0/649) | X 0% (0/102) | X 0% (0/68) | -| src/minter/Minter_v3.sol | X 99% (609/617) | X 99% (662/669) | X 93% (98/105) | X 99% (70/71) | +| src/minter/Minter_v3.sol | X 99% (620/628) | X 99% (679/686) | X 93% (99/106) | X 99% (69/70) | | src/minter/ReservePool_v1.sol | X 94% (15/16) | ✓ 100% (15/15) | ✓ 100% (3/3) | X 80% (4/5) | | src/minter/StabilityPoolManager_v1.sol | X 0% (0/166) | X 0% (0/177) | X 0% (0/21) | X 0% (0/24) | | src/minter/StabilityPoolManager_v2.sol | X 92% (175/191) | X 91% (188/206) | X 67% (20/30) | X 93% (25/27) | @@ -56,8 +56,8 @@ | src/price/PriceOracle_v1.sol | X 97% (31/32) | X 97% (36/37) | X 85% (11/13) | ✓ 100% (3/3) | | src/price/StakedETHWrappedPriceOracle_v1.sol | X 0% (0/29) | X 0% (0/27) | X 0% (0/5) | X 0% (0/4) | | src/reward/accumulator/MultipleRewardCompoundingAccumulator.sol | X 0% (0/136) | X 0% (0/171) | X 0% (0/16) | X 0% (0/21) | -| src/reward/accumulator/MultipleRewardCompoundingAccumulator_v2.sol | X 73% (107/147) | X 74% (137/184) | X 61% (11/18) | X 68% (15/22) | -| src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol | X 98% (148/151) | X 98% (191/194) | X 82% (14/17) | ✓ 100% (22/22) | +| src/reward/accumulator/MultipleRewardCompoundingAccumulator_v2.sol | X 73% (108/147) | X 76% (139/184) | X 61% (11/18) | X 68% (15/22) | +| src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol | X 97% (88/91) | X 97% (112/115) | X 75% (9/12) | ✓ 100% (12/12) | | src/reward/distributor/LinearMultipleRewardDistributor.sol | X 0% (0/77) | X 0% (0/86) | X 0% (0/12) | X 0% (0/14) | | src/reward/distributor/LinearMultipleRewardDistributor_v2.sol | X 58% (45/77) | X 63% (54/86) | X 25% (3/12) | X 50% (7/14) | | src/reward/distributor/LinearMultipleRewardDistributor_v3.sol | ✓ 100% (77/77) | ✓ 100% (86/86) | ✓ 100% (12/12) | ✓ 100% (14/14) | @@ -65,4 +65,4 @@ | src/util/ERC20MetadataLib_v1.sol | ✓ 100% (24/24) | ✓ 100% (22/22) | ✓ 100% (4/4) | ✓ 100% (4/4) | | src/util/FmtLib.sol | X 79% (15/19) | X 73% (19/26) | X 50% (2/4) | ✓ 100% (1/1) | | src/util/WordCodec.sol | ✓ 100% (15/15) | ✓ 100% (14/14) | ✓ 100% (0/0) | ✓ 100% (4/4) | -| Total | X 55% (4466/8067) | X 54% (4732/8729) | X 43% (392/922) | X 55% (647/1166) | +| Total | X 55% (4426/8096) | X 53% (4682/8756) | X 42% (388/928) | X 55% (637/1162) | diff --git a/regression/gas.txt b/regression/gas.txt index b2a4dace..64dcff4d 100644 --- a/regression/gas.txt +++ b/regression/gas.txt @@ -9,7 +9,7 @@ src/minter/Genesis_v1.sol:Genesis_v1 | claim | 8.751e+04 | | claimable | 1.161e+04 | | deposit | 6.747e+04 | -| endGenesis | 3.490e+05 | +| endGenesis | 3.488e+05 | | genesisIsEnded | 2.349e+03 | | initialize | 7.146e+04 | | owner | 2.401e+03 | @@ -19,54 +19,55 @@ src/minter/Genesis_v1.sol:Genesis_v1 src/minter/Minter_v3.sol:Minter_v3 | function name | max | |--------------------------------------------------|-----------| -| HARVESTER_ROLE | 2.830e+02 | -| LEVERAGED_TOKEN | 2.830e+02 | -| PEGGED_TOKEN | 3.050e+02 | -| WRAPPED_COLLATERAL_TOKEN | 2.830e+02 | -| ZERO_FEE_ROLE | 2.850e+02 | -| collateralRatio | 1.912e+04 | +| HARVESTER_ROLE | 2.610e+02 | +| LEVERAGED_TOKEN | 3.490e+02 | +| PEGGED_TOKEN | 2.830e+02 | +| WRAPPED_COLLATERAL_TOKEN | 3.280e+02 | +| ZERO_FEE_ROLE | 2.630e+02 | +| collateralRatio | 1.904e+04 | | collateralTokenBalance | 2.358e+03 | -| config | 4.895e+04 | +| config | 4.901e+04 | | feeReceiver | 2.442e+03 | -| freeMintLeveragedToken | 1.438e+05 | -| freeMintPeggedToken | 1.674e+05 | -| freeRedeemLeveragedToken | 8.668e+04 | -| freeRedeemPeggedToken | 1.346e+05 | -| grantRoles | 2.633e+04 | -| harvestable | 2.981e+04 | -| hasAllRoles | 2.637e+03 | -| hasAnyRole | 2.636e+03 | +| freeMintLeveragedToken | 1.437e+05 | +| freeMintPeggedToken | 1.673e+05 | +| freeRedeemLeveragedToken | 8.656e+04 | +| freeRedeemPeggedToken | 1.344e+05 | +| grantRoles | 2.640e+04 | +| harvestable | 2.970e+04 | +| hasAllRoles | 2.615e+03 | +| hasAnyRole | 2.614e+03 | | initialize | 1.844e+05 | -| leverageRatio | 1.957e+04 | -| leveragedTokenBalance | 1.042e+04 | -| leveragedTokenPrice | 3.000e+04 | -| mintLeveragedToken | 1.494e+05 | -| mintLeveragedTokenDryRun | 7.338e+04 | -| mintLeveragedTokenIncentiveRatio | 3.108e+04 | -| mintPeggedToken(uint256,address,uint256) | 1.911e+05 | -| mintPeggedToken(uint256,address,uint256,uint256) | 1.251e+05 | -| mintPeggedTokenDryRun(uint256) | 6.401e+04 | -| mintPeggedTokenDryRun(uint256,uint256) | 3.644e+04 | -| mintPeggedTokenIncentiveRatio | 3.012e+04 | -| owner | 2.402e+03 | +| leverageRatio | 1.948e+04 | +| leveragedTokenBalance | 1.040e+04 | +| leveragedTokenPrice | 2.988e+04 | +| mintLeveragedToken | 1.495e+05 | +| mintLeveragedTokenDryRun | 7.326e+04 | +| mintLeveragedTokenIncentiveRatio | 3.098e+04 | +| mintPeggedToken(uint256,address,uint256) | 1.910e+05 | +| mintPeggedToken(uint256,address,uint256,uint256) | 1.250e+05 | +| mintPeggedTokenDryRun(uint256) | 6.412e+04 | +| mintPeggedTokenDryRun(uint256,uint256) | 3.630e+04 | +| mintPeggedTokenIncentiveRatio | 3.002e+04 | +| owner | 2.380e+03 | +| peggedIncentivesByPegged | 5.772e+04 | | peggedTokenBalance | 2.409e+03 | -| peggedTokenPrice | 1.923e+04 | -| priceOracle | 2.427e+03 | +| peggedTokenPrice | 1.913e+04 | +| priceOracle | 2.472e+03 | | redeemLeveragedToken | 1.276e+05 | -| redeemLeveragedTokenDryRun | 6.317e+04 | -| redeemLeveragedTokenIncentiveRatio | 2.924e+04 | -| redeemPeggedForCollateralRatio | 1.961e+04 | -| redeemPeggedToken | 1.323e+05 | -| redeemPeggedTokenDryRun | 6.170e+04 | -| redeemPeggedTokenIncentiveRatio | 3.011e+04 | +| redeemLeveragedTokenDryRun | 6.304e+04 | +| redeemLeveragedTokenIncentiveRatio | 2.912e+04 | +| redeemPeggedForCollateralRatio | 1.946e+04 | +| redeemPeggedToken | 1.321e+05 | +| redeemPeggedTokenDryRun | 6.152e+04 | +| redeemPeggedTokenIncentiveRatio | 3.001e+04 | | reservePool | 2.411e+03 | -| reset | 2.869e+04 | +| reset | 2.859e+04 | | supportsInterface | 9.430e+02 | | sweep | 4.031e+04 | | transferOwnership | 1.207e+04 | | updateConfig | 2.954e+05 | | updateFeeReceiver | 2.635e+04 | -| updatePriceOracle | 2.636e+04 | +| updatePriceOracle | 2.634e+04 | | updateReservePool | 2.631e+04 | src/minter/ReservePool_v1.sol:ReservePool_v1 @@ -86,17 +87,17 @@ src/minter/StabilityPoolManager_v2.sol:StabilityPoolManager_v2 | function name | max | |----------------------------|-----------| | feeReceiver | 2.441e+03 | -| harvest | 4.487e+05 | +| harvest | 4.486e+05 | | harvestBountyRatio | 2.347e+03 | | harvestCutRatio | 2.371e+03 | -| harvestable | 3.052e+04 | +| harvestable | 3.041e+04 | | hasStabilityPool | 5.810e+02 | | initialize | 7.069e+04 | | owner | 2.423e+03 | -| rebalance | 5.585e+05 | +| rebalance | 5.581e+05 | | rebalanceBountyRatio | 2.354e+03 | | rebalanceThreshold | 2.370e+03 | -| rebalanceable | 2.933e+04 | +| rebalanceable | 2.926e+04 | | stabilityPools | 9.030e+02 | | supportsInterface | 5.690e+02 | | transferOwnership | 1.204e+04 | @@ -111,44 +112,44 @@ src/minter/StabilityPool_v3.sol:StabilityPool_v3 | function name | max | |------------------------|-----------| | ASSET_TOKEN | 2.820e+02 | -| DOMAIN_SEPARATOR | 6.190e+02 | -| LIQUIDATION_TOKEN | 3.500e+02 | -| REBALANCER_ROLE | 3.060e+02 | -| REWARD_DEPOSITOR_ROLE | 3.060e+02 | -| REWARD_MANAGER_ROLE | 3.270e+02 | -| allowance | 2.721e+03 | -| approve | 2.442e+04 | -| assetBalanceOf | 8.053e+03 | -| balanceOf | 5.789e+03 | -| checkpoint | 1.465e+05 | -| claim(address) | 2.636e+05 | -| claim(address,address) | 1.535e+05 | -| claimable | 2.488e+04 | -| claimed | 7.472e+03 | -| decimals | 2.890e+02 | -| deposit | 2.848e+05 | -| depositReward | 6.726e+04 | -| getWithdrawalRequest | 2.767e+03 | -| grantRoles | 2.638e+04 | -| historicalRewardTokens | 5.202e+03 | +| DOMAIN_SEPARATOR | 6.860e+02 | +| LIQUIDATION_TOKEN | 2.840e+02 | +| REBALANCER_ROLE | 2.840e+02 | +| REWARD_DEPOSITOR_ROLE | 2.620e+02 | +| REWARD_MANAGER_ROLE | 2.830e+02 | +| allowance | 2.699e+03 | +| approve | 2.444e+04 | +| assetBalanceOf | 8.049e+03 | +| balanceOf | 5.827e+03 | +| checkpoint | 1.451e+05 | +| claim | 2.594e+05 | +| claimTokens | 8.425e+04 | +| claimable | 2.173e+04 | +| claimed | 2.892e+03 | +| decimals | 2.670e+02 | +| deposit | 2.749e+05 | +| depositReward | 6.730e+04 | +| getWithdrawalRequest | 2.790e+03 | +| grantRoles | 2.640e+04 | +| historicalRewardTokens | 5.180e+03 | | initialize | 2.042e+05 | -| name | 5.720e+02 | -| nonces | 2.654e+03 | -| notifyLiquidation | 1.235e+05 | -| owner | 2.446e+03 | +| name | 5.050e+02 | +| nonces | 2.610e+03 | +| notifyLiquidation | 1.234e+05 | +| owner | 2.402e+03 | | permit | 5.063e+04 | -| proxiableUUID | 3.860e+02 | -| registerRewardToken | 8.850e+04 | -| requestWithdrawal | 2.503e+04 | -| sweep | 4.016e+04 | -| symbol | 5.770e+02 | -| totalAssetSupply | 2.423e+03 | -| totalSupply | 2.424e+03 | -| transfer | 1.880e+05 | -| transferFrom | 1.313e+05 | +| proxiableUUID | 3.410e+02 | +| registerRewardToken | 8.854e+04 | +| requestWithdrawal | 2.501e+04 | +| sweep | 4.023e+04 | +| symbol | 6.210e+02 | +| totalAssetSupply | 2.467e+03 | +| totalSupply | 2.446e+03 | +| transfer | 1.823e+05 | +| transferFrom | 1.256e+05 | | transferOwnership | 1.207e+04 | -| unregisterRewardToken | 9.144e+04 | -| withdraw | 2.586e+05 | +| unregisterRewardToken | 9.142e+04 | +| withdraw | 2.571e+05 | src/minter/TokenDistributor_v1.sol:TokenDistributor_v1 | function name | max | diff --git a/regression/sizes.txt b/regression/sizes.txt index f4aa12a2..c6e0d416 100644 --- a/regression/sizes.txt +++ b/regression/sizes.txt @@ -48,7 +48,7 @@ | StabilityPoolManager_v2 | 12,467 | 12,109 | 14,343 | 2,636,830 | 263.68 | | StabilityPool_v1 | 21,092 | 3,484 | 23,085 | 4,449,250 | 444.93 | | StabilityPool_v2 | 20,711 | 3,865 | 22,579 | 4,367,990 | 436.80 | -| StabilityPool_v3 | 23,412 | 1,164 | 25,791 | 4,940,310 | 494.03 | +| StabilityPool_v3 | 22,146 | 2,430 | 24,522 | 4,674,420 | 467.44 | | StakedETHWrappedPriceOracle_v1 | 3,695 | 20,881 | 4,653 | 785,530 | 78.55 | | TokenDistributor_v1 | 10,116 | 14,460 | 10,365 | 2,126,850 | 212.69 | | WordCodec | 85 | 24,491 | 135 | 18,350 | 1.84 | diff --git a/script/verify/sp-v2-data-prep-for-v3/run-upgrade-test-StabilityPool_v2 b/script/verify/sp-v2-data-prep-for-v3/run-upgrade-test-StabilityPool_v2 deleted file mode 100755 index 418976d6..00000000 --- a/script/verify/sp-v2-data-prep-for-v3/run-upgrade-test-StabilityPool_v2 +++ /dev/null @@ -1,136 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -CWD=$(pwd) - -echo "Running from $CWD" -echo "Script is in $SCRIPT_DIR" -echo "files generated in $CWD/tmp" - -# ── Configuration ────────────────────────────────────────────────────────────── - -BLOCK=25186514 # just before the actual deploy and upgrade -POOL_FILTER="${POOL_FILTER:-}" -FORGE_VERBOSITY="${FORGE_VERBOSITY:--vvv}" - -while [[ $# -gt 0 ]]; do - case "$1" in - --include) - POOL_FILTER="$2" - shift 2 - ;; - -h | --help) - echo "Usage: $(basename "$0") [--include ]" - echo "" - echo "Options:" - echo " --include Substring filter on pool labels (e.g., BTC, _col, GOLD_fxUSD_lev)" - echo "" - echo "Environment variables:" - echo " POOL_FILTER Same as --include (--include takes precedence)" - echo " FORGE_VERBOSITY Forge verbosity flag (default: -vvv)" - exit 0 - ;; - *) - echo "Unknown option: $1" >&2 - echo "Usage: $(basename "$0") [--include ]" >&2 - exit 1 - ;; - esac -done - -# ── Helpers ──────────────────────────────────────────────────────────────────── - -prompt() { - local msg="$1" - echo "" - echo "──────────────────────────────────────────────────────────────" - echo "$msg" - echo "──────────────────────────────────────────────────────────────" - read -rn1 -p "Ready? [Y/n] " response - echo "" - if [[ "$response" =~ ^[Nn]$ ]]; then - echo "Aborted." - exit 1 - fi -} - -run_capture() { - local version="$1" - local env_args=( - START_TIMESTAMP="$START_TIMESTAMP" - VERSION="$version" - ) - if [[ -n "$POOL_FILTER" ]]; then - env_args+=(POOL_FILTER="$POOL_FILTER") - fi - - echo "" - echo "Running $version capture..." - env "${env_args[@]}" forge test \ - --match-path script/test/MainnetForkUpgradeTest.t.sol \ - --fork-url local $FORGE_VERBOSITY - echo "" - echo "$version capture complete. Files in tmp/$version/" -} - -# ── Main ─────────────────────────────────────────────────────────────────────── - -echo "======================================================================" -echo " StabilityPool v2 storage state change Verification" -echo "======================================================================" -echo "" -echo "Fork block: $BLOCK" -if [[ -n "$POOL_FILTER" ]]; then - echo "Pool filter: $POOL_FILTER" -fi - -# Compute START_TIMESTAMP from the fork block -echo "" -echo "Computing START_TIMESTAMP from block $BLOCK..." -TS=$(cast block --rpc-url mainnet "$BLOCK" -f timestamp) -START_TIMESTAMP=$((TS + 12000)) -echo "Fork block timestamp: $TS" -echo "START_TIMESTAMP: $START_TIMESTAMP (+12000s buffer)" - -# ── Step 1: v1 capture ──────────────────────────────────────────────────────── - -prompt "Start anvil fork: script/anvil --block $BLOCK" - -run_capture v1 - -# ── Step 2: Deploy upgrade ──────────────────────────────────────────────────── - -prompt "Cycle anvil (Ctrl+C, then: script/anvil --block $BLOCK)" - -echo "" -echo "NOT Deploying StabilityPool v2 upgrade..." -# ./script/run-script Deploy_StabilityPool_v2_mainnet --network mainnet --salt harbor_v1 --broadcast --local - -# ── Step 3: v2 capture (same anvil instance) ────────────────────────────────── - -run_capture v2 - -# ── Step 4: Compare ────────────────────────────────────────────────────────── - -echo "" -echo "======================================================================" -echo " Comparison" -echo "======================================================================" -echo "" - -if command -v meld >/dev/null 2>&1; then - echo "Opening meld (v1 vs v2)..." - meld tmp/v1 tmp/v2 -else - echo "meld not found -- using diff:" - echo "" - echo "--- Pre-interaction (should be identical) ---" - diff -ru tmp/v1/pre tmp/v2/pre || true - echo "" - echo "--- Post-interaction (broken pools now work) ---" - diff -ru tmp/v1/post tmp/v2/post || true -fi - -echo "" -echo "Done. See script/test/upgrade-StabilityPool_v2.md for expected differences." diff --git a/src/interfaces/IMultipleRewardAccumulator_v3.sol b/src/interfaces/IMultipleRewardAccumulator_v3.sol new file mode 100644 index 00000000..d5069c63 --- /dev/null +++ b/src/interfaces/IMultipleRewardAccumulator_v3.sol @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MIT + +pragma solidity >=0.8.28 <0.9.0; + +// solhint-disable-next-line contract-name-capwords +interface IMultipleRewardAccumulator_v3 { + /********** + * Events * + **********/ + + /// @notice Emitted when user claim pending rewards. + /// @param account The address of user. + /// @param token The address of token claimed. + /// @param receiver The address of token receiver. + /// @param amount The amount of token claimed. + event Claim(address indexed account, address indexed token, address indexed receiver, uint256 amount); + + /********** + * Errors * + **********/ + + /// @dev Thrown when caller claim others reward to another user. + error ClaimOthersRewardToAnother(); + + /************************* + * Public View Functions * + *************************/ + + /// @notice Get the amount of pending rewards. + /// @param account The address of user to query. + /// @param token The address of reward token to query. + /// @return amount The amount of pending rewards. + function claimable(address account, address token) external view returns (uint256 amount); + + /// @notice Get the total amount of rewards claimed from this contract. + /// @param account The address of user to query. + /// @param token The address of reward token to query. + /// @return amount The amount of claimed rewards. + function claimed(address account, address token) external view returns (uint256 amount); + + /**************************** + * Public Mutator Functions * + ****************************/ + + /// @notice Update the global and user snapshot. + /// @param account The address of user to update. + function checkpoint(address account) external; + + /// @notice Claim pending rewards of all active tokens for the caller. + function claim() external; + + /// @notice Claim pending rewards of specifice (may be historical) reward tokens for the caller. + /// @param tokens The address list of historical reward tokens to claim. + function claimTokens(address[] memory tokens, uint256 maxAmount) external; +} diff --git a/src/minter/Minter_v3.sol b/src/minter/Minter_v3.sol index 1eef478c..76939f67 100644 --- a/src/minter/Minter_v3.sol +++ b/src/minter/Minter_v3.sol @@ -30,7 +30,7 @@ import {IReservePool} from "@harbor/interfaces/IReservePool.sol"; import {ConfigIncentiveLib} from "@harbor/minter/library/ConfigIncentiveLib.sol"; import {Config_v2} from "@harbor/minter/library/Config_v2.sol"; -/// @title Bao Minter +/// @title Harbor Minter /// @author rootminus0x1 based on (albeit significantly modified) Aladdin's FX system /// @notice Provides a gas-efficient, feature-rich implementation for the `IMinter` interface. /// Functions are provided for users to mint (for wrapped collateral) and redeem (for wrapped collateral) pegged and leveraged tokens @@ -579,8 +579,8 @@ contract Minter_v3 is // All mint-pegged rates are in [0, 1) enforced by config validation. // solhint-disable-next-line explicit-types uint bandCount = ConfigIncentiveLib._collateralRatioBandCount(config_); - // solhint-disable-next-line explicit-types // mintMaxFeeRatio = 0; not needed due to default value being 0 + // solhint-disable-next-line explicit-types for (uint i = 0; i < bandCount; i++) { int256 bandRatio = ConfigIncentiveLib._incentiveRatio(config_, i); if (bandRatio == 1 ether) { @@ -1371,7 +1371,7 @@ contract Minter_v3 is // (note we treat the disallow band as any other here, except that it is the terminal band) MintPeggedWorkspace memory w; w.band = _findBand(config_, cr.underlyingCollateral, cr.price, cr.peggedTokenBalance, false); - uint256 peggedTokenPriceE36 = _peggedTokenPriceE36(cr.peggedTokenBalance, cr.underlyingCollateral, cr.price); + w.peggedTokenPriceE36 = _peggedTokenPriceE36(cr.peggedTokenBalance, cr.underlyingCollateral, cr.price); w.underlyingCollateralInLeftE36 = wrappedCollateralIn * cr.rate; // scaled to 1e36 w.underlyingCollateralHeldE36 = cr.underlyingCollateral * 1 ether; // scaled to 1e36 @@ -1433,7 +1433,7 @@ contract Minter_v3 is uint256 peggedMintedInBandE36 = Math.mulDiv( collateralAddedInBandE36, cr.price * 1 ether, - peggedTokenPriceE36 + w.peggedTokenPriceE36 ); w.mintedE36 += peggedMintedInBandE36; diff --git a/src/minter/StabilityPool_v3.sol b/src/minter/StabilityPool_v3.sol index e238032d..dbbc3e84 100644 --- a/src/minter/StabilityPool_v3.sol +++ b/src/minter/StabilityPool_v3.sol @@ -247,15 +247,17 @@ contract StabilityPool_v3 is } LIQUIDATION_TOKEN = liquidationToken_; + if (withdrawalEndWindow_ == 0 || withdrawalStartDelay_ == 0) { + revert InvalidWithdrawalWindow(withdrawalStartDelay_, withdrawalEndWindow_); + } + // set these two to the same thing, for public visibility // their purpose is the same thing - preventing a complete emptying of a non-empty pool MIN_TOTAL_ASSET_SUPPLY = minTotalAssetSupply; MIN_DEPOSIT = minTotalAssetSupply; // set immutable withdrawal window params - if (withdrawalStartDelay_ == 0 || withdrawalEndWindow_ == 0) { - revert InvalidWithdrawalWindow(withdrawalStartDelay_, withdrawalEndWindow_); - } + WITHDRAWAL_START_DELAY = uint64(withdrawalStartDelay_); WITHDRAWAL_END_WINDOW = uint64(withdrawalEndWindow_); } diff --git a/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol b/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol index 427b1ad2..befc7fae 100644 --- a/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol +++ b/src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol @@ -7,7 +7,7 @@ import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol import {ReentrancyGuardTransientUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardTransientUpgradeable.sol"; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; -import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; +import {IMultipleRewardAccumulator_v3} from "@harbor/interfaces/IMultipleRewardAccumulator_v3.sol"; import {DecrementalFloatingPoint} from "@harbor/math/DecrementalFloatingPoint.sol"; import {LinearMultipleRewardDistributor_v3} from "@harbor/reward/distributor/LinearMultipleRewardDistributor_v3.sol"; @@ -115,7 +115,7 @@ import {LinearMultipleRewardDistributor_v3} from "@harbor/reward/distributor/Lin abstract contract MultipleRewardCompoundingAccumulator_v3 is ReentrancyGuardTransientUpgradeable, LinearMultipleRewardDistributor_v3, - IMultipleRewardAccumulator + IMultipleRewardAccumulator_v3 { using SafeERC20 for IERC20; using DecrementalFloatingPoint for uint128; @@ -128,7 +128,7 @@ abstract contract MultipleRewardCompoundingAccumulator_v3 is uint256 internal constant _REWARD_PRECISION = 1e18; /// @dev Compiler will pack this into single `uint256`. - struct RewardSnapshot { + struct RewardSnapshotNOTUSED { // The timestamp when the snapshot is updated. uint64 timestamp; // The reward integral until now. @@ -144,11 +144,11 @@ abstract contract MultipleRewardCompoundingAccumulator_v3 is } /// @dev Compiler will pack this into two `uint256`. - struct UserRewardSnapshot { + struct UserRewardSnapshotNOTUSED { // The claim data for the user. ClaimData rewards; // The reward snapshot for user. - RewardSnapshot checkpoint; + RewardSnapshotNOTUSED checkpoint; } /// @dev V2: widened integral from uint192 to uint256. Occupies 3 slots. @@ -166,8 +166,8 @@ abstract contract MultipleRewardCompoundingAccumulator_v3 is *************/ struct MultipleRewardCompoundingAccumulatorStorage { - /// @inheritdoc IMultipleRewardAccumulator - mapping(address => address) rewardReceiver; + /// @inheritdoc IMultipleRewardAccumulator_v3 + mapping(address => address) rewardReceiverNOTUSED; /// @notice Mapping from reward token address to global reward snapshot. /// /// - The inner mapping records the `acc` at different `exponent` @@ -175,65 +175,12 @@ abstract contract MultipleRewardCompoundingAccumulator_v3 is /// /// @dev The integral is defined as 1e18 * ∫(rate(t) * prod(t) / totalPoolShare(t) dt). mapping(address => mapping(uint8 => uint256)) tokenToExponentToIntegral; - /// @notice V1: Mapping from user address to reward token address to user reward snapshot. - /// @dev Kept for migration fallback. New data is written to userRewardSnapshotV2. - mapping(address => mapping(address => UserRewardSnapshot)) userRewardSnapshot; + /// @notice Mapping from user address to reward token address to user reward snapshot. + /// @dev Not used (and renamed); kept to retain the storage space layout. + mapping(address => mapping(address => UserRewardSnapshotNOTUSED)) userRewardSnapshotNOTUSED; /// @notice V2: Mapping from user address to reward token address to user reward snapshot. /// @dev Uses widened uint256 integral. All new writes go here. - mapping(address => mapping(address => UserRewardSnapshotV2)) userRewardSnapshotV2; - } - - // slither-disable-next-line dead-code - function _tokenToExponentToIntegral(address token, uint8 exponent) internal view returns (uint256 globalIntegral) { - MultipleRewardCompoundingAccumulatorStorage storage $ = _getMultipleRewardCompoundingAccumulatorStorage(); - globalIntegral = $.tokenToExponentToIntegral[token][exponent]; - } - - /// @dev Returns the full user reward snapshot with V2-first migration detection. - /// Centralises all migration logic in one place so callers don't need to know about V1/V2. - /// Fast path (migrated, integral > 0): 3 SLOADs from V2. - /// Rare path (migrated, integral = 0): 3 SLOADs from V2. - /// Fallback (unmigrated): 2 SLOADs from V1. - function _getUserRewardSnapshot( - address account, - address token - ) internal view returns (uint64 timestamp, uint256 integral, uint128 pending, uint128 claimed_) { - MultipleRewardCompoundingAccumulatorStorage storage $ = _getMultipleRewardCompoundingAccumulatorStorage(); - - // Fast path: check V2 integral (1 SLOAD) - uint256 v2Integral = $.userRewardSnapshotV2[account][token].integral; - if (v2Integral != 0) { - UserRewardSnapshotV2 storage v2 = $.userRewardSnapshotV2[account][token]; - return (v2.timestamp, v2Integral, v2.rewards.pending, v2.rewards.claimed); - } - - // Rare path: V2 integral is 0 — check if V2 is populated via timestamp (2 SLOADs) - uint64 v2Timestamp = $.userRewardSnapshotV2[account][token].timestamp; - if (v2Timestamp != 0) { - UserRewardSnapshotV2 storage v2 = $.userRewardSnapshotV2[account][token]; - return (v2Timestamp, 0, v2.rewards.pending, v2.rewards.claimed); - } - - // Not migrated: fall back to V1 (2 SLOADs from different mapping) - UserRewardSnapshot storage v1 = $.userRewardSnapshot[account][token]; - return (v1.checkpoint.timestamp, uint256(v1.checkpoint.integral), v1.rewards.pending, v1.rewards.claimed); - } - - /// @dev Writes the user reward snapshot to V2 storage. Always writes to V2. - function _setUserRewardSnapshot( - address account, - address token, - uint64 timestamp, - uint256 integral, - uint128 pending, - uint128 claimed_ - ) internal { - MultipleRewardCompoundingAccumulatorStorage storage $ = _getMultipleRewardCompoundingAccumulatorStorage(); - UserRewardSnapshotV2 storage v2 = $.userRewardSnapshotV2[account][token]; - v2.rewards.pending = pending; - v2.rewards.claimed = claimed_; - v2.timestamp = timestamp; - v2.integral = integral; + mapping(address => mapping(address => UserRewardSnapshotV2)) userRewardSnapshot; } // chisel eval 'keccak256(abi.encode(uint256(keccak256("bao.storage.MultipleRewardCompoundingAccumulator")) - 1)) & ~bytes32(uint256(0xff))' @@ -241,7 +188,7 @@ abstract contract MultipleRewardCompoundingAccumulator_v3 is 0x47ddc56aaabfe9761e2e64ce86720771c5fd1fd7ef0605da74e07d71de0e7900; function _getMultipleRewardCompoundingAccumulatorStorage() - private + internal pure returns (MultipleRewardCompoundingAccumulatorStorage storage $) { @@ -277,26 +224,29 @@ abstract contract MultipleRewardCompoundingAccumulator_v3 is * Public View Functions * *************************/ + /* deprecated function rewardReceiver(address account) external view returns (address) { MultipleRewardCompoundingAccumulatorStorage storage $ = _getMultipleRewardCompoundingAccumulatorStorage(); return $.rewardReceiver[account]; } + */ - /// @inheritdoc IMultipleRewardAccumulator + /// @inheritdoc IMultipleRewardAccumulator_v3 function claimable(address account, address token) external view virtual override returns (uint256) { return _claimable(account, token, true); } - /// @inheritdoc IMultipleRewardAccumulator + /// @inheritdoc IMultipleRewardAccumulator_v3 function claimed(address account, address token) external view returns (uint256) { - (, , , uint128 claimedAmount) = _getUserRewardSnapshot(account, token); - return claimedAmount; + MultipleRewardCompoundingAccumulatorStorage storage $ = _getMultipleRewardCompoundingAccumulatorStorage(); + return $.userRewardSnapshot[account][token].rewards.claimed; } /**************************** * Public Mutator Functions * ****************************/ + /* deprecated /// @inheritdoc IMultipleRewardAccumulator function setRewardReceiver(address newReceiver) external { address caller = _msgSender(); @@ -306,8 +256,9 @@ abstract contract MultipleRewardCompoundingAccumulator_v3 is emit UpdateRewardReceiver(caller, oldReceiver, newReceiver); } + */ - /// @inheritdoc IMultipleRewardAccumulator + /// @inheritdoc IMultipleRewardAccumulator_v3 function checkpoint(address account) external virtual override nonReentrant { _checkpoint(account); } @@ -316,57 +267,45 @@ abstract contract MultipleRewardCompoundingAccumulator_v3 is // Claim // ═══════════════════════════════════════════════════════════════════════ - /// @inheritdoc IMultipleRewardAccumulator + /// @inheritdoc IMultipleRewardAccumulator_v3 function claim() external override nonReentrant { - address account = _msgSender(); - _checkpoint(account); - address receiver = _resolveReceiver(account, address(0)); - address[] memory tokens = activeRewardTokens(); - for (uint256 i = 0; i < tokens.length; i++) { - _claimSingle(account, tokens[i], receiver, type(uint256).max); - } + _claimAll(activeRewardTokens(), type(uint256).max); } + /* deprecated /// @inheritdoc IMultipleRewardAccumulator function claim(address account) external override nonReentrant { - _checkpoint(account); - address receiver = _resolveReceiver(account, address(0)); - address[] memory tokens = activeRewardTokens(); - for (uint256 i = 0; i < tokens.length; i++) { - _claimSingle(account, tokens[i], receiver, type(uint256).max); - } + _claimAll(account, activeRewardTokens(), address(0)); } + */ + /* deprecated /// @inheritdoc IMultipleRewardAccumulator function claim(address account, address receiver) public override nonReentrant { if (account != _msgSender() && receiver != address(0)) { revert ClaimOthersRewardToAnother(); } - _checkpoint(account); - receiver = _resolveReceiver(account, receiver); - address[] memory tokens = activeRewardTokens(); - for (uint256 i = 0; i < tokens.length; i++) { - _claimSingle(account, tokens[i], receiver, type(uint256).max); - } + _claimAll(account, activeRewardTokens(), receiver); } + */ + /* deprecated /// @inheritdoc IMultipleRewardAccumulator function claimHistorical(address[] memory tokens) external nonReentrant { - address account = _msgSender(); - _checkpoint(account); - address receiver = _resolveReceiver(account, address(0)); - for (uint256 i = 0; i < tokens.length; i++) { - _claimSingle(account, tokens[i], receiver, type(uint256).max); - } + _claimAll(_msgSender(), tokens, address(0)); } + */ + /* deprecated /// @inheritdoc IMultipleRewardAccumulator function claimHistorical(address account, address[] memory tokens) external nonReentrant { - _checkpoint(account); - address receiver = _resolveReceiver(account, address(0)); - for (uint256 i = 0; i < tokens.length; i++) { - _claimSingle(account, tokens[i], receiver, type(uint256).max); - } + _claimAll(account, tokens, address(0)); + } + */ + + /// @inheritdoc IMultipleRewardAccumulator_v3 + function claimTokens(address[] memory tokens, uint256 maxAmount) external nonReentrant { + _claimAll(tokens, maxAmount); } /********************** @@ -411,23 +350,11 @@ abstract contract MultipleRewardCompoundingAccumulator_v3 is address account, address token, bool includeTemporalPending - ) internal view virtual returns (uint256) { - (, uint256 userCheckpointIntegral, uint128 userPending, ) = _getUserRewardSnapshot(account, token); - return _claimableFrom(account, token, includeTemporalPending, userCheckpointIntegral, userPending); - } - - /// @dev Core claimable calculation that accepts pre-read snapshot data. - /// Avoids re-reading the user snapshot when the caller already has it. - function _claimableFrom( - address account, - address token, - bool includeTemporalPending, - uint256 userCheckpointIntegral, - uint128 userPending ) internal view virtual returns (uint256 claimable_) { MultipleRewardCompoundingAccumulatorStorage storage $ = _getMultipleRewardCompoundingAccumulatorStorage(); + UserRewardSnapshotV2 storage snapshot = $.userRewardSnapshot[account][token]; - claimable_ = uint256(userPending); + claimable_ = uint256(snapshot.rewards.pending); (uint128 userProd, uint256 shares) = _getUserPoolShare(account); if (shares > 0) { @@ -447,6 +374,7 @@ abstract contract MultipleRewardCompoundingAccumulator_v3 is integral += DecrementalFloatingPoint._divByScaleFactor(integralAtScale, i); } } + uint256 userCheckpointIntegral = snapshot.integral; if (integral > userCheckpointIntegral) { claimable_ += Math.mulDiv( shares, @@ -482,66 +410,51 @@ abstract contract MultipleRewardCompoundingAccumulator_v3 is return; } + MultipleRewardCompoundingAccumulatorStorage storage $ = _getMultipleRewardCompoundingAccumulatorStorage(); (uint128 currentProd, ) = _getTotalPoolShare(); uint8 exponent = currentProd.exponent(); for (uint256 i = 0; i < totalLength; i++) { address token = (i < activeLength) ? activeTokens[i] : historicalTokens[i - activeLength]; - (, uint256 snapIntegral, uint128 snapPending, uint128 snapClaimed) = _getUserRewardSnapshot( - account, - token - ); - uint128 newPending = uint128(_claimableFrom(account, token, false, snapIntegral, snapPending)); - _setUserRewardSnapshot( - account, - token, - uint64(block.timestamp), - _tokenToExponentToIntegral(token, exponent), - newPending, - snapClaimed - ); + UserRewardSnapshotV2 storage snapshot = $.userRewardSnapshot[account][token]; + snapshot.rewards.pending = uint128(_claimable(account, token, false)); + snapshot.integral = $.tokenToExponentToIntegral[token][exponent]; + snapshot.timestamp = uint64(block.timestamp); } } } - /// @dev Resolve the receiver address: use stored receiver if set, otherwise account. - function _resolveReceiver(address account, address receiver) internal view returns (address) { - if (receiver == address(0)) { - MultipleRewardCompoundingAccumulatorStorage storage $ = _getMultipleRewardCompoundingAccumulatorStorage(); - receiver = $.rewardReceiver[account]; - if (receiver == address(0)) { - receiver = account; - } - } - return receiver; - } - /// @dev Internal function to claim up to maxAmount of a single reward token. /// Caller should make sure `_checkpoint` is called before this function. /// - /// @param account The address of user to claim. - /// @param token The address of reward token. - /// @param receiver The address of recipient of the reward token. - /// @param maxAmount The maximum amount to claim. Use type(uint256).max for all. - function _claimSingle( - address account, - address token, - address receiver, - uint256 maxAmount - ) internal virtual returns (uint256) { - (uint64 ts, uint256 integral, uint128 pending, uint128 claimed_) = _getUserRewardSnapshot(account, token); - uint256 amount = pending; - if (amount > maxAmount) { - amount = maxAmount; - } - if (amount > 0) { - _setUserRewardSnapshot(account, token, ts, integral, pending - uint128(amount), claimed_ + uint128(amount)); + /// @param tokens The list of reward token addresses. + /// @param maxAmount The maximum amount to be claimed. + function _claimAll(address[] memory tokens, uint256 maxAmount) internal virtual { + address account = _msgSender(); + address receiver = account; + + MultipleRewardCompoundingAccumulatorStorage storage $ = _getMultipleRewardCompoundingAccumulatorStorage(); + + _checkpoint(account); + + for (uint256 i = 0; i < tokens.length; i++) { + address token = tokens[i]; + UserRewardSnapshotV2 memory snapshot = $.userRewardSnapshot[account][token]; + + uint256 amount = snapshot.rewards.pending; + if (amount > maxAmount) { + amount = maxAmount; + } + if (amount > 0) { + emit Claim(account, token, receiver, amount); - IERC20(token).safeTransfer(receiver, amount); + IERC20(token).safeTransfer(receiver, amount); - emit Claim(account, token, receiver, amount); + snapshot.rewards.pending -= uint128(amount); + snapshot.rewards.claimed += uint128(amount); + $.userRewardSnapshot[account][token] = snapshot; + } } - return amount; } /// @inheritdoc LinearMultipleRewardDistributor_v3 @@ -560,13 +473,11 @@ abstract contract MultipleRewardCompoundingAccumulator_v3 is } uint8 exponent = currentProd.exponent(); - uint256 magnitude = uint256(currentProd.magnitude()); MultipleRewardCompoundingAccumulatorStorage storage $ = _getMultipleRewardCompoundingAccumulatorStorage(); uint256 integral = $.tokenToExponentToIntegral[token][exponent]; - uint256 toAdd = Math.mulDiv(amount * _REWARD_PRECISION, magnitude, totalShare); - integral += toAdd; + integral += Math.mulDiv(amount * _REWARD_PRECISION, uint256(currentProd.magnitude()), totalShare); $.tokenToExponentToIntegral[token][exponent] = integral; } diff --git a/test/GraphReward.t.sol b/test/GraphReward.t.sol index 2d849685..0c05fc29 100644 --- a/test/GraphReward.t.sol +++ b/test/GraphReward.t.sol @@ -9,7 +9,7 @@ import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; import {IMinter} from "@harbor/interfaces/IMinter.sol"; import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; -import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; +import {IMultipleRewardAccumulator_v3 as IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator_v3.sol"; import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; import "@harbor-test/Useful.sol"; diff --git a/test/GraphsLiquidate.t.sol b/test/GraphsLiquidate.t.sol index 662e25d4..4b4ff533 100644 --- a/test/GraphsLiquidate.t.sol +++ b/test/GraphsLiquidate.t.sol @@ -12,7 +12,7 @@ import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; import {IMinter} from "@harbor/interfaces/IMinter.sol"; import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; import {IStabilityPoolManager} from "@harbor/interfaces/IStabilityPoolManager.sol"; -import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; +import {IMultipleRewardAccumulator_v3 as IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator_v3.sol"; import {StabilityPoolManager_v2} from "@harbor/minter/StabilityPoolManager_v2.sol"; diff --git a/test/StabilityPoolClaimable.t.sol b/test/StabilityPoolClaimable.t.sol index e0b23464..6e5073fd 100644 --- a/test/StabilityPoolClaimable.t.sol +++ b/test/StabilityPoolClaimable.t.sol @@ -4,7 +4,7 @@ pragma solidity >=0.8.28 <0.9.0; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {ITokenHolder} from "@bao/TokenHolder.sol"; -import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; +import {IMultipleRewardAccumulator_v3 as IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator_v3.sol"; import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; @@ -624,6 +624,7 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { assertEq(IERC20(rewardToken2).balanceOf(user1), claimable2, "rewardToken2 claimed"); } + /* function testClaim_withReceiver() public { // claim(account, receiver) routes rewards to an explicit receiver. _depositForUsers(); @@ -638,7 +639,9 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { assertEq(IERC20(rewardToken1).balanceOf(receiver), claimable1, "receiver got tokens"); assertEq(IERC20(rewardToken1).balanceOf(user1), 0, "user1 got nothing"); } + */ + /* function testClaim_forOtherUser() public { // Anyone can trigger claim(account) for another user — tokens go to that user. _depositForUsers(); @@ -651,7 +654,9 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { assertEq(IERC20(rewardToken1).balanceOf(user1), claimable1, "user1 received tokens"); } + */ + /* function testClaim_cannotRedirectOthersReward() public { // Third party cannot redirect another user's rewards to an explicit receiver. _depositForUsers(); @@ -663,6 +668,7 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { vm.expectRevert(IMultipleRewardAccumulator.ClaimOthersRewardToAnother.selector); IMultipleRewardAccumulator(stabilityPoolCollateral).claim(user1, receiver); } + */ function testClaim_zeroClaimable() public { // claim() does not revert when there is nothing to claim. diff --git a/test/StabilityPoolExtras2.t.sol b/test/StabilityPoolExtras2.t.sol index 6becfe97..d76f549e 100644 --- a/test/StabilityPoolExtras2.t.sol +++ b/test/StabilityPoolExtras2.t.sol @@ -4,7 +4,7 @@ pragma solidity >=0.8.28 <0.9.0; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; -import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; +import {IMultipleRewardAccumulator_v3 as IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator_v3.sol"; import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; diff --git a/test/StabilityPoolLoss.t.sol b/test/StabilityPoolLoss.t.sol index 21681778..19c16670 100644 --- a/test/StabilityPoolLoss.t.sol +++ b/test/StabilityPoolLoss.t.sol @@ -3,7 +3,7 @@ pragma solidity >=0.8.28 <0.9.0; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; +import {IMultipleRewardAccumulator_v3 as IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator_v3.sol"; import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; diff --git a/test/StabilityPoolManager_v1.t.sol b/test/StabilityPoolManager_v1.t.sol index 1091f161..f31114d2 100644 --- a/test/StabilityPoolManager_v1.t.sol +++ b/test/StabilityPoolManager_v1.t.sol @@ -16,7 +16,7 @@ import {ITokenHolder} from "@bao/TokenHolder.sol"; import {IMinter} from "@harbor/interfaces/IMinter.sol"; import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; -import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; +import {IMultipleRewardAccumulator_v3 as IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator_v3.sol"; import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; import {IStabilityPoolManager} from "@harbor/interfaces/IStabilityPoolManager.sol"; import {IStabilityPoolManager_v2} from "@harbor/interfaces/IStabilityPoolManager_v2.sol"; diff --git a/test/StabilityPoolSpec.t.sol b/test/StabilityPoolSpec.t.sol index 41ef9446..579d1403 100644 --- a/test/StabilityPoolSpec.t.sol +++ b/test/StabilityPoolSpec.t.sol @@ -6,7 +6,7 @@ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; import {ITokenHolder} from "@bao/TokenHolder.sol"; -import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; +import {IMultipleRewardAccumulator_v3 as IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator_v3.sol"; import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; @@ -355,6 +355,7 @@ contract TestStabilityPoolSpec is TestStabilityPoolRebalanceSetUp { assertApproxEqRel(claimable, REWARD_AMOUNT, 0.01e18, "User1 should have claimable rewards after registration"); } + /* function testSetRewardReceiver() public { // User1 deposits vm.prank(user1); @@ -387,6 +388,7 @@ contract TestStabilityPoolSpec is TestStabilityPoolRebalanceSetUp { assertApproxEqRel(user3Balance, REWARD_AMOUNT, 1e16, "User3 should have received rewards"); assertEq(user1Balance, 0, "User1 should not have received rewards"); } + */ // Add remaining tests from original StabilityPoolSpec... } diff --git a/test/StabilityPoolUpgradeMigration.t.sol b/test/StabilityPoolUpgradeMigration.t.sol index c9d03154..da7d186b 100644 --- a/test/StabilityPoolUpgradeMigration.t.sol +++ b/test/StabilityPoolUpgradeMigration.t.sol @@ -10,7 +10,7 @@ import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; import {ITokenHolder} from "@bao/TokenHolder.sol"; import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; -import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; +import {IMultipleRewardAccumulator_v3 as IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator_v3.sol"; import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; import {IWrappedPriceOracle} from "@harbor/interfaces/IWrappedPriceOracle.sol"; @@ -156,7 +156,7 @@ contract TestStabilityPoolUpgradeMigration is TestStabilityPoolSetUp { // Claim uint256 steamBefore = IERC20(steam).balanceOf(user1); vm.prank(user1); - IMultipleRewardAccumulator(stabilityPoolCollateral).claim(user1); + IMultipleRewardAccumulator(stabilityPoolCollateral).claim(); assertGt(IERC20(steam).balanceOf(user1) - steamBefore, 0, "Claim works post-upgrade"); } @@ -278,7 +278,7 @@ contract TestStabilityPoolUpgradeMigration is TestStabilityPoolSetUp { if (v2_claimSteam > 0) { uint256 steamBefore = IERC20(steam).balanceOf(user1); vm.prank(user1); - IMultipleRewardAccumulator(stabilityPoolCollateral).claim(user1); + IMultipleRewardAccumulator(stabilityPoolCollateral).claim(); assertEq( IERC20(steam).balanceOf(user1) - steamBefore, v2_claimSteam, @@ -312,7 +312,7 @@ contract TestStabilityPoolUpgradeMigration is TestStabilityPoolSetUp { vm.warp(block.timestamp + 1 weeks); _depositReward(steam, 0); // distribute pending vm.prank(user1); - IMultipleRewardAccumulator(stabilityPoolCollateral).claim(user1); + IMultipleRewardAccumulator(stabilityPoolCollateral).claim(); // Apply liquidation if (doCompleteLiq) { @@ -358,7 +358,7 @@ contract TestStabilityPoolUpgradeMigration is TestStabilityPoolSetUp { // Post-upgrade: claim remaining if (v2_claimable > 0) { vm.prank(user1); - IMultipleRewardAccumulator(stabilityPoolCollateral).claim(user1); + IMultipleRewardAccumulator(stabilityPoolCollateral).claim(); uint256 totalClaimed = IMultipleRewardAccumulator(stabilityPoolCollateral).claimed(user1, steam); assertEq(totalClaimed, v2_claimed + v2_claimable, "Total claimed = previous + remaining"); } @@ -493,9 +493,9 @@ contract TestStabilityPoolUpgradeMigration is TestStabilityPoolSetUp { uint256 col1Before = IERC20(wrappedCollateralToken).balanceOf(user1); uint256 col2Before = IERC20(wrappedCollateralToken).balanceOf(user2); vm.prank(user1); - IMultipleRewardAccumulator(stabilityPoolCollateral).claim(user1); + IMultipleRewardAccumulator(stabilityPoolCollateral).claim(); vm.prank(user2); - IMultipleRewardAccumulator(stabilityPoolCollateral).claim(user2); + IMultipleRewardAccumulator(stabilityPoolCollateral).claim(); assertEq( IERC20(steam).balanceOf(user1) - steam1Before, IERC20(steam).balanceOf(user2) - steam2Before, @@ -549,7 +549,7 @@ contract TestStabilityPoolUpgradeMigration is TestStabilityPoolSetUp { vm.warp(block.timestamp + 1 weeks); _depositReward(steam, 0); // distribute pending vm.prank(user1); - IMultipleRewardAccumulator(stabilityPoolCollateral).claim(user1); + IMultipleRewardAccumulator(stabilityPoolCollateral).claim(); uint256 v1Claimed = IMultipleRewardAccumulator(stabilityPoolCollateral).claimed(user1, steam); assertGt(v1Claimed, 0, "V1 claimed > 0 before liquidation"); @@ -599,7 +599,7 @@ contract TestStabilityPoolUpgradeMigration is TestStabilityPoolSetUp { // Claim everything and verify total vm.prank(user1); - IMultipleRewardAccumulator(stabilityPoolCollateral).claim(user1); + IMultipleRewardAccumulator(stabilityPoolCollateral).claim(); uint256 totalClaimed = IMultipleRewardAccumulator(stabilityPoolCollateral).claimed(user1, steam); assertEq(totalClaimed, v1Claimed + path1Claimable, "Total claimed = v1 + all post-upgrade rewards"); } @@ -672,7 +672,7 @@ contract TestStabilityPoolUpgradeMigration is TestStabilityPoolSetUp { // Claim rewards post-withdrawal vm.prank(user1); - IMultipleRewardAccumulator(stabilityPoolCollateral).claim(user1); + IMultipleRewardAccumulator(stabilityPoolCollateral).claim(); assertGt( IMultipleRewardAccumulator(stabilityPoolCollateral).claimed(user1, steam), 0, @@ -745,7 +745,7 @@ contract TestStabilityPoolUpgradeMigration is TestStabilityPoolSetUp { // Post-upgrade: claim and verify total vm.prank(user1); - IMultipleRewardAccumulator(stabilityPoolCollateral).claim(user1); + IMultipleRewardAccumulator(stabilityPoolCollateral).claim(); uint256 totalSteam = IMultipleRewardAccumulator(stabilityPoolCollateral).claimed(user1, steam); assertEq(totalSteam, v1_claimable, "Full steam amount claimed post-upgrade"); @@ -821,7 +821,7 @@ contract TestStabilityPoolUpgradeMigration is TestStabilityPoolSetUp { // Post-upgrade: claim works vm.prank(user1); - IMultipleRewardAccumulator(stabilityPoolCollateral).claim(user1); + IMultipleRewardAccumulator(stabilityPoolCollateral).claim(); assertGt( IMultipleRewardAccumulator(stabilityPoolCollateral).claimed(user1, steam), 0, diff --git a/test/StabilityPool_v3_ERC20.t.sol b/test/StabilityPool_v3_ERC20.t.sol index 359db423..c79943de 100644 --- a/test/StabilityPool_v3_ERC20.t.sol +++ b/test/StabilityPool_v3_ERC20.t.sol @@ -6,7 +6,7 @@ import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IER import {ERC20} from "@solady/tokens/ERC20.sol"; import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; -import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; +import {IMultipleRewardAccumulator_v3 as IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator_v3.sol"; import {StabilityPool_v3} from "@harbor/minter/StabilityPool_v3.sol"; import {ERC20MetadataLib_v1} from "@harbor/util/ERC20MetadataLib_v1.sol"; diff --git a/test/deployment/RebalanceFairness.t.sol b/test/deployment/RebalanceFairness.t.sol index c4121af3..2e9812a1 100644 --- a/test/deployment/RebalanceFairness.t.sol +++ b/test/deployment/RebalanceFairness.t.sol @@ -11,7 +11,7 @@ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IMinter} from "@harbor/interfaces/IMinter.sol"; import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; import {IStabilityPoolManager} from "@harbor/interfaces/IStabilityPoolManager.sol"; -import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; +import {IMultipleRewardAccumulator_v3 as IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator_v3.sol"; import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; import {MockWrappedPriceOracle} from "@harbor-test/mocks/MockWrappedPriceOracle.sol"; diff --git a/test/deployment/RebalanceFairnessScan.t.sol b/test/deployment/RebalanceFairnessScan.t.sol index 0f8b9b49..9fea985e 100644 --- a/test/deployment/RebalanceFairnessScan.t.sol +++ b/test/deployment/RebalanceFairnessScan.t.sol @@ -7,7 +7,7 @@ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IMinter} from "@harbor/interfaces/IMinter.sol"; import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; import {IStabilityPoolManager} from "@harbor/interfaces/IStabilityPoolManager.sol"; -import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; +import {IMultipleRewardAccumulator_v3 as IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator_v3.sol"; import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; @@ -628,7 +628,7 @@ contract RebalanceFairnessScan is RebalanceFairnessSetUp { uint256 levClaimable = IMultipleRewardAccumulator(pool).claimable(who, leveraged); if (levClaimable > 0) { vm.startPrank(who); - IMultipleRewardAccumulator(pool).claim(who); + IMultipleRewardAccumulator(pool).claim(); uint256 levBal = IERC20(leveraged).balanceOf(who); IERC20(leveraged).approve(minter, levBal); IMinter(minter).freeRedeemLeveragedToken(levBal, who); // → wCOL to who @@ -639,7 +639,7 @@ contract RebalanceFairnessScan is RebalanceFairnessSetUp { uint256 wcolClaimable = IMultipleRewardAccumulator(pool).claimable(who, wrappedCollateral); if (wcolClaimable > 0) { vm.prank(who); - IMultipleRewardAccumulator(pool).claim(who); + IMultipleRewardAccumulator(pool).claim(); } // Step 3: Convert all wCOL in wallet → haXXX → deposit diff --git a/test/deployment/RewardSystem.t.sol b/test/deployment/RewardSystem.t.sol index b365a5dd..69e70ab7 100644 --- a/test/deployment/RewardSystem.t.sol +++ b/test/deployment/RewardSystem.t.sol @@ -11,7 +11,7 @@ import {Config_MinterMarket} from "@harbor-script/config/ConfigBase.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IMinter} from "@harbor/interfaces/IMinter.sol"; import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; -import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; +import {IMultipleRewardAccumulator_v3 as IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator_v3.sol"; import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; import {MockWrappedPriceOracle} from "@harbor-test/mocks/MockWrappedPriceOracle.sol"; @@ -113,7 +113,7 @@ contract AccumulatorTest is RewardSystemSetUp { // Claim vm.prank(alice); - IMultipleRewardAccumulator(stabilityPoolCollateral).claim(alice); + IMultipleRewardAccumulator(stabilityPoolCollateral).claim(); // claimed() should return the claimed amount uint256 claimedAmount = IMultipleRewardAccumulator(stabilityPoolCollateral).claimed(alice, wrappedCollateral); @@ -141,7 +141,7 @@ contract AccumulatorTest is RewardSystemSetUp { IMultipleRewardAccumulator(stabilityPoolCollateral).checkpoint(makeAddr("nobody")); } - // ── claim() and claim(account, receiver) ─────────────────── + // ──.claim() ─────────────────── function test_claimAll() public { _depositReward(wrappedCollateral, 10 ether); @@ -149,11 +149,12 @@ contract AccumulatorTest is RewardSystemSetUp { uint256 balBefore = IERC20(wrappedCollateral).balanceOf(alice); vm.prank(alice); - IMultipleRewardAccumulator(stabilityPoolCollateral).claim(alice); + IMultipleRewardAccumulator(stabilityPoolCollateral).claim(); uint256 received = IERC20(wrappedCollateral).balanceOf(alice) - balBefore; assertGt(received, 0, "claimed via claim()"); } + /* function test_claimToReceiver() public { _depositReward(wrappedCollateral, 10 ether); skip(8 days); @@ -183,6 +184,7 @@ contract AccumulatorTest is RewardSystemSetUp { vm.expectRevert(); IMultipleRewardAccumulator(stabilityPoolCollateral).claim(alice, makeAddr("thirdParty")); } + */ // ── claimHistorical ──────────────────────────────────────── @@ -195,17 +197,17 @@ contract AccumulatorTest is RewardSystemSetUp { // Bob and carol claim to drain the pool's distributable balance vm.prank(bob); - IMultipleRewardAccumulator(stabilityPoolCollateral).claim(bob); + IMultipleRewardAccumulator(stabilityPoolCollateral).claim(); vm.prank(carol); - IMultipleRewardAccumulator(stabilityPoolCollateral).claim(carol); + IMultipleRewardAccumulator(stabilityPoolCollateral).claim(); // Flush any remaining queued dust _depositReward(wrappedCollateral, 1); skip(8 days); vm.prank(bob); - IMultipleRewardAccumulator(stabilityPoolCollateral).claim(bob); + IMultipleRewardAccumulator(stabilityPoolCollateral).claim(); vm.prank(carol); - IMultipleRewardAccumulator(stabilityPoolCollateral).claim(carol); + IMultipleRewardAccumulator(stabilityPoolCollateral).claim(); // Alice still hasn't claimed — her pending is sitting in her snapshot // Unregister @@ -229,7 +231,7 @@ contract AccumulatorTest is RewardSystemSetUp { tokens[0] = wrappedCollateral; uint256 balBefore = IERC20(wrappedCollateral).balanceOf(alice); vm.prank(alice); - IMultipleRewardAccumulator(stabilityPoolCollateral).claimHistorical(tokens); + IMultipleRewardAccumulator(stabilityPoolCollateral).claimTokens(tokens, type(uint256).max); assertGt(IERC20(wrappedCollateral).balanceOf(alice) - balBefore, 0, "claimed historical"); } @@ -242,21 +244,22 @@ contract AccumulatorTest is RewardSystemSetUp { // Drain via bob and carol vm.prank(bob); - IMultipleRewardAccumulator(stabilityPoolCollateral).claim(bob); + IMultipleRewardAccumulator(stabilityPoolCollateral).claim(); vm.prank(carol); - IMultipleRewardAccumulator(stabilityPoolCollateral).claim(carol); + IMultipleRewardAccumulator(stabilityPoolCollateral).claim(); _depositReward(wrappedCollateral, 1); skip(8 days); vm.prank(bob); - IMultipleRewardAccumulator(stabilityPoolCollateral).claim(bob); + IMultipleRewardAccumulator(stabilityPoolCollateral).claim(); vm.prank(carol); - IMultipleRewardAccumulator(stabilityPoolCollateral).claim(carol); + IMultipleRewardAccumulator(stabilityPoolCollateral).claim(); uint256 managerRole = IMultipleRewardDistributor(stabilityPoolCollateral).REWARD_MANAGER_ROLE(); vm.prank(HARBOR_MULTISIG); IBaoRoles(stabilityPoolCollateral).grantRoles(address(this), managerRole); IMultipleRewardDistributor(stabilityPoolCollateral).unregisterRewardToken(wrappedCollateral); + /* // Bob triggers historical claim for alice — tokens go to alice address[] memory tokens = new address[](1); tokens[0] = wrappedCollateral; @@ -264,6 +267,7 @@ contract AccumulatorTest is RewardSystemSetUp { vm.prank(bob); IMultipleRewardAccumulator(stabilityPoolCollateral).claimHistorical(alice, tokens); assertGt(IERC20(wrappedCollateral).balanceOf(alice) - balBefore, 0, "alice got historical claim"); + */ } } @@ -322,7 +326,7 @@ contract DistributorTest is RewardSystemSetUp { // Claim all so pending is zero vm.prank(alice); - IMultipleRewardAccumulator(stabilityPoolCollateral).claim(alice); + IMultipleRewardAccumulator(stabilityPoolCollateral).claim(); uint256 managerRole = IMultipleRewardDistributor(stabilityPoolCollateral).REWARD_MANAGER_ROLE(); vm.prank(HARBOR_MULTISIG); diff --git a/test/mocks/IMockMultipleRewardCompoundingAccumulator.sol b/test/mocks/IMockMultipleRewardCompoundingAccumulator.sol index de08a83d..41d1ad73 100644 --- a/test/mocks/IMockMultipleRewardCompoundingAccumulator.sol +++ b/test/mocks/IMockMultipleRewardCompoundingAccumulator.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.28 <0.9.0; -import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; +import {IMultipleRewardAccumulator_v3 as IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator_v3.sol"; import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; diff --git a/test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v2.sol b/test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v2.sol index 21cea972..58a41f11 100644 --- a/test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v2.sol +++ b/test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v2.sol @@ -5,9 +5,9 @@ pragma solidity >=0.8.28 <0.9.0; // import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; -import {MultipleRewardCompoundingAccumulator_v3} from "@harbor/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol"; +import {MultipleRewardCompoundingAccumulator} from "@harbor/reward/accumulator/MultipleRewardCompoundingAccumulator_v2.sol"; -contract MockMultipleRewardCompoundingAccumulator_v2 is Initializable, MultipleRewardCompoundingAccumulator_v3 { +contract MockMultipleRewardCompoundingAccumulator_v2 is Initializable, MultipleRewardCompoundingAccumulator { event AccumulateReward(address token, uint256 amount); uint256 public totalPoolShare; @@ -15,10 +15,10 @@ contract MockMultipleRewardCompoundingAccumulator_v2 is Initializable, MultipleR uint256 public userPoolShare; uint128 public userProduct; - constructor(uint40 period) MultipleRewardCompoundingAccumulator_v3(_ROLE_0, _ROLE_1, period) {} + constructor(uint40 period) MultipleRewardCompoundingAccumulator(_ROLE_0, _ROLE_1, period) {} function initialize(address owner_) external initializer { - _initializeOwner(msg.sender, owner_); + _initializeOwner(owner_); __ReentrancyGuardTransient_init(); // __MultipleRewardCompoundingAccumulator_init(); } diff --git a/test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v3.sol b/test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v3.sol index 5e70a841..aedf9854 100644 --- a/test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v3.sol +++ b/test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v3.sol @@ -58,13 +58,19 @@ contract MockMultipleRewardCompoundingAccumulator_v3 is Initializable, MultipleR } function tokenToExponentToIntegral(address token, uint8 exponent) public view returns (uint256 globalIntegral) { - globalIntegral = _tokenToExponentToIntegral(token, exponent); + MultipleRewardCompoundingAccumulatorStorage storage $ = _getMultipleRewardCompoundingAccumulatorStorage(); + globalIntegral = $.tokenToExponentToIntegral[token][exponent]; } function userRewardSnapshot( address account, address token ) public view returns (uint64 timestamp, uint256 integral, uint128 pending, uint128 claimed_) { - (timestamp, integral, pending, claimed_) = _getUserRewardSnapshot(account, token); + MultipleRewardCompoundingAccumulatorStorage storage $ = _getMultipleRewardCompoundingAccumulatorStorage(); + UserRewardSnapshotV2 storage snapshot = $.userRewardSnapshot[account][token]; + timestamp = snapshot.timestamp; + integral = snapshot.integral; + pending = snapshot.rewards.pending; + claimed_ = snapshot.rewards.claimed; } } diff --git a/test/reward/accumulator/ClaimEquivalence.t.sol b/test/reward/accumulator/ClaimEquivalence.t.sol index 4f8955f0..8977e2d7 100644 --- a/test/reward/accumulator/ClaimEquivalence.t.sol +++ b/test/reward/accumulator/ClaimEquivalence.t.sol @@ -5,7 +5,7 @@ import {Test} from "forge-std/Test.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {MockERC20} from "@bao-test/mocks/MockERC20.sol"; -import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; +import {IMultipleRewardAccumulator_v3 as IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator_v3.sol"; import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; import {MockMultipleRewardCompoundingAccumulator_v3} from "@harbor-test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v3.sol"; @@ -90,6 +90,7 @@ contract ClaimTest is Test { assertGt(IERC20(rewardToken2).balanceOf(alice), 0, "alice got token2"); } + /* // ── Third party claim all (no receiver) ───────────────────────────── function test_thirdPartyClaimAll() public { @@ -99,7 +100,9 @@ contract ClaimTest is Test { IMultipleRewardAccumulator(accumulator).claim(alice); assertGt(IERC20(rewardToken1).balanceOf(alice), 0, "alice got token1 (claimed by bob)"); } + */ + /* // ── Self claim to explicit receiver ───────────────────────────────── function test_selfClaimToReceiver() public { @@ -110,7 +113,9 @@ contract ClaimTest is Test { assertGt(IERC20(rewardToken1).balanceOf(explicitReceiver), 0, "receiver got token1"); assertEq(IERC20(rewardToken1).balanceOf(alice), 0, "alice got nothing"); } + */ + /* // ── Third party claim to receiver → REVERT ────────────────────────── function test_thirdPartyClaimToReceiver_reverts() public { @@ -120,6 +125,7 @@ contract ClaimTest is Test { vm.expectRevert(IMultipleRewardAccumulator.ClaimOthersRewardToAnother.selector); IMultipleRewardAccumulator(accumulator).claim(alice, explicitReceiver); } + */ // ── Self historical ───────────────────────────────────────────────── @@ -129,11 +135,12 @@ contract ClaimTest is Test { address[] memory tokens = new address[](1); tokens[0] = rewardToken1; vm.prank(alice); - IMultipleRewardAccumulator(accumulator).claimHistorical(tokens); + IMultipleRewardAccumulator(accumulator).claimTokens(tokens, type(uint256).max); assertGt(IERC20(rewardToken1).balanceOf(alice), 0, "alice got token1"); assertEq(IERC20(rewardToken2).balanceOf(alice), 0, "token2 unclaimed"); } + /* // ── Third party historical ────────────────────────────────────────── function test_thirdPartyHistorical() public { @@ -145,7 +152,9 @@ contract ClaimTest is Test { IMultipleRewardAccumulator(accumulator).claimHistorical(alice, tokens); assertGt(IERC20(rewardToken1).balanceOf(alice), 0, "alice got token1 (claimed by bob)"); } + */ + /* // ═══════════════════════════════════════════════════════════════════════ // With stored receiver // ═══════════════════════════════════════════════════════════════════════ @@ -201,4 +210,5 @@ contract ClaimTest is Test { vm.expectRevert(IMultipleRewardAccumulator.ClaimOthersRewardToAnother.selector); IMultipleRewardAccumulator(accumulator).claim(alice, explicitReceiver); } + */ } diff --git a/test/reward/accumulator/MultipleRewardCompoundingAccumulator.t.sol b/test/reward/accumulator/MultipleRewardCompoundingAccumulator.t.sol index 569b477e..d90fe9ab 100644 --- a/test/reward/accumulator/MultipleRewardCompoundingAccumulator.t.sol +++ b/test/reward/accumulator/MultipleRewardCompoundingAccumulator.t.sol @@ -4,7 +4,7 @@ pragma solidity >=0.8.28 <0.9.0; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {ReentrancyGuardTransient} from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol"; -import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; +import {IMultipleRewardAccumulator_v3 as IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator_v3.sol"; import {IMockMultipleRewardCompoundingAccumulator} from "@harbor-test/mocks/IMockMultipleRewardCompoundingAccumulator.sol"; import {Test, Vm} from "forge-std/Test.sol"; @@ -126,20 +126,10 @@ contract MultipleRewardCompoundingAccumulatorTest is Test { // Test claim() vm.expectRevert(REENTRANT_ERROR); accumulator.reentrantCall(abi.encodeWithSelector(bytes4(keccak256("claim()")), "")); - - // Test claim(address) - vm.expectRevert(REENTRANT_ERROR); - accumulator.reentrantCall(abi.encodeWithSelector(bytes4(keccak256("claim(address)")), address(0))); - - // Test claim(address,address) - vm.expectRevert(REENTRANT_ERROR); - accumulator.reentrantCall( - abi.encodeWithSelector(bytes4(keccak256("claim(address,address)")), address(0), address(0)) - ); } } - function testReentrantClaimHistorical() public { + function testReentrantClaimTokens() public { for (uint256 i = 0; i < rewardCounts.length; i++) { uint256 rewardCount = rewardCounts[i]; uint40 periodLength = 1 weeks; @@ -150,15 +140,12 @@ contract MultipleRewardCompoundingAccumulatorTest is Test { // Test claimHistorical(address[]) vm.expectRevert(REENTRANT_ERROR); - accumulator.reentrantCall(abi.encodeWithSignature("claimHistorical(address[])", emptyArray)); - - // Test claimHistorical(address,address[]) - vm.expectRevert(REENTRANT_ERROR); accumulator.reentrantCall( - abi.encodeWithSignature("claimHistorical(address,address[])", address(0), emptyArray) + abi.encodeWithSignature("claimTokens(address[],uint256)", emptyArray, type(uint256).max) ); } } + struct TestParams { uint256 rewardCount; uint40 periodLength; @@ -292,6 +279,7 @@ contract MultipleRewardCompoundingAccumulatorTest is Test { } } + /* function testSetRewardReceiver() public { for (uint256 i = 0; i < rewardCounts.length; i++) { uint256 rewardCount = rewardCounts[i]; @@ -319,6 +307,7 @@ contract MultipleRewardCompoundingAccumulatorTest is Test { assertEq(accumulator.rewardReceiver(deployer), address(0)); } } + */ function testClaimWithoutRewardReceiver() public { for (uint256 i = 0; i < rewardCounts.length; i++) { @@ -349,9 +338,11 @@ contract MultipleRewardCompoundingAccumulatorTest is Test { accumulator.checkpoint(deployer); + /* // Test reverting when claiming other to other vm.expectRevert(abi.encodeWithSelector(IMultipleRewardAccumulator.ClaimOthersRewardToAnother.selector)); accumulator.claim(manager, deployer); + */ // Test claim caller uint256[] memory claimable = new uint256[](rewardCount); @@ -420,6 +411,7 @@ contract MultipleRewardCompoundingAccumulatorTest is Test { assertEq(claimed, 0); } + /* // Claim as manager for deployer for (uint256 j = 0; j < rewardCount; j++) { vm.expectEmit(true, true, true, true); @@ -438,7 +430,9 @@ contract MultipleRewardCompoundingAccumulatorTest is Test { assertEq(claimed, claimable[j]); assertEq(accumulator.claimed(deployer, tokenAddresses[j]), claimable[j]); } + */ + /* // Test claim to other // Reset the state for a new test (accumulator, tokenAddresses) = _setupAccumulator(rewardCount, periodLength); @@ -485,9 +479,11 @@ contract MultipleRewardCompoundingAccumulatorTest is Test { assertEq(claimed, claimable[j]); assertEq(accumulator.claimed(deployer, tokenAddresses[j]), claimable[j]); } + */ } } + /* function testClaimWithRewardReceiver() public { for (uint256 i = 0; i < rewardCounts.length; i++) { uint256 rewardCount = rewardCounts[i]; @@ -652,6 +648,7 @@ contract MultipleRewardCompoundingAccumulatorTest is Test { } } } + */ function testClaimHistoricalWithoutRewardReceiver() public { for (uint256 i = 0; i < rewardCounts.length; i++) { @@ -713,7 +710,7 @@ contract MultipleRewardCompoundingAccumulatorTest is Test { vm.expectEmit(true, true, true, true); emit IMultipleRewardAccumulator.Claim(deployer, tokenAddresses[j], deployer, claimable[j]); } - accumulator.claimHistorical(tokenAddresses); + accumulator.claimTokens(tokenAddresses, type(uint256).max); // Verify post-claim state for (uint256 j = 0; j < rewardCount; j++) { @@ -769,6 +766,7 @@ contract MultipleRewardCompoundingAccumulatorTest is Test { logs = vm.getRecordedLogs(); assertEq(logs.length, 0); + /* // Claim historical as manager for (uint256 j = 0; j < rewardCount; j++) { vm.expectEmit(true, true, true, true); @@ -787,9 +785,12 @@ contract MultipleRewardCompoundingAccumulatorTest is Test { assertEq(claimed, claimable[j]); assertEq(accumulator.claimed(deployer, tokenAddresses[j]), claimable[j]); } + */ } } + /* + function testClaimHistoricalWithRewardReceiver() public { for (uint256 i = 0; i < rewardCounts.length; i++) { uint256 rewardCount = rewardCounts[i]; @@ -930,6 +931,8 @@ contract MultipleRewardCompoundingAccumulatorTest is Test { } } } + */ + /// ═══════════════════════════════════════════════════════════════════════════════ /// INTEGRAL OVERFLOW BOUNDS ANALYSIS /// ═══════════════════════════════════════════════════════════════════════════════ From d97aceffb658fe91e37d6344b204eeb097608607 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Fri, 29 May 2026 19:56:06 +0100 Subject: [PATCH 092/232] first cut SP_v2 data migration --- ...igrate_StabilityPool_v2_Data_mainnet.s.sol | 4 +- .../MigrateBalancesTest.t.sol | 2 +- .../MigrateCaptureTest.t.sol | 2 +- .../sp-v2-data-prep-for-v3/collect-sp-holders | 185 +++++++++--------- .../migrate-StabilityPool_v2-data.md | 106 ++++++++++ .../run-migrate-StabilityPool_v2-data | 116 +++++++---- 6 files changed, 290 insertions(+), 125 deletions(-) create mode 100644 script/verify/sp-v2-data-prep-for-v3/migrate-StabilityPool_v2-data.md diff --git a/script/Migrate_StabilityPool_v2_Data_mainnet.s.sol b/script/Migrate_StabilityPool_v2_Data_mainnet.s.sol index cc441df6..33851e58 100644 --- a/script/Migrate_StabilityPool_v2_Data_mainnet.s.sol +++ b/script/Migrate_StabilityPool_v2_Data_mainnet.s.sol @@ -32,7 +32,7 @@ import {Script} from "forge-std/Script.sol"; /// 3. Restore proxy -> the StabilityPool_v2 implementation it had before /// /// Holders are read at runtime from per-pool files produced by -/// `script/verify/sp-v2-upgrade-prep-for-v3/collect-sp-holders` (UserDepositChange +/// `script/verify/sp-v2-data-prep-for-v3/collect-sp-holders` (UserDepositChange /// logs). Pools with no holder file, or an empty one, are skipped. /// /// Run via: @@ -54,7 +54,7 @@ contract Migrate_StabilityPool_v2_Data_mainnet is bytes32 internal constant IMPL_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; /// @dev Directory of per-pool holder files (one checksummed address per line, '#' comments). - string internal constant HOLDERS_DIR = "script/verify/sp-v2-upgrade-prep-for-v3/holders/"; + string internal constant HOLDERS_DIR = "tmp/sp-holders/"; /// @dev Deployed once, shared across all pools (no constructor params, deterministic bytecode). address internal migImpl; diff --git a/script/verify/sp-v2-data-prep-for-v3/MigrateBalancesTest.t.sol b/script/verify/sp-v2-data-prep-for-v3/MigrateBalancesTest.t.sol index 8e6b2155..afc6837a 100644 --- a/script/verify/sp-v2-data-prep-for-v3/MigrateBalancesTest.t.sol +++ b/script/verify/sp-v2-data-prep-for-v3/MigrateBalancesTest.t.sol @@ -39,7 +39,7 @@ contract MigrateBalancesTest is Deploy_MCAP_Minter, Deploy_SILVER_Minter { - string internal constant HOLDERS_DIR = "script/verify/sp-v2-upgrade-prep-for-v3/holders/"; + string internal constant HOLDERS_DIR = "tmp/sp-holders/"; address internal migImpl; diff --git a/script/verify/sp-v2-data-prep-for-v3/MigrateCaptureTest.t.sol b/script/verify/sp-v2-data-prep-for-v3/MigrateCaptureTest.t.sol index 7edfe377..e5fe461d 100644 --- a/script/verify/sp-v2-data-prep-for-v3/MigrateCaptureTest.t.sol +++ b/script/verify/sp-v2-data-prep-for-v3/MigrateCaptureTest.t.sol @@ -44,7 +44,7 @@ contract MigrateCaptureTest is { using LibString for string; - string internal constant HOLDERS_DIR = "script/verify/sp-v2-upgrade-prep-for-v3/holders/"; + string internal constant HOLDERS_DIR = "tmp/sp-holders/"; struct PoolConfig { address proxy; diff --git a/script/verify/sp-v2-data-prep-for-v3/collect-sp-holders b/script/verify/sp-v2-data-prep-for-v3/collect-sp-holders index 5f8e92df..914f851a 100755 --- a/script/verify/sp-v2-data-prep-for-v3/collect-sp-holders +++ b/script/verify/sp-v2-data-prep-for-v3/collect-sp-holders @@ -1,43 +1,49 @@ #!/usr/bin/env bash # collect-sp-holders — discover every account that has ever held a position in -# each Harbor stability pool, by scanning each SP proxy's `UserDepositChange` -# event logs. +# each Harbor stability pool, via the Etherscan `logs/getLogs` API. # -# Why UserDepositChange: an account gets a per-user reward snapshot exactly when -# it is checkpointed (deposit -> receiver, withdraw -> sender, liquidation -> -# account). UserDepositChange(owner, ...) fires on every one of those, so the -# distinct set of `owner` values is precisely the set of accounts that may carry -# legacy V1 accumulator storage. (Over-approximation is harmless: the on-chain -# remediate() skips any account with no V1 data.) +# Why Etherscan (not cast logs): some RPC providers cap eth_getLogs at ~10 blocks +# per call, which makes scanning ~1M blocks impossible. Etherscan's getLogs is not +# block-range limited (1000 records/page, paginated by block), same approach as the +# sibling `collect-holders` script (which uses the tokentx endpoint). # -# Output: one file per pool under holders/, one checksummed address per line, -# with a comment header. These files are read at runtime by -# Migrate_StabilityPool_v2_Data_mainnet.s.sol. +# Why UserDepositChange: an account gets a per-user reward snapshot exactly when it +# is checkpointed (deposit -> receiver, withdraw -> sender, liquidation -> account). +# UserDepositChange(owner, ...) fires on every one of those, so the distinct set of +# `owner` values is precisely the set of accounts that may carry legacy V1 storage. +# (Over-approximation is harmless: on-chain remediate() skips accounts with no V1 data.) # -# The RPC is resolved via foundry's `rpc_endpoints` alias (default: mainnet), -# which expands ${MAINNET_RPC_URL} from .env — this script never reads .env. +# Output: one file per pool under holders/, one checksummed address per line, with a +# comment header. Read at runtime by Migrate_StabilityPool_v2_Data_mainnet.s.sol and +# the verification tests. +# +# Requires ETHERSCAN_KEY in .env or the environment (sourced, never printed). # # Usage: -# collect-sp-holders # full scan -> holders/ -# collect-sp-holders --dry-run # print pool->address table, no RPC -# collect-sp-holders --from-block 24049000 --chunk 50000 +# collect-sp-holders # full scan -> holders/ +# collect-sp-holders --dry-run # print pool->address table, no API calls +# collect-sp-holders --to-block latest # override fixed upper bound # collect-sp-holders --out-dir tmp/holders-rerun # for the re-run/verify facility set -euo pipefail cd "$(git rev-parse --show-toplevel)" +# Load env for ETHERSCAN_KEY (same pattern as collect-holders). +# shellcheck source=/dev/null +[[ -f .env ]] && source .env + # ── Configuration ──────────────────────────────────────────────────────────── -NETWORK="mainnet" -FROM_BLOCK=24049000 # first harbor mainnet deployment (same as collect-holders) -TO_BLOCK="25186514" -CHUNK=50000 # block-range chunk size for eth_getLogs (range-limit safe) +CHAIN_ID=1 +FROM_BLOCK=24049000 # first harbor mainnet deployment (same as collect-holders) +TO_BLOCK="25186514" # fixed upper bound (reproducible; match the verification fork block) +OFFSET=1000 # Etherscan max records per page STATE_FILE="deployments/mainnet/harbor_v1.state.json" SALT_PREFIX="harbor_v1" -OUT_DIR="script/verify/sp-v2-upgrade-prep-for-v3/holders" +OUT_DIR="tmp/sp-holders" DRY_RUN=false -# CREATE3 (solady) — same constants the repo's script/cast wrapper uses. +# CREATE3 (solady) — same constants the repo's script/cast wrapper uses (local, no RPC). FACTORY="0xD696E56b3A054734d4C6DCBD32E11a278b0EC458" INIT_CODE_HASH="21c35dbe1b344a2488cf3321d6ce542f8e9f305544ff09e4993a62319a497c1f" # topic0 = keccak("UserDepositChange(address,uint256,uint256)") @@ -45,44 +51,18 @@ TOPIC0="0x5fa7d0e13a31540b6d42936e522173de31fa0c53aa5aff71e63c60fe02b2b50e" while [[ $# -gt 0 ]]; do case "$1" in - --network) - NETWORK="$2" - shift 2 - ;; - --from-block) - FROM_BLOCK="$2" - shift 2 - ;; - --to-block) - TO_BLOCK="$2" - shift 2 - ;; - --chunk) - CHUNK="$2" - shift 2 - ;; - --out-dir) - OUT_DIR="$2" - shift 2 - ;; - --dry-run) - DRY_RUN=true - shift - ;; - -h | --help) - sed -n '2,30p' "$0" - exit 0 - ;; - *) - echo "Unknown option: $1" >&2 - exit 1 - ;; + --from-block) FROM_BLOCK="$2"; shift 2 ;; + --to-block) TO_BLOCK="$2"; shift 2 ;; + --out-dir) OUT_DIR="$2"; shift 2 ;; + --dry-run) DRY_RUN=true; shift ;; + -h|--help) sed -n '2,30p' "$0"; exit 0 ;; + *) echo "Unknown option: $1" >&2; exit 1 ;; esac done # ── Helpers ────────────────────────────────────────────────────────────────── -# Compute the CREATE3 proxy address for a full salt string (no RPC). +# CREATE3 proxy address for a full salt string (local, no RPC). compute_create3_address() { local salt_string="$1" local salt preimage proxy_hash proxy rlp deployed_hash @@ -95,7 +75,7 @@ compute_create3_address() { echo "0x${deployed_hash:26}" } -# Distinct stability-pool salts (collateral + leveraged), e.g. "ETH::fxUSD::stabilityPoolCollateral". +# Distinct stability-pool salts, e.g. "ETH::fxUSD::stabilityPoolCollateral". pool_salts() { local salts salts=$(jq -r '.implementations[].proxy' "$STATE_FILE") @@ -106,38 +86,71 @@ pool_salts() { printf '%s\n' "$salts" | grep -E 'stabilityPool(Collateral|Leveraged)$' | sort -u } -# Extract the indexed `owner` (topic[1]) from a `cast logs --json` array on stdin. -extract_owners() { +# Parse one Etherscan getLogs response on stdin. +# Prints "COUNT LASTBLOCK" on line 1, then one owner address per subsequent line. +# Exits non-zero on a genuine API error (not "No records found"). +parse_page() { python3 -c ' import json, sys -data = json.load(sys.stdin) -for log in data: +raw = sys.stdin.read() +try: + d = json.loads(raw) +except Exception: + print("API returned non-JSON: " + raw[:200], file=sys.stderr) + sys.exit(2) +status = str(d.get("status", "0")) +result = d.get("result") +if status != "1": + msg = str(d.get("message", "")) + if "No records" in msg or result == []: + print("0 0") + sys.exit(0) + print("Etherscan error: %s %r" % (msg, result), file=sys.stderr) + sys.exit(2) +owners = [] +last = 0 +for log in result: topics = log.get("topics", []) if len(topics) >= 2: - print("0x" + topics[1][-40:]) + owners.append("0x" + topics[1][-40:]) + bn = int(log.get("blockNumber", "0x0"), 16) + if bn > last: + last = bn +print("%d %d" % (len(result), last)) +for o in owners: + print(o) ' } -# Fetch all owners for one proxy, scanning [FROM_BLOCK, latest] in chunks. +# Fetch all owner addresses for one proxy across [FROM_BLOCK, TO_BLOCK], paginating +# by advancing fromBlock when a page is full. fetch_owners() { - local proxy="$1" latest="$2" - local from="$FROM_BLOCK" to logs rc - while ((from <= latest)); do - to=$((from + CHUNK - 1)) - ((to > latest)) && to="$latest" - logs=$(cast logs --rpc-url "$NETWORK" --from-block "$from" --to-block "$to" \ - "$TOPIC0" --address "$proxy" --json) - rc=$? - if [[ $rc -ne 0 ]]; then - echo "ERROR: cast logs failed for $proxy blocks $from-$to (rc=$rc)" >&2 + local proxy="$1" + local from="$FROM_BLOCK" resp parsed meta count last + while true; do + if ! resp=$(curl -s "https://api.etherscan.io/v2/api?chainid=${CHAIN_ID}&module=logs&action=getLogs&address=${proxy}&topic0=${TOPIC0}&fromBlock=${from}&toBlock=${TO_BLOCK}&offset=${OFFSET}&page=1&apikey=${ETHERSCAN_KEY}"); then + echo "ERROR: curl failed for $proxy from $from" >&2 exit 1 fi - printf '%s' "$logs" | extract_owners - if [[ ${PIPESTATUS[1]} -ne 0 ]]; then - echo "ERROR: failed parsing logs for $proxy blocks $from-$to" >&2 + # pipefail makes this assignment's status the pipeline's; parse_page exits non-zero on a real API error. + if ! parsed=$(printf '%s' "$resp" | parse_page); then + echo "ERROR: parsing Etherscan response for $proxy from $from" >&2 exit 1 fi - from=$((to + 1)) + meta=$(printf '%s\n' "$parsed" | head -1) + count=${meta%% *} + last=${meta##* } + printf '%s\n' "$parsed" | tail -n +2 + # Stop when the page was not full. + if (( count < OFFSET )); then + break + fi + # Full page: continue from the last block (dedup handles overlap). Guard no-progress. + if (( last <= from )); then + echo "WARN: $proxy: full page ($count) all in block $last; possible truncation" >&2 + break + fi + from=$last done } @@ -147,41 +160,37 @@ mapfile -t SALTS < <(pool_salts) echo "# ${#SALTS[@]} stability pools from $STATE_FILE" >&2 if [[ "$DRY_RUN" == true ]]; then - echo "# DRY RUN — pool -> proxy address (no RPC)" >&2 + echo "# DRY RUN — pool -> proxy address (no API calls)" >&2 for salt in "${SALTS[@]}"; do printf '%-44s %s\n' "$salt" "$(compute_create3_address "${SALT_PREFIX}::${salt}")" done exit 0 fi -# Resolve `latest` once so all pools scan the same upper bound. -latest_block="$TO_BLOCK" -if [[ "$TO_BLOCK" == "latest" ]]; then - latest_block=$(cast block-number --rpc-url "$NETWORK") -fi -echo "# scanning blocks $FROM_BLOCK -> $latest_block (chunk $CHUNK)" >&2 +: "${ETHERSCAN_KEY:?Set ETHERSCAN_KEY in .env or environment}" +echo "# scanning UserDepositChange logs, blocks ${FROM_BLOCK} -> ${TO_BLOCK} (Etherscan)" >&2 mkdir -p "$OUT_DIR" for salt in "${SALTS[@]}"; do proxy=$(compute_create3_address "${SALT_PREFIX}::${salt}") out_file="${OUT_DIR}/${salt}.txt" + echo " scanning ${salt} (${proxy})..." >&2 - # Collect, dedupe, checksum. - mapfile -t owners < <(fetch_owners "$proxy" "$latest_block" | sort -uf) + mapfile -t owners < <(fetch_owners "$proxy" | sort -uf) { echo "# Stability pool holders: ${salt}" echo "# proxy: ${proxy}" - echo "# source: UserDepositChange logs, blocks ${FROM_BLOCK}-${latest_block}" + echo "# source: UserDepositChange logs (Etherscan), blocks ${FROM_BLOCK}-${TO_BLOCK}" echo "# generated: $(date -Iseconds)" for addr in "${owners[@]}"; do [[ -z "$addr" ]] && continue cast to-check-sum-address "$addr" done - } >"$out_file" + } > "$out_file" - echo " ${salt}: ${#owners[@]} holders -> ${out_file}" >&2 + echo " ${salt}: ${#owners[@]} holders -> ${out_file}" >&2 done echo "# done -> ${OUT_DIR}/" >&2 diff --git a/script/verify/sp-v2-data-prep-for-v3/migrate-StabilityPool_v2-data.md b/script/verify/sp-v2-data-prep-for-v3/migrate-StabilityPool_v2-data.md new file mode 100644 index 00000000..c460c615 --- /dev/null +++ b/script/verify/sp-v2-data-prep-for-v3/migrate-StabilityPool_v2-data.md @@ -0,0 +1,106 @@ +# StabilityPool v2 accumulator data migration (prep for v3) + +Force-migrate every stability pool's per-user reward data from the legacy **V1** +(`uint192` integral) format to the **V2** (`uint256` integral) format, then restore each +pool to its current `StabilityPool_v2` implementation. + +## Why + +When the pools went v1 → v2, the reward integral was widened to `uint256`. To avoid +disrupting users, v2's accumulator lazily migrates a user's data on first interaction and +keeps a **V1 read-fallback** for those not yet touched +(`MultipleRewardCompoundingAccumulator_v2._getUserRewardSnapshot`). That fallback can only +be removed safely once **every** remaining user is in V2 format. + +This step force-migrates the stragglers so a later `StabilityPool_v3` upgrade can delete the +fallback. **It ends on v2** — it does not upgrade to v3. The migration is a pure storage +copy: no user-visible value (balance, claimable, claimed, withdrawal request) changes. + +Key facts (verified): + +- The V1 mapping is **never written** by v2 — so the set of users needing migration was + frozen at the v1→v2 upgrade and can only shrink (users self-migrate on interaction). New + depositors are born directly in V2. +- `remediate` is a **pure copy** (V1 integral/timestamp/pending → V2), idempotent (skips + already-migrated and no-V1-data entries). The old V1 slot is never modified. +- The migration covers **active + historical** reward tokens: a user can carry V1 data for a + no-longer-active token, and `_checkpoint` snapshots both. + +## Pieces + +| File | Role | +|------|------| +| `collect-sp-holders` | Discover holders per pool via Etherscan `UserDepositChange` logs → `tmp/sp-holders/.txt` | +| `../../Migrate_StabilityPool_v2_Data_mainnet.s.sol` | The migration: per pool, queue upgrade→ForceMigrate, `remediate(tokens, holders)`, restore→v2. Reads the holder files. | +| `MigrateCaptureTest.t.sol` | **Prong A** (black-box): capture all pool/holder state + interactions to JSON for a before/after diff | +| `MigrateBalancesTest.t.sol` | **Prong B** (white-box): upgrade→ForceMigrate, assert `balances()` copies V1→V2 correctly | +| `run-migrate-StabilityPool_v2-data` | Local end-to-end verification driver | + +Holder discovery uses `UserDepositChange(owner,…)` — the event emitted whenever an account is +checkpointed (deposit→receiver, withdraw→sender, liquidation→account). The distinct `owner` +set is exactly the accounts that may carry V1 data. (Over-approximation is harmless: +`remediate` skips accounts with no V1 data.) This was cross-checked against the holder list +hardcoded in `script/test/MainnetForkUpgradeTest.t.sol`: the discovery set is complete and +precise (it found real holders that list missed, and the addresses that list had but discovery +did not were verified to have zero `UserDepositChange`/`Deposit`-as-receiver events). + +## Local verification (one command) + +Start nothing first — the runner prompts you. It regenerates holders, captures before, runs +both prongs + the real migrate script against a local anvil fork, captures after, and diffs. + +```bash +script/verify/sp-v2-data-prep-for-v3/run-migrate-StabilityPool_v2-data +# options: --block (default 25186514, matched to discovery), --include +``` + +What it does, in one anvil session (`forge test` forks in-memory; `run-script --broadcast +--local` persists to anvil — so the order is correct without cycling anvil): + +1. Discover holders → `tmp/sp-holders/` (Etherscan, `--to-block `). +2. **Prong A**: `VERSION=before` capture → `tmp/before/{pre,post}/*.json`. +3. **Prong B**: `MigrateBalancesTest` asserts the V1→V2 copy on the un-migrated node. +4. Run the **actual** `Migrate_StabilityPool_v2_Data_mainnet` (`--broadcast --local`) — this + persists upgrade→remediate→restore to anvil. +5. **Prong A**: `VERSION=after` capture → `tmp/after/{pre,post}/*.json`. +6. `diff -ru tmp/before tmp/after` — **MUST be empty** (the migration changed nothing + observable). `meld tmp/before tmp/after` if available. + +The empty diff also proves the pure-copy ≡ on-demand equivalence: "before" reads unmigrated +users via the V1 fallback, "after" reads the same users via pure-copied V2 data. + +## Producing the mainnet Safe batch + +```bash +# 1. Discover holders (fixed --to-block for reproducibility) +script/verify/sp-v2-data-prep-for-v3/collect-sp-holders --to-block + +# 2. Build the Safe batch JSON (no --local: writes deployments/mainnet/batch/*.json) +script/run-script Migrate_StabilityPool_v2_Data_mainnet --salt harbor_v1 --network mainnet --broadcast +``` + +The holder lists are embedded in each pool's `remediate` calldata in the batch JSON, so the +exact migrated set is auditable in the transaction the Safe signs. + +## Re-run / completeness check (after the Safe batch executes) + +The needs-migration set is frozen and can only shrink, so a post-execution re-run cannot find +genuinely-new users that need migration — it catches a *discovery miss*. To verify: + +```bash +# Re-discover up to a later block, into a separate dir, and diff. +script/verify/sp-v2-data-prep-for-v3/collect-sp-holders --to-block latest --out-dir tmp/sp-holders-rerun +diff -ru tmp/sp-holders tmp/sp-holders-rerun +``` + +Any address present only in the re-run is either a user who self-migrated in the interim +(harmless — already V2) or a new depositor (born in V2). If you want to be certain none carry +unmigrated V1 data, run `MigrateBalancesTest` (Prong B) against a fork with the re-run holder +files: it flags any holder with `oldIntegral != 0 && newIntegral == 0`. + +## Notes + +- `tmp/sp-holders/` is ephemeral (gitignored). The runner regenerates it; the mainnet flow + regenerates it in step 1 above. +- Fork block default (`25186514`) is kept in sync between `collect-sp-holders` (`TO_BLOCK`) and + the runner (`BLOCK`) so the verification forks exactly the state the holders were found in. diff --git a/script/verify/sp-v2-data-prep-for-v3/run-migrate-StabilityPool_v2-data b/script/verify/sp-v2-data-prep-for-v3/run-migrate-StabilityPool_v2-data index ee8c9eed..9bf5b354 100755 --- a/script/verify/sp-v2-data-prep-for-v3/run-migrate-StabilityPool_v2-data +++ b/script/verify/sp-v2-data-prep-for-v3/run-migrate-StabilityPool_v2-data @@ -16,43 +16,53 @@ # So a single anvil session works: in-memory captures/checks see the un-migrated node, the # migrate script mutates it, then the after-capture forks the migrated node. START_TIMESTAMP # normalizes the timestamp drift the migrate txs introduce. +# +# The fork block is resolved to a CONCRETE number once, then used identically for holder +# discovery (collect-sp-holders --to-block), the anvil fork, and START_TIMESTAMP — so the +# discovered set and the forked state always match. A full run records that block; later +# runs can reuse it (--block capture / --skip-capture). +# +# Usage: +# run-migrate-StabilityPool_v2-data # full run at latest (records the block) +# run-migrate-StabilityPool_v2-data --skip-capture # reuse saved holders + before-capture + block +# run-migrate-StabilityPool_v2-data --block # full run at a specific block +# run-migrate-StabilityPool_v2-data --block capture # full run at the last recorded block +# options: --include set -euo pipefail cd "$(git rev-parse --show-toplevel)" # ── Configuration ──────────────────────────────────────────────────────────── -BLOCK="${BLOCK:-latest}" SALT="${SALT:-harbor_v1}" NETWORK="${NETWORK:-mainnet}" POOL_FILTER="${POOL_FILTER:-}" FORGE_VERBOSITY="${FORGE_VERBOSITY:--vv}" -CAPTURE_TEST="script/verify/sp-v2-upgrade-prep-for-v3/MigrateCaptureTest.t.sol" -BALANCES_TEST="script/verify/sp-v2-upgrade-prep-for-v3/MigrateBalancesTest.t.sol" +HERE="$(dirname "$0")" +CAPTURE_TEST="script/verify/sp-v2-data-prep-for-v3/MigrateCaptureTest.t.sol" +BALANCES_TEST="script/verify/sp-v2-data-prep-for-v3/MigrateBalancesTest.t.sol" +HOLDERS_DIR="tmp/sp-holders" +BLOCK_FILE="tmp/sp-capture-block" # records the concrete block a full capture used + +BLOCK="" # n | latest | capture ; default chosen below +SKIP_CAPTURE=false while [[ $# -gt 0 ]]; do case "$1" in - --include) - POOL_FILTER="$2" - shift 2 - ;; - --block) - BLOCK="$2" - shift 2 - ;; + --include) POOL_FILTER="$2"; shift 2 ;; + --block) BLOCK="$2"; shift 2 ;; + --skip-capture) SKIP_CAPTURE=true; shift ;; -h | --help) - echo "Usage: $(basename "$0") [--block ] [--include ]" - echo "" - echo "Env: BLOCK, SALT (default harbor_v1), NETWORK (default mainnet)," - echo " POOL_FILTER, FORGE_VERBOSITY (default -vv)" - exit 0 - ;; - *) - echo "Unknown option: $1" >&2 - exit 1 - ;; + sed -n '/^# Usage:/,/pool-filter/p' "$0" | sed 's/^# \{0,1\}//' + exit 0 ;; + *) echo "Unknown option: $1" >&2; exit 1 ;; esac done +# Default block: full run -> latest; skip-capture -> the recorded capture block. +if [[ -z "$BLOCK" ]]; then + if $SKIP_CAPTURE; then BLOCK="capture"; else BLOCK="latest"; fi +fi + prompt() { echo "" echo "──────────────────────────────────────────────────────────────" @@ -75,25 +85,65 @@ capture() { env "${env_args[@]}" forge test --match-path "$CAPTURE_TEST" --fork-url local $FORGE_VERBOSITY } +# ── Resolve the fork block to a concrete number (used everywhere) ───────────── +case "$BLOCK" in + latest) + RESOLVED=$(cast block-number --rpc-url "$NETWORK") + ;; + capture) + if [[ ! -f "$BLOCK_FILE" ]]; then + echo "ERROR: no recorded capture block at $BLOCK_FILE — run a full capture first." >&2 + exit 1 + fi + RESOLVED=$(cat "$BLOCK_FILE") + ;; + *) + RESOLVED="$BLOCK" + ;; +esac + echo "======================================================================" echo " StabilityPool_v2 accumulator V1->V2 force-migration verification" echo "======================================================================" -echo " Fork block: $BLOCK" -echo " Salt: $SALT" -[[ -n "$POOL_FILTER" ]] && echo " Pool filter: $POOL_FILTER" +echo " Fork block: $RESOLVED (requested: $BLOCK)" +echo " Salt: $SALT" +echo " Skip capture: $SKIP_CAPTURE" +[[ -n "$POOL_FILTER" ]] && echo " Pool filter: $POOL_FILTER" -# Compute START_TIMESTAMP from the fork block (shared by before/after captures). -echo "" -echo "Computing START_TIMESTAMP from block $BLOCK..." -TS=$(cast block --rpc-url "$NETWORK" "$BLOCK" -f timestamp) +# START_TIMESTAMP from the resolved block (shared by before/after captures). +TS=$(cast block --rpc-url "$NETWORK" "$RESOLVED" -f timestamp) START_TIMESTAMP=$((TS + 12000)) -echo " block timestamp: $TS" -echo " START_TIMESTAMP: $START_TIMESTAMP (+12000s buffer)" +echo " START_TIMESTAMP: $START_TIMESTAMP (block ts $TS + 12000s)" + +if ! $SKIP_CAPTURE; then + # Record the block this capture is built against, so --block capture / --skip-capture reuse it. + mkdir -p "$(dirname "$BLOCK_FILE")" + echo "$RESOLVED" > "$BLOCK_FILE" -prompt "Start a fresh anvil fork: script/anvil --block $BLOCK" + # Step 0: (re)generate holder files at the SAME block (Etherscan; needs ETHERSCAN_KEY). + echo "" + echo "Discovering holders -> $HOLDERS_DIR (Etherscan, to-block $RESOLVED)..." + "$HERE/collect-sp-holders" --to-block "$RESOLVED" +else + # Reuse saved artifacts; fail clearly if they are missing. + if ! ls "$HOLDERS_DIR"/*.txt >/dev/null 2>&1; then + echo "ERROR: --skip-capture but no holder files in $HOLDERS_DIR — run a full capture first." >&2 + exit 1 + fi + if [[ ! -d tmp/before ]]; then + echo "ERROR: --skip-capture but tmp/before is missing — run a full capture first." >&2 + exit 1 + fi + echo "" + echo "Skip-capture: reusing $HOLDERS_DIR, tmp/before, and block $RESOLVED." +fi -# ── Prong A: capture BEFORE (in-memory; does not mutate anvil) ──────────────── -capture before +prompt "Start a fresh anvil fork: script/anvil --block $RESOLVED" + +# ── Prong A: capture BEFORE (skipped on --skip-capture; reuses saved tmp/before) ── +if ! $SKIP_CAPTURE; then + capture before +fi # ── Prong B: white-box balances() check (in-memory; un-migrated node) ───────── echo "" From 3e0e2f5d9551792cc2ddaf629edb7427aae9e96b Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Sat, 30 May 2026 14:33:39 +0100 Subject: [PATCH 093/232] v1->v2 data migration verification --- regression/coverage.txt | 2 +- ...igrate_StabilityPool_v2_Data_mainnet.s.sol | 2 +- script/Remediate_Accumulators.s.sol | 251 ----------------- script/Remediate_SPL_ETH_fxUSD.s.sol | 155 ----------- .../ForceMigrateAccumulator_v1.sol | 27 +- .../MigrateBalancesTest.t.sol | 47 +++- .../MigrateCaptureTest.t.sol | 14 +- .../migrate-StabilityPool_v2-data.md | 45 ++- .../run-migrate-StabilityPool_v2-data | 150 ++++++---- .../sp-v3-migration/SPv3MigrationTest.t.sol | 259 ------------------ 10 files changed, 205 insertions(+), 747 deletions(-) delete mode 100644 script/Remediate_Accumulators.s.sol delete mode 100644 script/Remediate_SPL_ETH_fxUSD.s.sol rename script/verify/{sp-v3-migration => sp-v2-data-prep-for-v3}/ForceMigrateAccumulator_v1.sol (90%) delete mode 100644 script/verify/sp-v3-migration/SPv3MigrationTest.t.sol diff --git a/regression/coverage.txt b/regression/coverage.txt index e2579e2e..7b028b2e 100644 --- a/regression/coverage.txt +++ b/regression/coverage.txt @@ -65,4 +65,4 @@ | src/util/ERC20MetadataLib_v1.sol | ✓ 100% (24/24) | ✓ 100% (22/22) | ✓ 100% (4/4) | ✓ 100% (4/4) | | src/util/FmtLib.sol | X 79% (15/19) | X 73% (19/26) | X 50% (2/4) | ✓ 100% (1/1) | | src/util/WordCodec.sol | ✓ 100% (15/15) | ✓ 100% (14/14) | ✓ 100% (0/0) | ✓ 100% (4/4) | -| Total | X 55% (4426/8096) | X 53% (4682/8756) | X 42% (388/928) | X 55% (637/1162) | +| Total | X 56% (4426/7959) | X 55% (4682/8556) | X 43% (388/896) | X 56% (637/1139) | diff --git a/script/Migrate_StabilityPool_v2_Data_mainnet.s.sol b/script/Migrate_StabilityPool_v2_Data_mainnet.s.sol index 33851e58..cb3f757d 100644 --- a/script/Migrate_StabilityPool_v2_Data_mainnet.s.sol +++ b/script/Migrate_StabilityPool_v2_Data_mainnet.s.sol @@ -6,7 +6,7 @@ import {LibString} from "@solady/utils/LibString.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import {Config_MinterMarket, MinterMarketConfigLib} from "@harbor-script/config/ConfigBase.sol"; -import {ForceMigrateAccumulator_v1} from "@harbor-script/verify/sp-v3-migration/ForceMigrateAccumulator_v1.sol"; +import {ForceMigrateAccumulator_v1} from "@harbor-script/verify/sp-v2-data-prep-for-v3/ForceMigrateAccumulator_v1.sol"; import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; import {Deploy_BTC_Minter} from "@harbor-script/src/Deploy_BTC_Minter.sol"; diff --git a/script/Remediate_Accumulators.s.sol b/script/Remediate_Accumulators.s.sol deleted file mode 100644 index 0df33228..00000000 --- a/script/Remediate_Accumulators.s.sol +++ /dev/null @@ -1,251 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.28 <0.9.0; - -import {console2 as console} from "forge-std/console2.sol"; -import {LibString} from "@solady/utils/LibString.sol"; -import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; -import {Config_MinterMarket, MinterMarketConfigLib} from "@harbor-script/config/ConfigBase.sol"; - -import {ForceMigrateAccumulator_v1} from "@harbor-script/verify/sp-v3-migration/ForceMigrateAccumulator_v1.sol"; -import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; - -import {Deploy_BTC_Minter} from "@harbor-script/src/Deploy_BTC_Minter.sol"; -import {Deploy_ETH_Minter} from "@harbor-script/src/Deploy_ETH_Minter.sol"; -import {Deploy_EUR_Minter} from "@harbor-script/src/Deploy_EUR_Minter.sol"; -import {Deploy_GOLD_Minter} from "@harbor-script/src/Deploy_GOLD_Minter.sol"; -import {Deploy_MCAP_Minter} from "@harbor-script/src/Deploy_MCAP_Minter.sol"; -import {Deploy_SILVER_Minter} from "@harbor-script/src/Deploy_SILVER_Minter.sol"; - -import {Script} from "forge-std/Script.sol"; -import {HarborDeployer} from "@harbor-script/src/HarborDeployer.sol"; - -/// @notice Force-migrate accumulator storage from V1 (uint192) to V2 (uint256) format -/// for all stability pools across all markets. -/// -/// Per pool, the Safe batch contains 3 atomic transactions: -/// 1. Upgrade proxy → ForceMigrateAccumulator_v1 -/// 2. Call remediate(tokens, holders) to copy V1 → V2 -/// 3. Restore proxy → existing StabilityPool_v2 implementation -/// -/// After this, all users have V2 data. The V1 fallback in the accumulator -/// still exists but never triggers. A subsequent upgrade to StabilityPool_v3 -/// removes the fallback permanently. -/// -/// Run via: -/// script/run-script Remediate_Accumulators --salt harbor_v1 --network mainnet --broadcast --local -contract Remediate_Accumulators is - Script, - Deploy_BTC_Minter, - Deploy_ETH_Minter, - Deploy_EUR_Minter, - Deploy_GOLD_Minter, - Deploy_MCAP_Minter, - Deploy_SILVER_Minter -{ - using LibString for address; - - // StabilityPoolCollateral and StabilityPoolLeveraged inherited from StabilityPool deployment helper - - // ERC1967 implementation slot - bytes32 constant IMPL_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; - - // Deployed once, shared across all pools (no constructor params) - address migImpl; - - function _doOneMinter(Config_MinterMarket[] memory markets) internal { - for (uint256 i = 0; i < markets.length; i++) { - string memory marketKey = MinterMarketConfigLib.salt(markets[i]); - - _remediatePool(marketKey, StabilityPoolCollateral); - _remediatePool(marketKey, StabilityPoolLeveraged); - } - } - - function _remediatePool(string memory marketKey, string memory spType) internal { - string memory key = _key(marketKey, spType); - address pool = _predictAddress(key); - - // Read current implementation (to restore after remediation) - address currentImpl = address(uint160(uint256(vm.load(pool, IMPL_SLOT)))); - require(currentImpl.code.length != 0, string.concat("no impl for ", _saltString(key))); - - // Read active reward tokens before upgrade (pauser fallback would revert) - address[] memory tokens = IMultipleRewardDistributor(pool).activeRewardTokens(); - - // Get holders for this pool (defined per-pool below) - address[] memory holders = _getHolders(marketKey, spType); - - if (holders.length == 0) { - console.log(" > %s: no holders, skipping", _saltString(key)); - return; - } - - console.log(" > %s: %d holders, %d tokens", _saltString(key), holders.length, tokens.length); - - // 1. Upgrade to migration contract - queue( - key, - abi.encodeCall(UUPSUpgradeable.upgradeToAndCall, (migImpl, "")), - "upgrade to ForceMigrateAccumulator_v1" - ); - - // 2. Remediate - queue( - pool, - abi.encodeCall(ForceMigrateAccumulator_v1.remediate, (tokens, holders)), - string.concat("remediate ", _saltString(key)) - ); - - // 3. Restore to original implementation - queue( - key, - abi.encodeCall(UUPSUpgradeable.upgradeToAndCall, (currentImpl, "")), - string.concat("restore to ", currentImpl.toHexString()) - ); - } - - function build() internal override { - Config_MinterMarket[] memory markets; - - vm.startBroadcast(); - migImpl = address(new ForceMigrateAccumulator_v1()); - console.log(" Migration impl: %s", migImpl); - vm.stopBroadcast(); - - (, markets) = createBTCMintersConfig(); - _doOneMinter(markets); - - (, markets) = createETHMintersConfig(); - _doOneMinter(markets); - - (, markets) = createEURMintersConfig(); - _doOneMinter(markets); - - (, markets) = createGOLDMintersConfig(); - _doOneMinter(markets); - - (, markets) = createMCAPMintersConfig(); - _doOneMinter(markets); - - (, markets) = createSILVERMintersConfig(); - _doOneMinter(markets); - } - - // ═══════════════════════════════════════════════════════════════════════ - // Holder lists per pool - // Source: Etherscan tokentx API query, 2026-03-28 - // Pools with 0 holders are omitted (MCAP markets, some GOLD/SILVER) - // ═══════════════════════════════════════════════════════════════════════ - - // solhint-disable func-name-mixedcase - - function _getHolders(string memory marketKey, string memory spType) internal pure returns (address[] memory) { - bytes32 key = keccak256(abi.encodePacked(marketKey, "::", spType)); - - if (key == keccak256("BTC::fxUSD::stabilityPoolCollateral")) return _holders_BTC_fxUSD_Col(); - if (key == keccak256("BTC::fxUSD::stabilityPoolLeveraged")) return _holders_BTC_fxUSD_Lev(); - if (key == keccak256("BTC::stETH::stabilityPoolCollateral")) return _holders_BTC_stETH_Col(); - if (key == keccak256("BTC::stETH::stabilityPoolLeveraged")) return _holders_BTC_stETH_Lev(); - if (key == keccak256("ETH::fxUSD::stabilityPoolCollateral")) return _holders_ETH_fxUSD_Col(); - if (key == keccak256("ETH::fxUSD::stabilityPoolLeveraged")) return _holders_ETH_fxUSD_Lev(); - if (key == keccak256("EUR::fxUSD::stabilityPoolCollateral")) return _holders_EUR_fxUSD_Col(); - if (key == keccak256("EUR::fxUSD::stabilityPoolLeveraged")) return _holders_EUR_fxUSD_Lev(); - if (key == keccak256("EUR::stETH::stabilityPoolCollateral")) return _holders_EUR_stETH_Col(); - if (key == keccak256("EUR::stETH::stabilityPoolLeveraged")) return _holders_EUR_stETH_Lev(); - if (key == keccak256("GOLD::fxUSD::stabilityPoolCollateral")) return _holders_GOLD_fxUSD_Col(); - if (key == keccak256("GOLD::fxUSD::stabilityPoolLeveraged")) return _holders_GOLD_fxUSD_Lev(); - if (key == keccak256("GOLD::stETH::stabilityPoolCollateral")) return _holders_GOLD_stETH_Col(); - if (key == keccak256("GOLD::stETH::stabilityPoolLeveraged")) return _holders_GOLD_stETH_Lev(); - if (key == keccak256("SILVER::fxUSD::stabilityPoolCollateral")) return _holders_SILVER_fxUSD_Col(); - if (key == keccak256("SILVER::fxUSD::stabilityPoolLeveraged")) return _holders_SILVER_fxUSD_Lev(); - if (key == keccak256("SILVER::stETH::stabilityPoolCollateral")) return _holders_SILVER_stETH_Col(); - if (key == keccak256("SILVER::stETH::stabilityPoolLeveraged")) return _holders_SILVER_stETH_Lev(); - - // MCAP pools: 0 holders - return new address[](0); - } - - function _holders_BTC_fxUSD_Col() internal pure returns (address[] memory h) { - h = new address[](9); - h[0] = 0x061B84FDe0aa74ecbF8eCDB0481576feE9Ae35aa; - h[1] = 0x1a9152528AEFbcD9E5df4E0770f4F510e7056913; - h[2] = 0x9bABfC1A1952a6ed2caC1922BFfE80c0506364a2; - h[3] = 0x9Dd897df19FfC27d6685E98Accc394f88a73e475; - h[4] = 0xaa17879e7cac3AEE12D6aa568691e638EF0C57f0; - h[5] = 0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e; - h[6] = 0xb9ab9578a34a05c86124c399735fdE44dEc80E7F; - h[7] = 0xbA26035B9CD76cdA5b767A966b8A3392E476Fc0F; - h[8] = 0xDD4dAd7E9FD518e271bEA1d820B95E3215D735D5; - } - - function _holders_BTC_fxUSD_Lev() internal pure returns (address[] memory h) { - h = new address[](13); - h[0] = 0x2880a6bb2cD1DF6E03dC8BbFBEd009DE586c2603; - h[1] = 0x5dE79E0C5632056B9FB19a740cE0f3EF03adEEB3; - h[2] = 0x742fC5146d7Ff18291E3B7499811AD87015Fc7E4; - h[3] = 0x754Ba099408892F500e3675b9816ea1B0dc33CBb; - h[4] = 0x7e4f98217A085F1a06332EDff805513b6Ea79357; - h[5] = 0x9Af8FBF66Bf3645f505D58614D7a13D411b99907; - h[6] = 0x9bABfC1A1952a6ed2caC1922BFfE80c0506364a2; - h[7] = 0x9Dd897df19FfC27d6685E98Accc394f88a73e475; - h[8] = 0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e; - h[9] = 0xb9ab9578a34a05c86124c399735fdE44dEc80E7F; - h[10] = 0xbA26035B9CD76cdA5b767A966b8A3392E476Fc0F; - h[11] = 0xDD0CDF8D98d9Ad3ADfaa49AaECD444Bfa01d9C9a; - h[12] = 0xDD4dAd7E9FD518e271bEA1d820B95E3215D735D5; - } - - // TODO: Add remaining holder lists for all pools - // For now, returning empty arrays for pools not yet populated - - function _holders_BTC_stETH_Col() internal pure returns (address[] memory) { - return new address[](0); - } - function _holders_BTC_stETH_Lev() internal pure returns (address[] memory) { - return new address[](0); - } - function _holders_ETH_fxUSD_Col() internal pure returns (address[] memory) { - return new address[](0); - } - function _holders_ETH_fxUSD_Lev() internal pure returns (address[] memory) { - return new address[](0); - } - function _holders_EUR_fxUSD_Col() internal pure returns (address[] memory) { - return new address[](0); - } - function _holders_EUR_fxUSD_Lev() internal pure returns (address[] memory) { - return new address[](0); - } - function _holders_EUR_stETH_Col() internal pure returns (address[] memory) { - return new address[](0); - } - function _holders_EUR_stETH_Lev() internal pure returns (address[] memory) { - return new address[](0); - } - function _holders_GOLD_fxUSD_Col() internal pure returns (address[] memory) { - return new address[](0); - } - function _holders_GOLD_fxUSD_Lev() internal pure returns (address[] memory) { - return new address[](0); - } - function _holders_GOLD_stETH_Col() internal pure returns (address[] memory) { - return new address[](0); - } - function _holders_GOLD_stETH_Lev() internal pure returns (address[] memory) { - return new address[](0); - } - function _holders_SILVER_fxUSD_Col() internal pure returns (address[] memory) { - return new address[](0); - } - function _holders_SILVER_fxUSD_Lev() internal pure returns (address[] memory) { - return new address[](0); - } - function _holders_SILVER_stETH_Col() internal pure returns (address[] memory) { - return new address[](0); - } - function _holders_SILVER_stETH_Lev() internal pure returns (address[] memory) { - return new address[](0); - } - - // solhint-enable func-name-mixedcase -} diff --git a/script/Remediate_SPL_ETH_fxUSD.s.sol b/script/Remediate_SPL_ETH_fxUSD.s.sol deleted file mode 100644 index e3c48e0b..00000000 --- a/script/Remediate_SPL_ETH_fxUSD.s.sol +++ /dev/null @@ -1,155 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.28 <0.9.0; - -import {LibString} from "@solady/utils/LibString.sol"; -import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; -import {PostRebalanceRemediationForStabilityPool_v2} from "@harbor-script/verify/spl-remediation/PostRebalanceRemediationForStabilityPool_v2.sol"; -import {Script} from "forge-std/Script.sol"; -import {HarborDeployer} from "@harbor-script/src/HarborDeployer.sol"; -import {WellKnownAddress} from "@bao-script/deployment/FactoryDeployer.sol"; -import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {IMinter} from "@harbor/interfaces/IMinter.sol"; -import {IBurnableRole} from "@bao/interfaces/IBurnableRole.sol"; - -interface Ownable { - function owner() external view returns (address); -} - -/// @notice Deploy remediation implementation and queue a Safe batch transaction for -/// the ETH::fxUSD sail stability pool (SPL) remediation. -/// -/// Produces one Safe batch file (03_remediate) containing 8 atomic transactions: -/// 1. Grant BURNER_ROLE to SPL on sailETH -/// 2. Grant ZERO_FEE_ROLE to SPL on minter -/// 3. Transfer 82.47 fxSAVE to SPL for collateral restoration -/// 4. Upgrade SPL to remediation contract and execute remediate() -/// 5. Restore SPL to StabilityPool_v2 -/// 6. Revoke BURNER_ROLE from SPL -/// 7. Revoke ZERO_FEE_ROLE from SPL -/// -/// Prerequisites (already executed on mainnet): -/// - SPL paused via Pause_SPL_ETH_fxUSD (proxy points to BaoPauser_v1) -/// - Claimer approved SPL to burnFrom 0.052 sailETH -/// - Bounty receiver approved SPL to burnFrom 0.088 sailETH -/// Minter_v2 upgrade is handled separately via Deploy_Minter_v2_mainnet. -/// -/// @dev See doc/remediation-ETH-fxUSD-SPL.md for full context. -/// @dev Run via: -/// script/run-script Remediate_SPL_ETH_fxUSD --salt harbor_v1 --network mainnet --broadcast --local -contract Remediate_SPL_ETH_fxUSD is Script, HarborDeployer { - using LibString for address; - - // ── Addresses ──────────────────────────────────────────────────────── - address constant BAO_PAUSER = 0xd8785d5C51aaDEb3AD1D015Cd67C8A34dBf58f61; - address constant EXISTING_V2_IMPL = 0x6C0D48839A0B1c9D79dDD4Ad3f407709E0f44be1; - address constant LEVERAGED = 0x0Cd6BB1a0cfD95e2779EDC6D17b664B481f2EB4C; // sailETH token - address constant MINTER = 0xd6E2F8e57b4aFB51C6fA4cbC012e1cE6aEad989F; - address constant WRAPPED_COLLATERAL = 0x7743e50F534a7f9F1791DdE7dCD89F7783Eefc39; // fxSAVE - address constant SPL = 0x438B29EC7a1770dDbA37D792F1A6e76231Ef8E06; - address constant REMEDIATOR_IMPL = 0x1aE2baBA0c81CA98ae1F6B1dB87A367b8f13E112; - - // ── Burn targets ───────────────────────────────────────────────────── - // Claimer claimed between v1 rebalances; holds 0.052 excess sailETH in wallet - address constant CLAIMER = 0xb9ab9578a34a05c86124c399735fdE44dEc80E7F; - uint256 constant CLAIMER_EXCESS = 0.052087080191248362 ether; - - // Bounty receiver from v1 rebalance #1; holds 0.088 excess sailETH - address constant BOUNTY_RECEIVER = 0xf1674FE69b2920b4de51E909cbf060dd78724CD8; - uint256 constant BOUNTY_EXCESS = 0.087632380029141447 ether; - - // ── Collateral gap ─────────────────────────────────────────────────── - // fxSAVE to deposit into minter to restore collateral extracted by Exiter's redeems - uint256 constant COLLATERAL_GAP = 82.466171119621162782 ether; - - function getWellKnownAddresses() public view override returns (WellKnownAddress[] memory addrs) { - WellKnownAddress[] memory base = super.getWellKnownAddresses(); - addrs = new WellKnownAddress[](base.length + 1); - for (uint256 i = 0; i < base.length; i++) addrs[i] = base[i]; - addrs[base.length] = WellKnownAddress({addr: CLAIMER, label: "claimer"}); - } - - function build() internal override { - string memory splKey = _key("ETH", "fxUSD", "stabilityPoolLeveraged"); - address spl = _predictAddress(splKey); - - require( - LEVERAGED == _predictAddress(_key("ETH", "fxUSD", "leveraged")), - "LEVERAGED is not the correct address" - ); - - // Verify the proxy is paused - address currentImpl = address( - uint160(uint256(vm.load(spl, 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc))) - ); - require(currentImpl == BAO_PAUSER, "SPL is not paused - run Pause_SPL_ETH_fxUSD first"); - - // Deploy remediation implementation - // vm.startBroadcast(); - // PostRebalanceRemediationForStabilityPool_v2 remediationImpl = new PostRebalanceRemediationForStabilityPool_v2( - // LEVERAGED, - // MINTER, - // owner() - // ); - // vm.stopBroadcast(); - require(REMEDIATOR_IMPL.code.length != 0, "remediator implementation not deployed"); - require(Ownable(REMEDIATOR_IMPL).owner() == Ownable(SPL).owner(), "remediator not owned correctly (vs SPL)"); - require(Ownable(REMEDIATOR_IMPL).owner() == Ownable(MINTER).owner(), "remediator owner != minter"); - - // Claimer and bounty receiver approvals already executed on mainnet. - - // ── Single batch: grant, fund, remediate, restore, revoke, upgrade ── - // Signed by: harbor multisig - // All transactions execute atomically in one Safe batch. - uint256 ZERO_FEE_ROLE = IMinter(MINTER).ZERO_FEE_ROLE(); - uint256 BURNER_ROLE = IBurnableRole(LEVERAGED).BURNER_ROLE(); - - // 1. Grant temporary roles - queue( - LEVERAGED, - abi.encodeCall(IBaoRoles.grantRoles, (spl, BURNER_ROLE)), - "grant BURNER_ROLE to SPL on sailETH" - ); - queue( - MINTER, - abi.encodeCall(IBaoRoles.grantRoles, (spl, ZERO_FEE_ROLE)), - "grant ZERO_FEE_ROLE to SPL on minter" - ); - - // 2. Transfer fxSAVE to SPL for collateral restoration - queue( - WRAPPED_COLLATERAL, - abi.encodeCall(IERC20.transfer, (spl, COLLATERAL_GAP)), - "transfer 82.47 fxSAVE to SPL" - ); - - // 3. Upgrade SPL to remediation contract and execute remediate() - queue( - splKey, - abi.encodeCall( - UUPSUpgradeable.upgradeToAndCall, - (REMEDIATOR_IMPL, abi.encodeCall(PostRebalanceRemediationForStabilityPool_v2.remediate, ())) - ), - string.concat("remediate: ", REMEDIATOR_IMPL.toHexString()) - ); - - // 4. Restore SPL to StabilityPool_v2 - queue( - splKey, - abi.encodeCall(UUPSUpgradeable.upgradeToAndCall, (EXISTING_V2_IMPL, "")), - string.concat("restore SPL: ", EXISTING_V2_IMPL.toHexString()) - ); - - // 5. Revoke temporary roles - queue( - LEVERAGED, - abi.encodeCall(IBaoRoles.revokeRoles, (spl, BURNER_ROLE)), - "revoke BURNER_ROLE from SPL on sailETH" - ); - queue( - MINTER, - abi.encodeCall(IBaoRoles.revokeRoles, (spl, ZERO_FEE_ROLE)), - "revoke ZERO_FEE_ROLE from SPL on minter" - ); - } -} diff --git a/script/verify/sp-v3-migration/ForceMigrateAccumulator_v1.sol b/script/verify/sp-v2-data-prep-for-v3/ForceMigrateAccumulator_v1.sol similarity index 90% rename from script/verify/sp-v3-migration/ForceMigrateAccumulator_v1.sol rename to script/verify/sp-v2-data-prep-for-v3/ForceMigrateAccumulator_v1.sol index 47258fbd..7c11397a 100644 --- a/script/verify/sp-v3-migration/ForceMigrateAccumulator_v1.sol +++ b/script/verify/sp-v2-data-prep-for-v3/ForceMigrateAccumulator_v1.sol @@ -24,16 +24,16 @@ import {HarborPauser_v1} from "@bao/HarborPauser_v1.sol"; contract ForceMigrateAccumulator_v1 is HarborPauser_v1 { // ── Storage layout (mirrors Accumulator_v2) ───────────────────────────── - struct ClaimData { - uint128 pending; - uint128 claimed; - } - struct RewardSnapshot { uint64 timestamp; uint192 integral; } + struct ClaimData { + uint128 pending; + uint128 claimed; + } + /// @dev V1: 2 slots per entry. struct UserRewardSnapshot { ClaimData rewards; @@ -48,8 +48,10 @@ contract ForceMigrateAccumulator_v1 is HarborPauser_v1 { } struct AccumulatorStorage { + // these are not used but are needed as placeholders mapping(address => address) rewardReceiver; mapping(address => mapping(uint8 => uint256)) tokenToExponentToIntegral; + // user -> token -> reward snapshot mapping(address => mapping(address => UserRewardSnapshot)) userRewardSnapshot; mapping(address => mapping(address => UserRewardSnapshotV2)) userRewardSnapshotV2; } @@ -79,6 +81,15 @@ contract ForceMigrateAccumulator_v1 is HarborPauser_v1 { newIntegral = $.userRewardSnapshotV2[account][token].integral; } + function snapshots( + address account, + address token + ) external view returns (UserRewardSnapshot memory v1, UserRewardSnapshotV2 memory v2) { + AccumulatorStorage storage $ = _getAccumulatorStorage(); + v1 = $.userRewardSnapshot[account][token]; + v2 = $.userRewardSnapshotV2[account][token]; + } + // ── Remediation ───────────────────────────────────────────────────────── /// @notice Copy V1 snapshot data to V2 format for each holder/token pair. @@ -102,9 +113,9 @@ contract ForceMigrateAccumulator_v1 is HarborPauser_v1 { // Skip if no V1 data UserRewardSnapshot storage v1 = $.userRewardSnapshot[account][token]; - if (v1.checkpoint.timestamp == 0) { - continue; - } + // if (v1.checkpoint.timestamp == 0) { + // continue; + // } // Copy V1 → V2 v2.rewards.pending = v1.rewards.pending; diff --git a/script/verify/sp-v2-data-prep-for-v3/MigrateBalancesTest.t.sol b/script/verify/sp-v2-data-prep-for-v3/MigrateBalancesTest.t.sol index afc6837a..09d7861a 100644 --- a/script/verify/sp-v2-data-prep-for-v3/MigrateBalancesTest.t.sol +++ b/script/verify/sp-v2-data-prep-for-v3/MigrateBalancesTest.t.sol @@ -6,7 +6,7 @@ import {console2 as console} from "forge-std/console2.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; -import {ForceMigrateAccumulator_v1} from "@harbor-script/verify/sp-v3-migration/ForceMigrateAccumulator_v1.sol"; +import {ForceMigrateAccumulator_v1} from "@harbor-script/verify/sp-v2-data-prep-for-v3/ForceMigrateAccumulator_v1.sol"; import {Config_MinterMarket, MinterMarketConfigLib} from "@harbor-script/config/ConfigBase.sol"; import {Deploy_BTC_Minter} from "@harbor-script/src/Deploy_BTC_Minter.sol"; @@ -82,6 +82,20 @@ contract MigrateBalancesTest is } } + function logV1(ForceMigrateAccumulator_v1.UserRewardSnapshot memory v1) private view { + console.log(" v1.timestamp: %s", v1.checkpoint.timestamp); + console.log(" v1.integral: %s", v1.checkpoint.integral); + console.log(" v1.pending: %s", v1.rewards.pending); + console.log(" v1.claimed: %s", v1.rewards.claimed); + } + + function logV2(ForceMigrateAccumulator_v1.UserRewardSnapshotV2 memory v2) private view { + console.log(" v2.timestamp: %s", v2.timestamp); + console.log(" v2.integral: %s", v2.integral); + console.log(" v2.pending: %s", v2.rewards.pending); + console.log(" v2.claimed: %s", v2.rewards.claimed); + } + function _checkPool(string memory marketKey, string memory spType) internal returns (bool) { string memory saltKey = string.concat(marketKey, "::", spType); address pool = _predictAddress(_key(marketKey, spType)); @@ -103,20 +117,41 @@ contract MigrateBalancesTest is ForceMigrateAccumulator_v1 mig = ForceMigrateAccumulator_v1(pool); // 2. Snapshot raw (V1, V2) integrals before remediation. + console.log("scanning pre state..."); uint256[][] memory preOld = new uint256[][](holders.length); uint256[][] memory preNew = new uint256[][](holders.length); for (uint256 h = 0; h < holders.length; h++) { preOld[h] = new uint256[](tokens.length); preNew[h] = new uint256[](tokens.length); for (uint256 t = 0; t < tokens.length; t++) { + string memory label = string.concat( + saltKey, + " holder ", + vm.toString(holders[h]), + " token ", + vm.toString(t) + ); + console.log(" ", label); + (preOld[h][t], preNew[h][t]) = mig.balances(holders[h], tokens[t]); + + ( + ForceMigrateAccumulator_v1.UserRewardSnapshot memory v1, + ForceMigrateAccumulator_v1.UserRewardSnapshotV2 memory v2 + ) = mig.snapshots(holders[h], tokens[t]); + logV1(v1); + logV2(v2); } } + console.log("done scanning pre state"); // 3. Remediate. vm.prank(owner); mig.remediate(tokens, holders); + console.log("done remediating."); + + console.log("scanning post state..."); // 4. Assert the copy is correct for every holder/token. for (uint256 h = 0; h < holders.length; h++) { for (uint256 t = 0; t < tokens.length; t++) { @@ -133,17 +168,27 @@ contract MigrateBalancesTest is assertEq(postOld, preOld[h][t], string.concat("old changed: ", label)); if (preNew[h][t] != 0) { + console.log(" ", label, " already migrated"); // Already migrated: V2 unchanged. assertEq(postNew, preNew[h][t], string.concat("already-migrated changed: ", label)); } else if (preOld[h][t] != 0) { + console.log(" ", label, " now migrated"); // Was unmigrated: V2 now equals V1 (pure copy). assertEq(postNew, preOld[h][t], string.concat("not copied: ", label)); } else { + console.log(" ", label, " no v1 data to migrate"); // No V1 data: stays zero. assertEq(postNew, 0, string.concat("spurious V2: ", label)); } + ( + ForceMigrateAccumulator_v1.UserRewardSnapshot memory v1, + ForceMigrateAccumulator_v1.UserRewardSnapshotV2 memory v2 + ) = mig.snapshots(holders[h], tokens[t]); + logV1(v1); + logV2(v2); } } + console.log("done scanning post state."); console.log(" > %s: %d holders OK", saltKey, holders.length); return true; diff --git a/script/verify/sp-v2-data-prep-for-v3/MigrateCaptureTest.t.sol b/script/verify/sp-v2-data-prep-for-v3/MigrateCaptureTest.t.sol index e5fe461d..4d79b050 100644 --- a/script/verify/sp-v2-data-prep-for-v3/MigrateCaptureTest.t.sol +++ b/script/verify/sp-v2-data-prep-for-v3/MigrateCaptureTest.t.sol @@ -207,7 +207,11 @@ contract MigrateCaptureTest is // Claim for each user for (uint256 i = 0; i < users.length; i++) { vm.prank(users[i]); - try IMultipleRewardAccumulator(proxy).claim(users[i]) { + // Use the no-arg claim() (selector 0x4e71d92d). vm.prank above makes msg.sender = users[i], + // so this is a self-claim under both v2 and v3. The claim(address) overload exists on v2 + // but was removed in v3 — using it here makes the v3 capture's claims revert and surface + // as spurious diffs. + try IMultipleRewardAccumulator(proxy).claim() { vm.serializeString(_jsonKey, string.concat(prefix, "_user_", vm.toString(i), "_success"), "true"); } catch { vm.serializeString(_jsonKey, string.concat(prefix, "_user_", vm.toString(i), "_success"), "false"); @@ -264,11 +268,9 @@ contract MigrateCaptureTest is _serializeUint(string.concat(ui, "_withdrawalEnd"), uint256(wEnd)); } - vm.serializeString( - _jsonKey, - string.concat(ui, "_rewardReceiver"), - vm.toString(IMultipleRewardAccumulator(proxy).rewardReceiver(user)) - ); + // rewardReceiver intentionally not captured: removed in v3 (so reading it reverts), and + // unused on mainnet (no one called setRewardReceiver — value would always be 0x0). + // The migration does not touch reward-receiver storage, so dropping it loses no signal. for (uint256 t = 0; t < activeTokens.length; t++) { string memory ut = string.concat(ui, "_reward_", vm.toString(t)); diff --git a/script/verify/sp-v2-data-prep-for-v3/migrate-StabilityPool_v2-data.md b/script/verify/sp-v2-data-prep-for-v3/migrate-StabilityPool_v2-data.md index c460c615..c52b4bfd 100644 --- a/script/verify/sp-v2-data-prep-for-v3/migrate-StabilityPool_v2-data.md +++ b/script/verify/sp-v2-data-prep-for-v3/migrate-StabilityPool_v2-data.md @@ -34,7 +34,8 @@ Key facts (verified): | `../../Migrate_StabilityPool_v2_Data_mainnet.s.sol` | The migration: per pool, queue upgrade→ForceMigrate, `remediate(tokens, holders)`, restore→v2. Reads the holder files. | | `MigrateCaptureTest.t.sol` | **Prong A** (black-box): capture all pool/holder state + interactions to JSON for a before/after diff | | `MigrateBalancesTest.t.sol` | **Prong B** (white-box): upgrade→ForceMigrate, assert `balances()` copies V1→V2 correctly | -| `run-migrate-StabilityPool_v2-data` | Local end-to-end verification driver | +| `../../Deploy_StabilityPool_v3_mainnet.s.sol` | **Prod** v2→v3 upgrade, reused unmodified for **Validation 2**. Plain `upgradeToAndCall(impl, "")` (no initialize/reinitializer — v3 sets immutables in its constructor; the one-shot data fix lives in ForceMigrate). On `--local` it touches only the local state copy. | +| `run-migrate-StabilityPool_v2-data` | Local end-to-end driver: discover → before → prongs → **migrate (prod)** → after (v2 diff) → **v3 (prod)** → after-v3 (v3 diff) | Holder discovery uses `UserDepositChange(owner,…)` — the event emitted whenever an account is checkpointed (deposit→receiver, withdraw→sender, liquidation→account). The distinct `owner` @@ -46,28 +47,42 @@ did not were verified to have zero `UserDepositChange`/`Deposit`-as-receiver eve ## Local verification (one command) -Start nothing first — the runner prompts you. It regenerates holders, captures before, runs -both prongs + the real migrate script against a local anvil fork, captures after, and diffs. +Start nothing first — the runner prompts you to start anvil. It resolves the fork block to a +concrete number, regenerates holders at that block, captures before, runs both prongs + the +real migrate script, captures after, **then upgrades to v3 and captures again** — two diffs. ```bash -script/verify/sp-v2-data-prep-for-v3/run-migrate-StabilityPool_v2-data -# options: --block (default 25186514, matched to discovery), --include +script/verify/sp-v2-data-prep-for-v3/run-migrate-StabilityPool_v2-data # full run at latest +script/verify/sp-v2-data-prep-for-v3/run-migrate-StabilityPool_v2-data --skip-capture # reuse saved before/holders/block +script/verify/sp-v2-data-prep-for-v3/run-migrate-StabilityPool_v2-data --block +# --block: latest (default, → concrete number, recorded) | capture (last recorded block) | +# --skip-capture: reuse tmp/sp-holders + tmp/before + recorded block; skip discovery & before-capture +# --include ``` What it does, in one anvil session (`forge test` forks in-memory; `run-script --broadcast ---local` persists to anvil — so the order is correct without cycling anvil): +--local` persists to anvil — so the order is correct without cycling anvil). The block is +resolved once and used identically for discovery, the fork, and START_TIMESTAMP: -1. Discover holders → `tmp/sp-holders/` (Etherscan, `--to-block `). +1. Discover holders → `tmp/sp-holders/` (Etherscan, `--to-block `); record the block. 2. **Prong A**: `VERSION=before` capture → `tmp/before/{pre,post}/*.json`. 3. **Prong B**: `MigrateBalancesTest` asserts the V1→V2 copy on the un-migrated node. -4. Run the **actual** `Migrate_StabilityPool_v2_Data_mainnet` (`--broadcast --local`) — this - persists upgrade→remediate→restore to anvil. -5. **Prong A**: `VERSION=after` capture → `tmp/after/{pre,post}/*.json`. -6. `diff -ru tmp/before tmp/after` — **MUST be empty** (the migration changed nothing - observable). `meld tmp/before tmp/after` if available. - -The empty diff also proves the pure-copy ≡ on-demand equivalence: "before" reads unmigrated -users via the V1 fallback, "after" reads the same users via pure-copied V2 data. +4. Run the **actual** `Migrate_StabilityPool_v2_Data_mainnet` (`--broadcast --local`) — persists + upgrade→remediate→restore-v2 to anvil. +5. **Prong A**: `VERSION=after` capture → `tmp/after/…`. +6. **Validation 1 (v2 transparency)**: `diff tmp/before tmp/after` — **MUST be empty**. Proves the + migration changed nothing observable. (Also proves pure-copy ≡ on-demand: "before" reads + unmigrated users via the V1 fallback, "after" via pure-copied V2 data.) +7. Run `Deploy_StabilityPool_v3_mainnet` (`--broadcast --local`) — upgrades all pools to v3. +8. **Prong A**: `VERSION=after-v3` capture → `tmp/after-v3/…`. +9. **Validation 2 (v3 completeness)**: `diff tmp/before tmp/after-v3` — **MUST be empty**. v3 has + no V1 fallback, so any holder the migrate **missed** reads `claimable=0` here and the diff + exposes it. This is the gate that closes Validation 1's blind spot. + +**Why two validations:** ending on v2 keeps the fallback, so Validation 1's diff cannot, alone, +detect an *incomplete* migration (a missed holder still reads correctly via V1). Validation 2 +removes the fallback (v3) and re-checks against the same baseline, so incompleteness surfaces. +The v3 upgrade here is verification-only — the production migration ends on v2. ## Producing the mainnet Safe batch diff --git a/script/verify/sp-v2-data-prep-for-v3/run-migrate-StabilityPool_v2-data b/script/verify/sp-v2-data-prep-for-v3/run-migrate-StabilityPool_v2-data index 9bf5b354..4ae131ea 100755 --- a/script/verify/sp-v2-data-prep-for-v3/run-migrate-StabilityPool_v2-data +++ b/script/verify/sp-v2-data-prep-for-v3/run-migrate-StabilityPool_v2-data @@ -12,6 +12,12 @@ # reads raw (V1, V2) integrals via balances(), remediates, and asserts the copy is correct. # Runs in-memory on the un-migrated node, so it does not disturb anvil. # +# Two validation gates via before/after capture diffs: +# Validation 1 (v2 transparency): after the migrate (pool ends on v2), diff vs before — proves +# nothing observable changed. The v2 fallback means this ALONE can't detect a missed holder. +# Validation 2 (v3 completeness): then upgrade all pools to StabilityPool_v3 (no fallback) and +# diff vs before — a holder the migrate missed now reads claimable=0, so the diff exposes it. +# # forge test forks anvil IN-MEMORY (no writeback); run-script --broadcast --local PERSISTS. # So a single anvil session works: in-memory captures/checks see the un-migrated node, the # migrate script mutates it, then the after-capture forks the migrated node. START_TIMESTAMP @@ -20,63 +26,80 @@ # The fork block is resolved to a CONCRETE number once, then used identically for holder # discovery (collect-sp-holders --to-block), the anvil fork, and START_TIMESTAMP — so the # discovered set and the forked state always match. A full run records that block; later -# runs can reuse it (--block capture / --skip-capture). +# runs can reuse it (--block capture). # # Usage: # run-migrate-StabilityPool_v2-data # full run at latest (records the block) -# run-migrate-StabilityPool_v2-data --skip-capture # reuse saved holders + before-capture + block +# run-migrate-StabilityPool_v2-data --block capture # reuse saved holders at the recorded block (errors if none recorded) # run-migrate-StabilityPool_v2-data --block # full run at a specific block -# run-migrate-StabilityPool_v2-data --block capture # full run at the last recorded block # options: --include set -euo pipefail cd "$(git rev-parse --show-toplevel)" # ── Configuration ──────────────────────────────────────────────────────────── -SALT="${SALT:-harbor_v1}" -NETWORK="${NETWORK:-mainnet}" -POOL_FILTER="${POOL_FILTER:-}" +SALT="harbor_v1" +NETWORK="mainnet" +POOL_FILTER="" FORGE_VERBOSITY="${FORGE_VERBOSITY:--vv}" HERE="$(dirname "$0")" CAPTURE_TEST="script/verify/sp-v2-data-prep-for-v3/MigrateCaptureTest.t.sol" BALANCES_TEST="script/verify/sp-v2-data-prep-for-v3/MigrateBalancesTest.t.sol" HOLDERS_DIR="tmp/sp-holders" -BLOCK_FILE="tmp/sp-capture-block" # records the concrete block a full capture used +BLOCK_FILE="tmp/sp-capture-block" # records the concrete block a capture used -BLOCK="" # n | latest | capture ; default chosen below -SKIP_CAPTURE=false +BLOCK="" # n | latest | capture ; default = latest while [[ $# -gt 0 ]]; do case "$1" in - --include) POOL_FILTER="$2"; shift 2 ;; - --block) BLOCK="$2"; shift 2 ;; - --skip-capture) SKIP_CAPTURE=true; shift ;; + --include) + POOL_FILTER="$2" + shift 2 + ;; + --block) + BLOCK="$2" + shift 2 + ;; -h | --help) sed -n '/^# Usage:/,/pool-filter/p' "$0" | sed 's/^# \{0,1\}//' - exit 0 ;; - *) echo "Unknown option: $1" >&2; exit 1 ;; + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + exit 1 + ;; esac done -# Default block: full run -> latest; skip-capture -> the recorded capture block. -if [[ -z "$BLOCK" ]]; then - if $SKIP_CAPTURE; then BLOCK="capture"; else BLOCK="latest"; fi -fi +# Default block. "--block capture" is also the signal to skip discovery +# and reuse the saved artifacts (errors below if BLOCK_FILE is missing). +[[ -z "$BLOCK" ]] && BLOCK="latest" +SKIP_CAPTURE_HOLDERS=false +[[ "$BLOCK" == "capture" ]] && SKIP_CAPTURE_HOLDERS=true + +echotty() { + echo "$*" + + if [[ ! -t 1 ]]; then + echo "$*" >/dev/tty + fi +} prompt() { - echo "" - echo "──────────────────────────────────────────────────────────────" - echo "$1" - echo "──────────────────────────────────────────────────────────────" + echotty "" + echotty "──────────────────────────────────────────────────────────────" + echotty "$1" + echotty "──────────────────────────────────────────────────────────────" read -rn1 -p "Ready? [Y/n] " response - echo "" + echotty "" >/dev/tty + if [[ "$response" =~ ^[Nn]$ ]]; then - echo "Aborted." + echotty "Aborted." exit 1 fi } -capture() { +capture_state() { local version="$1" local env_args=(START_TIMESTAMP="$START_TIMESTAMP" VERSION="$version") [[ -n "$POOL_FILTER" ]] && env_args+=(POOL_FILTER="$POOL_FILTER") @@ -85,6 +108,24 @@ capture() { env "${env_args[@]}" forge test --match-path "$CAPTURE_TEST" --fork-url local $FORGE_VERBOSITY } +# Diff tmp/before against tmp/; returns non-zero on differences. +compare_state() { + local other="$1" title="$2" + echo "" + echo "──────────────────────────────────────────────────────────────" + echo " $title — expect NO differences" + echo "──────────────────────────────────────────────────────────────" + if command -v meld >/dev/null 2>&1; then + meld tmp/before "tmp/$other" & + fi + if diff -ru tmp/before "tmp/$other"; then + echo " ✅ PASS: tmp/before == tmp/$other" + return 0 + fi + echo " ❌ FAIL: differences above ($title)" + return 1 +} + # ── Resolve the fork block to a concrete number (used everywhere) ───────────── case "$BLOCK" in latest) @@ -107,7 +148,7 @@ echo " StabilityPool_v2 accumulator V1->V2 force-migration verification" echo "======================================================================" echo " Fork block: $RESOLVED (requested: $BLOCK)" echo " Salt: $SALT" -echo " Skip capture: $SKIP_CAPTURE" +echo " Skip capture: $SKIP_CAPTURE_HOLDERS" [[ -n "$POOL_FILTER" ]] && echo " Pool filter: $POOL_FILTER" # START_TIMESTAMP from the resolved block (shared by before/after captures). @@ -115,10 +156,10 @@ TS=$(cast block --rpc-url "$NETWORK" "$RESOLVED" -f timestamp) START_TIMESTAMP=$((TS + 12000)) echo " START_TIMESTAMP: $START_TIMESTAMP (block ts $TS + 12000s)" -if ! $SKIP_CAPTURE; then - # Record the block this capture is built against, so --block capture / --skip-capture reuse it. +if ! $SKIP_CAPTURE_HOLDERS; then + # Record the block this capture is built against, so --block capture reuse it. mkdir -p "$(dirname "$BLOCK_FILE")" - echo "$RESOLVED" > "$BLOCK_FILE" + echo "$RESOLVED" >"$BLOCK_FILE" # Step 0: (re)generate holder files at the SAME block (Etherscan; needs ETHERSCAN_KEY). echo "" @@ -135,15 +176,19 @@ else exit 1 fi echo "" - echo "Skip-capture: reusing $HOLDERS_DIR, tmp/before, and block $RESOLVED." + echo "Skip-capture: reusing $HOLDERS_DIR and block $RESOLVED." fi prompt "Start a fresh anvil fork: script/anvil --block $RESOLVED" -# ── Prong A: capture BEFORE (skipped on --skip-capture; reuses saved tmp/before) ── -if ! $SKIP_CAPTURE; then - capture before -fi +# once anvil is started, set the owner balance - this is dubious: it should already have balance +# but fixing that is not part of the verification, so we donate a ton of eth to the owner +echo "giving the multisig some ETH..." +cast rpc anvil_setBalance 0x9bABfC1A1952a6ed2caC1922BFfE80c0506364a2 0x21e19e0c9bab2400000 +cast balance 0x9bABfC1A1952a6ed2caC1922BFfE80c0506364a2 + +# ── Prong A: capture BEFORE ── +capture_state before # ── Prong B: white-box balances() check (in-memory; un-migrated node) ───────── echo "" @@ -155,23 +200,28 @@ echo "" echo "Running Migrate_StabilityPool_v2_Data_mainnet (--broadcast --local)..." ./script/run-script Migrate_StabilityPool_v2_Data_mainnet --network "$NETWORK" --salt "$SALT" --broadcast --local -# ── Prong A: capture AFTER (forks the now-migrated node) ────────────────────── -capture after +# ── Validation 1 (v2 transparency): capture AFTER the migrate, diff vs before ── +# Pool ends on v2 (fallback still present), so this proves the migration changed +# nothing observable. NOTE: with the fallback present this diff cannot, on its own, +# detect an INCOMPLETE migration (a missed holder still reads correctly via V1). +capture_state after +fail=0 +compare_state after "Validation 1: v2 transparency (migrate, ends on v2)" || fail=1 + +# ── Validation 2 (v3 completeness): upgrade to v3 (no fallback), diff vs before ── +# StabilityPool_v3 removes the V1 fallback, so any holder the migrate script MISSED +# now reads claimable=0 under v3 → the diff exposes it. This is the completeness gate. +echo "" +echo "Upgrading all pools to StabilityPool_v3 (completeness detector)..." +./script/run-script Deploy_StabilityPool_v3_mainnet --network "$NETWORK" --salt "$SALT" --broadcast --local +capture_state after-v3 +compare_state after-v3 "Validation 2: v3 completeness (no fallback — exposes any unmigrated holder)" || fail=1 -# ── Compare (expect: NO differences) ────────────────────────────────────────── echo "" echo "======================================================================" -echo " Comparison — expect NO differences (migration is transparent)" -echo "======================================================================" -if command -v meld >/dev/null 2>&1; then - meld tmp/before tmp/after -else - if diff -ru tmp/before tmp/after; then - echo "" - echo "✅ PASS: before and after are identical." - else - echo "" - echo "❌ FAIL: differences found above — migration changed user-visible state." - exit 1 - fi +if ((fail)); then + echo " ❌ FAIL — see differences above." + exit 1 fi +echo " ✅ PASS — v2 transparent AND v3-complete: safe to remove the fallback." +echo "======================================================================" diff --git a/script/verify/sp-v3-migration/SPv3MigrationTest.t.sol b/script/verify/sp-v3-migration/SPv3MigrationTest.t.sol deleted file mode 100644 index eb907c96..00000000 --- a/script/verify/sp-v3-migration/SPv3MigrationTest.t.sol +++ /dev/null @@ -1,259 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.28 <0.9.0; - -import {BaoTest} from "@bao-test/BaoTest.sol"; -import {HarborDeployer} from "@harbor-script/src/HarborDeployer.sol"; -import {IBaoOwnable} from "@bao/interfaces/IBaoOwnable.sol"; -import {IStabilityPool} from "@harbor/interfaces/IStabilityPool.sol"; -import {IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator.sol"; -import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {IMinter} from "@harbor/interfaces/IMinter.sol"; -import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; -import {ForceMigrateAccumulator_v1} from "@harbor-script/verify/sp-v3-migration/ForceMigrateAccumulator_v1.sol"; -import {StabilityPool_v3} from "@harbor/minter/StabilityPool_v3.sol"; -import {console2 as console} from "forge-std/console2.sol"; - -/// @title SPv3MigrationTest -/// @notice Mainnet fork test that validates the full migration lifecycle: -/// snapshot → upgrade to ForceMigrateAccumulator_v1 → remediate → upgrade to StabilityPool_v3 -/// Verifies all user-visible values are preserved and post-migration operations work. -/// -/// Run: forge test --mc SPv3MigrationTest --fork-url mainnet -vv -contract SPv3MigrationTest is BaoTest, HarborDeployer { - // ── Addresses ─────────────────────────────────────────────────────────── - - address spc; // collateral stability pool (ETH::fxUSD) - address minter; - address wrappedCollateral; - address peg; - address proxyOwner; - - // ── Per-holder snapshot ───────────────────────────────────────────────── - - struct HolderState { - address holder; - uint256 balance; - uint256 claimableCollateral; - uint256 claimedCollateral; - } - - HolderState[] pre; - - // ── Holders for ETH::fxUSD::stabilityPoolCollateral ───────────────────── - // Source: Etherscan tokentx API query (doc/migration-sp-v3-holders.md) - - function _holders() internal pure returns (address[] memory h) { - h = new address[](24); - h[0] = 0x13F210c8bAf5f5DBAFf3E917E2e5A49E73BBAF12; - h[1] = 0x1a9152528AEFbcD9E5df4E0770f4F510e7056913; - h[2] = 0x31632636D664895f1BD9D03f5F7c162A2A6980EB; - h[3] = 0x3dFc49e5112005179Da613BdE5973229082dAc35; - h[4] = 0x4382916A88b6EEd40530ef47deD0D402563dDACa; - h[5] = 0x4dAf8ce9D729ca4F121381ec4B22123627C1C004; - h[6] = 0x50b3Bf1B3119afc37A25c841c06C1BDD05Da1Fab; - h[7] = 0x742fC5146d7Ff18291E3B7499811AD87015Fc7E4; - h[8] = 0x7F14A89F5333A334C0EA3B5AAA7Dc2c8E1C72de6; - h[9] = 0x81253f3Fc43D5e399610beE4D7a235826A7663b8; - h[10] = 0x8b7698945dBCedF33F5e8d9E62B1Af8101318575; - h[11] = 0x9bABfC1A1952a6ed2caC1922BFfE80c0506364a2; - h[12] = 0x9db1D99D1C79A3A2C0123fcd0abB13d9B7c75657; - h[13] = 0xAE7Dbb17bc40D53A6363409c6B1ED88d3cFdc31e; - h[14] = 0xb9ab9578a34a05c86124c399735fdE44dEc80E7F; - h[15] = 0xbA26035B9CD76cdA5b767A966b8A3392E476Fc0F; - h[16] = 0xc16A44D0759ec03c677E97eF020a2345d4dC27Fb; - h[17] = 0xDC1330EF8dc913C39bd29F9523418eEEacEf03D6; - h[18] = 0xdd9c0BB1102D45357bEC81BbdffBb615D64C0ff9; - h[19] = 0xE39165aDE355988EFb24dA4f2403971101134CAB; - h[20] = 0xeEbF37253066532aFeB3FAcb4F2a411703353A83; - h[21] = 0xef5E7606769400DC667DDC520C911D84405e61b7; - h[22] = 0xF7e64540f42094497E2De0F06232992b03942898; - h[23] = 0xf7e9CAaaEEb6cC9657E1Dd490F044114AecC31B2; - } - - // ── Setup ─────────────────────────────────────────────────────────────── - - function setUp() public { - vm.createSelectFork(vm.rpcUrl("mainnet")); - _setSaltPrefix("harbor_v1"); - spc = _predictAddress(_key("ETH", "fxUSD", "stabilityPoolCollateral")); - minter = _predictAddress(_key("ETH", "fxUSD", "minter")); - peg = _predictAddress(_key("ETH", "pegged")); - wrappedCollateral = IMinter(minter).WRAPPED_COLLATERAL_TOKEN(); - proxyOwner = IBaoOwnable(spc).owner(); - - _snapshotAll(); - _runMigration(); - } - - function _snapshotAll() internal { - address[] memory holders = _holders(); - for (uint256 i = 0; i < holders.length; i++) { - pre.push( - HolderState({ - holder: holders[i], - balance: IStabilityPool(spc).assetBalanceOf(holders[i]), - claimableCollateral: IMultipleRewardAccumulator(spc).claimable(holders[i], wrappedCollateral), - claimedCollateral: IMultipleRewardAccumulator(spc).claimed(holders[i], wrappedCollateral) - }) - ); - } - } - - function _runMigration() internal { - // Capture reward tokens BEFORE upgrading (pauser fallback reverts all other calls) - address[] memory tokens = IMultipleRewardDistributor(spc).activeRewardTokens(); - address[] memory holders = _holders(); - - // 1. Upgrade to migration contract - vm.prank(proxyOwner); - UUPSUpgradeable(spc).upgradeToAndCall(address(new ForceMigrateAccumulator_v1()), ""); - - ForceMigrateAccumulator_v1 mig = ForceMigrateAccumulator_v1(spc); - - // 2. Snapshot pre-remediation balances - uint256[][] memory preOld = new uint256[][](holders.length); - uint256[][] memory preNew = new uint256[][](holders.length); - for (uint256 i = 0; i < holders.length; i++) { - preOld[i] = new uint256[](tokens.length); - preNew[i] = new uint256[](tokens.length); - for (uint256 j = 0; j < tokens.length; j++) { - (preOld[i][j], preNew[i][j]) = mig.balances(holders[i], tokens[j]); - } - } - - // 3. Remediate: copy V1 → V2 for all holders - vm.prank(proxyOwner); - mig.remediate(tokens, holders); - - // 4. Verify post-remediation for each case - for (uint256 i = 0; i < holders.length; i++) { - for (uint256 j = 0; j < tokens.length; j++) { - (uint256 postOld, uint256 postNew) = mig.balances(holders[i], tokens[j]); - string memory label = string.concat(vm.toString(holders[i]), " token ", vm.toString(j)); - - // old slot is never modified by remediate - assertEq(postOld, preOld[i][j], string.concat("old unchanged: ", label)); - - if (preNew[i][j] != 0) { - // already migrated: new unchanged (remediate skipped this user) - assertEq(postNew, preNew[i][j], string.concat("already migrated, new unchanged: ", label)); - } else if (preOld[i][j] != 0) { - // was unmigrated: new == old (remediate copied) - assertEq(postNew, preOld[i][j], string.concat("migrated, new == old: ", label)); - } else { - // no data: both still zero - assertEq(postNew, 0, string.concat("no data, new still 0: ", label)); - } - } - } - - // 5. Upgrade to v3 - vm.prank(proxyOwner); - UUPSUpgradeable(spc).upgradeToAndCall( - address( - new StabilityPool_v3( - minter, - wrappedCollateral, - 3600, - 90000, - 1 ether, - "Harbor SP: haETH-fxUSD collateral", - "spETH-FXUSD-C" - ) - ), - "" - ); - } - - // ── Tests: State Preservation ─────────────────────────────────────────── - - function test_balancesPreserved() public view { - for (uint256 i = 0; i < pre.length; i++) { - assertEq( - IStabilityPool(spc).assetBalanceOf(pre[i].holder), - pre[i].balance, - string.concat("balance: ", vm.toString(pre[i].holder)) - ); - } - } - - function test_claimablePreserved() public view { - for (uint256 i = 0; i < pre.length; i++) { - assertEq( - IMultipleRewardAccumulator(spc).claimable(pre[i].holder, wrappedCollateral), - pre[i].claimableCollateral, - string.concat("claimable: ", vm.toString(pre[i].holder)) - ); - } - } - - function test_claimedPreserved() public view { - for (uint256 i = 0; i < pre.length; i++) { - assertEq( - IMultipleRewardAccumulator(spc).claimed(pre[i].holder, wrappedCollateral), - pre[i].claimedCollateral, - string.concat("claimed: ", vm.toString(pre[i].holder)) - ); - } - } - - function test_totalSupplyPreserved() public view { - uint256 total = 0; - for (uint256 i = 0; i < pre.length; i++) { - total += pre[i].balance; - } - assertApproxEqAbs(IStabilityPool(spc).totalAssetSupply(), total, pre.length, "totalSupply ~ sum of balances"); - } - - // ── Tests: Post-Migration Operations ──────────────────────────────────── - - function test_deposit() public { - address newUser = makeAddr("newUser"); - uint256 amount = 1 ether; - deal(peg, newUser, amount); - vm.startPrank(newUser); - IERC20(peg).approve(spc, amount); - IStabilityPool(spc).deposit(amount, newUser, 0); - vm.stopPrank(); - assertEq(IStabilityPool(spc).assetBalanceOf(newUser), amount, "deposit works on v3"); - } - - function test_claim() public { - for (uint256 i = 0; i < pre.length; i++) { - if (pre[i].claimableCollateral == 0) continue; - uint256 balBefore = IERC20(wrappedCollateral).balanceOf(pre[i].holder); - vm.prank(pre[i].holder); - IMultipleRewardAccumulator(spc).claim(); - uint256 received = IERC20(wrappedCollateral).balanceOf(pre[i].holder) - balBefore; - assertEq(received, pre[i].claimableCollateral, string.concat("claim: ", vm.toString(pre[i].holder))); - break; // test one holder - } - } - - function test_transfer() public { - // Find a holder with balance - for (uint256 i = 0; i < pre.length; i++) { - if (pre[i].balance == 0) continue; - address from = pre[i].holder; - address to = makeAddr("recipient"); - uint256 amount = pre[i].balance / 10; - - vm.prank(from); - StabilityPool_v3(spc).transfer(to, amount); - - assertEq(StabilityPool_v3(spc).balanceOf(to), amount, "recipient balance"); - assertEq(StabilityPool_v3(spc).balanceOf(from), pre[i].balance - amount, "sender balance"); - break; - } - } - - function test_erc20Metadata() public view { - StabilityPool_v3 sp3 = StabilityPool_v3(spc); - assertEq(bytes(sp3.name()).length > 0, true, "name not empty"); - assertEq(bytes(sp3.symbol()).length > 0, true, "symbol not empty"); - assertEq(sp3.decimals(), 18, "decimals"); - console.log(" name: %s", sp3.name()); - console.log(" symbol: %s", sp3.symbol()); - } -} From c1ce00f5d7c6ced666a1f79eb974ac12518811ae Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Sat, 30 May 2026 15:16:44 +0100 Subject: [PATCH 094/232] removed duplicated tests for accumulator --- test/StabilityPoolClaimable.t.sol | 76 -- test/StabilityPoolSpec.t.sol | 37 - test/deployment/RewardSystem.t.sol | 135 ---- .../reward/accumulator/ClaimEquivalence.t.sol | 214 ------ ...MultipleRewardCompoundingAccumulator.t.sol | 654 ------------------ 5 files changed, 1116 deletions(-) delete mode 100644 test/reward/accumulator/ClaimEquivalence.t.sol diff --git a/test/StabilityPoolClaimable.t.sol b/test/StabilityPoolClaimable.t.sol index 6e5073fd..14ed86d6 100644 --- a/test/StabilityPoolClaimable.t.sol +++ b/test/StabilityPoolClaimable.t.sol @@ -601,80 +601,4 @@ contract TestStabilityPoolClaimable is TestStabilityPoolRebalanceSetUp { "User claimable after full liquidation: %s" ); } - - // ═══════════════════════════════════════════════════════════════════════ - // claim() routing tests - // ═══════════════════════════════════════════════════════════════════════ - - function testClaim_claimsAllTokens() public { - // claim() claims all active reward tokens at once. - _depositForUsers(); - _depositRewardAndWait(rewardToken1, 100 ether); - _depositRewardAndWait(rewardToken2, 200 ether); - - uint256 claimable1 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1); - uint256 claimable2 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken2); - assertGt(claimable1, 0, "should have claimable rewardToken1"); - assertGt(claimable2, 0, "should have claimable rewardToken2"); - - vm.prank(user1); - IMultipleRewardAccumulator(stabilityPoolCollateral).claim(); - - assertEq(IERC20(rewardToken1).balanceOf(user1), claimable1, "rewardToken1 claimed"); - assertEq(IERC20(rewardToken2).balanceOf(user1), claimable2, "rewardToken2 claimed"); - } - - /* - function testClaim_withReceiver() public { - // claim(account, receiver) routes rewards to an explicit receiver. - _depositForUsers(); - _depositRewardAndWait(rewardToken1, 100 ether); - - uint256 claimable1 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1); - address receiver = makeAddr("receiver"); - - vm.prank(user1); - IMultipleRewardAccumulator(stabilityPoolCollateral).claim(user1, receiver); - - assertEq(IERC20(rewardToken1).balanceOf(receiver), claimable1, "receiver got tokens"); - assertEq(IERC20(rewardToken1).balanceOf(user1), 0, "user1 got nothing"); - } - */ - - /* - function testClaim_forOtherUser() public { - // Anyone can trigger claim(account) for another user — tokens go to that user. - _depositForUsers(); - _depositRewardAndWait(rewardToken1, 100 ether); - - uint256 claimable1 = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, rewardToken1); - - vm.prank(user2); - IMultipleRewardAccumulator(stabilityPoolCollateral).claim(user1); - - assertEq(IERC20(rewardToken1).balanceOf(user1), claimable1, "user1 received tokens"); - } - */ - - /* - function testClaim_cannotRedirectOthersReward() public { - // Third party cannot redirect another user's rewards to an explicit receiver. - _depositForUsers(); - _depositRewardAndWait(rewardToken1, 100 ether); - - address receiver = makeAddr("receiver"); - - vm.prank(user2); - vm.expectRevert(IMultipleRewardAccumulator.ClaimOthersRewardToAnother.selector); - IMultipleRewardAccumulator(stabilityPoolCollateral).claim(user1, receiver); - } - */ - - function testClaim_zeroClaimable() public { - // claim() does not revert when there is nothing to claim. - _depositForUsers(); - vm.prank(user1); - IMultipleRewardAccumulator(stabilityPoolCollateral).claim(); - assertEq(IERC20(rewardToken1).balanceOf(user1), 0, "nothing claimed"); - } } diff --git a/test/StabilityPoolSpec.t.sol b/test/StabilityPoolSpec.t.sol index 579d1403..f1105c9c 100644 --- a/test/StabilityPoolSpec.t.sol +++ b/test/StabilityPoolSpec.t.sol @@ -354,41 +354,4 @@ contract TestStabilityPoolSpec is TestStabilityPoolRebalanceSetUp { uint256 claimable = IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(rewardToken)); assertApproxEqRel(claimable, REWARD_AMOUNT, 0.01e18, "User1 should have claimable rewards after registration"); } - - /* - function testSetRewardReceiver() public { - // User1 deposits - vm.prank(user1); - IStabilityPool(stabilityPoolCollateral).deposit(DEPOSIT_AMOUNT, user1, 0); - - // Distribute rewards - vm.startPrank(rewardDepositor); - IMultipleRewardDistributor(stabilityPoolCollateral).depositReward(address(rewardToken), REWARD_AMOUNT); - vm.stopPrank(); - skip(7 days); // Wait for rewards to accumulate - - // User1 sets reward receiver to user3 - vm.prank(user1); - IMultipleRewardAccumulator(stabilityPoolCollateral).setRewardReceiver(user3); - - // User1 claims rewards which should go to user3 - vm.prank(user1); - IMultipleRewardAccumulator(stabilityPoolCollateral).claim(); - assertEq( - IMultipleRewardAccumulator(stabilityPoolCollateral).claimable(user1, address(rewardToken)), - 0, - "User1 should have no claimable rewards after claiming" - ); - - // Check user3 received the rewards - uint256 user3Balance = IERC20(rewardToken).balanceOf(user3); - uint256 user1Balance = IERC20(rewardToken).balanceOf(user1); - - // Assert that rewards were transferred correctly - assertApproxEqRel(user3Balance, REWARD_AMOUNT, 1e16, "User3 should have received rewards"); - assertEq(user1Balance, 0, "User1 should not have received rewards"); - } - */ - - // Add remaining tests from original StabilityPoolSpec... } diff --git a/test/deployment/RewardSystem.t.sol b/test/deployment/RewardSystem.t.sol index 69e70ab7..426bf4cf 100644 --- a/test/deployment/RewardSystem.t.sol +++ b/test/deployment/RewardSystem.t.sol @@ -153,41 +153,6 @@ contract AccumulatorTest is RewardSystemSetUp { uint256 received = IERC20(wrappedCollateral).balanceOf(alice) - balBefore; assertGt(received, 0, "claimed via claim()"); } - - /* - function test_claimToReceiver() public { - _depositReward(wrappedCollateral, 10 ether); - skip(8 days); - - address receiver = makeAddr("receiver"); - vm.prank(alice); - IMultipleRewardAccumulator(stabilityPoolCollateral).claim(alice, receiver); - assertGt(IERC20(wrappedCollateral).balanceOf(receiver), 0, "receiver got tokens"); - } - - function test_claimOthersToSelf() public { - _depositReward(wrappedCollateral, 10 ether); - skip(8 days); - - // bob claims alice's rewards — tokens go to alice (receiver=address(0)) - vm.prank(bob); - IMultipleRewardAccumulator(stabilityPoolCollateral).claim(alice, address(0)); - assertGt(IERC20(wrappedCollateral).balanceOf(alice), 0, "alice received"); - } - - function test_claimOthersToThirdParty_reverts() public { - _depositReward(wrappedCollateral, 10 ether); - skip(8 days); - - // bob tries to claim alice's rewards to a third party — should revert - vm.prank(bob); - vm.expectRevert(); - IMultipleRewardAccumulator(stabilityPoolCollateral).claim(alice, makeAddr("thirdParty")); - } - */ - - // ── claimHistorical ──────────────────────────────────────── - function test_claimHistorical() public { _depositReward(wrappedCollateral, 30 ether); skip(8 days); @@ -234,41 +199,6 @@ contract AccumulatorTest is RewardSystemSetUp { IMultipleRewardAccumulator(stabilityPoolCollateral).claimTokens(tokens, type(uint256).max); assertGt(IERC20(wrappedCollateral).balanceOf(alice) - balBefore, 0, "claimed historical"); } - - function test_claimHistorical_forAccount() public { - _depositReward(wrappedCollateral, 30 ether); - skip(8 days); - - // Checkpoint alice but don't claim - IMultipleRewardAccumulator(stabilityPoolCollateral).checkpoint(alice); - - // Drain via bob and carol - vm.prank(bob); - IMultipleRewardAccumulator(stabilityPoolCollateral).claim(); - vm.prank(carol); - IMultipleRewardAccumulator(stabilityPoolCollateral).claim(); - _depositReward(wrappedCollateral, 1); - skip(8 days); - vm.prank(bob); - IMultipleRewardAccumulator(stabilityPoolCollateral).claim(); - vm.prank(carol); - IMultipleRewardAccumulator(stabilityPoolCollateral).claim(); - - uint256 managerRole = IMultipleRewardDistributor(stabilityPoolCollateral).REWARD_MANAGER_ROLE(); - vm.prank(HARBOR_MULTISIG); - IBaoRoles(stabilityPoolCollateral).grantRoles(address(this), managerRole); - IMultipleRewardDistributor(stabilityPoolCollateral).unregisterRewardToken(wrappedCollateral); - - /* - // Bob triggers historical claim for alice — tokens go to alice - address[] memory tokens = new address[](1); - tokens[0] = wrappedCollateral; - uint256 balBefore = IERC20(wrappedCollateral).balanceOf(alice); - vm.prank(bob); - IMultipleRewardAccumulator(stabilityPoolCollateral).claimHistorical(alice, tokens); - assertGt(IERC20(wrappedCollateral).balanceOf(alice) - balBefore, 0, "alice got historical claim"); - */ - } } // ═══════════════════════════════════════════════════════════════ @@ -281,69 +211,4 @@ contract DistributorTest is RewardSystemSetUp { address[] memory historical = IMultipleRewardDistributor(stabilityPoolCollateral).historicalRewardTokens(); assertEq(historical.length, 0, "no historical tokens initially"); } - - function test_registerRewardToken_zeroAddress_reverts() public { - uint256 managerRole = IMultipleRewardDistributor(stabilityPoolCollateral).REWARD_MANAGER_ROLE(); - vm.prank(HARBOR_MULTISIG); - IBaoRoles(stabilityPoolCollateral).grantRoles(address(this), managerRole); - - vm.expectRevert(); - IMultipleRewardDistributor(stabilityPoolCollateral).registerRewardToken(address(0)); - } - - function test_registerRewardToken_duplicate_reverts() public { - uint256 managerRole = IMultipleRewardDistributor(stabilityPoolCollateral).REWARD_MANAGER_ROLE(); - vm.prank(HARBOR_MULTISIG); - IBaoRoles(stabilityPoolCollateral).grantRoles(address(this), managerRole); - - // wrappedCollateral is already registered - vm.expectRevert(); - IMultipleRewardDistributor(stabilityPoolCollateral).registerRewardToken(wrappedCollateral); - } - - function test_unregisterRewardToken_withPendingRewards_reverts() public { - address alice = makeAddr("alice"); - _mintAndDeposit(alice, 100 ether); - - // Deposit reward that hasn't fully distributed - _depositReward(wrappedCollateral, 10 ether); - // Don't wait — rewards still pending - - uint256 managerRole = IMultipleRewardDistributor(stabilityPoolCollateral).REWARD_MANAGER_ROLE(); - vm.prank(HARBOR_MULTISIG); - IBaoRoles(stabilityPoolCollateral).grantRoles(address(this), managerRole); - - vm.expectRevert(); - IMultipleRewardDistributor(stabilityPoolCollateral).unregisterRewardToken(wrappedCollateral); - } - - function test_unregisterAndReregister() public { - address alice = makeAddr("alice"); - _mintAndDeposit(alice, 100 ether); - - _depositReward(wrappedCollateral, 10 ether); - skip(8 days); // Wait for full distribution - - // Claim all so pending is zero - vm.prank(alice); - IMultipleRewardAccumulator(stabilityPoolCollateral).claim(); - - uint256 managerRole = IMultipleRewardDistributor(stabilityPoolCollateral).REWARD_MANAGER_ROLE(); - vm.prank(HARBOR_MULTISIG); - IBaoRoles(stabilityPoolCollateral).grantRoles(address(this), managerRole); - - // Unregister - IMultipleRewardDistributor(stabilityPoolCollateral).unregisterRewardToken(wrappedCollateral); - - // Historical should contain it - address[] memory historical = IMultipleRewardDistributor(stabilityPoolCollateral).historicalRewardTokens(); - assertEq(historical.length, 1, "one historical token"); - - // Re-register — moves from historical back to active - IMultipleRewardDistributor(stabilityPoolCollateral).registerRewardToken(wrappedCollateral); - - // Historical should be empty again - historical = IMultipleRewardDistributor(stabilityPoolCollateral).historicalRewardTokens(); - assertEq(historical.length, 0, "historical cleared after re-register"); - } } diff --git a/test/reward/accumulator/ClaimEquivalence.t.sol b/test/reward/accumulator/ClaimEquivalence.t.sol deleted file mode 100644 index 8977e2d7..00000000 --- a/test/reward/accumulator/ClaimEquivalence.t.sol +++ /dev/null @@ -1,214 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.28 <0.9.0; - -import {Test} from "forge-std/Test.sol"; -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {MockERC20} from "@bao-test/mocks/MockERC20.sol"; - -import {IMultipleRewardAccumulator_v3 as IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator_v3.sol"; -import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; -import {IBaoRoles} from "@bao/interfaces/IBaoRoles.sol"; -import {MockMultipleRewardCompoundingAccumulator_v3} from "@harbor-test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v3.sol"; - -/// @title ClaimTest -/// @notice Verifies claim() and claimHistorical() routing across all supported call signatures. -/// -/// Authorization matrix: -/// | Scenario | Call | Expected | -/// |---------------------------|--------------------------------------|-----------------| -/// | Self claim all | claim() | tokens → self | -/// | 3rd party claim all | claim(other) | tokens → other | -/// | Self claim to receiver | claim(self, recv) | tokens → recv | -/// | 3rd party to receiver | claim(other, recv) | REVERT | -/// | Self historical | claimHistorical(tokens) | tokens → self | -/// | 3rd party historical | claimHistorical(other, tokens) | tokens → other | -/// | All above + stored recv | same paths | → stored recv | -/// -/// Run: forge test --mc ClaimTest -vv -contract ClaimTest is Test { - address deployer; - address alice; - address bob; - address storedReceiver; - address explicitReceiver; - - address accumulator; - address rewardToken1; - address rewardToken2; - - uint256 constant REWARD_AMOUNT = 100 ether; - uint256 constant POOL_SHARE = 10 ether; - uint128 constant PRODUCT = uint128(1e36); - - function setUp() public { - deployer = address(this); - alice = makeAddr("alice"); - bob = makeAddr("bob"); - storedReceiver = makeAddr("storedReceiver"); - explicitReceiver = makeAddr("explicitReceiver"); - - accumulator = address(new MockMultipleRewardCompoundingAccumulator_v3(1 weeks)); - MockMultipleRewardCompoundingAccumulator_v3(accumulator).initialize(deployer, deployer); - - rewardToken1 = address(new MockERC20("Token1", "T1", 18)); - rewardToken2 = address(new MockERC20("Token2", "T2", 18)); - - // Grant manager role and register tokens - uint256 managerRole = IMultipleRewardDistributor(accumulator).REWARD_MANAGER_ROLE(); - IBaoRoles(accumulator).grantRoles(deployer, managerRole); - IMultipleRewardDistributor(accumulator).registerRewardToken(rewardToken1); - IMultipleRewardDistributor(accumulator).registerRewardToken(rewardToken2); - - // Set pool shares so rewards accrue - MockMultipleRewardCompoundingAccumulator_v3(accumulator).setTotalPoolShare(POOL_SHARE, PRODUCT); - MockMultipleRewardCompoundingAccumulator_v3(accumulator).setUserPoolShare(POOL_SHARE, PRODUCT); - } - - /// @dev Deposit rewards for both tokens and advance time so they're fully claimable. - function _depositRewards() internal { - MockERC20(rewardToken1).mint(deployer, REWARD_AMOUNT); - MockERC20(rewardToken2).mint(deployer, REWARD_AMOUNT); - IERC20(rewardToken1).approve(accumulator, REWARD_AMOUNT); - IERC20(rewardToken2).approve(accumulator, REWARD_AMOUNT); - IMultipleRewardDistributor(accumulator).depositReward(rewardToken1, REWARD_AMOUNT); - IMultipleRewardDistributor(accumulator).depositReward(rewardToken2, REWARD_AMOUNT); - vm.warp(block.timestamp + 2 weeks); - } - - // ═══════════════════════════════════════════════════════════════════════ - // Without stored receiver - // ═══════════════════════════════════════════════════════════════════════ - - // ── Self claim all ────────────────────────────────────────────────── - - function test_selfClaimAll() public { - // claim() with no args claims all active tokens for msg.sender. - _depositRewards(); - vm.prank(alice); - IMultipleRewardAccumulator(accumulator).claim(); - assertGt(IERC20(rewardToken1).balanceOf(alice), 0, "alice got token1"); - assertGt(IERC20(rewardToken2).balanceOf(alice), 0, "alice got token2"); - } - - /* - // ── Third party claim all (no receiver) ───────────────────────────── - - function test_thirdPartyClaimAll() public { - // claim(account) lets a third party trigger claims — tokens go to the account. - _depositRewards(); - vm.prank(bob); - IMultipleRewardAccumulator(accumulator).claim(alice); - assertGt(IERC20(rewardToken1).balanceOf(alice), 0, "alice got token1 (claimed by bob)"); - } - */ - - /* - // ── Self claim to explicit receiver ───────────────────────────────── - - function test_selfClaimToReceiver() public { - // claim(account, receiver) routes tokens to an explicit receiver. - _depositRewards(); - vm.prank(alice); - IMultipleRewardAccumulator(accumulator).claim(alice, explicitReceiver); - assertGt(IERC20(rewardToken1).balanceOf(explicitReceiver), 0, "receiver got token1"); - assertEq(IERC20(rewardToken1).balanceOf(alice), 0, "alice got nothing"); - } - */ - - /* - // ── Third party claim to receiver → REVERT ────────────────────────── - - function test_thirdPartyClaimToReceiver_reverts() public { - // Third party cannot redirect another account's rewards. - _depositRewards(); - vm.prank(bob); - vm.expectRevert(IMultipleRewardAccumulator.ClaimOthersRewardToAnother.selector); - IMultipleRewardAccumulator(accumulator).claim(alice, explicitReceiver); - } - */ - - // ── Self historical ───────────────────────────────────────────────── - - function test_selfHistorical() public { - // claimHistorical(tokens) claims a specific list of tokens for msg.sender. - _depositRewards(); - address[] memory tokens = new address[](1); - tokens[0] = rewardToken1; - vm.prank(alice); - IMultipleRewardAccumulator(accumulator).claimTokens(tokens, type(uint256).max); - assertGt(IERC20(rewardToken1).balanceOf(alice), 0, "alice got token1"); - assertEq(IERC20(rewardToken2).balanceOf(alice), 0, "token2 unclaimed"); - } - - /* - // ── Third party historical ────────────────────────────────────────── - - function test_thirdPartyHistorical() public { - // claimHistorical(account, tokens) lets a third party trigger historical claims. - _depositRewards(); - address[] memory tokens = new address[](1); - tokens[0] = rewardToken1; - vm.prank(bob); - IMultipleRewardAccumulator(accumulator).claimHistorical(alice, tokens); - assertGt(IERC20(rewardToken1).balanceOf(alice), 0, "alice got token1 (claimed by bob)"); - } - */ - - /* - // ═══════════════════════════════════════════════════════════════════════ - // With stored receiver - // ═══════════════════════════════════════════════════════════════════════ - - function _setStoredReceiver() internal { - vm.prank(alice); - IMultipleRewardAccumulator(accumulator).setRewardReceiver(storedReceiver); - } - - // ── Self claim all → stored receiver ──────────────────────────────── - - function test_selfClaimAll_storedReceiver() public { - // When a stored receiver is set, claim() sends tokens there. - _setStoredReceiver(); - _depositRewards(); - vm.prank(alice); - IMultipleRewardAccumulator(accumulator).claim(); - assertGt(IERC20(rewardToken1).balanceOf(storedReceiver), 0, "stored receiver got token1"); - assertEq(IERC20(rewardToken1).balanceOf(alice), 0, "alice got nothing"); - } - - // ── Third party claim all → stored receiver ───────────────────────── - - function test_thirdPartyClaimAll_storedReceiver() public { - // Third party claim(account) respects the account's stored receiver. - _setStoredReceiver(); - _depositRewards(); - vm.prank(bob); - IMultipleRewardAccumulator(accumulator).claim(alice); - assertGt(IERC20(rewardToken1).balanceOf(storedReceiver), 0, "stored receiver got token1"); - assertEq(IERC20(rewardToken1).balanceOf(alice), 0, "alice got nothing"); - } - - // ── Self claim to explicit receiver overrides stored ───────────────── - - function test_selfClaimToExplicit_overridesStored() public { - // An explicit receiver passed to claim(account, receiver) overrides the stored one. - _setStoredReceiver(); - _depositRewards(); - vm.prank(alice); - IMultipleRewardAccumulator(accumulator).claim(alice, explicitReceiver); - assertGt(IERC20(rewardToken1).balanceOf(explicitReceiver), 0, "explicit receiver got token1"); - assertEq(IERC20(rewardToken1).balanceOf(storedReceiver), 0, "stored receiver got nothing"); - } - - // ── Third party + stored receiver + explicit → REVERT ─────────────── - - function test_thirdPartyToExplicit_storedReceiver_reverts() public { - // Third party cannot override the stored receiver by passing an explicit one. - _setStoredReceiver(); - _depositRewards(); - vm.prank(bob); - vm.expectRevert(IMultipleRewardAccumulator.ClaimOthersRewardToAnother.selector); - IMultipleRewardAccumulator(accumulator).claim(alice, explicitReceiver); - } - */ -} diff --git a/test/reward/accumulator/MultipleRewardCompoundingAccumulator.t.sol b/test/reward/accumulator/MultipleRewardCompoundingAccumulator.t.sol index d90fe9ab..120e29fa 100644 --- a/test/reward/accumulator/MultipleRewardCompoundingAccumulator.t.sol +++ b/test/reward/accumulator/MultipleRewardCompoundingAccumulator.t.sol @@ -279,660 +279,6 @@ contract MultipleRewardCompoundingAccumulatorTest is Test { } } - /* - function testSetRewardReceiver() public { - for (uint256 i = 0; i < rewardCounts.length; i++) { - uint256 rewardCount = rewardCounts[i]; - uint40 periodLength = 1 weeks; - - (IMockMultipleRewardCompoundingAccumulator accumulator, ) = _setupAccumulator(rewardCount, periodLength); - - // Initial value should be zero address - assertEq(accumulator.rewardReceiver(deployer), address(0)); - - // Set receiver - vm.expectEmit(true, true, true, true); - emit IMultipleRewardAccumulator.UpdateRewardReceiver(deployer, address(0), receiver); - accumulator.setRewardReceiver(receiver); - - // Check updated value - assertEq(accumulator.rewardReceiver(deployer), receiver); - - // Reset to zero address - vm.expectEmit(true, true, true, true); - emit IMultipleRewardAccumulator.UpdateRewardReceiver(deployer, receiver, address(0)); - accumulator.setRewardReceiver(address(0)); - - // Check value is reset - assertEq(accumulator.rewardReceiver(deployer), address(0)); - } - } - */ - - function testClaimWithoutRewardReceiver() public { - for (uint256 i = 0; i < rewardCounts.length; i++) { - uint256 rewardCount = rewardCounts[i]; - uint40 periodLength = 1 weeks; - uint256 baseRewardAmount = 2233 * 1 ether; - uint256 totalPoolShare = 1234 * 1 ether; - uint256 userPoolShare = 456 * 1 ether; - - ( - IMockMultipleRewardCompoundingAccumulator accumulator, - address[] memory tokenAddresses - ) = _setupAccumulator(rewardCount, periodLength); - - // Setup reward state - accumulator.setTotalPoolShare(totalPoolShare, 1 ether); - accumulator.setUserPoolShare(userPoolShare, 1 ether); - - for (uint256 j = 0; j < rewardCount; j++) { - uint256 depositAmount = baseRewardAmount * (j + 1); - accumulator.depositReward(tokenAddresses[j], depositAmount); - } - - // Advance time if period length > 0 - if (periodLength > 0) { - vm.warp(block.timestamp + periodLength); - } - - accumulator.checkpoint(deployer); - - /* - // Test reverting when claiming other to other - vm.expectRevert(abi.encodeWithSelector(IMultipleRewardAccumulator.ClaimOthersRewardToAnother.selector)); - accumulator.claim(manager, deployer); - */ - - // Test claim caller - uint256[] memory claimable = new uint256[](rewardCount); - uint256[] memory balanceBefore = new uint256[](rewardCount); - - for (uint256 j = 0; j < rewardCount; j++) { - claimable[j] = accumulator.claimable(deployer, tokenAddresses[j]); - balanceBefore[j] = IERC20(tokenAddresses[j]).balanceOf(deployer); - - // Verify pending rewards - (, , uint256 pending, uint256 claimed) = accumulator.userRewardSnapshot(deployer, tokenAddresses[j]); - assertGt(pending, 0); - assertEq(claimed, 0); - } - - // Execute claim and check events - for (uint256 j = 0; j < rewardCount; j++) { - vm.expectEmit(true, true, true, true); - emit IMultipleRewardAccumulator.Claim(deployer, tokenAddresses[j], deployer, claimable[j]); - } - accumulator.claim(); - - // Verify post-claim state - for (uint256 j = 0; j < rewardCount; j++) { - assertEq(accumulator.claimable(deployer, tokenAddresses[j]), 0); - assertEq(IERC20(tokenAddresses[j]).balanceOf(deployer), balanceBefore[j] + claimable[j]); - - (, , uint256 pending, uint256 claimed) = accumulator.userRewardSnapshot(deployer, tokenAddresses[j]); - assertEq(pending, 0); - assertEq(claimed, claimable[j]); - assertEq(accumulator.claimed(deployer, tokenAddresses[j]), claimable[j]); - } - - // Claiming again should not emit events - vm.recordLogs(); - accumulator.claim(); - Vm.Log[] memory logs = vm.getRecordedLogs(); - assertEq(logs.length, 0); - - // Test claim other - // Reset the state for a new test - (accumulator, tokenAddresses) = _setupAccumulator(rewardCount, periodLength); - - // Setup reward state again - accumulator.setTotalPoolShare(totalPoolShare, 1 ether); - accumulator.setUserPoolShare(userPoolShare, 1 ether); - - for (uint256 j = 0; j < rewardCount; j++) { - uint256 depositAmount = baseRewardAmount * (j + 1); - accumulator.depositReward(tokenAddresses[j], depositAmount); - } - - if (periodLength > 0) { - vm.warp(block.timestamp + periodLength); - } - - accumulator.checkpoint(deployer); - - // Store claimable amounts and balances - for (uint256 j = 0; j < rewardCount; j++) { - claimable[j] = accumulator.claimable(deployer, tokenAddresses[j]); - balanceBefore[j] = IERC20(tokenAddresses[j]).balanceOf(deployer); - - (, , uint256 pending, uint256 claimed) = accumulator.userRewardSnapshot(deployer, tokenAddresses[j]); - assertGt(pending, 0); - assertEq(claimed, 0); - } - - /* - // Claim as manager for deployer - for (uint256 j = 0; j < rewardCount; j++) { - vm.expectEmit(true, true, true, true); - emit IMultipleRewardAccumulator.Claim(deployer, tokenAddresses[j], deployer, claimable[j]); - } - vm.prank(manager); - accumulator.claim(deployer); - - // Verify post-claim state - for (uint256 j = 0; j < rewardCount; j++) { - assertEq(accumulator.claimable(deployer, tokenAddresses[j]), 0); - assertEq(IERC20(tokenAddresses[j]).balanceOf(deployer), balanceBefore[j] + claimable[j]); - - (, , uint256 pending, uint256 claimed) = accumulator.userRewardSnapshot(deployer, tokenAddresses[j]); - assertEq(pending, 0); - assertEq(claimed, claimable[j]); - assertEq(accumulator.claimed(deployer, tokenAddresses[j]), claimable[j]); - } - */ - - /* - // Test claim to other - // Reset the state for a new test - (accumulator, tokenAddresses) = _setupAccumulator(rewardCount, periodLength); - - // Setup reward state again - accumulator.setTotalPoolShare(totalPoolShare, 1 ether); - accumulator.setUserPoolShare(userPoolShare, 1 ether); - - for (uint256 j = 0; j < rewardCount; j++) { - uint256 depositAmount = baseRewardAmount * (j + 1); - accumulator.depositReward(tokenAddresses[j], depositAmount); - } - - if (periodLength > 0) { - vm.warp(block.timestamp + periodLength); - } - - accumulator.checkpoint(deployer); - - // Store claimable amounts and balances for manager - for (uint256 j = 0; j < rewardCount; j++) { - claimable[j] = accumulator.claimable(deployer, tokenAddresses[j]); - balanceBefore[j] = IERC20(tokenAddresses[j]).balanceOf(manager); - - (, , uint256 pending, uint256 claimed) = accumulator.userRewardSnapshot(deployer, tokenAddresses[j]); - assertGt(pending, 0); - assertEq(claimed, 0); - } - - // Claim to manager - for (uint256 j = 0; j < rewardCount; j++) { - vm.expectEmit(true, true, true, true); - emit IMultipleRewardAccumulator.Claim(deployer, tokenAddresses[j], manager, claimable[j]); - } - accumulator.claim(deployer, manager); - - // Verify post-claim state - for (uint256 j = 0; j < rewardCount; j++) { - assertEq(accumulator.claimable(deployer, tokenAddresses[j]), 0); - assertEq(IERC20(tokenAddresses[j]).balanceOf(manager), balanceBefore[j] + claimable[j]); - - (, , uint256 pending, uint256 claimed) = accumulator.userRewardSnapshot(deployer, tokenAddresses[j]); - assertEq(pending, 0); - assertEq(claimed, claimable[j]); - assertEq(accumulator.claimed(deployer, tokenAddresses[j]), claimable[j]); - } - */ - } - } - - /* - function testClaimWithRewardReceiver() public { - for (uint256 i = 0; i < rewardCounts.length; i++) { - uint256 rewardCount = rewardCounts[i]; - uint40 periodLength = 1 weeks; - uint256 baseRewardAmount = 2233 * 1 ether; - uint256 totalPoolShare = 1234 * 1 ether; - uint256 userPoolShare = 456 * 1 ether; - - ( - IMockMultipleRewardCompoundingAccumulator accumulator, - address[] memory tokenAddresses - ) = _setupAccumulator(rewardCount, periodLength); - - // Setup reward state - accumulator.setTotalPoolShare(totalPoolShare, 1 ether); - accumulator.setUserPoolShare(userPoolShare, 1 ether); - - for (uint256 j = 0; j < rewardCount; j++) { - uint256 depositAmount = baseRewardAmount * (j + 1); - accumulator.depositReward(tokenAddresses[j], depositAmount); - } - - if (periodLength > 0) { - vm.warp(block.timestamp + periodLength); - } - - accumulator.checkpoint(deployer); - - // Set reward receiver - accumulator.setRewardReceiver(receiver); - - // Test reverting when claiming other to other - vm.expectRevert(abi.encodeWithSelector(IMultipleRewardAccumulator.ClaimOthersRewardToAnother.selector)); - accumulator.claim(manager, deployer); - - // Test claim caller - should go to reward receiver - uint256[] memory claimable = new uint256[](rewardCount); - uint256[] memory balanceBefore = new uint256[](rewardCount); - - for (uint256 j = 0; j < rewardCount; j++) { - claimable[j] = accumulator.claimable(deployer, tokenAddresses[j]); - balanceBefore[j] = IERC20(tokenAddresses[j]).balanceOf(receiver); - - (, , uint256 pending, uint256 claimed) = accumulator.userRewardSnapshot(deployer, tokenAddresses[j]); - assertGt(pending, 0); - assertEq(claimed, 0); - } - - // Execute claim and check events - for (uint256 j = 0; j < rewardCount; j++) { - vm.expectEmit(true, true, true, true); - emit IMultipleRewardAccumulator.Claim(deployer, tokenAddresses[j], receiver, claimable[j]); - } - accumulator.claim(); - - // Verify post-claim state - tokens should go to receiver - for (uint256 j = 0; j < rewardCount; j++) { - assertEq(accumulator.claimable(deployer, tokenAddresses[j]), 0); - assertEq(IERC20(tokenAddresses[j]).balanceOf(receiver), balanceBefore[j] + claimable[j]); - - (, , uint256 pending, uint256 claimed) = accumulator.userRewardSnapshot(deployer, tokenAddresses[j]); - assertEq(pending, 0); - assertEq(claimed, claimable[j]); - assertEq(accumulator.claimed(deployer, tokenAddresses[j]), claimable[j]); - } - - // Test claim other - should go to reward receiver - // Reset the state for a new test - (accumulator, tokenAddresses) = _setupAccumulator(rewardCount, periodLength); - - // Setup reward state again - accumulator.setTotalPoolShare(totalPoolShare, 1 ether); - accumulator.setUserPoolShare(userPoolShare, 1 ether); - - for (uint256 j = 0; j < rewardCount; j++) { - uint256 depositAmount = baseRewardAmount * (j + 1); - accumulator.depositReward(tokenAddresses[j], depositAmount); - } - - if (periodLength > 0) { - vm.warp(block.timestamp + periodLength); - } - - accumulator.checkpoint(deployer); - accumulator.setRewardReceiver(receiver); - - // Store claimable amounts and balances - for (uint256 j = 0; j < rewardCount; j++) { - claimable[j] = accumulator.claimable(deployer, tokenAddresses[j]); - balanceBefore[j] = IERC20(tokenAddresses[j]).balanceOf(receiver); - - (, , uint256 pending, uint256 claimed) = accumulator.userRewardSnapshot(deployer, tokenAddresses[j]); - assertGt(pending, 0); - assertEq(claimed, 0); - } - - // Claim as manager for deployer - should go to receiver - for (uint256 j = 0; j < rewardCount; j++) { - vm.expectEmit(true, true, true, true); - emit IMultipleRewardAccumulator.Claim(deployer, tokenAddresses[j], receiver, claimable[j]); - } - vm.prank(manager); - accumulator.claim(deployer); - - // Verify post-claim state - for (uint256 j = 0; j < rewardCount; j++) { - assertEq(accumulator.claimable(deployer, tokenAddresses[j]), 0); - assertEq(IERC20(tokenAddresses[j]).balanceOf(receiver), balanceBefore[j] + claimable[j]); - - (, , uint256 pending, uint256 claimed) = accumulator.userRewardSnapshot(deployer, tokenAddresses[j]); - assertEq(pending, 0); - assertEq(claimed, claimable[j]); - assertEq(accumulator.claimed(deployer, tokenAddresses[j]), claimable[j]); - } - - // Test claim to other - override reward receiver - // Reset the state for a new test - (accumulator, tokenAddresses) = _setupAccumulator(rewardCount, periodLength); - - // Setup reward state again - accumulator.setTotalPoolShare(totalPoolShare, 1 ether); - accumulator.setUserPoolShare(userPoolShare, 1 ether); - - for (uint256 j = 0; j < rewardCount; j++) { - uint256 depositAmount = baseRewardAmount * (j + 1); - accumulator.depositReward(tokenAddresses[j], depositAmount); - } - - if (periodLength > 0) { - vm.warp(block.timestamp + periodLength); - } - - accumulator.checkpoint(deployer); - accumulator.setRewardReceiver(receiver); - - // Store claimable amounts and balances for manager - for (uint256 j = 0; j < rewardCount; j++) { - claimable[j] = accumulator.claimable(deployer, tokenAddresses[j]); - balanceBefore[j] = IERC20(tokenAddresses[j]).balanceOf(manager); - - (, , uint256 pending, uint256 claimed) = accumulator.userRewardSnapshot(deployer, tokenAddresses[j]); - assertGt(pending, 0); - assertEq(claimed, 0); - } - - // Claim to manager - overrides reward receiver - for (uint256 j = 0; j < rewardCount; j++) { - vm.expectEmit(true, true, true, true); - emit IMultipleRewardAccumulator.Claim(deployer, tokenAddresses[j], manager, claimable[j]); - } - accumulator.claim(deployer, manager); - - // Verify post-claim state - for (uint256 j = 0; j < rewardCount; j++) { - assertEq(accumulator.claimable(deployer, tokenAddresses[j]), 0); - assertEq(IERC20(tokenAddresses[j]).balanceOf(manager), balanceBefore[j] + claimable[j]); - - (, , uint256 pending, uint256 claimed) = accumulator.userRewardSnapshot(deployer, tokenAddresses[j]); - assertEq(pending, 0); - assertEq(claimed, claimable[j]); - assertEq(accumulator.claimed(deployer, tokenAddresses[j]), claimable[j]); - } - } - } - */ - - function testClaimHistoricalWithoutRewardReceiver() public { - for (uint256 i = 0; i < rewardCounts.length; i++) { - uint256 rewardCount = rewardCounts[i]; - uint40 periodLength = 1 weeks; - uint256 baseRewardAmount = 2233 * 1 ether; - uint256 totalPoolShare = 1234 * 1 ether; - uint256 userPoolShare = 456 * 1 ether; - - ( - IMockMultipleRewardCompoundingAccumulator accumulator, - address[] memory tokenAddresses - ) = _setupAccumulator(rewardCount, periodLength); - - // Setup reward state - accumulator.setTotalPoolShare(totalPoolShare, 1 ether); - accumulator.setUserPoolShare(userPoolShare, 1 ether); - - for (uint256 j = 0; j < rewardCount; j++) { - uint256 depositAmount = baseRewardAmount * (j + 1); - accumulator.depositReward(tokenAddresses[j], depositAmount); - } - - if (periodLength > 0) { - vm.warp(block.timestamp + periodLength); - } - - accumulator.checkpoint(address(0)); - - // Unregister tokens to make them historical - for (uint256 j = 0; j < rewardCount; j++) { - vm.prank(manager); - accumulator.unregisterRewardToken(tokenAddresses[j]); - } - - accumulator.checkpoint(deployer); - - // Test claim caller - uint256[] memory claimable = new uint256[](rewardCount); - uint256[] memory balanceBefore = new uint256[](rewardCount); - - for (uint256 j = 0; j < rewardCount; j++) { - claimable[j] = accumulator.claimable(deployer, tokenAddresses[j]); - balanceBefore[j] = IERC20(tokenAddresses[j]).balanceOf(deployer); - - (, , uint256 pending, uint256 claimed) = accumulator.userRewardSnapshot(deployer, tokenAddresses[j]); - assertGt(pending, 0); - assertEq(claimed, 0); - } - - // Regular claim should not emit events (tokens unregistered) - vm.recordLogs(); - accumulator.claim(); - Vm.Log[] memory logs = vm.getRecordedLogs(); - assertEq(logs.length, 0); - - // Claim historical - for (uint256 j = 0; j < rewardCount; j++) { - vm.expectEmit(true, true, true, true); - emit IMultipleRewardAccumulator.Claim(deployer, tokenAddresses[j], deployer, claimable[j]); - } - accumulator.claimTokens(tokenAddresses, type(uint256).max); - - // Verify post-claim state - for (uint256 j = 0; j < rewardCount; j++) { - assertEq(accumulator.claimable(deployer, tokenAddresses[j]), 0); - assertEq(IERC20(tokenAddresses[j]).balanceOf(deployer), balanceBefore[j] + claimable[j]); - - (, , uint256 pending, uint256 claimed) = accumulator.userRewardSnapshot(deployer, tokenAddresses[j]); - assertEq(pending, 0); - assertEq(claimed, claimable[j]); - assertEq(accumulator.claimed(deployer, tokenAddresses[j]), claimable[j]); - } - - // Test claim other - // Reset the state for a new test - (accumulator, tokenAddresses) = _setupAccumulator(rewardCount, periodLength); - - // Setup reward state again - accumulator.setTotalPoolShare(totalPoolShare, 1 ether); - accumulator.setUserPoolShare(userPoolShare, 1 ether); - - for (uint256 j = 0; j < rewardCount; j++) { - uint256 depositAmount = baseRewardAmount * (j + 1); - accumulator.depositReward(tokenAddresses[j], depositAmount); - } - - if (periodLength > 0) { - vm.warp(block.timestamp + periodLength); - } - - accumulator.checkpoint(address(0)); - - // Unregister tokens - for (uint256 j = 0; j < rewardCount; j++) { - vm.prank(manager); - accumulator.unregisterRewardToken(tokenAddresses[j]); - } - - accumulator.checkpoint(deployer); - - // Store claimable amounts and balances - for (uint256 j = 0; j < rewardCount; j++) { - claimable[j] = accumulator.claimable(deployer, tokenAddresses[j]); - balanceBefore[j] = IERC20(tokenAddresses[j]).balanceOf(deployer); - - (, , uint256 pending, uint256 claimed) = accumulator.userRewardSnapshot(deployer, tokenAddresses[j]); - assertGt(pending, 0); - assertEq(claimed, 0); - } - - // Regular claim should not emit events - vm.recordLogs(); - accumulator.claim(); - logs = vm.getRecordedLogs(); - assertEq(logs.length, 0); - - /* - // Claim historical as manager - for (uint256 j = 0; j < rewardCount; j++) { - vm.expectEmit(true, true, true, true); - emit IMultipleRewardAccumulator.Claim(deployer, tokenAddresses[j], deployer, claimable[j]); - } - vm.prank(manager); - accumulator.claimHistorical(deployer, tokenAddresses); - - // Verify post-claim state - for (uint256 j = 0; j < rewardCount; j++) { - assertEq(accumulator.claimable(deployer, tokenAddresses[j]), 0); - assertEq(IERC20(tokenAddresses[j]).balanceOf(deployer), balanceBefore[j] + claimable[j]); - - (, , uint256 pending, uint256 claimed) = accumulator.userRewardSnapshot(deployer, tokenAddresses[j]); - assertEq(pending, 0); - assertEq(claimed, claimable[j]); - assertEq(accumulator.claimed(deployer, tokenAddresses[j]), claimable[j]); - } - */ - } - } - - /* - - function testClaimHistoricalWithRewardReceiver() public { - for (uint256 i = 0; i < rewardCounts.length; i++) { - uint256 rewardCount = rewardCounts[i]; - uint40 periodLength = 1 weeks; - uint256 baseRewardAmount = 2233 * 1 ether; - uint256 totalPoolShare = 1234 * 1 ether; - uint256 userPoolShare = 456 * 1 ether; - - ( - IMockMultipleRewardCompoundingAccumulator accumulator, - address[] memory tokenAddresses - ) = _setupAccumulator(rewardCount, periodLength); - - // Setup reward state - accumulator.setTotalPoolShare(totalPoolShare, 1 ether); - accumulator.setUserPoolShare(userPoolShare, 1 ether); - - for (uint256 j = 0; j < rewardCount; j++) { - uint256 depositAmount = baseRewardAmount * (j + 1); - accumulator.depositReward(tokenAddresses[j], depositAmount); - } - - if (periodLength > 0) { - vm.warp(block.timestamp + periodLength); - } - - accumulator.checkpoint(address(0)); - - // Unregister tokens to make them historical - for (uint256 j = 0; j < rewardCount; j++) { - vm.prank(manager); - accumulator.unregisterRewardToken(tokenAddresses[j]); - } - - accumulator.checkpoint(deployer); - - // Set reward receiver - accumulator.setRewardReceiver(receiver); - - // Test claim caller - uint256[] memory claimable = new uint256[](rewardCount); - uint256[] memory balanceBefore = new uint256[](rewardCount); - - for (uint256 j = 0; j < rewardCount; j++) { - claimable[j] = accumulator.claimable(deployer, tokenAddresses[j]); - balanceBefore[j] = IERC20(tokenAddresses[j]).balanceOf(receiver); - - (, , uint256 pending, uint256 claimed) = accumulator.userRewardSnapshot(deployer, tokenAddresses[j]); - assertGt(pending, 0); - assertEq(claimed, 0); - } - - // Regular claim should not emit events (tokens unregistered) - vm.recordLogs(); - accumulator.claim(); - Vm.Log[] memory logs = vm.getRecordedLogs(); - assertEq(logs.length, 0); - - // Claim historical - should go to receiver - for (uint256 j = 0; j < rewardCount; j++) { - vm.expectEmit(true, true, true, true); - emit IMultipleRewardAccumulator.Claim(deployer, tokenAddresses[j], receiver, claimable[j]); - } - accumulator.claimHistorical(tokenAddresses); - - // Verify post-claim state - for (uint256 j = 0; j < rewardCount; j++) { - assertEq(accumulator.claimable(deployer, tokenAddresses[j]), 0); - assertEq(IERC20(tokenAddresses[j]).balanceOf(receiver), balanceBefore[j] + claimable[j]); - - (, , uint256 pending, uint256 claimed) = accumulator.userRewardSnapshot(deployer, tokenAddresses[j]); - assertEq(pending, 0); - assertEq(claimed, claimable[j]); - assertEq(accumulator.claimed(deployer, tokenAddresses[j]), claimable[j]); - } - - // Test claim other - // Reset the state for a new test - (accumulator, tokenAddresses) = _setupAccumulator(rewardCount, periodLength); - - // Setup reward state again - accumulator.setTotalPoolShare(totalPoolShare, 1 ether); - accumulator.setUserPoolShare(userPoolShare, 1 ether); - - for (uint256 j = 0; j < rewardCount; j++) { - uint256 depositAmount = baseRewardAmount * (j + 1); - accumulator.depositReward(tokenAddresses[j], depositAmount); - } - - if (periodLength > 0) { - vm.warp(block.timestamp + periodLength); - } - - accumulator.checkpoint(address(0)); - - // Unregister tokens - for (uint256 j = 0; j < rewardCount; j++) { - vm.prank(manager); - accumulator.unregisterRewardToken(tokenAddresses[j]); - } - - accumulator.checkpoint(deployer); - accumulator.setRewardReceiver(receiver); - - // Store claimable amounts and balances - for (uint256 j = 0; j < rewardCount; j++) { - claimable[j] = accumulator.claimable(deployer, tokenAddresses[j]); - balanceBefore[j] = IERC20(tokenAddresses[j]).balanceOf(receiver); - - (, , uint256 pending, uint256 claimed) = accumulator.userRewardSnapshot(deployer, tokenAddresses[j]); - assertGt(pending, 0); - assertEq(claimed, 0); - } - - // Regular claim should not emit events - vm.recordLogs(); - accumulator.claim(); - logs = vm.getRecordedLogs(); - assertEq(logs.length, 0); - - // Claim historical as manager - should go to receiver - for (uint256 j = 0; j < rewardCount; j++) { - vm.expectEmit(true, true, true, true); - emit IMultipleRewardAccumulator.Claim(deployer, tokenAddresses[j], receiver, claimable[j]); - } - vm.prank(manager); - accumulator.claimHistorical(deployer, tokenAddresses); - - // Verify post-claim state - for (uint256 j = 0; j < rewardCount; j++) { - assertEq(accumulator.claimable(deployer, tokenAddresses[j]), 0); - assertEq(IERC20(tokenAddresses[j]).balanceOf(receiver), balanceBefore[j] + claimable[j]); - - (, , uint256 pending, uint256 claimed) = accumulator.userRewardSnapshot(deployer, tokenAddresses[j]); - assertEq(pending, 0); - assertEq(claimed, claimable[j]); - assertEq(accumulator.claimed(deployer, tokenAddresses[j]), claimable[j]); - } - } - } - */ - /// ═══════════════════════════════════════════════════════════════════════════════ /// INTEGRAL OVERFLOW BOUNDS ANALYSIS /// ═══════════════════════════════════════════════════════════════════════════════ From e7eac979b1ec8f94adcde01777bb3b6df2c8d0a3 Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Sat, 30 May 2026 15:26:53 +0100 Subject: [PATCH 095/232] added test for new claiming functionality --- regression/coverage.txt | 4 +- regression/gas.txt | 2 +- regression/sizes.txt | 2 +- ...te_StabilityPool_v2_Data_ETH_mainnet.s.sol | 179 ++++ ...MultipleRewardCompoundingAccumulator.t.sol | 781 ++++-------------- 5 files changed, 335 insertions(+), 633 deletions(-) create mode 100644 script/Migrate_StabilityPool_v2_Data_ETH_mainnet.s.sol diff --git a/regression/coverage.txt b/regression/coverage.txt index 7b028b2e..e2333b61 100644 --- a/regression/coverage.txt +++ b/regression/coverage.txt @@ -57,7 +57,7 @@ | src/price/StakedETHWrappedPriceOracle_v1.sol | X 0% (0/29) | X 0% (0/27) | X 0% (0/5) | X 0% (0/4) | | src/reward/accumulator/MultipleRewardCompoundingAccumulator.sol | X 0% (0/136) | X 0% (0/171) | X 0% (0/16) | X 0% (0/21) | | src/reward/accumulator/MultipleRewardCompoundingAccumulator_v2.sol | X 73% (108/147) | X 76% (139/184) | X 61% (11/18) | X 68% (15/22) | -| src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol | X 97% (88/91) | X 97% (112/115) | X 75% (9/12) | ✓ 100% (12/12) | +| src/reward/accumulator/MultipleRewardCompoundingAccumulator_v3.sol | X 98% (89/91) | X 98% (113/115) | X 83% (10/12) | ✓ 100% (12/12) | | src/reward/distributor/LinearMultipleRewardDistributor.sol | X 0% (0/77) | X 0% (0/86) | X 0% (0/12) | X 0% (0/14) | | src/reward/distributor/LinearMultipleRewardDistributor_v2.sol | X 58% (45/77) | X 63% (54/86) | X 25% (3/12) | X 50% (7/14) | | src/reward/distributor/LinearMultipleRewardDistributor_v3.sol | ✓ 100% (77/77) | ✓ 100% (86/86) | ✓ 100% (12/12) | ✓ 100% (14/14) | @@ -65,4 +65,4 @@ | src/util/ERC20MetadataLib_v1.sol | ✓ 100% (24/24) | ✓ 100% (22/22) | ✓ 100% (4/4) | ✓ 100% (4/4) | | src/util/FmtLib.sol | X 79% (15/19) | X 73% (19/26) | X 50% (2/4) | ✓ 100% (1/1) | | src/util/WordCodec.sol | ✓ 100% (15/15) | ✓ 100% (14/14) | ✓ 100% (0/0) | ✓ 100% (4/4) | -| Total | X 56% (4426/7959) | X 55% (4682/8556) | X 43% (388/896) | X 56% (637/1139) | +| Total | X 55% (4427/8019) | X 54% (4683/8625) | X 43% (389/906) | X 56% (637/1145) | diff --git a/regression/gas.txt b/regression/gas.txt index 64dcff4d..ff5fda75 100644 --- a/regression/gas.txt +++ b/regression/gas.txt @@ -139,7 +139,7 @@ src/minter/StabilityPool_v3.sol:StabilityPool_v3 | owner | 2.402e+03 | | permit | 5.063e+04 | | proxiableUUID | 3.410e+02 | -| registerRewardToken | 8.854e+04 | +| registerRewardToken | 7.294e+04 | | requestWithdrawal | 2.501e+04 | | sweep | 4.023e+04 | | symbol | 6.210e+02 | diff --git a/regression/sizes.txt b/regression/sizes.txt index c6e0d416..18c9bb2e 100644 --- a/regression/sizes.txt +++ b/regression/sizes.txt @@ -41,7 +41,7 @@ | MinterMarketConfigLib | 85 | 24,491 | 135 | 18,350 | 1.84 | | Minter_v1 | 23,681 | 895 | 25,515 | 4,991,350 | 499.14 | | Minter_v2 | 23,675 | 901 | 25,509 | 4,990,090 | 499.01 | -| Minter_v3 | 24,515 | 61 | 26,342 | 5,166,420 | 516.64 | +| Minter_v3 | 24,535 | 41 | 26,362 | 5,170,620 | 517.06 | | PriceOracle_v1 | 1,958 | 22,618 | 2,010 | 411,700 | 41.17 | | ReservePool_v1 | 4,760 | 19,816 | 5,009 | 1,002,090 | 100.21 | | StabilityPoolManager_v1 | 11,209 | 13,367 | 13,057 | 2,372,370 | 237.24 | diff --git a/script/Migrate_StabilityPool_v2_Data_ETH_mainnet.s.sol b/script/Migrate_StabilityPool_v2_Data_ETH_mainnet.s.sol new file mode 100644 index 00000000..8780e3a6 --- /dev/null +++ b/script/Migrate_StabilityPool_v2_Data_ETH_mainnet.s.sol @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.28 <0.9.0; + +import {console2 as console} from "forge-std/console2.sol"; +import {LibString} from "@solady/utils/LibString.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; + +import {Config_MinterMarket, MinterMarketConfigLib} from "@harbor-script/config/ConfigBase.sol"; +import {ForceMigrateAccumulator_v1} from "@harbor-script/verify/sp-v2-data-prep-for-v3/ForceMigrateAccumulator_v1.sol"; +import {IMultipleRewardDistributor} from "@harbor/interfaces/IMultipleRewardDistributor.sol"; + +import {Deploy_ETH_Minter} from "@harbor-script/src/Deploy_ETH_Minter.sol"; + +import {Script} from "forge-std/Script.sol"; + +/// @notice Force-migrate accumulator storage from the legacy V1 (uint192 integral) +/// format to the V2 (uint256 integral) format for all stability pools, then restore +/// each pool to its current StabilityPool_v2 implementation. +/// +/// This is the "prep for v3" step: once every remaining user is in V2 format, the +/// V1 read-fallback in the accumulator is dead code and a later StabilityPool_v3 +/// upgrade can remove it safely. The proxy ends on v2 here — no v3 upgrade. +/// +/// Per pool, the Safe batch contains 3 atomic transactions: +/// 1. Upgrade proxy -> ForceMigrateAccumulator_v1 (pauses the pool) +/// 2. remediate(tokens, holders) -> pure copy of V1 snapshot data into V2 +/// 3. Restore proxy -> the StabilityPool_v2 implementation it had before +/// +/// Holders are read at runtime from per-pool files produced by +/// `script/verify/sp-v2-data-prep-for-v3/collect-sp-holders` (UserDepositChange +/// logs). Pools with no holder file, or an empty one, are skipped. +/// +/// Run via: +/// script/run-script Migrate_StabilityPool_v2_Data_mainnet --salt harbor_v1 --network mainnet --broadcast --local +contract Migrate_StabilityPool_v2_Data_mainnet is Script, Deploy_ETH_Minter { + using LibString for address; + + // StabilityPoolCollateral / StabilityPoolLeveraged inherited from the StabilityPool deployment helper. + + /// @dev ERC1967 implementation slot. + bytes32 internal constant IMPL_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; + + /// @dev Directory of per-pool holder files (one checksummed address per line, '#' comments). + string internal constant HOLDERS_DIR = "tmp/sp-holders/"; + + /// @dev Deployed once, shared across all pools (no constructor params, deterministic bytecode). + address internal migImpl; + + function _doOneMinter(Config_MinterMarket[] memory markets) internal { + for (uint256 i = 0; i < markets.length; i++) { + string memory marketKey = MinterMarketConfigLib.salt(markets[i]); + _migratePool(marketKey, StabilityPoolCollateral); + _migratePool(marketKey, StabilityPoolLeveraged); + } + } + + function _migratePool(string memory marketKey, string memory spType) internal { + string memory key = _key(marketKey, spType); + address pool = _predictAddress(key); + + // Read current implementation (restored after remediation). + address currentImpl = address(uint160(uint256(vm.load(pool, IMPL_SLOT)))); + require(currentImpl.code.length != 0, string.concat("no impl for ", _saltString(key))); + + // Read reward tokens before the upgrade (the pauser fallback reverts all other calls). + // Include BOTH active and historical: _checkpoint snapshots both, so a user can carry V1 + // data for a no-longer-active token. remediate() skips tokens with no V1 data, so passing + // historical tokens is harmless and makes the migration complete (safe to remove the + // fallback in a later v3 upgrade). + address[] memory tokens = _concat( + IMultipleRewardDistributor(pool).activeRewardTokens(), + IMultipleRewardDistributor(pool).historicalRewardTokens() + ); + + // Holders for this pool, from the generated file. + address[] memory holders = _readHolders(marketKey, spType); + + if (holders.length == 0) { + console.log(" > %s: no holders, skipping", _saltString(key)); + return; + } + + console.log(" > %s: %d holders, %d tokens", _saltString(key), holders.length, tokens.length); + + // 1. Upgrade to the migration contract. + queue( + key, + abi.encodeCall(UUPSUpgradeable.upgradeToAndCall, (migImpl, "")), + "upgrade to ForceMigrateAccumulator_v1" + ); + + // 2. Remediate: copy V1 -> V2 for every holder/token pair. + queue( + pool, + abi.encodeCall(ForceMigrateAccumulator_v1.remediate, (tokens, holders)), + string.concat("remediate ", _saltString(key)) + ); + + // 3. Restore the original (StabilityPool_v2) implementation. + queue( + key, + abi.encodeCall(UUPSUpgradeable.upgradeToAndCall, (currentImpl, "")), + string.concat("restore to ", currentImpl.toHexString()) + ); + } + + /// @dev Read a pool's holder list from HOLDERS_DIR/::.txt. + /// Lines starting with '#' are comments; every other non-empty line is an address. + /// Returns an empty array when the file is absent (pool skipped by the caller). + function _readHolders(string memory marketKey, string memory spType) internal returns (address[] memory holders) { + string memory path = string.concat(HOLDERS_DIR, marketKey, "::", spType, ".txt"); + if (!vm.isFile(path)) { + return new address[](0); + } + + // First pass: count address lines. + uint256 count = 0; + while (true) { + string memory line = vm.readLine(path); + if (bytes(line).length == 0) { + break; + } + if (_isAddressLine(line)) { + count++; + } + } + vm.closeFile(path); + + // Second pass: parse them. + holders = new address[](count); + uint256 idx = 0; + while (true) { + string memory line = vm.readLine(path); + if (bytes(line).length == 0) { + break; + } + if (_isAddressLine(line)) { + holders[idx] = vm.parseAddress(line); + idx++; + } + } + vm.closeFile(path); + } + + /// @dev True for a non-comment, non-empty line (an address). '#' (0x23) marks a comment. + function _isAddressLine(string memory line) internal pure returns (bool) { + bytes memory b = bytes(line); + if (b.length == 0) { + return false; + } + if (b[0] == 0x23) { + return false; + } + return true; + } + + /// @dev Concatenate two address arrays. + function _concat(address[] memory a, address[] memory b) internal pure returns (address[] memory out) { + out = new address[](a.length + b.length); + for (uint256 i = 0; i < a.length; i++) { + out[i] = a[i]; + } + for (uint256 i = 0; i < b.length; i++) { + out[a.length + i] = b[i]; + } + } + + function build() internal override { + Config_MinterMarket[] memory markets; + + vm.startBroadcast(); + migImpl = address(new ForceMigrateAccumulator_v1()); + console.log(" Migration impl: %s", migImpl); + vm.stopBroadcast(); + + (, markets) = createETHMintersConfig(); + _doOneMinter(markets); + } +} diff --git a/test/reward/accumulator/MultipleRewardCompoundingAccumulator.t.sol b/test/reward/accumulator/MultipleRewardCompoundingAccumulator.t.sol index 120e29fa..851d113a 100644 --- a/test/reward/accumulator/MultipleRewardCompoundingAccumulator.t.sol +++ b/test/reward/accumulator/MultipleRewardCompoundingAccumulator.t.sol @@ -7,7 +7,7 @@ import {ReentrancyGuardTransient} from "@openzeppelin/contracts/utils/Reentrancy import {IMultipleRewardAccumulator_v3 as IMultipleRewardAccumulator} from "@harbor/interfaces/IMultipleRewardAccumulator_v3.sol"; import {IMockMultipleRewardCompoundingAccumulator} from "@harbor-test/mocks/IMockMultipleRewardCompoundingAccumulator.sol"; -import {Test, Vm} from "forge-std/Test.sol"; +import {Test} from "forge-std/Test.sol"; import {MockERC20} from "@bao-test/mocks/MockERC20.sol"; import {MockMultipleRewardCompoundingAccumulator_v3} from "@harbor-test/mocks/reward/accumulator/MockMultipleRewardCompoundingAccumulator_v3.sol"; @@ -531,638 +531,161 @@ contract MultipleRewardCompoundingAccumulatorTest is Test { assertGt(claimable, 0, "User has non-zero claimable at minimum rate"); assertApproxEqAbs(claimable, 604800, 10, "Claimable matches accumulated amount"); } -} -/* -import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; -import { MockERC20, MockMultipleRewardCompoundingAccumulator } from "@/types/index"; -import { expect } from "chai"; -import { MaxUint256, ZeroAddress, ZeroHash, toBigInt } from "ethers"; -import { ethers, network } from "hardhat"; - -describe("MultipleRewardCompoundingAccumulator.spec", async () => { - for (const rewardCount of [1, 3]) { - const periodLength = 86400 * 7; - const precision = 10n ** 18n; - - let deployer: HardhatEthersSigner; - let manager: HardhatEthersSigner; - let receiver: HardhatEthersSigner; - - let tokens: MockERC20[]; - let tokenAddresses: string[]; - let accumulator: MockMultipleRewardCompoundingAccumulator; - - context(`run with period[${periodLength}] rewards[${rewardCount}]`, async () => { - beforeEach(async () => { - [deployer, manager, receiver] = await ethers.getSigners(); - const MockERC20 = await ethers.getContractFactory("MockERC20", deployer); - const MockMultipleRewardCompoundingAccumulator = await ethers.getContractFactory( - "MockMultipleRewardCompoundingAccumulator", - deployer - ); + // ═══════════════════════════════════════════════════════════════════════ + // claimTokens(tokens, maxAmount) — v3 per-token cap behaviour + // ═══════════════════════════════════════════════════════════════════════ + + /// @dev Stake the test contract, deposit rewards, warp the full period and checkpoint — + /// so each token has a known, non-zero pending read straight from the snapshot + /// (source of truth — avoids replicating the integral math here). + function _stakeAndAccruePending( + uint256 rewardCount + ) + internal + returns ( + IMockMultipleRewardCompoundingAccumulator accumulator, + address[] memory tokenAddresses, + uint256[] memory pending + ) + { + uint40 periodLength = 1 weeks; + (accumulator, tokenAddresses) = _setupAccumulator(rewardCount, periodLength); + accumulator.setTotalPoolShare(1234 ether, 1 ether); + accumulator.setUserPoolShare(456 ether, 1 ether); + // Different deposit per token so the multi-token test exercises distinct pending values. + for (uint256 j = 0; j < rewardCount; j++) { + accumulator.depositReward(tokenAddresses[j], 1000 ether * (j + 1)); + } + vm.warp(block.timestamp + periodLength); + accumulator.checkpoint(deployer); + pending = new uint256[](rewardCount); + for (uint256 j = 0; j < rewardCount; j++) { + (, , uint256 p, ) = accumulator.userRewardSnapshot(deployer, tokenAddresses[j]); + require(p > 0, "no pending: bad setup"); + pending[j] = p; + } + } + + /// @notice maxAmount below pending: exactly maxAmount is transferred and the remainder + /// is left in pending. claimed advances by maxAmount. + function test_claimTokens_capBindsBelowPending_partialTransfer() public { + ( + IMockMultipleRewardCompoundingAccumulator accumulator, + address[] memory tokenAddresses, + uint256[] memory pending + ) = _stakeAndAccruePending(1); + + uint256 cap = pending[0] / 3; + uint256 balBefore = IERC20(tokenAddresses[0]).balanceOf(deployer); + + IMultipleRewardAccumulator(address(accumulator)).claimTokens(tokenAddresses, cap); + + assertEq(IERC20(tokenAddresses[0]).balanceOf(deployer) - balBefore, cap, "transferred == cap"); + (, , uint256 pendingAfter, uint256 claimedAfter) = accumulator.userRewardSnapshot(deployer, tokenAddresses[0]); + assertEq(claimedAfter, cap, "claimed += cap"); + assertEq(pendingAfter, pending[0] - cap, "pending -= cap"); + } + + /// @notice maxAmount at or above pending: the full pending is paid out and pending goes to zero. + function test_claimTokens_capAtOrAbovePending_fullTransfer() public { + ( + IMockMultipleRewardCompoundingAccumulator accumulator, + address[] memory tokenAddresses, + uint256[] memory pending + ) = _stakeAndAccruePending(1); + + uint256 balBefore = IERC20(tokenAddresses[0]).balanceOf(deployer); + + IMultipleRewardAccumulator(address(accumulator)).claimTokens(tokenAddresses, pending[0] * 10); + + assertEq(IERC20(tokenAddresses[0]).balanceOf(deployer) - balBefore, pending[0], "transferred == full pending"); + (, , uint256 pendingAfter, uint256 claimedAfter) = accumulator.userRewardSnapshot(deployer, tokenAddresses[0]); + assertEq(pendingAfter, 0, "pending zeroed"); + assertEq(claimedAfter, pending[0], "claimed == original pending"); + } - tokens = []; - tokenAddresses = []; - for (let i = 0; i < rewardCount; i++) { - tokens.push(await MockERC20.deploy("R", "R", 18)); - tokenAddresses.push(await tokens[i].getAddress()); - await tokens[i].mint(deployer.address, ethers.parseEther("1000000")); + /// @notice maxAmount is applied INDEPENDENTLY per token (not a total budget across the array). + /// With tokens that have different pending values, each one is capped to maxAmount in the same call. + function test_claimTokens_capAppliedPerTokenIndependently() public { + ( + IMockMultipleRewardCompoundingAccumulator accumulator, + address[] memory tokenAddresses, + uint256[] memory pending + ) = _stakeAndAccruePending(3); + + // Pick a cap that binds on every token (smaller than the smallest pending). + uint256 cap = pending[0]; + for (uint256 j = 1; j < pending.length; j++) { + if (pending[j] < cap) { + cap = pending[j]; + } } - accumulator = await MockMultipleRewardCompoundingAccumulator.deploy(periodLength); - await accumulator.initialize(); + cap = cap / 2; - await accumulator.grantRole(await accumulator.REWARD_MANAGER_ROLE(), manager.address); - for (let i = 0; i < rewardCount; i++) { - await accumulator.connect(manager).registerRewardToken(await tokens[i].getAddress(), deployer.address); - await tokens[i].approve(await accumulator.getAddress(), MaxUint256); + uint256[] memory balBefore = new uint256[](3); + for (uint256 j = 0; j < 3; j++) { + balBefore[j] = IERC20(tokenAddresses[j]).balanceOf(deployer); } - }); - - context("initialization", async () => { - it("should initialize correctly", async () => { - expect(await accumulator.periodLength()).to.eq(periodLength); - expect(await accumulator.getActiveRewardTokens()).to.deep.eq(tokenAddresses); - expect(await accumulator.getHistoricalRewardTokens()).to.deep.eq([]); - expect(await accumulator.hasRole(ZeroHash, deployer.address)).to.eq(true); - }); - }); - - context("reentrant", async () => { - it("should prevent reentrant on checkpoint", async () => { - await expect( - accumulator.reentrantCall(accumulator.interface.encodeFunctionData("checkpoint", [ZeroAddress])) - ).to.revertedWith("ReentrancyGuard: reentrant call"); - }); - - it("should prevent reentrant on claim", async () => { - await expect(accumulator.reentrantCall(accumulator.interface.encodeFunctionData("claim()"))).to.revertedWith( - "ReentrancyGuard: reentrant call" - ); - await expect( - accumulator.reentrantCall(accumulator.interface.encodeFunctionData("claim(address)", [ZeroAddress])) - ).to.revertedWith("ReentrancyGuard: reentrant call"); - await expect( - accumulator.reentrantCall( - accumulator.interface.encodeFunctionData("claim(address,address)", [ZeroAddress, ZeroAddress]) - ) - ).to.revertedWith("ReentrancyGuard: reentrant call"); - }); - it("should prevent reentrant on claimHistorical", async () => { - await expect( - accumulator.reentrantCall( - accumulator.interface.encodeFunctionData("claimHistorical(address,address[])", [ZeroAddress, []]) - ) - ).to.revertedWith("ReentrancyGuard: reentrant call"); - await expect( - accumulator.reentrantCall(accumulator.interface.encodeFunctionData("claimHistorical(address[])", [[]])) - ).to.revertedWith("ReentrancyGuard: reentrant call"); - }); - }); - - context("#checkpoint", async () => { - const BaseRewardAmount = ethers.parseEther("2233"); - const TotalPoolShare = ethers.parseEther("1234"); - const UserPoolShare = ethers.parseEther("456"); - - beforeEach(async () => { - await accumulator.setTotalPoolShare(TotalPoolShare, 10n ** 18n); - await accumulator.setUserPoolShare(UserPoolShare, 10n ** 18n); - for (let i = 0; i < rewardCount; i++) { - const depositedAmount = BaseRewardAmount * toBigInt(i + 1); - await accumulator.depositReward(await tokens[i].getAddress(), depositedAmount); - } - }); - - it("should succeed when only checkpoint global snapshot", async () => { - const timestamp = (await ethers.provider.getBlock("latest"))!.timestamp; - await network.provider.send("evm_setNextBlockTimestamp", [timestamp + periodLength]); - await accumulator.checkpoint(ZeroAddress); - - for (let i = 0; i < rewardCount; i++) { - const snapshot = await accumulator.tokenToEpochExponentToIntegral(await tokens[i].getAddress(), 0); - const depositedAmount = BaseRewardAmount * toBigInt(i + 1); - const rate = depositedAmount / toBigInt(periodLength); - expect(snapshot.integral).to.closeTo( - (rate * toBigInt(periodLength) * precision * precision) / TotalPoolShare, - 100n * precision - ); - expect(snapshot.timestamp).to.eq(timestamp + periodLength); - } - }); - - it("should succeed, when checkpoint normal user", async () => { - let timestamp = (await ethers.provider.getBlock("latest"))!.timestamp; - await network.provider.send("evm_setNextBlockTimestamp", [timestamp + periodLength]); - await accumulator.checkpoint(deployer.address); - - for (let i = 0; i < rewardCount; i++) { - const snapshot = await accumulator.tokenToEpochExponentToIntegral(await tokens[i].getAddress(), 0); - const depositedAmount = BaseRewardAmount * toBigInt(i + 1); - const rate = depositedAmount / toBigInt(periodLength); - expect(snapshot.integral).to.closeTo( - (rate * toBigInt(periodLength) * precision * precision) / TotalPoolShare, - 100n * precision + + IMultipleRewardAccumulator(address(accumulator)).claimTokens(tokenAddresses, cap); + + for (uint256 j = 0; j < 3; j++) { + string memory tag = string.concat(" (token ", vm.toString(j), ")"); + assertEq( + IERC20(tokenAddresses[j]).balanceOf(deployer) - balBefore[j], + cap, + string.concat("transferred == cap per token", tag) ); - expect(snapshot.timestamp).to.eq(timestamp + periodLength); - - const userSnapshot = await accumulator.userRewardSnapshot(deployer.address, await tokens[i].getAddress()); - expect(userSnapshot.checkpoint.timestamp).to.eq(timestamp + periodLength); - expect(userSnapshot.checkpoint.integral).to.eq(snapshot.integral); - expect(userSnapshot.rewards.pending).to.closeTo( - (depositedAmount * UserPoolShare) / TotalPoolShare, - userSnapshot.rewards.pending / 1000000n - ); // error within 0.00001% - } - - // deposit again - for (let i = 0; i < rewardCount; i++) { - const depositedAmount = BaseRewardAmount * toBigInt(i + 1); - await accumulator.depositReward(await tokens[i].getAddress(), depositedAmount); - } - timestamp = (await ethers.provider.getBlock("latest"))!.timestamp; - await network.provider.send("evm_setNextBlockTimestamp", [timestamp + periodLength]); - await accumulator.checkpoint(deployer.address); - - for (let i = 0; i < rewardCount; i++) { - const snapshot = await accumulator.tokenToEpochExponentToIntegral(await tokens[i].getAddress(), 0); - const depositedAmount = BaseRewardAmount * toBigInt(i + 1); - const rate = depositedAmount / toBigInt(periodLength); - expect(snapshot.integral).to.closeTo( - ((rate * toBigInt(periodLength) * precision * precision) / TotalPoolShare) * 2n, - 1000n * precision + (, , uint256 pendingAfter, uint256 claimedAfter) = accumulator.userRewardSnapshot( + deployer, + tokenAddresses[j] ); - expect(snapshot.timestamp).to.eq(timestamp + periodLength); - - const userSnapshot = await accumulator.userRewardSnapshot(deployer.address, await tokens[i].getAddress()); - expect(userSnapshot.checkpoint.timestamp).to.eq(timestamp + periodLength); - expect(userSnapshot.checkpoint.integral).to.eq(snapshot.integral); - expect(userSnapshot.rewards.pending).to.closeTo( - ((depositedAmount * UserPoolShare) / TotalPoolShare) * 2n, - userSnapshot.rewards.pending / 1000000n - ); // error within 0.00001% - } - }); - }); - - context("#setRewardReceiver", async () => { - it("should succeed", async () => { - expect(await accumulator.rewardReceiver(deployer.address)).to.eq(ZeroAddress); - await expect(accumulator.connect(deployer).setRewardReceiver(receiver.address)) - .to.emit(accumulator, "UpdateRewardReceiver") - .withArgs(deployer.address, ZeroAddress, receiver.address); - expect(await accumulator.rewardReceiver(deployer.address)).to.eq(receiver.address); - await expect(accumulator.connect(deployer).setRewardReceiver(ZeroAddress)) - .to.emit(accumulator, "UpdateRewardReceiver") - .withArgs(deployer.address, receiver.address, ZeroAddress); - expect(await accumulator.rewardReceiver(deployer.address)).to.eq(ZeroAddress); - }); - }); - - context("#claim without setting rewardReceiver", async () => { - const BaseRewardAmount = ethers.parseEther("2233"); - const TotalPoolShare = ethers.parseEther("1234"); - const UserPoolShare = ethers.parseEther("456"); - - beforeEach(async () => { - await accumulator.setTotalPoolShare(TotalPoolShare, 10n ** 18n); - await accumulator.setUserPoolShare(UserPoolShare, 10n ** 18n); - for (let i = 0; i < rewardCount; i++) { - const depositedAmount = BaseRewardAmount * toBigInt(i + 1); - await accumulator.depositReward(await tokens[i].getAddress(), depositedAmount); - } - if (periodLength > 0) { - const timestamp = (await ethers.provider.getBlock("latest"))!.timestamp; - await network.provider.send("evm_setNextBlockTimestamp", [timestamp + periodLength]); - } - await accumulator.checkpoint(deployer.address); - }); - - it("should revert when claim other to other", async () => { - await expect( - accumulator["claim(address,address)"](manager.address, deployer.address) - ).to.revertedWithCustomError(accumulator, "ClaimOthersRewardToAnother"); - }); - - it("should succeed when claim caller", async () => { - const claimable = []; - const before = []; - for (let i = 0; i < rewardCount; i++) { - claimable.push(await accumulator.claimable(deployer.address, await tokens[i].getAddress())); - before.push(await tokens[i].balanceOf(deployer.address)); - const snapshot = await accumulator.userRewardSnapshot(deployer.address, await tokens[i].getAddress()); - expect(snapshot.rewards.pending).to.gt(0n); - expect(snapshot.rewards.claimed).to.eq(0n); - } - const tx = accumulator["claim()"](); - for (let i = 0; i < rewardCount; i++) { - await expect(tx) - .to.emit(accumulator, "Claim") - .withArgs(deployer.address, await tokens[i].getAddress(), deployer.address, claimable[i]); - } - for (let i = 0; i < rewardCount; i++) { - expect(await accumulator.claimable(deployer.address, await tokens[i].getAddress())).to.eq(0n); - expect(await tokens[i].balanceOf(deployer.address)).to.eq(before[i] + claimable[i]); - const snapshot = await accumulator.userRewardSnapshot(deployer.address, await tokens[i].getAddress()); - expect(snapshot.rewards.pending).to.eq(0n); - expect(snapshot.rewards.claimed).to.eq(claimable[i]); - expect(await accumulator.claimed(deployer.address, await tokens[i].getAddress())).to.eq(claimable[i]); - } - await expect(accumulator["claim()"]()).to.not.emit(accumulator, "Claim"); - for (let i = 0; i < rewardCount; i++) { - expect(await accumulator.claimable(deployer.address, await tokens[i].getAddress())).to.eq(0n); - expect(await tokens[i].balanceOf(deployer.address)).to.eq(before[i] + claimable[i]); - const snapshot = await accumulator.userRewardSnapshot(deployer.address, await tokens[i].getAddress()); - expect(snapshot.rewards.pending).to.eq(0n); - expect(snapshot.rewards.claimed).to.eq(claimable[i]); - expect(await accumulator.claimed(deployer.address, await tokens[i].getAddress())).to.eq(claimable[i]); - } - }); - - it("should succeed when claim other", async () => { - const claimable = []; - const before = []; - for (let i = 0; i < rewardCount; i++) { - claimable.push(await accumulator.claimable(deployer.address, await tokens[i].getAddress())); - before.push(await tokens[i].balanceOf(deployer.address)); - const snapshot = await accumulator.userRewardSnapshot(deployer.address, await tokens[i].getAddress()); - expect(snapshot.rewards.pending).to.gt(0n); - expect(snapshot.rewards.claimed).to.eq(0n); - } - const tx = accumulator.connect(manager)["claim(address)"](deployer.address); - for (let i = 0; i < rewardCount; i++) { - await expect(tx) - .to.emit(accumulator, "Claim") - .withArgs(deployer.address, await tokens[i].getAddress(), deployer.address, claimable[i]); - } - for (let i = 0; i < rewardCount; i++) { - expect(await accumulator.claimable(deployer.address, await tokens[i].getAddress())).to.eq(0n); - expect(await tokens[i].balanceOf(deployer.address)).to.eq(before[i] + claimable[i]); - const snapshot = await accumulator.userRewardSnapshot(deployer.address, await tokens[i].getAddress()); - expect(snapshot.rewards.pending).to.eq(0n); - expect(snapshot.rewards.claimed).to.eq(claimable[i]); - expect(await accumulator.claimed(deployer.address, await tokens[i].getAddress())).to.eq(claimable[i]); - } - await expect(accumulator.connect(manager)["claim(address)"](deployer.address)).to.not.emit( - accumulator, - "Claim" - ); - for (let i = 0; i < rewardCount; i++) { - expect(await accumulator.claimable(deployer.address, await tokens[i].getAddress())).to.eq(0n); - expect(await tokens[i].balanceOf(deployer.address)).to.eq(before[i] + claimable[i]); - const snapshot = await accumulator.userRewardSnapshot(deployer.address, await tokens[i].getAddress()); - expect(snapshot.rewards.pending).to.eq(0n); - expect(snapshot.rewards.claimed).to.eq(claimable[i]); - expect(await accumulator.claimed(deployer.address, await tokens[i].getAddress())).to.eq(claimable[i]); - } - }); - - it("should succeed when claim to other", async () => { - const claimable = []; - const before = []; - for (let i = 0; i < rewardCount; i++) { - claimable.push(await accumulator.claimable(deployer.address, await tokens[i].getAddress())); - before.push(await tokens[i].balanceOf(manager.address)); - const snapshot = await accumulator.userRewardSnapshot(deployer.address, await tokens[i].getAddress()); - expect(snapshot.rewards.pending).to.gt(0n); - expect(snapshot.rewards.claimed).to.eq(0n); - } - const tx = accumulator["claim(address,address)"](deployer.address, manager.address); - for (let i = 0; i < rewardCount; i++) { - await expect(tx) - .to.emit(accumulator, "Claim") - .withArgs(deployer.address, await tokens[i].getAddress(), manager.address, claimable[i]); - } - for (let i = 0; i < rewardCount; i++) { - expect(await accumulator.claimable(deployer.address, await tokens[i].getAddress())).to.eq(0n); - expect(await tokens[i].balanceOf(manager.address)).to.eq(before[i] + claimable[i]); - const snapshot = await accumulator.userRewardSnapshot(deployer.address, await tokens[i].getAddress()); - expect(snapshot.rewards.pending).to.eq(0n); - expect(snapshot.rewards.claimed).to.eq(claimable[i]); - expect(await accumulator.claimed(deployer.address, await tokens[i].getAddress())).to.eq(claimable[i]); - } - await expect(accumulator["claim(address,address)"](deployer.address, manager.address)).to.not.emit( - accumulator, - "Claim" - ); - for (let i = 0; i < rewardCount; i++) { - expect(await accumulator.claimable(deployer.address, await tokens[i].getAddress())).to.eq(0n); - expect(await tokens[i].balanceOf(manager.address)).to.eq(before[i] + claimable[i]); - const snapshot = await accumulator.userRewardSnapshot(deployer.address, await tokens[i].getAddress()); - expect(snapshot.rewards.pending).to.eq(0n); - expect(snapshot.rewards.claimed).to.eq(claimable[i]); - expect(await accumulator.claimed(deployer.address, await tokens[i].getAddress())).to.eq(claimable[i]); - } - }); - }); - - context("#claim with setting rewardReceiver", async () => { - const BaseRewardAmount = ethers.parseEther("2233"); - const TotalPoolShare = ethers.parseEther("1234"); - const UserPoolShare = ethers.parseEther("456"); - - beforeEach(async () => { - await accumulator.setTotalPoolShare(TotalPoolShare, 10n ** 18n); - await accumulator.setUserPoolShare(UserPoolShare, 10n ** 18n); - for (let i = 0; i < rewardCount; i++) { - const depositedAmount = BaseRewardAmount * toBigInt(i + 1); - await accumulator.depositReward(await tokens[i].getAddress(), depositedAmount); - } - if (periodLength > 0) { - const timestamp = (await ethers.provider.getBlock("latest"))!.timestamp; - await network.provider.send("evm_setNextBlockTimestamp", [timestamp + periodLength]); - } - await accumulator.checkpoint(deployer.address); - await accumulator.connect(deployer).setRewardReceiver(receiver.address); - }); - - it("should revert when claim other to other", async () => { - await expect( - accumulator["claim(address,address)"](manager.address, deployer.address) - ).to.revertedWithCustomError(accumulator, "ClaimOthersRewardToAnother"); - }); - - it("should succeed when claim caller", async () => { - const claimable = []; - const before = []; - for (let i = 0; i < rewardCount; i++) { - claimable.push(await accumulator.claimable(deployer.address, await tokens[i].getAddress())); - before.push(await tokens[i].balanceOf(receiver.address)); - const snapshot = await accumulator.userRewardSnapshot(deployer.address, await tokens[i].getAddress()); - expect(snapshot.rewards.pending).to.gt(0n); - expect(snapshot.rewards.claimed).to.eq(0n); - } - const tx = accumulator["claim()"](); - for (let i = 0; i < rewardCount; i++) { - await expect(tx) - .to.emit(accumulator, "Claim") - .withArgs(deployer.address, await tokens[i].getAddress(), receiver.address, claimable[i]); - } - for (let i = 0; i < rewardCount; i++) { - expect(await accumulator.claimable(deployer.address, await tokens[i].getAddress())).to.eq(0n); - expect(await tokens[i].balanceOf(receiver.address)).to.eq(before[i] + claimable[i]); - const snapshot = await accumulator.userRewardSnapshot(deployer.address, await tokens[i].getAddress()); - expect(snapshot.rewards.pending).to.eq(0n); - expect(snapshot.rewards.claimed).to.eq(claimable[i]); - expect(await accumulator.claimed(deployer.address, await tokens[i].getAddress())).to.eq(claimable[i]); - } - await expect(accumulator["claim()"]()).to.not.emit(accumulator, "Claim"); - for (let i = 0; i < rewardCount; i++) { - expect(await accumulator.claimable(deployer.address, await tokens[i].getAddress())).to.eq(0n); - expect(await tokens[i].balanceOf(receiver.address)).to.eq(before[i] + claimable[i]); - const snapshot = await accumulator.userRewardSnapshot(deployer.address, await tokens[i].getAddress()); - expect(snapshot.rewards.pending).to.eq(0n); - expect(snapshot.rewards.claimed).to.eq(claimable[i]); - expect(await accumulator.claimed(deployer.address, await tokens[i].getAddress())).to.eq(claimable[i]); - } - }); - - it("should succeed when claim other", async () => { - const claimable = []; - const before = []; - for (let i = 0; i < rewardCount; i++) { - claimable.push(await accumulator.claimable(deployer.address, await tokens[i].getAddress())); - before.push(await tokens[i].balanceOf(receiver.address)); - const snapshot = await accumulator.userRewardSnapshot(deployer.address, await tokens[i].getAddress()); - expect(snapshot.rewards.pending).to.gt(0n); - expect(snapshot.rewards.claimed).to.eq(0n); - } - const tx = accumulator.connect(manager)["claim(address)"](deployer.address); - for (let i = 0; i < rewardCount; i++) { - await expect(tx) - .to.emit(accumulator, "Claim") - .withArgs(deployer.address, await tokens[i].getAddress(), receiver.address, claimable[i]); - } - for (let i = 0; i < rewardCount; i++) { - expect(await accumulator.claimable(deployer.address, await tokens[i].getAddress())).to.eq(0n); - expect(await tokens[i].balanceOf(receiver.address)).to.eq(before[i] + claimable[i]); - const snapshot = await accumulator.userRewardSnapshot(deployer.address, await tokens[i].getAddress()); - expect(snapshot.rewards.pending).to.eq(0n); - expect(snapshot.rewards.claimed).to.eq(claimable[i]); - expect(await accumulator.claimed(deployer.address, await tokens[i].getAddress())).to.eq(claimable[i]); - } - await expect(accumulator.connect(manager)["claim(address)"](deployer.address)).to.not.emit( - accumulator, - "Claim" - ); - for (let i = 0; i < rewardCount; i++) { - expect(await accumulator.claimable(deployer.address, await tokens[i].getAddress())).to.eq(0n); - expect(await tokens[i].balanceOf(receiver.address)).to.eq(before[i] + claimable[i]); - const snapshot = await accumulator.userRewardSnapshot(deployer.address, await tokens[i].getAddress()); - expect(snapshot.rewards.pending).to.eq(0n); - expect(snapshot.rewards.claimed).to.eq(claimable[i]); - expect(await accumulator.claimed(deployer.address, await tokens[i].getAddress())).to.eq(claimable[i]); - } - }); - - it("should succeed when claim to other", async () => { - const claimable = []; - const before = []; - for (let i = 0; i < rewardCount; i++) { - claimable.push(await accumulator.claimable(deployer.address, await tokens[i].getAddress())); - before.push(await tokens[i].balanceOf(manager.address)); - const snapshot = await accumulator.userRewardSnapshot(deployer.address, await tokens[i].getAddress()); - expect(snapshot.rewards.pending).to.gt(0n); - expect(snapshot.rewards.claimed).to.eq(0n); - } - const tx = accumulator["claim(address,address)"](deployer.address, manager.address); - for (let i = 0; i < rewardCount; i++) { - await expect(tx) - .to.emit(accumulator, "Claim") - .withArgs(deployer.address, await tokens[i].getAddress(), manager.address, claimable[i]); - } - for (let i = 0; i < rewardCount; i++) { - expect(await accumulator.claimable(deployer.address, await tokens[i].getAddress())).to.eq(0n); - expect(await tokens[i].balanceOf(manager.address)).to.eq(before[i] + claimable[i]); - const snapshot = await accumulator.userRewardSnapshot(deployer.address, await tokens[i].getAddress()); - expect(snapshot.rewards.pending).to.eq(0n); - expect(snapshot.rewards.claimed).to.eq(claimable[i]); - expect(await accumulator.claimed(deployer.address, await tokens[i].getAddress())).to.eq(claimable[i]); - } - await expect(accumulator["claim(address,address)"](deployer.address, manager.address)).to.not.emit( - accumulator, - "Claim" - ); - for (let i = 0; i < rewardCount; i++) { - expect(await accumulator.claimable(deployer.address, await tokens[i].getAddress())).to.eq(0n); - expect(await tokens[i].balanceOf(manager.address)).to.eq(before[i] + claimable[i]); - const snapshot = await accumulator.userRewardSnapshot(deployer.address, await tokens[i].getAddress()); - expect(snapshot.rewards.pending).to.eq(0n); - expect(snapshot.rewards.claimed).to.eq(claimable[i]); - expect(await accumulator.claimed(deployer.address, await tokens[i].getAddress())).to.eq(claimable[i]); - } - }); - }); - - context("#claimHistorical without setting rewardReceiver", async () => { - const BaseRewardAmount = ethers.parseEther("2233"); - const TotalPoolShare = ethers.parseEther("1234"); - const UserPoolShare = ethers.parseEther("456"); - - beforeEach(async () => { - await accumulator.setTotalPoolShare(TotalPoolShare, 10n ** 18n); - await accumulator.setUserPoolShare(UserPoolShare, 10n ** 18n); - for (let i = 0; i < rewardCount; i++) { - const depositedAmount = BaseRewardAmount * toBigInt(i + 1); - await accumulator.depositReward(await tokens[i].getAddress(), depositedAmount); - } - if (periodLength > 0) { - const timestamp = (await ethers.provider.getBlock("latest"))!.timestamp; - await network.provider.send("evm_setNextBlockTimestamp", [timestamp + periodLength]); - } - await accumulator.checkpoint(ZeroAddress); - for (let i = 0; i < rewardCount; i++) { - await accumulator.connect(manager).unregisterRewardToken(await tokens[i].getAddress()); - } - await accumulator.checkpoint(deployer.address); - }); - - it("should succeed when claim caller", async () => { - const claimable = []; - const before = []; - for (let i = 0; i < rewardCount; i++) { - claimable.push(await accumulator.claimable(deployer.address, await tokens[i].getAddress())); - before.push(await tokens[i].balanceOf(deployer.address)); - const snapshot = await accumulator.userRewardSnapshot(deployer.address, await tokens[i].getAddress()); - expect(snapshot.rewards.pending).to.gt(0n); - expect(snapshot.rewards.claimed).to.eq(0n); - } - await expect(accumulator["claim()"]()).to.not.emit(accumulator, "Claim"); - const tx = accumulator["claimHistorical(address[])"](tokenAddresses); - for (let i = 0; i < rewardCount; i++) { - await expect(tx) - .to.emit(accumulator, "Claim") - .withArgs(deployer.address, await tokens[i].getAddress(), deployer.address, claimable[i]); - } - for (let i = 0; i < rewardCount; i++) { - expect(await accumulator.claimable(deployer.address, await tokens[i].getAddress())).to.eq(0n); - expect(await tokens[i].balanceOf(deployer.address)).to.eq(before[i] + claimable[i]); - const snapshot = await accumulator.userRewardSnapshot(deployer.address, await tokens[i].getAddress()); - expect(snapshot.rewards.pending).to.eq(0n); - expect(snapshot.rewards.claimed).to.eq(claimable[i]); - expect(await accumulator.claimed(deployer.address, await tokens[i].getAddress())).to.eq(claimable[i]); - } - await expect(accumulator["claimHistorical(address[])"](tokenAddresses)).to.not.emit(accumulator, "Claim"); - }); - - it("should succeed when claim other", async () => { - const claimable = []; - const before = []; - for (let i = 0; i < rewardCount; i++) { - claimable.push(await accumulator.claimable(deployer.address, await tokens[i].getAddress())); - before.push(await tokens[i].balanceOf(deployer.address)); - const snapshot = await accumulator.userRewardSnapshot(deployer.address, await tokens[i].getAddress()); - expect(snapshot.rewards.pending).to.gt(0n); - expect(snapshot.rewards.claimed).to.eq(0n); - } - await expect(accumulator["claim()"]()).to.not.emit(accumulator, "Claim"); - const tx = accumulator - .connect(manager) - ["claimHistorical(address,address[])"](deployer.address, tokenAddresses); - for (let i = 0; i < rewardCount; i++) { - await expect(tx) - .to.emit(accumulator, "Claim") - .withArgs(deployer.address, await tokens[i].getAddress(), deployer.address, claimable[i]); - } - for (let i = 0; i < rewardCount; i++) { - expect(await accumulator.claimable(deployer.address, await tokens[i].getAddress())).to.eq(0n); - expect(await tokens[i].balanceOf(deployer.address)).to.eq(before[i] + claimable[i]); - const snapshot = await accumulator.userRewardSnapshot(deployer.address, await tokens[i].getAddress()); - expect(snapshot.rewards.pending).to.eq(0n); - expect(snapshot.rewards.claimed).to.eq(claimable[i]); - expect(await accumulator.claimed(deployer.address, await tokens[i].getAddress())).to.eq(claimable[i]); - } - await expect( - accumulator.connect(manager)["claimHistorical(address,address[])"](deployer.address, tokenAddresses) - ).to.not.emit(accumulator, "Claim"); - }); - }); - - context("#claimHistorical with setting rewardReceiver", async () => { - const BaseRewardAmount = ethers.parseEther("2233"); - const TotalPoolShare = ethers.parseEther("1234"); - const UserPoolShare = ethers.parseEther("456"); - - beforeEach(async () => { - await accumulator.setTotalPoolShare(TotalPoolShare, 10n ** 18n); - await accumulator.setUserPoolShare(UserPoolShare, 10n ** 18n); - for (let i = 0; i < rewardCount; i++) { - const depositedAmount = BaseRewardAmount * toBigInt(i + 1); - await accumulator.depositReward(await tokens[i].getAddress(), depositedAmount); - } - if (periodLength > 0) { - const timestamp = (await ethers.provider.getBlock("latest"))!.timestamp; - await network.provider.send("evm_setNextBlockTimestamp", [timestamp + periodLength]); - } - await accumulator.checkpoint(ZeroAddress); - for (let i = 0; i < rewardCount; i++) { - await accumulator.connect(manager).unregisterRewardToken(await tokens[i].getAddress()); - } - await accumulator.checkpoint(deployer.address); - await accumulator.connect(deployer).setRewardReceiver(receiver.address); - }); - - it("should succeed when claim caller", async () => { - const claimable = []; - const before = []; - for (let i = 0; i < rewardCount; i++) { - claimable.push(await accumulator.claimable(deployer.address, await tokens[i].getAddress())); - before.push(await tokens[i].balanceOf(receiver.address)); - const snapshot = await accumulator.userRewardSnapshot(deployer.address, await tokens[i].getAddress()); - expect(snapshot.rewards.pending).to.gt(0n); - expect(snapshot.rewards.claimed).to.eq(0n); - } - await expect(accumulator["claim()"]()).to.not.emit(accumulator, "Claim"); - const tx = accumulator["claimHistorical(address[])"](tokenAddresses); - for (let i = 0; i < rewardCount; i++) { - await expect(tx) - .to.emit(accumulator, "Claim") - .withArgs(deployer.address, await tokens[i].getAddress(), receiver.address, claimable[i]); - } - for (let i = 0; i < rewardCount; i++) { - expect(await accumulator.claimable(deployer.address, await tokens[i].getAddress())).to.eq(0n); - expect(await tokens[i].balanceOf(receiver.address)).to.eq(before[i] + claimable[i]); - const snapshot = await accumulator.userRewardSnapshot(deployer.address, await tokens[i].getAddress()); - expect(snapshot.rewards.pending).to.eq(0n); - expect(snapshot.rewards.claimed).to.eq(claimable[i]); - expect(await accumulator.claimed(deployer.address, await tokens[i].getAddress())).to.eq(claimable[i]); - } - await expect(accumulator["claimHistorical(address[])"](tokenAddresses)).to.not.emit(accumulator, "Claim"); - }); - - it("should succeed when claim other", async () => { - const claimable = []; - const before = []; - for (let i = 0; i < rewardCount; i++) { - claimable.push(await accumulator.claimable(deployer.address, await tokens[i].getAddress())); - before.push(await tokens[i].balanceOf(receiver.address)); - const snapshot = await accumulator.userRewardSnapshot(deployer.address, await tokens[i].getAddress()); - expect(snapshot.rewards.pending).to.gt(0n); - expect(snapshot.rewards.claimed).to.eq(0n); - } - await expect(accumulator["claim()"]()).to.not.emit(accumulator, "Claim"); - const tx = accumulator - .connect(manager) - ["claimHistorical(address,address[])"](deployer.address, tokenAddresses); - for (let i = 0; i < rewardCount; i++) { - await expect(tx) - .to.emit(accumulator, "Claim") - .withArgs(deployer.address, await tokens[i].getAddress(), receiver.address, claimable[i]); - } - for (let i = 0; i < rewardCount; i++) { - expect(await accumulator.claimable(deployer.address, await tokens[i].getAddress())).to.eq(0n); - expect(await tokens[i].balanceOf(receiver.address)).to.eq(before[i] + claimable[i]); - const snapshot = await accumulator.userRewardSnapshot(deployer.address, await tokens[i].getAddress()); - expect(snapshot.rewards.pending).to.eq(0n); - expect(snapshot.rewards.claimed).to.eq(claimable[i]); - expect(await accumulator.claimed(deployer.address, await tokens[i].getAddress())).to.eq(claimable[i]); - } - await expect( - accumulator.connect(manager)["claimHistorical(address,address[])"](deployer.address, tokenAddresses) - ).to.not.emit(accumulator, "Claim"); - }); - }); - }); - } -}); -*/ + assertEq(claimedAfter, cap, string.concat("claimed == cap", tag)); + assertEq(pendingAfter, pending[j] - cap, string.concat("pending -= cap", tag)); + } + } + + /// @notice maxAmount == 0 is a no-op: no transfer, pending unchanged, claimed unchanged. + function test_claimTokens_capZero_noTransfer() public { + ( + IMockMultipleRewardCompoundingAccumulator accumulator, + address[] memory tokenAddresses, + uint256[] memory pending + ) = _stakeAndAccruePending(1); + + uint256 balBefore = IERC20(tokenAddresses[0]).balanceOf(deployer); + + IMultipleRewardAccumulator(address(accumulator)).claimTokens(tokenAddresses, 0); + + assertEq(IERC20(tokenAddresses[0]).balanceOf(deployer), balBefore, "no tokens transferred"); + (, , uint256 pendingAfter, uint256 claimedAfter) = accumulator.userRewardSnapshot(deployer, tokenAddresses[0]); + assertEq(pendingAfter, pending[0], "pending unchanged"); + assertEq(claimedAfter, 0, "claimed unchanged"); + } + + /// @notice After a partial cap-bound claim, the remainder is still claimable in a second uncapped call + /// (no new rewards accrue because we don't advance time). + function test_claimTokens_remainderClaimableAfterPartial() public { + ( + IMockMultipleRewardCompoundingAccumulator accumulator, + address[] memory tokenAddresses, + uint256[] memory pending + ) = _stakeAndAccruePending(1); + + uint256 firstCap = pending[0] / 4; + IMultipleRewardAccumulator(address(accumulator)).claimTokens(tokenAddresses, firstCap); + + uint256 balBefore = IERC20(tokenAddresses[0]).balanceOf(deployer); + IMultipleRewardAccumulator(address(accumulator)).claimTokens(tokenAddresses, type(uint256).max); + + assertEq( + IERC20(tokenAddresses[0]).balanceOf(deployer) - balBefore, + pending[0] - firstCap, + "second claim drains the remainder" + ); + (, , uint256 pendingAfter, uint256 claimedAfter) = accumulator.userRewardSnapshot(deployer, tokenAddresses[0]); + assertEq(pendingAfter, 0, "pending fully drained"); + assertEq(claimedAfter, pending[0], "claimed == original pending"); + } +} From d116a3ad32d13422b0f3c2ce7212b54ca919b6dd Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Mon, 1 Jun 2026 16:12:52 +0100 Subject: [PATCH 096/232] fix github actions rate limit issues --- lib/bao-base | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/bao-base b/lib/bao-base index 45cd2657..7d08b7a5 160000 --- a/lib/bao-base +++ b/lib/bao-base @@ -1 +1 @@ -Subproject commit 45cd26574f1cd04a1c20e64b0cc5592f0fad51ad +Subproject commit 7d08b7a5f1b343df9144d1cfcce1c3df42819eb2 From 22818cf501eac5a016a0617b86f60f004b548f3f Mon Sep 17 00:00:00 2001 From: rootminus0x1 Date: Wed, 3 Jun 2026 14:09:08 +0100 Subject: [PATCH 097/232] added check for changes to v1->v2 SP data verification --- foundry.lock | 2 +- lib/bao-base | 2 +- .../ForceMigrateAccumulator_v1.sol | 41 ++++++++++---- .../compare-migration-artifacts | 53 +++++++++++++++++++ .../run-migrate-StabilityPool_v2-data | 40 ++++++++++++-- 5 files changed, 123 insertions(+), 15 deletions(-) create mode 100755 script/verify/sp-v2-data-prep-for-v3/compare-migration-artifacts diff --git a/foundry.lock b/foundry.lock index 525da884..582fbbbf 100644 --- a/foundry.lock +++ b/foundry.lock @@ -2,7 +2,7 @@ "lib/bao-base": { "branch": { "name": "main", - "rev": "45cd26574f1cd04a1c20e64b0cc5592f0fad51ad" + "rev": "7d08b7a5f1b343df9144d1cfcce1c3df42819eb2" } }, "lib/chainlink-brownie-contracts": { diff --git a/lib/bao-base b/lib/bao-base index 7d08b7a5..016b33fd 160000 --- a/lib/bao-base +++ b/lib/bao-base @@ -1 +1 @@ -Subproject commit 7d08b7a5f1b343df9144d1cfcce1c3df42819eb2 +Subproject commit 016b33fd71fde6e7bee09af76de977d42a01fac1 diff --git a/script/verify/sp-v2-data-prep-for-v3/ForceMigrateAccumulator_v1.sol b/script/verify/sp-v2-data-prep-for-v3/ForceMigrateAccumulator_v1.sol index 7c11397a..a5afa556 100644 --- a/script/verify/sp-v2-data-prep-for-v3/ForceMigrateAccumulator_v1.sol +++ b/script/verify/sp-v2-data-prep-for-v3/ForceMigrateAccumulator_v1.sol @@ -3,6 +3,7 @@ pragma solidity 0.8.30; import {HarborPauser_v1} from "@bao/HarborPauser_v1.sol"; +import {console2 as console} from "forge-std/console2.sol"; /// @title ForceMigrateAccumulator_v1 /// @notice One-shot upgrade that copies user reward snapshot data from @@ -68,7 +69,17 @@ contract ForceMigrateAccumulator_v1 is HarborPauser_v1 { // ── Events ────────────────────────────────────────────────────────────── - event AccountMigrated(address indexed account, address indexed token); + event AccountAlreadyMigrated(address indexed account, address indexed token); + event AccountNoDataToMigrate(address indexed account, address indexed token); + event AccountMigrated( + address indexed account, + address indexed token, + uint256 pending, + uint256 claimed, + uint256 timestamp, + uint256 integral + ); + event MigrationComplete(uint256 holderCount, uint256 tokenCount); // ── View ───────────────────────────────────────────────────────────────── @@ -105,25 +116,37 @@ contract ForceMigrateAccumulator_v1 is HarborPauser_v1 { for (uint256 j = 0; j < tokens.length; j++) { address token = tokens[j]; - // Skip if already migrated + // Skip if already migrated: any non-zero value in v2 UserRewardSnapshotV2 storage v2 = $.userRewardSnapshotV2[account][token]; - if (v2.integral != 0 || v2.timestamp != 0) { + if (v2.integral != 0 || v2.timestamp != 0 || v2.rewards.pending != 0 || v2.rewards.claimed != 0) { + emit AccountAlreadyMigrated(account, token); + console.log("AccountAlreadyMigrated(%s, %s)", account, token); continue; } - // Skip if no V1 data + // Skip if no V1 data: all zero in v1 UserRewardSnapshot storage v1 = $.userRewardSnapshot[account][token]; - // if (v1.checkpoint.timestamp == 0) { - // continue; - // } + if ( + v1.checkpoint.integral == 0 && + v1.checkpoint.timestamp == 0 && + v1.rewards.pending == 0 && + v1.rewards.claimed == 0 + ) { + emit AccountNoDataToMigrate(account, token); + console.log("AccountNoDataToMigrate(%s, %s)", account, token); + } - // Copy V1 → V2 + // only get here if there is non-zero data in v1 and all zero data in v2 + // So, copy V1 → V2 v2.rewards.pending = v1.rewards.pending; v2.rewards.claimed = v1.rewards.claimed; v2.timestamp = v1.checkpoint.timestamp; v2.integral = uint256(v1.checkpoint.integral); - emit AccountMigrated(account, token); + emit AccountMigrated(account, token, v2.rewards.pending, v2.rewards.claimed, v2.timestamp, v2.integral); + console.log("AccountMigrated(%s, %s):", account, token); + console.log(" pending=%s, claimed=%s", v2.rewards.pending, v2.rewards.claimed); + console.log(" timestamp=%s, integral=%s", v2.timestamp, v2.integral); } } diff --git a/script/verify/sp-v2-data-prep-for-v3/compare-migration-artifacts b/script/verify/sp-v2-data-prep-for-v3/compare-migration-artifacts new file mode 100755 index 00000000..d541055e --- /dev/null +++ b/script/verify/sp-v2-data-prep-for-v3/compare-migration-artifacts @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# compare-migration-artifacts DIR1 DIR2 +# +# Compare two directories produced by run-migrate-StabilityPool_v2-data --compare-dir. +# Applies transforms to eliminate known run-to-run noise before diffing. +# Originals in DIR1/DIR2 are never modified. +# +# Transforms applied to forge-log-raw.txt: +# - ISO timestamp in the batch JSON filename logged by the Deployer +# - Elapsed time reported by run-script +# Each transform replaces at most one occurrence (no /g flag). +# +# Usage: +# compare-migration-artifacts tmp/comparison/run1 tmp/comparison/run2 +# compare-migration-artifacts tmp/comparison/inefficient tmp/comparison/efficient + +set -euo pipefail + +DIR1="${1:?usage: $0 DIR1 DIR2}" +DIR2="${2:?usage: $0 DIR1 DIR2}" + +[[ -d "$DIR1" ]] || { echo "ERROR: $DIR1 is not a directory" >&2; exit 1; } +[[ -d "$DIR2" ]] || { echo "ERROR: $DIR2 is not a directory" >&2; exit 1; } + +TMP1=$(mktemp -d) +TMP2=$(mktemp -d) +trap 'rm -rf "$TMP1" "$TMP2"' EXIT + +cp -r "$DIR1/." "$TMP1/" +cp -r "$DIR2/." "$TMP2/" + +# ── Transforms (forge-log-raw.txt only) ────────────────────────────────────── + +# ISO timestamp embedded in the batch JSON filename: 2026-06-03T12:45:30Z +sed -Ei \ + 's|Migrate_StabilityPool_v2_Data_mainnet_[0-9T:Z-]+_harbor_multisig\.json|Migrate_StabilityPool_v2_Data_mainnet__harbor_multisig.json|' \ + "$TMP1/forge-log-raw.txt" "$TMP2/forge-log-raw.txt" + +# Elapsed time: " Took: 0m 42s" +sed -Ei \ + 's/ Took: [0-9]+m [0-9]+s/ Took: