Add QianRWARouter (tokenized-stock / RWA swap router)
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user