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.
250 lines
11 KiB
Solidity
250 lines
11 KiB
Solidity
// 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));
|
||
}
|
||
}
|