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
+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();
}
}