Add QianRWARouter (tokenized-stock / RWA swap router)

This commit is contained in:
Qian
2026-07-18 04:28:53 +00:00
parent 3eeeb2cf82
commit 1de94f7877
3 changed files with 193 additions and 1 deletions
+12 -1
View File
@@ -57,11 +57,22 @@ 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 PoolManager never calls it on a swap. Qian launches ship with no fee, tax, or
anti-snipe hook. anti-snipe hook.
## Tokenized stocks (RWA)
`QianRWARouter` trades freely-transferable real-world-asset tokens, such as
tokenized stocks, for plain 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.
Deployed on Robinhood Chain at `0xEDAB0aB0059A1e3794c3Ab8fF4569242b97f2eE6`,
source-verified on https://robinhoodchain.blockscout.com.
## Layout ## Layout
- `dtm-v3/contracts`: Uniswap V3 factory stack (`QianFactoryV3`, `QianToken`, LP provider) - `dtm-v3/contracts`: Uniswap V3 factory stack (`QianFactoryV3`, `QianToken`, LP provider)
- `dtm-v4/contracts`: Uniswap V4 factory stack, the shared `QianLocker`, - `dtm-v4/contracts`: Uniswap V4 factory stack, the shared `QianLocker`,
`QianFeeRouter`, and `QianSwapRouter`, plus `GuardHook` `QianFeeRouter`, and `QianSwapRouter`, plus `GuardHook` and `QianRWARouter`
- `chains/`: chain config and deployed addresses - `chains/`: chain config and deployed addresses
## Build ## 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. - **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. - **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. - **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.
- **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 ## Accepted design tradeoffs
+180
View File
@@ -0,0 +1,180 @@
// ~ https://Qian.ag ~
// 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 "./interfaces/IWETH.sol";
import "./errors/Errors.sol";
/// @notice Buy/sell freely-transferable RWA tokens (e.g. tokenized stocks) with plain ETH, routed
/// exact-in through a caller-supplied Uniswap V4 PoolKey path (WETH -> ... -> token). Holds no
/// balances between transactions; the untrusted path can only revert or return a fill minOut rejects.
contract QianRWARouter is ReentrancyGuard {
using SafeERC20 for IERC20;
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 TreasuryUpdated(address indexed oldTreasury, address indexed newTreasury);
uint256 public constant FEE_BPS = 100;
// Extreme tick bounds so each hop swaps against the full range.
uint160 internal constant MIN_SQRT_LIMIT = 4295128740;
uint160 internal constant MAX_SQRT_LIMIT = 1461446703485210103287273052203988822378723970341;
address public immutable weth;
address public immutable poolManager;
address public owner;
address public pendingOwner;
address public treasury;
constructor(address _weth, address _treasury, address _poolManager) {
if (_weth == address(0) || _treasury == address(0) || _poolManager == address(0)) {
revert InvalidConfig();
}
weth = _weth;
treasury = _treasury;
poolManager = _poolManager;
owner = msg.sender;
}
/// @notice Buy `token` with ETH along `path` (WETH -> ... -> token); reverts if the path's final
/// output isn't `token`. `deadline` rejects a stale tx held back to lock in a price.
function buy(PoolKey[] calldata path, address token, uint256 minTokensOut, uint256 deadline)
external
payable
nonReentrant
returns (uint256 tokensOut)
{
if (block.timestamp > deadline) revert Expired();
if (msg.value == 0 || path.length == 0) revert InvalidConfig();
uint256 fee = (msg.value * FEE_BPS) / 10_000;
uint256 amountIn = msg.value - fee;
IWETH(weth).deposit{value: msg.value}();
IERC20(weth).safeTransfer(treasury, fee);
(address outCurrency, uint256 outAmount) = _swapPath(path, weth, amountIn, msg.sender);
if (outCurrency != token) revert InvalidConfig();
tokensOut = outAmount;
if (tokensOut < minTokensOut) revert InsufficientOutput();
emit Bought(token, msg.sender, msg.value, fee, tokensOut);
}
/// @notice Sell `token` for ETH along `path` (token -> ... -> WETH); needs prior token approval.
/// `deadline` rejects a stale tx held back to lock in a price.
function sell(PoolKey[] calldata path, address token, uint256 tokensIn, uint256 minEthOut, uint256 deadline)
external
nonReentrant
returns (uint256 ethOut)
{
if (block.timestamp > deadline) revert Expired();
if (tokensIn == 0 || path.length == 0) revert InvalidConfig();
// Fee-on-transfer support: swap what actually arrived, not the nominal amount.
uint256 balBefore = IERC20(token).balanceOf(address(this));
IERC20(token).safeTransferFrom(msg.sender, address(this), tokensIn);
tokensIn = IERC20(token).balanceOf(address(this)) - balBefore;
if (tokensIn == 0) revert InvalidConfig();
(address outCurrency, uint256 wethOut) = _swapPath(path, token, tokensIn, address(this));
if (outCurrency != weth) revert InvalidConfig();
uint256 fee = (wethOut * FEE_BPS) / 10_000;
ethOut = wethOut - fee;
if (ethOut < minEthOut) revert InsufficientOutput();
IERC20(weth).safeTransfer(treasury, fee);
IWETH(weth).withdraw(ethOut);
(bool ok, ) = msg.sender.call{value: ethOut}("");
if (!ok) revert TransferFailed();
emit Sold(token, msg.sender, tokensIn, fee, ethOut);
}
/// @dev Open the PoolManager lock; the multi-hop runs in unlockCallback.
function _swapPath(PoolKey[] calldata path, address inputCurrency, uint256 amountIn, address recipient)
internal
returns (address outCurrency, uint256 outAmount)
{
bytes memory result = IPoolManager(poolManager).unlock(abi.encode(path, inputCurrency, amountIn, recipient));
(outCurrency, outAmount) = abi.decode(result, (address, uint256));
}
/// @notice PoolManager callback: each hop's exact output feeds the next hop's input, so
/// intermediates net to zero — only the initial input is settled and the final output taken.
function unlockCallback(bytes calldata data) external returns (bytes memory) {
if (msg.sender != poolManager) revert Unauthorized();
(PoolKey[] memory path, address inputCurrency, uint256 amountIn, address recipient) =
abi.decode(data, (PoolKey[], address, uint256, address));
IPoolManager manager = IPoolManager(poolManager);
address curCurrency = inputCurrency;
uint256 curAmount = amountIn;
for (uint256 i = 0; i < path.length; i++) {
PoolKey memory key = path[i];
address c0 = Currency.unwrap(key.currency0);
address c1 = Currency.unwrap(key.currency1);
bool zeroForOne;
if (curCurrency == c0) {
zeroForOne = true;
} else if (curCurrency == c1) {
zeroForOne = false;
} else {
revert InvalidConfig(); // hop's currencies don't include the last output
}
BalanceDelta delta = manager.swap(
key,
SwapParams({
zeroForOne: zeroForOne,
amountSpecified: -int256(curAmount),
sqrtPriceLimitX96: zeroForOne ? MIN_SQRT_LIMIT : MAX_SQRT_LIMIT
}),
""
);
int128 outDelta = zeroForOne ? BalanceDeltaLibrary.amount1(delta) : BalanceDeltaLibrary.amount0(delta);
if (outDelta <= 0) revert InsufficientOutput();
curAmount = uint256(int256(outDelta));
curCurrency = zeroForOne ? c1 : c0;
}
// A trade too large to fill hop 0 leaves the input under-settled and v4 reverts the whole
// tx (safe, no funds move); callers size to liquidity.
manager.sync(Currency.wrap(inputCurrency));
IERC20(inputCurrency).safeTransfer(poolManager, amountIn);
manager.settle();
manager.take(Currency.wrap(curCurrency), recipient, curAmount);
return abi.encode(curCurrency, curAmount);
}
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);
}
/// @dev Only WETH.withdraw sends ETH here.
receive() external payable {
if (msg.sender != weth) revert Unauthorized();
}
}