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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user