Qian on Arc Testnet

This commit is contained in:
Qian
2026-07-18 08:54:32 +00:00
commit 3a2e241006
8 changed files with 301 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Qian ([email protected])
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+60
View File
@@ -0,0 +1,60 @@
<div align="center">
<a href="https://qian.ag"><img src="https://qian.ag/branding/qian-glyph-2048.png" alt="Qian" width="120"></a>
# Qian Contracts (Arc Testnet)
**[qian.ag](https://qian.ag)** · [Explorer](https://testnet.arcscan.app) · [License: MIT](LICENSE)
</div>
Qian is a multichain exchange and payments layer for every asset. This repo
holds what Qian runs on [Arc](https://arc.io), Circle's stablecoin-native L1,
during its public testnet: QianPay, the payment-settlement contract behind
[qian.ag/pay](https://qian.ag/pay). MIT licensed. No proxies, and no admin key
over user funds.
Gas on Arc is USDC, so a payment settles without the payer ever holding a
separate gas token. The rest of the Qian stack follows when Arc reaches
mainnet.
## Deployed addresses
| Contract | Address |
| --- | --- |
| QianPay | `0x73Da198D58fFD43B5754a2d72D989cab1c9aF0ce` |
Source-verified on https://testnet.arcscan.app.
## How it works
- **QianPay** settles payment links: pay a fixed USDC amount straight to a
wallet. Stateless, no admin functions, 0.5% platform fee on the USDC side.
On chains with Uniswap V3 it also accepts any token a V3 path can turn into
USDC; Arc testnet has no canonical Uniswap deployment yet, so the
`swapRouter02` constructor slot holds an inert placeholder and only
`payUSDC` is active. Same source as the
[Base deployment](https://github.com/QianExchange/base-contracts).
## Layout
- `dtm-v4/contracts`: `QianPay` and the interfaces it calls
- `chains/`: chain config and the deployed address
## Build
Solidity 0.8.24 with the optimizer on (1 run, `viaIR` enabled). The exact
verified build lives on https://testnet.arcscan.app; this repo is the
plain-source mirror.
## Dependencies
OpenZeppelin (SafeERC20, ReentrancyGuard) and the Uniswap V3 router interface,
kept in-tree under `dtm-v4/contracts/interfaces`. No forked or vendored AMM
code.
## Security
See [SECURITY.md](SECURITY.md) for the security model, the design tradeoffs, and how to report a vulnerability.
## License
MIT. Contact: dev@qian.ag
+23
View File
@@ -0,0 +1,23 @@
# Security Policy
## Reporting a vulnerability
Email dev@qian.ag with the details and a proof of concept if you have one. Report it to us first and give us time to ship a fix before you disclose it.
## Security model
- **Immutable locker.** `QianLocker` holds the launch LP position and has no withdrawal function. Nobody can pull the liquidity.
- **No proxies or upgrades.** Every contract ships as final bytecode. The controller can repoint the treasury, fee sink, and hook whitelist, but no one can swap the contract logic.
- **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.
- **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.
## Accepted design tradeoffs
- **Liquidity stays locked forever.** There is no unlock, migration, or emergency withdrawal path anywhere in the code.
- **No launch-time trading protections.** No dead window, wallet cap, or anti-snipe tax. Every swap-time hook flag reads false.
- **Fee liquidation has no slippage floor.** `collectFees` swaps the platform's token-side share to WETH in the same call with no minimum-out. The amounts are tiny, and only the treasury's own revenue is exposed, never user or creator funds.
## Scope
Contracts under `dtm-v3/contracts` and `dtm-v4/contracts`, deployed on Robinhood Chain, Base, and Ethereum. We verify every core contract's source on each chain's block explorer.
+12
View File
@@ -0,0 +1,12 @@
{
"chain": "arc-testnet",
"chainId": 5042002,
"qianPay": "0x73Da198D58fFD43B5754a2d72D989cab1c9aF0ce",
"qianPayDeployer": "0x92B92aA9fa5AefC957ab77904f23cf14d7369fA7",
"qianPayTreasury": "0x92B92aA9fa5AefC957ab77904f23cf14d7369fA7",
"qianPaySwapRouter02": "0x3600000000000000000000000000000000000000",
"qianPaySwapRouter02Placeholder": true,
"qianPayBlock": 52409927,
"qianPayTx": "0xfad468b076cdb2bad4cdf26317c34fe3f71dcb97962887ed993d80254e984b70",
"qianPayDeployedAt": "2026-07-18"
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "Arc Testnet",
"slug": "arc-testnet",
"chainId": 5042002,
"rpcUrl": "https://rpc.testnet.arc.network",
"usdc": "0x3600000000000000000000000000000000000000",
"usdcNote": "ERC20 interface, 6 decimals. Native gas token is ALSO USDC but at 18 decimals precision (Arc-specific) - do not confuse the two.",
"swapRouter02": "0x3600000000000000000000000000000000000000",
"swapRouter02Placeholder": true,
"swapRouter02Note": "No canonical Uniswap V3 SwapRouter02 published in Arc testnet docs (docs.arc.io/arc/references/contract-addresses lists only USDC/Permit2/Multicall3/CREATE2Factory). Set to the USDC address itself as a deliberate inert placeholder: nonzero (passes QianPay's constructor zero-check), has no exactOutput function, so payWithToken always reverts loudly. payUSDC is unaffected.",
"explorerApi": "https://testnet.arcscan.app/api/v2/smart-contracts",
"explorerUrl": "https://testnet.arcscan.app",
"faucet": "https://faucet.circle.com"
}
+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 {}
}
+23
View File
@@ -0,0 +1,23 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
error BadRouterConfig();
error DeployDisabled();
error InsufficientDeployFee();
error DeployFeeTooHigh();
error InvalidFeeVault();
error InvalidConfig();
error InvalidReceiver();
error InvalidTickRange();
error NotController();
error Unauthorized();
error TransferFailed();
error InsufficientTokenBalance(uint256 required, uint256 available);
error HookNotApproved();
error HookAlreadyConfigured();
error InvalidTaxRate();
error InvalidThreshold();
error InvalidPoolFee();
error InsufficientOutput();
error Expired();
+13
View File
@@ -0,0 +1,13 @@
// SPDX-License-Identifier: MIT
// Qian Exchange - https://qian.ag
pragma solidity ^0.8.24;
interface IWETH {
function deposit() external payable;
function withdraw(uint256 amount) external;
function transfer(address to, uint256 amount) external returns (bool);
function transferFrom(address from, address to, uint256 amount) external returns (bool);
function approve(address spender, uint256 amount) external returns (bool);
function balanceOf(address account) external view returns (uint256);
}