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
+30 -8
View File
@@ -63,22 +63,44 @@ time it does nothing: every swap-time flag reads false, so the Uniswap V4
PoolManager never calls it on a swap. Qian launches ship with no fee, tax, or
anti-snipe hook.
## Tokenized stocks (RWA)
## Real-world asset pairs (RWA)
`QianRWARouter` trades freely-transferable real-world-asset tokens, such as
tokenized stocks, for ETH. Each trade supplies its own Uniswap V4 pool
path (WETH -> ... -> token); the router runs it exact-in, takes a 1% fee on the
WETH leg, and holds no balances between transactions. It routes swaps against
pools that already exist, and neither issues nor custodies the underlying asset.
Robinhood Chain carries tokenized stocks as plain ERC20s. Three contracts put
them to work on Qian. All three route against existing Uniswap V4 pools; the
underlying asset stays with its issuer.
Deployed on Robinhood Chain at `0xEDAB0aB0059A1e3794c3Ab8fF4569242b97f2eE6`,
- **QianRWAFactory** launches tokens paired to an approved quote asset (a
tokenized stock or USDG) instead of WETH. The launch works like the core
factories: full supply into a locked one-sided Uniswap V4 position. The quote
is a per-launch parameter against a controller allowlist, each quote carries
its own liquidity tiers, and the deploy fee is exact (no dev buy).
- **QianRWAFeeRouter** splits a quote-paired token's accrued trading fees and
pays them out as plain ERC20 transfers of the quote. A transfer the recipient
cannot take, for example when a stock issuer blocklists the address, accrues
to `owed` instead of reverting; `claimOwed` releases it later. One blocked
address never wedges a collect for everyone else.
- **QianRWARouter** trades RWA tokens for ETH along a caller-supplied V4 pool
path, exact-in with a 1% fee on the WETH leg. `swap()` does the same for
ERC20-in/ERC20-out routes, fee on the input asset. The router keeps no
balances between transactions.
This stack is Robinhood-only and runs on its own locker and guard hook. Full
record in `chains/robinhood-rwa-deployment.json`; every address is
source-verified on https://robinhoodchain.blockscout.com.
| Contract | Address |
| --- | --- |
| QianRWAFactory | `0x5A4ecC6fb7Ab62EC69cB0902397A5EB0a3Cb16D3` |
| QianRWAFeeRouter | `0xc3a89be96b8BDb1123d2Fa40f18000e3FB1A7aAf` |
| QianRWARouter | `0x8495A347f71e819dB9e5eC12c1FC64C79DbD8708` |
| QianLocker (RWA stack) | `0x38ceAf1eF6528Aa5775820696A7D14e64136a22d` |
| GuardHook (RWA stack) | `0x10bbb04ad4ef277daffeecc37a3019dc08dee000` |
## Layout
- `dtm-v3/contracts`: Uniswap V3 factory stack (`QianFactoryV3`, `QianToken`, LP provider)
- `dtm-v4/contracts`: Uniswap V4 factory stack, the shared `QianLocker`,
`QianFeeRouter`, and `QianSwapRouter`, plus `GuardHook` and `QianRWARouter`
`QianFeeRouter`, and `QianSwapRouter`, plus `GuardHook` and the RWA stack\n (`QianRWAFactory`, `QianRWAFeeRouter`, `QianRWARouter`)
- `chains/`: chain config and deployed addresses
## Build
+1
View File
@@ -11,6 +11,7 @@ Email [email protected] with the details and a proof of concept if you have one. Repor
- **Renounced tokens.** A launched token's `owner()` is the zero address. It has no mint, pause, blacklist, or transfer hook.
- **Permissionless fees.** Anyone can call `QianFeeRouter.collectFees(token)`. It pays the creator and treasury on a fixed split and moves no user funds.
- **Launch integrity.** `GuardHook` lets only the factory initialize a pool, so nobody can front-run a launch and brick it. It runs no trade-time logic.
- **Deferred RWA payouts.** A stock issuer can blocklist any address, including a fee recipient. `QianRWAFeeRouter` books a failed payout to `owed` instead of reverting, and `claimOwed` releases it later, so one blocked address never blocks the collect. Its treasury-side liquidation swap runs under try/catch and retries on the next collect.
- **Untrusted swap paths.** `QianRWARouter` runs a caller-supplied Uniswap V4 pool path and keeps no balances between trades. A path that can't fill reverts the whole transaction, and any shortfall trips the minimum-out check, so a bad path only fails that trade and moves no other funds.
## Accepted design tradeoffs
+31
View File
@@ -0,0 +1,31 @@
{
"chain": "robinhood",
"deployedAt": "2026-07-19T20:51:38.473Z",
"deployer": "0x92B92aA9fa5AefC957ab77904f23cf14d7369fA7",
"locker": "0x38ceAf1eF6528Aa5775820696A7D14e64136a22d",
"rwaFactory": "0x5A4ecC6fb7Ab62EC69cB0902397A5EB0a3Cb16D3",
"rwaFeeRouter": "0xc3a89be96b8BDb1123d2Fa40f18000e3FB1A7aAf",
"create2Factory": "0xe744510974F2c61dDAC2290238f8CBd375AeF1f2",
"guardHook": "0x10bbb04ad4ef277daffeecc37a3019dc08dee000",
"guardHookSalt": "0xcd353772db37b52865c269b4cf6643eb600565237ae1dbc6ae0bf1f57b2cfaec",
"tiersUsd": [
1900,
3700,
9300,
18600
],
"quotes": {
"SPCX": "0x4a0E65A3EcceC6dBe60AE065F2e7bb85Fae35eEa",
"NVDA": "0xd0601CE157Db5bdC3162BbaC2a2C8aF5320D9EEC",
"TSLA": "0x322F0929c4625eD5bAd873c95208D54E1c003b2d",
"SPY": "0x117cc2133c37B721F49dE2A7a74833232B3B4C0C",
"QQQ": "0xD5f3879160bc7c32ebb4dC785F8a4F505888de68",
"AMZN": "0x12f190a9F9d7D37a250758b26824B97CE941bF54",
"GOOGL": "0x2e0847E8910a9732eB3fb1bb4b70a580ADAD4FE3",
"AAPL": "0xaF3D76f1834A1d425780943C99Ea8A608f8a93f9",
"MSFT": "0xe93237C50D904957Cf27E7B1133b510C669c2e74",
"META": "0xc0D6457C16Cc70d6790Dd43521C899C87ce02f35",
"AMD": "0x86923f96303D656E4aa86D9d42D1e57ad2023fdC",
"USDG": "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168"
}
}
+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