Contracts
Architecture
Eight contracts, roughly 850 lines. The design rule that organizes all of them: assets are immutable, periphery is rotatable.
The layers
user ──mint──▶ PositionManager ──deposit──▶ YieldVault ──deploy──▶ AaveV3Adapter ──▶ Aave V3
◀─ lUSD + receipt NFT ─┘ │ shares │ aUSDC
│ │
user ──redeem (burn lUSD)─▶ ReceiptRedeemer ───┴──withdraw─────────────┘
user ⇄ ReceiptMarketplace (escrowed listings + standing bids, settled in lUSD)
Assets — the contracts that hold value or record claims — never migrate:
LUSDToken— the synth ERC-20. Minting is restricted to the PositionManager, burning to the ReceiptRedeemer, and burning still spends the holder's allowance: no privileged contract can destroy your balance without your approval.WithdrawalReceipt— the ERC-721 claim. Records asset, principal, vault shares, mint ratio, debt, and unlock time. Redeemed receipts are kept and flagged, never burned.YieldVault— share accounting. Deposits are manager-only, redemptions redeemer-only, and the share math carries a virtual offset that neutralizes first-depositor inflation attacks.AaveV3Adapter— the venue. Holds the interest-bearing aToken. Swapping venues means deploying a new adapter and migrating atomically — shares and receipts never notice.
Periphery — the contracts that hold logic but no funds — can be replaced by granting roles to a new version and revoking the old:
MarketRegistry— each market names its asset, vault, synth, ratio bounds, and withdrawal delay. The vault and synth bindings are write-once: once a market has live receipts, no one — including the owner — can re-point or re-denominate them.PositionManager— the mint entry point. Debt is computed in the underlying's own unit, scaled to the synth's decimals: the same-denomination law that removes oracles applies to every current and future market.ReceiptRedeemer— redemption. Charges the performance fee on yield only, and deliberately ignores a market'senabledflag: no configuration state can ever trap funds.ReceiptMarketplace— escrowed fixed-price listings and standing bids. Standalone; holds escrow but no protocol roles.
Read the deployed source
The same files the deployment was built from, embedded at build time:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {ISynth} from "./interfaces/ISynth.sol";
import {WithdrawalReceipt} from "./WithdrawalReceipt.sol";
import {ICollateralVault} from "./interfaces/ICollateralVault.sol";
import {MarketRegistry} from "./MarketRegistry.sol";
/// @title PositionManager
/// @notice Mint entry point. A deposit produces two freely transferable
/// assets: the market's synth (lUSD for stable markets — debt
/// denominated in the underlying's own unit, scaled to the synth's
/// decimals) and a WithdrawalReceipt NFT carrying the right to redeem
/// the principal plus accrued yield after the market's withdrawal
/// delay. Because debt and collateral share a denomination, there is
/// no oracle and nothing to liquidate — for any market.
contract PositionManager is Ownable, ReentrancyGuard {
using SafeERC20 for IERC20;
WithdrawalReceipt public immutable receipt;
MarketRegistry public immutable registry;
event Minted(
address indexed user,
address indexed asset,
address indexed synth,
uint256 principal,
uint256 shares,
uint256 mintRatioBps,
uint256 debtAmount,
uint256 receiptId
);
error MarketDisabled();
error RatioOutOfRange();
error ZeroAmount();
error ZeroAddress();
constructor(WithdrawalReceipt receipt_, MarketRegistry registry_) Ownable(msg.sender) {
if (address(receipt_) == address(0) || address(registry_) == address(0)) {
revert ZeroAddress();
}
receipt = receipt_;
registry = registry_;
}
/// @notice Deposit `amount` of `asset` and mint the market's synth + a
/// withdrawal receipt.
/// @param asset collateral asset (must be a registered, enabled market)
/// @param amount principal in asset native decimals
/// @param mintRatioBps chosen ratio within the market's [min, max] range
function mint(address asset, uint256 amount, uint256 mintRatioBps)
external
nonReentrant
returns (uint256 receiptId, uint256 debtAmount)
{
if (amount == 0) revert ZeroAmount();
MarketRegistry.Market memory m = registry.getMarket(asset);
if (!m.enabled) revert MarketDisabled();
if (mintRatioBps < m.minMintRatioBps || mintRatioBps > m.maxMintRatioBps) revert RatioOutOfRange();
// Pull principal from user, then deposit into the market vault.
IERC20(asset).safeTransferFrom(msg.sender, address(this), amount);
IERC20(asset).forceApprove(m.vault, amount);
uint256 shares = ICollateralVault(m.vault).deposit(amount);
// Debt in the market's synth: principal scaled from asset decimals to
// synth decimals, times the chosen ratio. debtAmount may be 0 when
// mintRatioBps is 0 (a pure yield deposit: the receipt still earns
// venue yield and redeems for principal+yield, burning 0 synth).
// synth.mint(_, 0) is a harmless no-op.
debtAmount = amount * (10 ** (m.synthDecimals - m.assetDecimals)) * mintRatioBps / 10_000;
ISynth(m.synth).mint(msg.sender, debtAmount);
receiptId = receipt.mint(
msg.sender,
asset,
amount,
shares,
mintRatioBps,
debtAmount,
block.timestamp + m.withdrawalDelay
);
emit Minted(msg.sender, asset, m.synth, amount, shares, mintRatioBps, debtAmount, receiptId);
}
/// @notice Preview the synth output for a given deposit. Reverts on a
/// disabled market or out-of-range ratio exactly as mint would, so
/// a preview can never quote a mint that is guaranteed to revert.
function previewMint(address asset, uint256 amount, uint256 mintRatioBps)
external
view
returns (uint256 debtAmount, uint256 shares, uint256 unlockTime)
{
MarketRegistry.Market memory m = registry.getMarket(asset);
if (!m.enabled) revert MarketDisabled();
if (mintRatioBps < m.minMintRatioBps || mintRatioBps > m.maxMintRatioBps) revert RatioOutOfRange();
debtAmount = amount * (10 ** (m.synthDecimals - m.assetDecimals)) * mintRatioBps / 10_000;
shares = ICollateralVault(m.vault).previewDeposit(amount);
unlockTime = block.timestamp + m.withdrawalDelay;
}
}PositionManager.sol · 107 linesdeployed at 0xb7D8…Aa8C ↗
782 lines across 8 files — embedded from the repository at build time.
For function-level integration details, see Integration notes. For what the admin can and cannot change, see Admin & governance.