// SPDX-License-Identifier: MIT pragma solidity 0.8.23; import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; interface IPriceOracle { function getPrice(address asset) external view returns (uint256); // 1e18-normalised USD price } /** * @title RWATransferAdapter — permissioned RWA → cash conversion for * liquidators who can't legally hold BUIDL/USDY/OUSG * @notice The problem: * - BUIDL is a Securitize-permissioned token. Holders MUST be on * Securitize allowlist. * - On AERE, BUIDL.e (Hyperlane synthetic) is the unrestricted * representation, but to redeem on Ethereum the bridged receiver * must be allowlisted. * - A liquidator who wins BUIDL.e collateral via AereLendingMarket * liquidation can hold it but cannot redeem → effectively stuck * with an illiquid asset. * * The fix: * - This adapter accepts BUIDL.e (or any registered permissioned * RWA) and pays out PAYOUT_TOKEN (USDC.e) at oracle price minus * a fixed haircut. * - The Foundation Seychelles IBC (which IS allowlisted on * Securitize for BUIDL, completes Ondo institutional KYC for * USDY/OUSG) periodically sweeps the accumulated RWA via * `sweepRWA` and unwinds it through Securitize / Ondo * off-chain. The USDC.e they get back replenishes this adapter. * * IMMUTABILITY: * - PAYOUT_TOKEN (USDC.e) and ORACLE are fixed at deploy * - FOUNDATION (Securitize-allowlisted entity) is fixed at deploy * - Per-asset haircutBps + rateLimitPerPeriod are immutable per * registration (Foundation can register new params for a new * asset but cannot change a registered one) * * FOUNDATION CAN: * - Register new assets (with haircut + rate limit) * - Pause assets (emergency) * - Sweep accumulated RWA via `sweepRWA(asset, amount, recipient)` * * FOUNDATION CANNOT: * - Touch the PAYOUT_TOKEN treasury — the only exit for USDC.e * is `redeem` paying it to a liquidator. * - Change any registered asset's haircut or rate limit * - Withdraw RWA before it has fully accrued via `redeem` * (the sweep only sees what redemptions have deposited) */ contract RWATransferAdapter is ReentrancyGuard { /* ------------------------------- immutable ------------------------------- */ address public immutable PAYOUT_TOKEN; // USDC.e address public immutable ORACLE; address public immutable FOUNDATION; uint8 public immutable PAYOUT_DECIMALS; /* --------------------------------- types --------------------------------- */ struct AssetConfig { bool registered; bool paused; uint16 haircutBps; // e.g. 100 = 1% off oracle price (covers bridge fee + delay risk) uint256 rateLimitPerPeriod; // max payout in PAYOUT_TOKEN units per period uint32 periodSeconds; // e.g. 86400 = daily uint8 assetDecimals; } struct AssetState { uint256 periodRedeemed; // sum of payouts paid in current period uint64 periodStart; uint256 totalAccumulated; // sum of RWA pulled in (= what sweepRWA can remove) } /* --------------------------------- state -------------------------------- */ mapping(address => AssetConfig) public configs; mapping(address => AssetState) public states; /* --------------------------------- events ------------------------------- */ event AssetRegistered(address indexed asset, uint16 haircutBps, uint256 rateLimitPerPeriod, uint32 periodSeconds); event AssetPausedSet(address indexed asset, bool paused); event Redeemed(address indexed user, address indexed asset, uint256 rwaIn, uint256 payoutOut, uint256 priceUsd); event Swept(address indexed asset, address indexed recipient, uint256 amount); /* --------------------------------- errors ------------------------------- */ error NotFoundation(); error AlreadyRegistered(); error UnknownAsset(); error AssetIsPaused(); error RateLimitExceeded(uint256 wouldBe, uint256 cap); error InsufficientPayoutLiquidity(); error InsufficientSweepBalance(); error TransferFailed(); error ZeroAddress(); error ZeroAmount(); error BadHaircut(); /* ------------------------------- modifiers ------------------------------- */ modifier onlyFoundation() { if (msg.sender != FOUNDATION) revert NotFoundation(); _; } /* ----------------------------- constructor ------------------------------ */ constructor(address payoutToken, address oracle, address foundation, uint8 payoutDecimals) { if (payoutToken == address(0) || oracle == address(0) || foundation == address(0)) revert ZeroAddress(); PAYOUT_TOKEN = payoutToken; ORACLE = oracle; FOUNDATION = foundation; PAYOUT_DECIMALS = payoutDecimals; } /* ----------------------------- registration ----------------------------- */ function registerAsset( address asset, uint16 haircutBps, uint256 rateLimitPerPeriod, uint32 periodSeconds, uint8 assetDecimals ) external onlyFoundation { if (asset == address(0)) revert ZeroAddress(); if (configs[asset].registered) revert AlreadyRegistered(); if (haircutBps > 5_000) revert BadHaircut(); // sanity cap 50% configs[asset] = AssetConfig({ registered: true, paused: false, haircutBps: haircutBps, rateLimitPerPeriod: rateLimitPerPeriod, periodSeconds: periodSeconds, assetDecimals: assetDecimals }); states[asset] = AssetState({ periodRedeemed: 0, periodStart: uint64(block.timestamp), totalAccumulated: 0 }); emit AssetRegistered(asset, haircutBps, rateLimitPerPeriod, periodSeconds); } function setAssetPaused(address asset, bool paused) external onlyFoundation { AssetConfig storage c = configs[asset]; if (!c.registered) revert UnknownAsset(); c.paused = paused; emit AssetPausedSet(asset, paused); } /* -------------------------------- redeem -------------------------------- */ /// @notice Permissionless: caller deposits `rwaAmount` of `asset`, receives /// PAYOUT_TOKEN at oracle price minus haircutBps, subject to the /// per-period rate limit. function redeem(address asset, uint256 rwaAmount) external nonReentrant returns (uint256 payoutOut) { AssetConfig memory c = configs[asset]; if (!c.registered) revert UnknownAsset(); if (c.paused) revert AssetIsPaused(); if (rwaAmount == 0) revert ZeroAmount(); uint256 rwaPriceUsd = IPriceOracle(ORACLE).getPrice(asset); // 1e18 USD uint256 payoutPriceUsd = IPriceOracle(ORACLE).getPrice(PAYOUT_TOKEN); // value in 18-dec USD uint256 valueUsd = _scaleUp(rwaAmount, c.assetDecimals) * rwaPriceUsd / 1e18; // haircut valueUsd = valueUsd * (10_000 - c.haircutBps) / 10_000; // convert to payout token amount payoutOut = _scaleDown(valueUsd * 1e18 / payoutPriceUsd, PAYOUT_DECIMALS); if (payoutOut == 0) revert ZeroAmount(); // H3 fix: TRUE rolling budget via linear leaky-bucket decay. // The previous tumbling window allowed 2× cap drain at the boundary. // Now: drain = max(0, periodRedeemed - elapsed * cap / periodSeconds) // and we cap (drain + new) ≤ rateLimitPerPeriod, then advance the // notional period anchor to "now". A burst of new traffic immediately // following a full-cap burst will simply revert until enough time // elapsed for the bucket to leak below the new headroom. AssetState storage s = states[asset]; uint256 elapsed = block.timestamp - uint256(s.periodStart); if (elapsed > c.periodSeconds) elapsed = c.periodSeconds; uint256 leaked = (uint256(c.rateLimitPerPeriod) * elapsed) / uint256(c.periodSeconds); uint256 outstanding = s.periodRedeemed > leaked ? s.periodRedeemed - leaked : 0; if (outstanding + payoutOut > c.rateLimitPerPeriod) { revert RateLimitExceeded(outstanding + payoutOut, c.rateLimitPerPeriod); } s.periodStart = uint64(block.timestamp); s.periodRedeemed = outstanding + payoutOut; if (IERC20(PAYOUT_TOKEN).balanceOf(address(this)) < payoutOut) revert InsufficientPayoutLiquidity(); // Pull RWA, send payout. CEI ordering. if (!IERC20(asset).transferFrom(msg.sender, address(this), rwaAmount)) revert TransferFailed(); s.totalAccumulated += rwaAmount; if (!IERC20(PAYOUT_TOKEN).transfer(msg.sender, payoutOut)) revert TransferFailed(); emit Redeemed(msg.sender, asset, rwaAmount, payoutOut, rwaPriceUsd); } /* ------------------------------- sweep ---------------------------------- */ /// @notice Foundation pulls accumulated RWA to its Securitize-allowlisted /// wallet for off-chain redemption. Cannot pull more than has /// been deposited via `redeem` — the contract balance is the /// ceiling, and accounting is strict. function sweepRWA(address asset, uint256 amount, address recipient) external onlyFoundation nonReentrant { AssetConfig memory c = configs[asset]; if (!c.registered) revert UnknownAsset(); if (recipient == address(0)) revert ZeroAddress(); if (amount == 0) revert ZeroAmount(); AssetState storage s = states[asset]; if (amount > s.totalAccumulated) revert InsufficientSweepBalance(); s.totalAccumulated -= amount; if (!IERC20(asset).transfer(recipient, amount)) revert TransferFailed(); emit Swept(asset, recipient, amount); } /* ---------------------------------- views ------------------------------- */ function periodRemaining(address asset) external view returns (uint256) { AssetConfig memory c = configs[asset]; if (!c.registered) return 0; AssetState memory s = states[asset]; uint256 elapsed = block.timestamp - uint256(s.periodStart); if (elapsed > c.periodSeconds) elapsed = c.periodSeconds; uint256 leaked = (uint256(c.rateLimitPerPeriod) * elapsed) / uint256(c.periodSeconds); uint256 outstanding = s.periodRedeemed > leaked ? s.periodRedeemed - leaked : 0; return c.rateLimitPerPeriod > outstanding ? c.rateLimitPerPeriod - outstanding : 0; } function payoutLiquidity() external view returns (uint256) { return IERC20(PAYOUT_TOKEN).balanceOf(address(this)); } /* ------------------------------ internal -------------------------------- */ function _scaleUp(uint256 amount, uint8 dec) internal pure returns (uint256) { if (dec == 18) return amount; if (dec < 18) return amount * (10 ** (18 - dec)); return amount / (10 ** (dec - 18)); } function _scaleDown(uint256 amount, uint8 dec) internal pure returns (uint256) { if (dec == 18) return amount; if (dec < 18) return amount / (10 ** (18 - dec)); return amount * (10 ** (dec - 18)); } }