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` | | QianFactoryV4 | `0x750d0bc66baa12CF1710a8ba939B97DB4aD005c3` |
| QianFeeRouter | `0xDe598E5F0b428B38EFaA6e887458FEF70bFA9436` | | QianFeeRouter | `0xDe598E5F0b428B38EFaA6e887458FEF70bFA9436` |
| QianSwapRouter | `0xEE540d2496cfFDf17E29a04bcd7b6dc795C61d66` | | QianSwapRouter | `0xEE540d2496cfFDf17E29a04bcd7b6dc795C61d66` |
| QianPay | `0xa3faE6c7e97506145c29f2E7a887b345f49Cf36B` |
| Create2Factory | `0x175ae2c5EC7edaD3eE88e892C677bBDE815A2803` | | Create2Factory | `0x175ae2c5EC7edaD3eE88e892C677bBDE815A2803` |
| GuardHook | `0xa27D4eef437Ab754074c8C9E6bCbC55184dE2000` | | 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 - **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 at launch. It lives in the PoolKey, so it never changes after that. V3 pools
use the fixed 1% tier. 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, - **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. 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-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`, `QianSwapRouter`, and `QianPay`, plus `GuardHook`
- `chains/`: chain config and deployed addresses - `chains/`: chain config and deployed addresses
## Build ## 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 {}
}