Add the RWA pair launchpad stack: QianRWAFactory, QianRWAFeeRouter, QianRWARouter V2

Quote-paired launches: full supply locked one-sided against an allowlisted
quote asset (tokenized stock or USDG). Fee payouts defer to owed/claimOwed
when a recipient cannot take the transfer. Router V2 adds ERC20-in/out swap().
Robinhood-only stack on its own locker and guard hook; deployment record in
chains/robinhood-rwa-deployment.json.
This commit is contained in:
Qian
2026-07-19 21:32:01 +00:00
parent 65c74ef4a9
commit 297962041e
6 changed files with 524 additions and 9 deletions
+271
View File
@@ -0,0 +1,271 @@
// ~ https://Qian.ag ~ Real-World Asset Pair Factory ~
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import "./v4/LiquidityProviderV4.sol";
import "./QianToken.sol";
import "./interfaces/IPoolManager.sol";
import "./interfaces/ICurrency.sol";
import "./interfaces/IHooks.sol";
import "./interfaces/IConfigurableHook.sol";
import "./errors/Errors.sol";
import "./QianLocker.sol";
/// @notice Launches tokens against an allowlisted quote asset (tokenized stocks, stablecoins) instead of WETH; no dev buy.
contract QianRWAFactory is ReentrancyGuard {
using SafeERC20 for IERC20;
using LiquidityProviderV4 for LiquidityProviderV4.Config;
using CurrencyLibrary for Currency;
event ERC20TokenCreated(address tokenAddress);
event QuotePaired(address indexed token, address indexed quote);
event QuoteApprovalChanged(address indexed quote, bool approved);
event DeployFeeUpdated(uint256 oldFee, uint256 newFee);
event HookApprovalChanged(address indexed hook, bool approved);
event HookConfigured(address indexed token, address indexed hook, bytes32 poolId);
event ControllerTransferInitiated(address indexed currentController, address indexed pendingController);
event ControllerTransferred(address indexed oldController, address indexed newController);
event GuardHookUpdated(address indexed hook);
event DeployCoinToggled(bool enabled);
event CreatorChanged(address indexed token, address indexed oldCreator, address indexed newCreator);
struct TokenConfig {
uint256 deployTime;
string metadata;
address hookAddress;
}
struct LiquidityConfig {
uint160 sqrtPriceX96A;
uint160 sqrtPriceX96B;
int24 tickLower;
int24 tickUpper;
uint256 amount0Desired;
uint256 amount1Desired;
}
mapping(address => TokenConfig) public tokenConfigs;
/// @notice Indexer read surface; the quote is the poolKey currency that isn't the token.
mapping(address => PoolKey) public tokenToPoolKey;
mapping(address => address) public tokenQuote;
uint256 public tokenCount = 0;
address public platformController;
address public pendingController;
uint256 public deployFee = 0.0005 ether;
address public feeVault;
/// @notice Configs are PER QUOTE — sqrt prices encode the quote's decimals and USD value.
mapping(address => mapping(uint256 => LiquidityConfig)) public liquidityConfigs;
mapping(address => uint256) public liquidityConfigCount;
mapping(address => bool) public approvedQuotes;
mapping(address => bool) public approvedHooks;
/// @notice hookAddress=0 resolves to this, never to address(0) (fails closed).
address public guardHook;
bool public deployCoinEnabled = true;
address public immutable poolManager;
address public immutable locker;
int24 private constant TICK_SPACING = 60;
uint256 private constant FULL_TOKEN_SUPPLY = 1_000_000_000 * 10 ** 18;
uint24 public constant MIN_POOL_FEE = 5000;
uint24 public constant MAX_POOL_FEE = 200000;
constructor(address _poolManager, address _locker) {
if (_poolManager == address(0) || _locker == address(0)) revert BadRouterConfig();
platformController = msg.sender;
feeVault = msg.sender;
poolManager = _poolManager;
locker = _locker;
}
function deployCoin(
string memory _name,
string memory _symbol,
string memory _metadata,
bytes32 salt,
address quote,
uint256 configId,
address hookAddress,
bytes calldata hookConfig,
uint256 maxDeployFee,
uint24 poolFee
) public payable nonReentrant returns (address tokenAddress) {
if (!deployCoinEnabled) revert DeployDisabled();
if (!approvedQuotes[quote]) revert InvalidConfig();
if (configId >= liquidityConfigCount[quote]) revert InvalidConfig();
if (deployFee > maxDeployFee) revert DeployFeeTooHigh();
if (poolFee < MIN_POOL_FEE || poolFee > MAX_POOL_FEE) revert InvalidPoolFee();
// no dev buy on quote pairs: exact fee only, paid in raw ETH
if (msg.value != deployFee) revert InsufficientDeployFee();
address hook = hookAddress == address(0) ? guardHook : hookAddress;
if (!approvedHooks[hook]) revert HookNotApproved();
{
uint256 size;
assembly { size := extcodesize(hook) }
if (size == 0) revert InvalidConfig();
}
QianToken t = new QianToken{salt: salt}(_name, _symbol, _metadata, msg.sender, address(this));
emit ERC20TokenCreated(address(t));
tokenAddress = address(t);
emit QuotePaired(tokenAddress, quote);
tokenQuote[tokenAddress] = quote;
_provideLiquidity(tokenAddress, quote, configId, hook, poolFee, msg.sender);
if (hook != guardHook) {
PoolKey memory poolKey = tokenToPoolKey[tokenAddress];
bytes32 poolId = IConfigurableHook(hook).getPoolId(poolKey);
IConfigurableHook(hook).configureHook(poolId, tokenAddress, hookConfig);
emit HookConfigured(tokenAddress, hook, poolId);
}
if (msg.value > 0 && feeVault != address(0)) {
(bool ok, ) = feeVault.call{value: msg.value}("");
if (!ok) revert TransferFailed();
}
tokenConfigs[tokenAddress] = TokenConfig({
deployTime: block.timestamp,
metadata: _metadata,
hookAddress: hook
});
tokenCount++;
}
/// @notice Controller-only: reassign a token's creator fee rights (approved CTO).
function changeTokenFeeReceiver(address tokenAddress, address newCreator) external {
if (msg.sender != platformController) revert NotController();
if (newCreator == address(0)) revert InvalidReceiver();
address oldCreator = tokenCreator(tokenAddress);
if (oldCreator == address(0)) revert InvalidConfig();
emit CreatorChanged(tokenAddress, oldCreator, newCreator);
QianLocker(locker).setCreator(tokenAddress, newCreator);
}
/// @notice Controller-only; vet off-chain first: plain ERC20 only — no rebase, no fee-on-transfer.
function setQuoteApproved(address quote, bool approved) external {
if (msg.sender != platformController) revert NotController();
if (quote == address(0)) revert InvalidConfig();
approvedQuotes[quote] = approved;
emit QuoteApprovalChanged(quote, approved);
}
function setHookApproved(address hook, bool approved) external {
if (msg.sender != platformController) revert NotController();
approvedHooks[hook] = approved;
emit HookApprovalChanged(hook, approved);
}
function setDeployFee(uint256 _deployFee) external {
if (msg.sender != platformController) revert NotController();
uint256 oldFee = deployFee;
deployFee = _deployFee;
emit DeployFeeUpdated(oldFee, _deployFee);
}
function setFeeVault(address _feeVault) external {
if (msg.sender != platformController) revert NotController();
if (_feeVault == address(0)) revert InvalidFeeVault();
feeVault = _feeVault;
}
function setGuardHook(address hook) external {
if (msg.sender != platformController) revert NotController();
if (hook == address(0)) revert InvalidConfig();
guardHook = hook;
emit GuardHookUpdated(hook);
}
function toggleDeployCoin() external {
if (msg.sender != platformController) revert NotController();
deployCoinEnabled = !deployCoinEnabled;
emit DeployCoinToggled(deployCoinEnabled);
}
function transferController(address newController) external {
if (msg.sender != platformController) revert NotController();
if (newController == address(0)) revert InvalidReceiver();
pendingController = newController;
emit ControllerTransferInitiated(platformController, newController);
}
function acceptController() external {
if (msg.sender != pendingController) revert Unauthorized();
address oldController = platformController;
platformController = pendingController;
pendingController = address(0);
emit ControllerTransferred(oldController, platformController);
}
function setLiquidityConfig(
address quote,
uint256 id,
uint160 sqrtA,
uint160 sqrtB,
int24 tickL,
int24 tickU,
uint256 amt0,
uint256 amt1
) external returns (uint256) {
if (msg.sender != platformController) revert NotController();
if (quote == address(0)) revert InvalidConfig();
if (tickL >= tickU) revert InvalidConfig();
uint256 configId = id == type(uint256).max ? liquidityConfigCount[quote]++ : id;
if (id != type(uint256).max && id >= liquidityConfigCount[quote]) revert InvalidConfig();
liquidityConfigs[quote][configId] = LiquidityConfig(sqrtA, sqrtB, tickL, tickU, amt0, amt1);
return configId;
}
/// @notice Live receiver of the 70% creator fees (CTO-aware), served by the locker.
function tokenCreator(address token) public view returns (address) {
return QianLocker(locker).creatorOf(token);
}
function getTokenInfo(address token) external view returns (
string memory name,
string memory symbol,
address creator,
uint256 deployTime,
string memory metadata,
address hookAddress
) {
TokenConfig memory config = tokenConfigs[token];
QianToken t = QianToken(token);
return (t.name(), t.symbol(), tokenCreator(token), config.deployTime, config.metadata, config.hookAddress);
}
function _provideLiquidity(
address tokenA,
address quote,
uint256 configId,
address hookAddress,
uint24 poolFee,
address creator
) internal {
LiquidityConfig memory config = liquidityConfigs[quote][configId];
LiquidityProviderV4.Config memory libConfig = LiquidityProviderV4.Config({
sqrtPriceX96A: config.sqrtPriceX96A,
sqrtPriceX96B: config.sqrtPriceX96B,
tickLower: config.tickLower,
tickUpper: config.tickUpper,
amount0Desired: config.amount0Desired,
amount1Desired: config.amount1Desired
});
LiquidityProviderV4.LiquidityParams memory liqParams = LiquidityProviderV4.provideLiquidity(
poolManager,
tokenA,
quote,
libConfig,
hookAddress,
TICK_SPACING,
poolFee
);
tokenToPoolKey[tokenA] = liqParams.poolKey;
IERC20 token = IERC20(tokenA);
uint256 tokenBalance = token.balanceOf(address(this));
// exact or revert: the locker mints for amount0Desired and has no withdrawal path
if (tokenBalance != config.amount0Desired) {
revert InsufficientTokenBalance(config.amount0Desired, tokenBalance);
}
token.safeTransfer(locker, tokenBalance);
QianLocker(locker).lockV4(liqParams.poolKey, liqParams.tickLower, liqParams.tickUpper, liqParams.liquidity, tokenA, creator);
}
}
+167
View File
@@ -0,0 +1,167 @@
// ~ https://Qian.ag ~ Real-World Asset Pair Fee Router ~
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import "./interfaces/IPoolManager.sol";
import "./interfaces/ICurrency.sol";
import "./QianLocker.sol";
import "./errors/Errors.sol";
/// @notice Fee router for quote-paired V4 launches; payouts are plain ERC20 transfers of the pool's quote (read from the locker binding), one router serves every quote.
contract QianRWAFeeRouter is ReentrancyGuard {
using SafeERC20 for IERC20;
event FeesCollected(
address indexed token,
address indexed creator,
uint256 creatorQuote,
uint256 treasuryQuote,
uint256 tokenSideTotal,
uint256 platformTokenSide
);
event TreasuryUpdated(address indexed oldTreasury, address indexed newTreasury);
event TokensLiquidated(address indexed token, uint256 amountIn, uint256 quoteOut);
/// @dev recipient could not take the transfer (issuer blocklist); amount is claimable via claimOwed
event PayoutDeferred(address indexed to, address indexed asset, uint256 amount);
/// @dev liquidation swap failed (e.g. pool price griefed to the tick limit); retried next collect
event LiquidationDeferred(address indexed token, uint256 amount);
event OwedClaimed(address indexed to, address indexed asset, uint256 amount, address paidTo);
uint256 public constant TREASURY_BPS = 3000; // 30% treasury / 70% creator
/// @notice Deferred payouts: recipient => asset => amount (accrued when a transfer fails).
mapping(address => mapping(address => uint256)) public owed;
address public immutable locker;
address public immutable poolManager;
address public owner;
address public pendingOwner;
address public treasury;
constructor(address _locker, address _treasury, address _poolManager) {
if (_locker == address(0) || _treasury == address(0) || _poolManager == address(0)) {
revert InvalidConfig();
}
locker = _locker;
treasury = _treasury;
poolManager = _poolManager;
owner = msg.sender;
}
/// @notice Collect and distribute accrued LP fees for a quote-paired token in one call.
function collectFees(address token) external nonReentrant returns (uint256 creatorQuote, uint256 treasuryQuote) {
QianLocker.Binding memory b = QianLocker(locker).bindingOf(token);
if (b.kind != 2) revert InvalidConfig(); // V4 bindings only
if (b.creator == address(0)) revert InvalidReceiver();
address c0 = Currency.unwrap(b.poolKey.currency0);
address c1 = Currency.unwrap(b.poolKey.currency1);
address quote = c0 == token ? c1 : c0;
uint256 quoteBefore = IERC20(quote).balanceOf(address(this));
uint256 tokenBefore = IERC20(token).balanceOf(address(this));
QianLocker(locker).collect(token, address(this));
uint256 collectedQuote = IERC20(quote).balanceOf(address(this)) - quoteBefore;
uint256 collectedTokenSide = IERC20(token).balanceOf(address(this)) - tokenBefore;
if (collectedQuote > 0) {
treasuryQuote = (collectedQuote * TREASURY_BPS) / 10_000;
creatorQuote = collectedQuote - treasuryQuote;
if (treasuryQuote > 0) _pay(quote, treasury, treasuryQuote, true);
if (creatorQuote > 0) _pay(quote, b.creator, creatorQuote, false);
}
uint256 platformTokenSide = 0;
if (collectedTokenSide > 0) {
platformTokenSide = (collectedTokenSide * TREASURY_BPS) / 10_000;
uint256 creatorTokenSide = collectedTokenSide - platformTokenSide;
if (creatorTokenSide > 0) _pay(token, b.creator, creatorTokenSide, false);
}
emit FeesCollected(token, b.creator, creatorQuote, treasuryQuote, collectedTokenSide, platformTokenSide);
// full-balance sweep is safe only because the token side (QianToken) can never defer
uint256 bal = IERC20(token).balanceOf(address(this));
if (bal > 0) _liquidate(token, quote, b, bal);
}
/// @dev A failed transfer (issuer blocklist) accrues to `owed` instead of reverting the collect.
function _pay(address asset, address to, uint256 amount, bool isTreasury) internal {
(bool ok, bytes memory ret) = asset.call(abi.encodeCall(IERC20.transfer, (to, amount)));
if (ok && (ret.length == 0 || abi.decode(ret, (bool)))) return;
// explicit flag, not address equality: a creator who IS the treasury keeps their own key
address key = isTreasury ? address(this) : to;
owed[key][asset] += amount;
emit PayoutDeferred(to, asset, amount);
}
/// @notice Retry a deferred payout; `to == address(this)` is the treasury bucket, paid to the CURRENT treasury.
function claimOwed(address asset, address to) external nonReentrant {
uint256 amount = owed[to][asset];
if (amount == 0) revert InvalidConfig();
owed[to][asset] = 0;
address paidTo = to == address(this) ? treasury : to;
IERC20(asset).safeTransfer(paidTo, amount);
emit OwedClaimed(to, asset, amount, paidTo);
}
/// @dev Sell token holdings into the quote; non-fatal, a griefed pool price defers to the next collect.
function _liquidate(address token, address quote, QianLocker.Binding memory b, uint256 amountIn) internal {
uint256 quoteBefore = IERC20(quote).balanceOf(address(this));
bool zeroForOne = token < quote;
try IPoolManager(poolManager).unlock(abi.encode(b.poolKey, zeroForOne, amountIn)) {}
catch { emit LiquidationDeferred(token, amountIn); return; }
uint256 quoteOut = IERC20(quote).balanceOf(address(this)) - quoteBefore;
if (quoteOut > 0) _pay(quote, treasury, quoteOut, true);
emit TokensLiquidated(token, amountIn, quoteOut);
}
/// @notice PoolManager callback for liquidations (exact-in token -> quote).
function unlockCallback(bytes calldata data) external returns (bytes memory) {
if (msg.sender != poolManager) revert Unauthorized();
(PoolKey memory key, bool zeroForOne, uint256 amountIn) = abi.decode(data, (PoolKey, bool, uint256));
IPoolManager manager = IPoolManager(poolManager);
SwapParams memory params = SwapParams({
zeroForOne: zeroForOne,
amountSpecified: -int256(amountIn),
sqrtPriceLimitX96: zeroForOne ? uint160(4295128740) : uint160(1461446703485210103287273052203988822378723970341)
});
BalanceDelta delta = manager.swap(key, params, "");
int128 d0 = BalanceDeltaLibrary.amount0(delta);
int128 d1 = BalanceDeltaLibrary.amount1(delta);
if (d0 < 0) {
manager.sync(key.currency0);
IERC20(Currency.unwrap(key.currency0)).safeTransfer(poolManager, uint256(-int256(d0)));
manager.settle();
}
if (d1 < 0) {
manager.sync(key.currency1);
IERC20(Currency.unwrap(key.currency1)).safeTransfer(poolManager, uint256(-int256(d1)));
manager.settle();
}
if (d0 > 0) manager.take(key.currency0, address(this), uint256(int256(d0)));
if (d1 > 0) manager.take(key.currency1, address(this), uint256(int256(d1)));
return "";
}
function setTreasury(address newTreasury) external {
if (msg.sender != owner) revert Unauthorized();
if (newTreasury == address(0)) revert InvalidReceiver();
emit TreasuryUpdated(treasury, newTreasury);
treasury = newTreasury;
}
/// @notice Two-step owner handover, same shape as everywhere else in the stack.
function transferOwner(address newOwner) external {
if (msg.sender != owner) revert Unauthorized();
if (newOwner == address(0)) revert InvalidReceiver();
pendingOwner = newOwner;
}
function acceptOwner() external {
if (msg.sender != pendingOwner) revert Unauthorized();
owner = pendingOwner;
pendingOwner = address(0);
}
}
+24 -1
View File
@@ -1,4 +1,4 @@
// ~ https://Qian.ag ~
// ~ https://Qian.ag ~ Real-World Asset Router ~
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
@@ -18,6 +18,7 @@ contract QianRWARouter is ReentrancyGuard {
event Bought(address indexed token, address indexed buyer, uint256 ethIn, uint256 fee, uint256 tokensOut);
event Sold(address indexed token, address indexed seller, uint256 tokensIn, uint256 fee, uint256 ethOut);
event Swapped(address indexed tokenIn, address indexed tokenOut, address indexed trader, uint256 amountIn, uint256 fee, uint256 amountOut);
event TreasuryUpdated(address indexed oldTreasury, address indexed newTreasury);
uint256 public constant FEE_BPS = 100;
@@ -96,6 +97,28 @@ contract QianRWARouter is ReentrancyGuard {
emit Sold(token, msg.sender, tokensIn, fee, ethOut);
}
/// @notice Swap any ERC20 for any other along `path`; 1% fee on the input side. Needs prior approval.
function swap(PoolKey[] calldata path, address tokenIn, uint256 amountIn, address tokenOut, uint256 minOut, uint256 deadline)
external
nonReentrant
returns (uint256 amountOut)
{
if (block.timestamp > deadline) revert Expired();
if (amountIn == 0 || path.length == 0 || tokenIn == tokenOut) revert InvalidConfig();
// fee-on-transfer support: swap what actually arrived, not the nominal amount
uint256 balBefore = IERC20(tokenIn).balanceOf(address(this));
IERC20(tokenIn).safeTransferFrom(msg.sender, address(this), amountIn);
amountIn = IERC20(tokenIn).balanceOf(address(this)) - balBefore;
if (amountIn == 0) revert InvalidConfig();
uint256 fee = (amountIn * FEE_BPS) / 10_000;
IERC20(tokenIn).safeTransfer(treasury, fee);
(address outCurrency, uint256 outAmount) = _swapPath(path, tokenIn, amountIn - fee, msg.sender);
if (outCurrency != tokenOut) revert InvalidConfig();
amountOut = outAmount;
if (amountOut < minOut) revert InsufficientOutput();
emit Swapped(tokenIn, tokenOut, msg.sender, amountIn, fee, amountOut);
}
/// @dev Open the PoolManager lock; the multi-hop runs in unlockCallback.
function _swapPath(PoolKey[] calldata path, address inputCurrency, uint256 amountIn, address recipient)
internal