Add QianPay: payment-link settlement
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
Reference in New Issue
Block a user