Add QianPay: payment-link settlement

This commit is contained in:
Qian
2026-07-18 08:47:37 +00:00
parent 860ba4648a
commit 3f7ec85fbd
2 changed files with 140 additions and 1 deletions
+5 -1
View File
@@ -24,6 +24,7 @@ repo is the Base copy (chainId 8453); the addresses below hold on every chain.
| QianFactoryV4 | `0x750d0bc66baa12CF1710a8ba939B97DB4aD005c3` |
| QianFeeRouter | `0xDe598E5F0b428B38EFaA6e887458FEF70bFA9436` |
| QianSwapRouter | `0xEE540d2496cfFDf17E29a04bcd7b6dc795C61d66` |
| QianPay | `0xa3faE6c7e97506145c29f2E7a887b345f49Cf36B` |
| Create2Factory | `0x175ae2c5EC7edaD3eE88e892C677bBDE815A2803` |
| GuardHook | `0xa27D4eef437Ab754074c8C9E6bCbC55184dE2000` |
@@ -43,6 +44,9 @@ Every address above is source-verified on https://basescan.org.
- **Creator tax** is the Uniswap V4 pool fee, anywhere from 0.5% to 20%, picked
at launch. It lives in the PoolKey, so it never changes after that. V3 pools
use the fixed 1% tier.
- **QianPay** settles payment links: pay a fixed USDC amount straight to a
wallet, in USDC or anything a Uniswap V3 path can turn into USDC. Stateless,
no admin functions, 0.5% platform fee on the USDC side.
- **QianSwapRouter** handles user trades: buy or sell any Qian token for ETH,
with a 1% platform fee on the WETH leg. It keeps no balances between trades.
@@ -66,7 +70,7 @@ anti-snipe hook.
- `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`
`QianFeeRouter`, `QianSwapRouter`, and `QianPay`, plus `GuardHook`
- `chains/`: chain config and deployed addresses
## Build
+135
View File
@@ -0,0 +1,135 @@
// ~ 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/IWETH.sol";
import "./errors/Errors.sol";
interface ISwapRouter02 {
struct ExactOutputParams {
bytes path;
address recipient;
uint256 amountOut;
uint256 amountInMaximum;
}
function exactOutput(ExactOutputParams calldata params) external payable returns (uint256 amountIn);
}
/// @notice Stateless settlement for Qian payment links. Payer settles in USDC
/// directly, in any ERC20 via an exact-output Uniswap V3 swap, or in plain ETH
/// (Base only, by usage — no chain guard in code). FEE_BPS of the USDC amount
/// goes to treasury on every path; the rest goes straight to recipient. No
/// balances are ever held here between transactions.
contract QianPay is ReentrancyGuard {
using SafeERC20 for IERC20;
event Paid(bytes32 indexed intentRef, address indexed recipient, address payer, address tokenIn, uint256 usdcOut);
uint256 public constant FEE_BPS = 50; // 0.5% of the USDC amount, every path
IERC20 public immutable USDC;
address public immutable TREASURY;
address public immutable SWAP_ROUTER02;
constructor(address usdc, address treasury, address swapRouter02) {
if (usdc == address(0) || treasury == address(0) || swapRouter02 == address(0)) revert InvalidConfig();
USDC = IERC20(usdc);
TREASURY = treasury;
SWAP_ROUTER02 = swapRouter02;
}
/// @notice Pay directly in USDC.
function payUSDC(bytes32 intentRef, address recipient, uint256 amount) external nonReentrant {
if (recipient == address(0)) revert InvalidReceiver();
if (amount == 0) revert InvalidConfig();
USDC.safeTransferFrom(msg.sender, address(this), amount);
_settle(intentRef, recipient, address(USDC), amount);
}
/// @notice Pay in any ERC20 via an exact-output V3 swap to USDC. Pulls up to
/// `maxIn` of `tokenIn`, refunds whatever the swap didn't spend (balance-diff,
/// fee-on-transfer safe).
function payWithToken(bytes32 intentRef, address recipient, uint256 usdcAmount, address tokenIn, uint256 maxIn, bytes calldata path) external nonReentrant {
if (recipient == address(0)) revert InvalidReceiver();
if (usdcAmount == 0 || maxIn == 0) revert InvalidConfig();
uint256 balBefore = IERC20(tokenIn).balanceOf(address(this));
IERC20(tokenIn).safeTransferFrom(msg.sender, address(this), maxIn);
uint256 pulled = IERC20(tokenIn).balanceOf(address(this)) - balBefore;
IERC20(tokenIn).forceApprove(SWAP_ROUTER02, pulled);
uint256 spent = ISwapRouter02(SWAP_ROUTER02).exactOutput(
ISwapRouter02.ExactOutputParams({
path: path,
recipient: address(this),
amountOut: usdcAmount,
amountInMaximum: pulled
})
);
IERC20(tokenIn).forceApprove(SWAP_ROUTER02, 0);
if (pulled > spent) {
IERC20(tokenIn).safeTransfer(msg.sender, pulled - spent);
}
_settle(intentRef, recipient, tokenIn, usdcAmount);
}
/// @notice Pay in plain ETH via an exact-output V3 swap to USDC. Base only
/// by usage (no chain guard in code). Wraps msg.value to WETH — the WETH
/// address is read off the tail of `path` (the tokenIn of an exact-output
/// path is its last 20 bytes) — then swaps and refunds unspent ETH.
function payETH(bytes32 intentRef, address recipient, uint256 usdcAmount, bytes calldata path) external payable nonReentrant {
if (recipient == address(0)) revert InvalidReceiver();
if (usdcAmount == 0 || msg.value == 0) revert InvalidConfig();
if (path.length < 20) revert InvalidConfig();
address weth = _lastToken(path);
IWETH(weth).deposit{value: msg.value}();
IERC20(weth).forceApprove(SWAP_ROUTER02, msg.value);
uint256 spent = ISwapRouter02(SWAP_ROUTER02).exactOutput(
ISwapRouter02.ExactOutputParams({
path: path,
recipient: address(this),
amountOut: usdcAmount,
amountInMaximum: msg.value
})
);
IERC20(weth).forceApprove(SWAP_ROUTER02, 0);
if (msg.value > spent) {
uint256 refund = msg.value - spent;
IWETH(weth).withdraw(refund);
(bool ok, ) = msg.sender.call{value: refund}("");
if (!ok) revert TransferFailed();
}
_settle(intentRef, recipient, weth, usdcAmount);
}
/// @dev FEE_BPS of usdcAmount to treasury, remainder to recipient, emit Paid.
/// Assumes this contract already holds >= usdcAmount of USDC.
function _settle(bytes32 intentRef, address recipient, address tokenIn, uint256 usdcAmount) private {
uint256 fee = (usdcAmount * FEE_BPS) / 10_000;
USDC.safeTransfer(TREASURY, fee);
USDC.safeTransfer(recipient, usdcAmount - fee);
emit Paid(intentRef, recipient, msg.sender, tokenIn, usdcAmount);
}
/// @dev Exact-output V3 paths encode tokenIn as the last 20 bytes.
function _lastToken(bytes calldata path) private pure returns (address token) {
assembly {
token := shr(96, calldataload(add(path.offset, sub(path.length, 20))))
}
}
/// @dev Expected source: this contract's own WETH.withdraw() during a payETH
/// refund. Not enforced — no weth immutable exists to check msg.sender against
/// (constructor signature is fixed; WETH varies per-call via `path`), so stray
/// direct ETH sends are accepted and unrecoverable. Deliberate trade-off.
receive() external payable {}
}