aere-contracts/contracts/lending/RWATransferAdapter.sol
Aere Network a13a649b77
Some checks are pending
contracts-ci / Install (lockfile) → compile → full test suite (push) Waiting to run
contracts-ci / PQC known-answer tests (NIST vectors) (push) Waiting to run
contracts-ci / Coverage (scoped, with artifacts) (push) Waiting to run
Initial public release
Aere Network public source. Everything here can be checked against the live
chain (chain id 2800, https://rpc.aere.network).

Scope note, stated up front rather than buried: consensus on chain 2800 is
classical secp256k1 ECDSA QBFT. The post-quantum work in this repository is at
the signature, precompile, account and transport layers. Nothing here makes the
consensus post-quantum, and no document in it should be read as claiming so.
2026-07-20 01:02:37 +03:00

250 lines
11 KiB
Solidity
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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));
}
}