Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 |

Expand All @@ -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`.
Expand Down
155 changes: 155 additions & 0 deletions contracts/extensions/LucidlyAdapter.sol
Original file line number Diff line number Diff line change
@@ -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();
}
}
14 changes: 14 additions & 0 deletions contracts/interfaces/ILucidlyVault.sol
Original file line number Diff line number Diff line change
@@ -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);
}
77 changes: 77 additions & 0 deletions contracts/mocks/MockLucidlyVault.sol
Original file line number Diff line number Diff line change
@@ -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;
}
}
110 changes: 110 additions & 0 deletions forge-test/LucidlyAdapter.t.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading