From 7741243236f3804615eca36e99084b0c5ae4f614 Mon Sep 17 00:00:00 2001 From: MyTH-zyxeon Date: Wed, 20 May 2026 16:23:42 +0900 Subject: [PATCH] feat: add Lucidly reserve adapter --- README.md | 6 + contracts/extensions/LucidlyAdapter.sol | 155 ++++++++++++++++++++++++ contracts/interfaces/ILucidlyVault.sol | 14 +++ contracts/mocks/MockLucidlyVault.sol | 77 ++++++++++++ forge-test/LucidlyAdapter.t.sol | 110 +++++++++++++++++ 5 files changed, 362 insertions(+) create mode 100644 contracts/extensions/LucidlyAdapter.sol create mode 100644 contracts/interfaces/ILucidlyVault.sol create mode 100644 contracts/mocks/MockLucidlyVault.sol create mode 100644 forge-test/LucidlyAdapter.t.sol diff --git a/README.md b/README.md index f44fb12..5c7c824 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ Built on learnings from real stablecoin deployments. Production-grade Solidity c - **Compliance module** — KYC status per address, geography-based transfer restrictions, transaction limits; flow diagrams in [`docs/compliance-module.md`](docs/compliance-module.md) - **Minting gateway** — compliance-checked minting, redemption queue, fee management - **Burn toll extension** — optional mint/redeem hook that routes configurable toll revenue to a floor-pool adapter for governance-token buy-and-burn execution +- **Lucidly reserve adapter** — parks excess liquid reserve into a syUSD-style vault with liquid-buffer rebalancing - **Depeg defence** — `DepegGuard` state machine monitors the collateral price feed and pauses mints / stablecoin on threshold breaches; see [`docs/depeg-guard.md`](docs/depeg-guard.md) - **Multi-geography** — configurable per jurisdiction (see `config/geographies/`) - **Deployment scripts** — India, Singapore, and UAE deployment plans with jurisdiction-specific compliance limits; see [`docs/multi-geography-deployments.md`](docs/multi-geography-deployments.md) @@ -147,6 +148,7 @@ config/geographies/ | `ComplianceModule.sol` | KYC, geography restrictions, transaction limits | | `Minter.sol` | Gateway — compliance + reserve checks before mint/redeem | | `extensions/BurnToll.sol` | Optional 0.5% default mint/redeem toll routed to a floor-pool buy-and-burn adapter. Spec: [`docs/burn-toll.md`](docs/burn-toll.md) | +| `extensions/LucidlyAdapter.sol` | Lucidly syUSD-style reserve parking adapter with configurable liquid buffer | | `DepegGuard.sol` | Depeg-defence watchdog — Normal/Caution/Hard state machine, pauses mints + stablecoin on threshold breaches. Spec: [`docs/depeg-guard.md`](docs/depeg-guard.md) | | `ChainlinkPoRAdapter.sol` | Adapter for Chainlink Proof of Reserves feeds | @@ -156,6 +158,10 @@ config/geographies/ - `lib/forge-std` is already vendored for the test harness; no extra `forge install` step is needed for a normal local checkout. - If `forge` is installed outside your shell `PATH`, invoke it with your local Foundry bin path or add that directory to `PATH` first. +## Lucidly Reserve Parking + +`LucidlyAdapter` keeps a configurable reserve buffer liquid while parking excess USDC-style reserve assets into a Lucidly syUSD-style vault. Operators call `rebalance()` to park excess reserve, `unpark(amount)` before redemptions that exceed the liquid buffer, and `harvestYield()` on an epoch cadence to report accrued yield. Tests use `MockLucidlyVault`; production deployments should wire the final Lucidly interface for the target chain. + ## Contributing We welcome contributions. See open issues tagged `good-first-issue`. diff --git a/contracts/extensions/LucidlyAdapter.sol b/contracts/extensions/LucidlyAdapter.sol new file mode 100644 index 0000000..0dc36f4 --- /dev/null +++ b/contracts/extensions/LucidlyAdapter.sol @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "@openzeppelin/contracts/access/Ownable.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +import "../interfaces/ILucidlyVault.sol"; + +/** + * @title LucidlyAdapter + * @notice Parks excess reserve assets into a Lucidly syUSD-style vault while + * preserving a configurable liquid buffer for redemptions. + */ +contract LucidlyAdapter is Ownable { + using SafeERC20 for IERC20; + + uint256 public constant BPS = 10_000; + + IERC20 public immutable reserveAsset; + ILucidlyVault public immutable lucidlyVault; + + uint256 public targetLiquidReserveBps; + uint256 public harvestEpoch; + uint256 public lastHarvestAt; + uint256 public lastHarvestAssets; + + event TargetLiquidReserveUpdated(uint256 targetLiquidReserveBps); + event HarvestEpochUpdated(uint256 harvestEpoch); + event Parked(uint256 assets, uint256 shares); + event Unparked(uint256 requestedAssets, uint256 receivedAssets); + event YieldHarvested(uint256 yieldAssets, uint256 totalManagedAssets); + + error InvalidTargetLiquidReserve(); + error InvalidHarvestEpoch(); + error LucidlyVaultPaused(); + error AssetMismatch(address expected, address actual); + error HarvestTooEarly(uint256 nextHarvestAt); + + constructor( + address asset_, + address lucidlyVault_, + uint256 targetLiquidReserveBps_, + uint256 harvestEpoch_ + ) Ownable(msg.sender) { + if (targetLiquidReserveBps_ > BPS) { + revert InvalidTargetLiquidReserve(); + } + if (harvestEpoch_ == 0) revert InvalidHarvestEpoch(); + + reserveAsset = IERC20(asset_); + lucidlyVault = ILucidlyVault(lucidlyVault_); + if (lucidlyVault.asset() != asset_) { + revert AssetMismatch(asset_, lucidlyVault.asset()); + } + + targetLiquidReserveBps = targetLiquidReserveBps_; + harvestEpoch = harvestEpoch_; + lastHarvestAt = block.timestamp; + lastHarvestAssets = totalManagedAssets(); + } + + function setTargetLiquidReserveBps(uint256 targetLiquidReserveBps_) external onlyOwner { + if (targetLiquidReserveBps_ > BPS) revert InvalidTargetLiquidReserve(); + targetLiquidReserveBps = targetLiquidReserveBps_; + emit TargetLiquidReserveUpdated(targetLiquidReserveBps_); + } + + function setHarvestEpoch(uint256 harvestEpoch_) external onlyOwner { + if (harvestEpoch_ == 0) revert InvalidHarvestEpoch(); + harvestEpoch = harvestEpoch_; + emit HarvestEpochUpdated(harvestEpoch_); + } + + function rebalance() external onlyOwner returns (uint256 parkedAssets, uint256 shares) { + _revertIfVaultPaused(); + + uint256 liquid = liquidReserveAssets(); + uint256 total = liquid + parkedReserveAssets(); + uint256 desiredLiquid = _targetLiquidAssets(total); + if (liquid <= desiredLiquid) { + return (0, 0); + } + + parkedAssets = liquid - desiredLiquid; + reserveAsset.forceApprove(address(lucidlyVault), parkedAssets); + shares = lucidlyVault.deposit(parkedAssets, address(this)); + reserveAsset.forceApprove(address(lucidlyVault), 0); + + emit Parked(parkedAssets, shares); + } + + function unpark(uint256 requestedAssets) external onlyOwner returns (uint256 receivedAssets) { + _revertIfVaultPaused(); + + uint256 liquid = liquidReserveAssets(); + if (liquid >= requestedAssets) { + emit Unparked(requestedAssets, 0); + return 0; + } + + uint256 neededAssets = requestedAssets - liquid; + uint256 availableAssets = parkedReserveAssets(); + uint256 redeemAssets = neededAssets < availableAssets ? neededAssets : availableAssets; + if (redeemAssets == 0) { + emit Unparked(requestedAssets, 0); + return 0; + } + + uint256 balanceBefore = liquidReserveAssets(); + lucidlyVault.withdraw(redeemAssets, address(this), address(this)); + receivedAssets = liquidReserveAssets() - balanceBefore; + + emit Unparked(requestedAssets, receivedAssets); + } + + function harvestYield() external onlyOwner returns (uint256 yieldAssets) { + uint256 nextHarvestAt = lastHarvestAt + harvestEpoch; + if (block.timestamp < nextHarvestAt) revert HarvestTooEarly(nextHarvestAt); + + uint256 currentAssets = totalManagedAssets(); + if (currentAssets > lastHarvestAssets) { + yieldAssets = currentAssets - lastHarvestAssets; + } + + lastHarvestAt = block.timestamp; + lastHarvestAssets = currentAssets; + emit YieldHarvested(yieldAssets, currentAssets); + } + + function syncHarvestBaseline() external onlyOwner { + lastHarvestAt = block.timestamp; + lastHarvestAssets = totalManagedAssets(); + } + + function liquidReserveAssets() public view returns (uint256) { + return reserveAsset.balanceOf(address(this)); + } + + function parkedReserveAssets() public view returns (uint256) { + return lucidlyVault.convertToAssets(lucidlyVault.balanceOf(address(this))); + } + + function totalManagedAssets() public view returns (uint256) { + return liquidReserveAssets() + parkedReserveAssets(); + } + + function _targetLiquidAssets(uint256 totalAssets) internal view returns (uint256) { + return (totalAssets * targetLiquidReserveBps) / BPS; + } + + function _revertIfVaultPaused() internal view { + if (lucidlyVault.paused()) revert LucidlyVaultPaused(); + } +} diff --git a/contracts/interfaces/ILucidlyVault.sol b/contracts/interfaces/ILucidlyVault.sol new file mode 100644 index 0000000..7df5e1c --- /dev/null +++ b/contracts/interfaces/ILucidlyVault.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +interface ILucidlyVault { + function asset() external view returns (address); + function deposit(uint256 assets, address receiver) external returns (uint256 shares); + function withdraw(uint256 assets, address receiver, address owner) + external + returns (uint256 shares); + function convertToAssets(uint256 shares) external view returns (uint256 assets); + function convertToShares(uint256 assets) external view returns (uint256 shares); + function balanceOf(address account) external view returns (uint256); + function paused() external view returns (bool); +} diff --git a/contracts/mocks/MockLucidlyVault.sol b/contracts/mocks/MockLucidlyVault.sol new file mode 100644 index 0000000..0fea0a6 --- /dev/null +++ b/contracts/mocks/MockLucidlyVault.sol @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +import "../interfaces/ILucidlyVault.sol"; + +contract MockLucidlyVault is ILucidlyVault { + using SafeERC20 for IERC20; + + IERC20 private immutable _asset; + bool public paused; + uint256 public totalAssetsManaged; + uint256 public totalShares; + + mapping(address => uint256) public balanceOf; + + constructor(address asset_) { + _asset = IERC20(asset_); + } + + function asset() external view returns (address) { + return address(_asset); + } + + function setPaused(bool paused_) external { + paused = paused_; + } + + function deposit(uint256 assets, address receiver) external returns (uint256 shares) { + require(!paused, "vault paused"); + shares = convertToShares(assets); + require(shares > 0, "zero shares"); + + _asset.safeTransferFrom(msg.sender, address(this), assets); + totalAssetsManaged += assets; + totalShares += shares; + balanceOf[receiver] += shares; + } + + function withdraw(uint256 assets, address receiver, address owner) + external + returns (uint256 shares) + { + require(!paused, "vault paused"); + shares = convertToShares(assets); + if (convertToAssets(shares) < assets) { + shares += 1; + } + require(balanceOf[owner] >= shares, "insufficient shares"); + + balanceOf[owner] -= shares; + totalShares -= shares; + totalAssetsManaged -= assets; + _asset.safeTransfer(receiver, assets); + } + + function convertToAssets(uint256 shares) public view returns (uint256 assets) { + if (totalShares == 0 || totalAssetsManaged == 0) { + return shares; + } + return (shares * totalAssetsManaged) / totalShares; + } + + function convertToShares(uint256 assets) public view returns (uint256 shares) { + if (totalShares == 0 || totalAssetsManaged == 0) { + return assets; + } + return (assets * totalShares) / totalAssetsManaged; + } + + function addYield(uint256 amount) external { + _asset.safeTransferFrom(msg.sender, address(this), amount); + totalAssetsManaged += amount; + } +} diff --git a/forge-test/LucidlyAdapter.t.sol b/forge-test/LucidlyAdapter.t.sol new file mode 100644 index 0000000..d9b4e7b --- /dev/null +++ b/forge-test/LucidlyAdapter.t.sol @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +import "../contracts/extensions/LucidlyAdapter.sol"; +import "../contracts/mocks/MockLucidlyVault.sol"; + +contract MockUSDC is ERC20 { + constructor() ERC20("Mock USDC", "USDC") {} + + function decimals() public pure override returns (uint8) { + return 6; + } + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} + +contract LucidlyAdapterTest is Test { + MockUSDC public usdc; + MockLucidlyVault public vault; + LucidlyAdapter public adapter; + + address public admin = address(this); + address public stranger = address(0xBEEF); + + function setUp() public { + usdc = new MockUSDC(); + vault = new MockLucidlyVault(address(usdc)); + adapter = new LucidlyAdapter(address(usdc), address(vault), 2_000, 7 days); + } + + function test_rebalanceParksExcessAboveLiquidTarget() public { + usdc.mint(address(adapter), 1_000_000_000); + + (uint256 parkedAssets, uint256 shares) = adapter.rebalance(); + + assertEq(parkedAssets, 800_000_000); + assertEq(shares, 800_000_000); + assertEq(usdc.balanceOf(address(adapter)), 200_000_000); + assertEq(adapter.parkedReserveAssets(), 800_000_000); + } + + function test_unparkRedeemsOnlyAmountNeededToReachRequestedLiquidBuffer() public { + usdc.mint(address(adapter), 1_000_000_000); + adapter.rebalance(); + + uint256 receivedAssets = adapter.unpark(500_000_000); + + assertEq(receivedAssets, 300_000_000); + assertEq(usdc.balanceOf(address(adapter)), 500_000_000); + assertEq(adapter.parkedReserveAssets(), 500_000_000); + } + + function test_unparkPartialFillWhenVaultHasLessThanRequestedNeed() public { + usdc.mint(address(adapter), 1_000_000_000); + adapter.rebalance(); + + uint256 receivedAssets = adapter.unpark(1_500_000_000); + + assertEq(receivedAssets, 800_000_000); + assertEq(usdc.balanceOf(address(adapter)), 1_000_000_000); + assertEq(adapter.parkedReserveAssets(), 0); + } + + function test_onlyOwnerCanRebalanceAndUnpark() public { + usdc.mint(address(adapter), 1_000_000_000); + + vm.startPrank(stranger); + vm.expectRevert(); + adapter.rebalance(); + vm.expectRevert(); + adapter.unpark(1); + vm.stopPrank(); + } + + function test_pausedLucidlyVaultBlocksRebalanceAndUnpark() public { + usdc.mint(address(adapter), 1_000_000_000); + vault.setPaused(true); + + vm.expectRevert(LucidlyAdapter.LucidlyVaultPaused.selector); + adapter.rebalance(); + + vault.setPaused(false); + adapter.rebalance(); + vault.setPaused(true); + + vm.expectRevert(LucidlyAdapter.LucidlyVaultPaused.selector); + adapter.unpark(500_000_000); + } + + function test_harvestYieldReportsEpochGain() public { + usdc.mint(address(adapter), 1_000_000_000); + adapter.rebalance(); + adapter.syncHarvestBaseline(); + + usdc.mint(admin, 100_000_000); + usdc.approve(address(vault), 100_000_000); + vault.addYield(100_000_000); + + vm.warp(block.timestamp + 7 days); + uint256 yieldAssets = adapter.harvestYield(); + + assertEq(yieldAssets, 100_000_000); + assertEq(adapter.totalManagedAssets(), 1_100_000_000); + } +}