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's enabled flag: 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.