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` |
|
| 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
|
||||||
|
|||||||
@@ -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