FujinRobinhoodSwapRouter
0x5c14466d977bf8a5477eb61e9628a5fe4318a177
Verification
Verified
v0.8.28+commit.7893614a
Type
Contract
10,609 bytes
ABI entries
45
11 read · 11 write
License
none
Contract information
- Address
- 0x5c14466d977bf8a5477eb61e9628a5fe4318a177
- Chain
- Robinhood Chain (4663)
- Compiler
- v0.8.28+commit.7893614a
- Optimization
- Enabled
- Creator
- 0x321249980b…45072D4577
- Creation tx
- 0xe1ad3bf214…1f60455462
Token
Not a token
This contract does not expose ERC-20 metadata.
Read contract (11)
BPS() → uint256
MAX_FEE_BPS() → uint256
accruedEthFees() → uint256
feeBps() → uint256
feeRecipient() → address
owner() → address
paused() → bool
pendingOwner() → address
swapRouter() → address
v2Router() → address
wNative() → address
Events (8)
FeeBpsUpdatedFeeRecipientUpdatedFeesWithdrawnNormalBuyNormalSellOwnershipTransferStartedOwnershipTransferredPausedUpdated
ABI
[
{
"inputs": [
{
"internalType": "address",
"name": "swapRouter_",
"type": "address"
},
{
"internalType": "address",
"name": "v2Router_",
"type": "address"
},
{
"internalType": "address",
"name": "wNative_",
"type": "address"
},
{
"internalType": "address",
"name": "feeRecipient_",
"type": "address"
},
{
"internalType": "uint256",
"name": "feeBps_",
"type": "uint256"
}
],
"stateMutability": "nonpayable",
"type": "constructor"
},
{
"inputs": [],
"name": "DeadlineExpired",
"type": "error"
},
{
"inputs": [],
"name": "EthTransferFailed",
"type": "error"
},
{
"inputs": [],
"name": "FeeTooHigh",
"type": "error"
},
{
"inputs": [],
"name": "InvalidPath",
"type": "error"
},
{
"inputs": [],
"name": "InvalidRecipient",
"type": "error"
},
{
"inputs": [],
"name": "NoFees",
"type": "error"
},
{
"inputs": [],
"name": "NotOwner",
"type": "error"
},
{
"inputs": [],
"name": "Paused",
"type": "error"
},
{
"inputs": [],
"name": "Reentrancy",
"type": "error"
},
{
"inputs": [],
"name": "SlippageExceeded",
"type": "error"
},
{
"inputs": [],
"name": "TokenTransferFailed",
"type": "error"
},
{
"inputs": [],
"name": "ZeroAddress",
"type": "error"
},
{
"inputs": [],
"name": "ZeroAmount",
"type": "error"
},
{
"anonymous": false,
"inputs": [
{
"indexed": false,
"internalType": "uint256",
"name": "oldFeeBps",
"type": "uint256"
},
{
"indexed": false,
"internalType": "uint256",
"name": "newFeeBps",
"type": "uint256"
}
],
"name": "FeeBpsUpdated",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "oldRecipient",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "newRecipient",
"type": "address"
}
],
"name": "FeeRecipientUpdated",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "to",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"name": "FeesWithdrawn",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "token",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "buyer",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "recipient",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "ethIn",
"type": "uint256"
},
{
"indexed": false,
"internalType": "uint256",
"name": "fujinFee",
"type": "uint256"
},
{
"indexed": false,
"internalType": "uint256",
"name": "tokensOut",
"type": "uint256"
},
{
"indexed": false,
"internalType": "bytes32",
"name": "quoteId",
"type": "bytes32"
},
{
"indexed": false,
"internalType": "string",
"name": "route",
"type": "string"
}
],
"name": "NormalBuy",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "token",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "seller",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "recipient",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "tokenAmount",
"type": "uint256"
},
{
"indexed": false,
"internalType": "uint256",
"name": "ethOut",
"type": "uint256"
},
{
"indexed": false,
"internalType": "uint256",
"name": "fujinFee",
"type": "uint256"
},
{
"indexed": false,
"internalType": "bytes32",
"name": "quoteId",
"type": "bytes32"
},
{
"indexed": false,
"internalType": "string",
"name": "route",
"type": "string"
}
],
"name": "NormalSell",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "previousOwner",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "newOwner",
"type": "address"
}
],
"name": "OwnershipTransferStarted",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "previousOwner",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "newOwner",
"type": "address"
}
],
"name": "OwnershipTransferred",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": false,
"internalType": "bool",
"name": "paused",
"type": "bool"
}
],
"name": "PausedUpdated",
"type": "event"
},
{
"inputs": [],
"name": "BPS",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "MAX_FEE_BPS",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "acceptOwnership",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [],
"name": "accruedEthFees",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address[]",
"name": "path",
"type": "address[]"
},
{
"internalType": "uint256",
"name": "minTokensOut",
"type": "uint256"
},
{
"internalType": "address",
"name": "recipient",
"type": "address"
},
{
"internalType": "uint256",
"name": "deadline",
"type": "uint256"
},
{
"internalType": "bytes32",
"name": "quoteId",
"type": "bytes32"
}
],
"name": "buyV2",
"outputs": [
{
"internalType": "uint256",
"name": "tokensOut",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "fujinFee",
"type": "uint256"
}
],
"stateMutability": "payable",
"type": "function"
},
{
"inputs": [
{
"internalType": "bytes",
"name": "path",
"type": "bytes"
},
{
"internalType": "address",
"name": "tokenOut",
"type": "address"
},
{
"internalType": "uint256",
"name": "minTokensOut",
"type": "uint256"
},
{
"internalType": "address",
"name": "recipient",
"type": "address"
},
{
"internalType": "uint256",
"name": "deadline",
"type": "uint256"
},
{
"internalType": "bytes32",
"name": "quoteId",
"type": "bytes32"
}
],
"name": "buyV3Path",
"outputs": [
{
"internalType": "uint256",
"name": "tokensOut",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "fujinFee",
"type": "uint256"
}
],
"stateMutability": "payable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "token",
"type": "address"
},
{
"internalType": "uint24",
"name": "poolFee",
"type": "uint24"
},
{
"internalType": "uint256",
"name": "minTokensOut",
"type": "uint256"
},
{
"internalType": "address",
"name": "recipient",
"type": "address"
},
{
"internalType": "uint256",
"name": "deadline",
"type": "uint256"
},
{
"internalType": "bytes32",
"name": "quoteId",
"type": "bytes32"
}
],
"name": "buyV3Single",
"outputs": [
{
"internalType": "uint256",
"name": "tokensOut",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "fujinFee",
"type": "uint256"
}
],
"stateMutability": "payable",
"type": "function"
},
{
"inputs": [],
"name": "feeBps",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "feeRecipient",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "owner",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "paused",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "pendingOwner",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "token",
"type": "address"
},
{
"internalType": "uint256",
"name": "tokenAmount",
"type": "uint256"
},
{
"internalType": "address[]",
"name": "path",
"type": "address[]"
},
{
"internalType": "uint256",
"name": "minSwapEthOut",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "minRecipientEthOut",
"type": "uint256"
},
{
"internalType": "address",
"name": "recipient",
"type": "address"
},
{
"internalType": "uint256",
"name": "deadline",
"type": "uint256"
},
{
"internalType": "bytes32",
"name": "quoteId",
"type": "bytes32"
}
],
"name": "sellV2",
"outputs": [
{
"internalType": "uint256",
"name": "recipientEthOut",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "fujinFee",
"type": "uint256"
}
],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "token",
"type": "address"
},
{
"internalType": "uint256",
"name": "tokenAmount",
"type": "uint256"
},
{
"internalType": "bytes",
"name": "path",
"type": "bytes"
},
{
"internalType": "uint256",
"name": "minSwapEthOut",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "minRecipientEthOut",
"type": "uint256"
},
{
"internalType": "address",
"name": "recipient",
"type": "address"
},
{
"internalType": "uint256",
"name": "deadline",
"type": "uint256"
},
{
"internalType": "bytes32",
"name": "quoteId",
"type": "bytes32"
}
],
"name": "sellV3Path",
"outputs": [
{
"internalType": "uint256",
"name": "recipientEthOut",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "fujinFee",
"type": "uint256"
}
],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "token",
"type": "address"
},
{
"internalType": "uint256",
"name": "tokenAmount",
"type": "uint256"
},
{
"internalType": "uint24",
"name": "poolFee",
"type": "uint24"
},
{
"internalType": "uint256",
"name": "minSwapEthOut",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "minRecipientEthOut",
"type": "uint256"
},
{
"internalType": "address",
"name": "recipient",
"type": "address"
},
{
"internalType": "uint256",
"name": "deadline",
"type": "uint256"
},
{
"internalType": "bytes32",
"name": "quoteId",
"type": "bytes32"
}
],
"name": "sellV3Single",
"outputs": [
{
"internalType": "uint256",
"name": "recipientEthOut",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "fujinFee",
"type": "uint256"
}
],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "newFeeRecipient",
"type": "address"
}
],
"name": "setFeeRecipient",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "bool",
"name": "newPaused",
"type": "bool"
}
],
"name": "setPaused",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [],
"name": "swapRouter",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "newOwner",
"type": "address"
}
],
"name": "transferOwnership",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [],
"name": "v2Router",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "wNative",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"name": "withdrawFees",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"stateMutability": "payable",
"type": "receive"
}
]Source code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
interface IERC20 {
function balanceOf(address account) external view returns (uint256);
function allowance(address owner, address spender) external view returns (uint256);
function approve(address spender, uint256 amount) external returns (bool);
function transfer(address to, uint256 amount) external returns (bool);
function transferFrom(address from, address to, uint256 amount) external returns (bool);
}
interface IHoodLaunchpad {
function buy(address token, uint256 minTokensOut) external payable;
function sell(address token, uint256 tokenAmount, uint256 minEthOut) external;
function isHoodToken(address token) external view returns (bool);
function quoteBuy(address token, uint256 ethIn)
external
view
returns (uint256 tokensOut, uint256 fee, uint256 ethUsed);
function quoteSell(address token, uint256 tokenAmount) external view returns (uint256 ethOut, uint256 fee);
}
interface IWETH is IERC20 {
function withdraw(uint256 amount) external;
}
interface IUniV2Router {
function swapExactETHForTokensSupportingFeeOnTransferTokens(
uint256 amountOutMin,
address[] calldata path,
address to,
uint256 deadline
) external payable;
function swapExactTokensForETHSupportingFeeOnTransferTokens(
uint256 amountIn,
uint256 amountOutMin,
address[] calldata path,
address to,
uint256 deadline
) external;
}
interface IUniV3SwapRouter {
struct ExactInputSingleParams {
address tokenIn;
address tokenOut;
uint24 fee;
address recipient;
uint256 amountIn;
uint256 amountOutMinimum;
uint160 sqrtPriceLimitX96;
}
struct ExactInputParams {
bytes path;
address recipient;
uint256 amountIn;
uint256 amountOutMinimum;
}
function exactInputSingle(ExactInputSingleParams calldata params)
external
payable
returns (uint256 amountOut);
function exactInput(ExactInputParams calldata params)
external
payable
returns (uint256 amountOut);
}
/// @notice Minimal non-custodial router for hood.fun curve trades through Fujin.
/// @dev HoodLaunchpad.buy() sends bought tokens to msg.sender, so this router
/// receives buy output first, then forwards it to the user atomically.
/// Consequence: Hood's Trade event and guard accounting see this router as
/// the direct trader. Keep that in mind for early-block/anti-snipe limits.
contract FujinHoodRouter {
uint256 public constant BPS = 10_000;
uint256 public constant MAX_FEE_BPS = 1_000; // 10%
address public immutable hoodLaunchpad;
address public owner;
address public pendingOwner;
address public feeRecipient;
uint256 public immutable feeBps;
uint256 public accruedEthFees;
bool public paused;
uint256 private locked = 1;
event HoodBuy(
address indexed token,
address indexed buyer,
address indexed recipient,
uint256 ethIn,
uint256 fujinFee,
uint256 refund,
uint256 tokensOut,
bytes32 quoteId
);
event HoodSell(
address indexed token,
address indexed seller,
address indexed recipient,
uint256 tokenAmount,
uint256 ethOut,
uint256 fujinFee,
bytes32 quoteId
);
event FeeBpsUpdated(uint256 oldFeeBps, uint256 newFeeBps);
event FeeRecipientUpdated(address indexed oldRecipient, address indexed newRecipient);
event FeesWithdrawn(address indexed to, uint256 amount);
event PausedUpdated(bool paused);
event OwnershipTransferStarted(address indexed previousOwner, address indexed newOwner);
event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);
error DeadlineExpired();
error EthTransferFailed();
error FeeTooHigh();
error InvalidRecipient();
error InvalidToken();
error NoFees();
error NotOwner();
error Paused();
error Reentrancy();
error SlippageExceeded();
error TokenTransferFailed();
error ZeroAddress();
error ZeroAmount();
modifier onlyOwner() {
if (msg.sender != owner) revert NotOwner();
_;
}
modifier nonReentrant() {
if (locked != 1) revert Reentrancy();
locked = 2;
_;
locked = 1;
}
modifier whenNotPaused() {
if (paused) revert Paused();
_;
}
constructor(address hoodLaunchpad_, address feeRecipient_, uint256 feeBps_) {
if (hoodLaunchpad_ == address(0) || feeRecipient_ == address(0)) revert ZeroAddress();
if (feeBps_ > MAX_FEE_BPS) revert FeeTooHigh();
hoodLaunchpad = hoodLaunchpad_;
owner = msg.sender;
feeRecipient = feeRecipient_;
feeBps = feeBps_;
emit OwnershipTransferred(address(0), msg.sender);
emit FeeRecipientUpdated(address(0), feeRecipient_);
emit FeeBpsUpdated(0, feeBps_);
}
receive() external payable {
if (msg.sender != hoodLaunchpad) revert InvalidRecipient();
}
function quoteBuyAfterFujinFee(address token, uint256 ethIn)
external
view
returns (uint256 tokensOut, uint256 hoodFee, uint256 ethUsed, uint256 fujinFee, uint256 ethToCurve)
{
if (ethIn == 0) return (0, 0, 0, 0, 0);
fujinFee = (ethIn * feeBps) / BPS;
ethToCurve = ethIn - fujinFee;
(tokensOut, hoodFee, ethUsed) = IHoodLaunchpad(hoodLaunchpad).quoteBuy(token, ethToCurve);
}
function quoteSellAfterFujinFee(address token, uint256 tokenAmount)
external
view
returns (uint256 recipientEthOut, uint256 hoodFee, uint256 fujinFee, uint256 launchpadEthOut)
{
if (tokenAmount == 0) return (0, 0, 0, 0);
(launchpadEthOut, hoodFee) = IHoodLaunchpad(hoodLaunchpad).quoteSell(token, tokenAmount);
fujinFee = (launchpadEthOut * feeBps) / BPS;
recipientEthOut = launchpadEthOut - fujinFee;
}
function buyHoodCurve(
address token,
uint256 minTokensOut,
address recipient,
uint256 deadline,
bytes32 quoteId
) external payable nonReentrant whenNotPaused returns (uint256 tokensOut, uint256 refund) {
if (block.timestamp > deadline) revert DeadlineExpired();
if (msg.value == 0) revert ZeroAmount();
if (recipient == address(0)) revert ZeroAddress();
if (!IHoodLaunchpad(hoodLaunchpad).isHoodToken(token)) revert InvalidToken();
uint256 balanceBefore = address(this).balance - msg.value;
uint256 tokenBefore = IERC20(token).balanceOf(address(this));
uint256 fujinFee = (msg.value * feeBps) / BPS;
uint256 ethToCurve = msg.value - fujinFee;
if (ethToCurve == 0) revert ZeroAmount();
IHoodLaunchpad(hoodLaunchpad).buy{value: ethToCurve}(token, minTokensOut);
tokensOut = IERC20(token).balanceOf(address(this)) - tokenBefore;
if (tokensOut < minTokensOut || tokensOut == 0) revert SlippageExceeded();
uint256 expectedRouterBalance = balanceBefore + fujinFee;
if (address(this).balance > expectedRouterBalance) {
refund = address(this).balance - expectedRouterBalance;
}
accruedEthFees += fujinFee;
_safeTransfer(token, recipient, tokensOut);
if (refund != 0) _sendEth(recipient, refund);
emit HoodBuy(token, msg.sender, recipient, msg.value, fujinFee, refund, tokensOut, quoteId);
}
function sellHoodCurve(
address token,
uint256 tokenAmount,
uint256 minLaunchpadEthOut,
uint256 minRecipientEthOut,
address recipient,
uint256 deadline,
bytes32 quoteId
) external nonReentrant whenNotPaused returns (uint256 recipientEthOut, uint256 fujinFee) {
if (block.timestamp > deadline) revert DeadlineExpired();
if (tokenAmount == 0) revert ZeroAmount();
if (recipient == address(0)) revert ZeroAddress();
if (!IHoodLaunchpad(hoodLaunchpad).isHoodToken(token)) revert InvalidToken();
uint256 balanceBefore = address(this).balance;
_safeTransferFrom(token, msg.sender, address(this), tokenAmount);
_forceApprove(token, hoodLaunchpad, tokenAmount);
IHoodLaunchpad(hoodLaunchpad).sell(token, tokenAmount, minLaunchpadEthOut);
uint256 launchpadEthOut = address(this).balance - balanceBefore;
fujinFee = (launchpadEthOut * feeBps) / BPS;
recipientEthOut = launchpadEthOut - fujinFee;
if (recipientEthOut < minRecipientEthOut || recipientEthOut == 0) revert SlippageExceeded();
accruedEthFees += fujinFee;
_sendEth(recipient, recipientEthOut);
emit HoodSell(token, msg.sender, recipient, tokenAmount, launchpadEthOut, fujinFee, quoteId);
}
function withdrawFees(uint256 amount) external onlyOwner nonReentrant {
uint256 toWithdraw = amount == 0 ? accruedEthFees : amount;
if (toWithdraw == 0 || toWithdraw > accruedEthFees) revert NoFees();
accruedEthFees -= toWithdraw;
_sendEth(feeRecipient, toWithdraw);
emit FeesWithdrawn(feeRecipient, toWithdraw);
}
function setFeeRecipient(address newFeeRecipient) external onlyOwner {
if (newFeeRecipient == address(0)) revert ZeroAddress();
emit FeeRecipientUpdated(feeRecipient, newFeeRecipient);
feeRecipient = newFeeRecipient;
}
function setPaused(bool newPaused) external onlyOwner {
paused = newPaused;
emit PausedUpdated(newPaused);
}
function transferOwnership(address newOwner) external onlyOwner {
if (newOwner == address(0)) revert ZeroAddress();
pendingOwner = newOwner;
emit OwnershipTransferStarted(owner, newOwner);
}
function acceptOwnership() external {
if (msg.sender != pendingOwner) revert NotOwner();
address oldOwner = owner;
owner = msg.sender;
pendingOwner = address(0);
emit OwnershipTransferred(oldOwner, msg.sender);
}
function _sendEth(address to, uint256 amount) internal {
(bool ok,) = to.call{value: amount}("");
if (!ok) revert EthTransferFailed();
}
function _safeTransfer(address token, address to, uint256 amount) internal {
(bool ok, bytes memory data) = token.call(abi.encodeCall(IERC20.transfer, (to, amount)));
if (!ok || (data.length != 0 && !abi.decode(data, (bool)))) revert TokenTransferFailed();
}
function _safeTransferFrom(address token, address from, address to, uint256 amount) internal {
(bool ok, bytes memory data) = token.call(abi.encodeCall(IERC20.transferFrom, (from, to, amount)));
if (!ok || (data.length != 0 && !abi.decode(data, (bool)))) revert TokenTransferFailed();
}
function _forceApprove(address token, address spender, uint256 amount) internal {
if (IERC20(token).allowance(address(this), spender) >= amount) return;
_callApprove(token, spender, 0);
_callApprove(token, spender, amount);
}
function _callApprove(address token, address spender, uint256 amount) internal {
(bool ok, bytes memory data) = token.call(abi.encodeCall(IERC20.approve, (spender, amount)));
if (!ok || (data.length != 0 && !abi.decode(data, (bool)))) revert TokenTransferFailed();
}
}
/// @notice Non-custodial Fujin router for normal/graduated Robinhood token swaps.
/// @dev Deliberately narrow: only configured Robinhood V2/V3 routers are callable.
contract FujinRobinhoodSwapRouter {
uint256 public constant BPS = 10_000;
uint256 public constant MAX_FEE_BPS = 1_000; // 10%
address public immutable swapRouter;
address public immutable v2Router;
address public immutable wNative;
address public owner;
address public pendingOwner;
address public feeRecipient;
uint256 public immutable feeBps;
uint256 public accruedEthFees;
bool public paused;
uint256 private locked = 1;
event NormalBuy(
address indexed token,
address indexed buyer,
address indexed recipient,
uint256 ethIn,
uint256 fujinFee,
uint256 tokensOut,
bytes32 quoteId,
string route
);
event NormalSell(
address indexed token,
address indexed seller,
address indexed recipient,
uint256 tokenAmount,
uint256 ethOut,
uint256 fujinFee,
bytes32 quoteId,
string route
);
event FeeBpsUpdated(uint256 oldFeeBps, uint256 newFeeBps);
event FeeRecipientUpdated(address indexed oldRecipient, address indexed newRecipient);
event FeesWithdrawn(address indexed to, uint256 amount);
event PausedUpdated(bool paused);
event OwnershipTransferStarted(address indexed previousOwner, address indexed newOwner);
event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);
error DeadlineExpired();
error EthTransferFailed();
error FeeTooHigh();
error InvalidPath();
error InvalidRecipient();
error NoFees();
error NotOwner();
error Paused();
error Reentrancy();
error SlippageExceeded();
error TokenTransferFailed();
error ZeroAddress();
error ZeroAmount();
modifier onlyOwner() {
if (msg.sender != owner) revert NotOwner();
_;
}
modifier nonReentrant() {
if (locked != 1) revert Reentrancy();
locked = 2;
_;
locked = 1;
}
modifier whenNotPaused() {
if (paused) revert Paused();
_;
}
constructor(
address swapRouter_,
address v2Router_,
address wNative_,
address feeRecipient_,
uint256 feeBps_
) {
if (swapRouter_ == address(0) || v2Router_ == address(0) || wNative_ == address(0) || feeRecipient_ == address(0)) {
revert ZeroAddress();
}
if (feeBps_ > MAX_FEE_BPS) revert FeeTooHigh();
swapRouter = swapRouter_;
v2Router = v2Router_;
wNative = wNative_;
feeRecipient = feeRecipient_;
feeBps = feeBps_;
owner = msg.sender;
emit OwnershipTransferred(address(0), msg.sender);
emit FeeRecipientUpdated(address(0), feeRecipient_);
emit FeeBpsUpdated(0, feeBps_);
}
receive() external payable {
if (msg.sender != wNative && msg.sender != v2Router && msg.sender != swapRouter) {
revert InvalidRecipient();
}
}
function buyV3Single(
address token,
uint24 poolFee,
uint256 minTokensOut,
address recipient,
uint256 deadline,
bytes32 quoteId
) external payable nonReentrant whenNotPaused returns (uint256 tokensOut, uint256 fujinFee) {
_checkTrade(recipient, deadline);
if (token == address(0) || token == wNative) revert InvalidPath();
uint256 tokenBefore = IERC20(token).balanceOf(recipient);
uint256 ethToSwap = _takeBuyFee(msg.value);
fujinFee = msg.value - ethToSwap;
IUniV3SwapRouter(swapRouter).exactInputSingle{value: ethToSwap}(
IUniV3SwapRouter.ExactInputSingleParams({
tokenIn: wNative,
tokenOut: token,
fee: poolFee,
recipient: recipient,
amountIn: ethToSwap,
amountOutMinimum: minTokensOut,
sqrtPriceLimitX96: 0
})
);
tokensOut = IERC20(token).balanceOf(recipient) - tokenBefore;
if (tokensOut < minTokensOut || tokensOut == 0) revert SlippageExceeded();
emit NormalBuy(token, msg.sender, recipient, msg.value, fujinFee, tokensOut, quoteId, "v3");
}
function buyV3Path(
bytes calldata path,
address tokenOut,
uint256 minTokensOut,
address recipient,
uint256 deadline,
bytes32 quoteId
) external payable nonReentrant whenNotPaused returns (uint256 tokensOut, uint256 fujinFee) {
_checkTrade(recipient, deadline);
_requireV3Path(path, wNative, tokenOut);
if (tokenOut == address(0) || tokenOut == wNative) revert InvalidPath();
uint256 tokenBefore = IERC20(tokenOut).balanceOf(recipient);
uint256 ethToSwap = _takeBuyFee(msg.value);
fujinFee = msg.value - ethToSwap;
IUniV3SwapRouter(swapRouter).exactInput{value: ethToSwap}(
IUniV3SwapRouter.ExactInputParams({
path: path,
recipient: recipient,
amountIn: ethToSwap,
amountOutMinimum: minTokensOut
})
);
tokensOut = IERC20(tokenOut).balanceOf(recipient) - tokenBefore;
if (tokensOut < minTokensOut || tokensOut == 0) revert SlippageExceeded();
emit NormalBuy(tokenOut, msg.sender, recipient, msg.value, fujinFee, tokensOut, quoteId, "v3-path");
}
function buyV2(
address[] calldata path,
uint256 minTokensOut,
address recipient,
uint256 deadline,
bytes32 quoteId
) external payable nonReentrant whenNotPaused returns (uint256 tokensOut, uint256 fujinFee) {
_checkTrade(recipient, deadline);
if (path.length < 2) revert InvalidPath();
address tokenOut = path[path.length - 1];
_requireV2Path(path, wNative, tokenOut);
if (tokenOut == wNative) revert InvalidPath();
uint256 tokenBefore = IERC20(tokenOut).balanceOf(recipient);
uint256 ethToSwap = _takeBuyFee(msg.value);
fujinFee = msg.value - ethToSwap;
IUniV2Router(v2Router).swapExactETHForTokensSupportingFeeOnTransferTokens{value: ethToSwap}(
minTokensOut,
path,
recipient,
deadline
);
tokensOut = IERC20(tokenOut).balanceOf(recipient) - tokenBefore;
if (tokensOut < minTokensOut || tokensOut == 0) revert SlippageExceeded();
emit NormalBuy(tokenOut, msg.sender, recipient, msg.value, fujinFee, tokensOut, quoteId, "v2");
}
function sellV3Single(
address token,
uint256 tokenAmount,
uint24 poolFee,
uint256 minSwapEthOut,
uint256 minRecipientEthOut,
address recipient,
uint256 deadline,
bytes32 quoteId
) external nonReentrant whenNotPaused returns (uint256 recipientEthOut, uint256 fujinFee) {
_checkTrade(recipient, deadline);
if (token == address(0) || token == wNative || tokenAmount == 0) revert InvalidPath();
_safeTransferFrom(token, msg.sender, address(this), tokenAmount);
_forceApprove(token, swapRouter, tokenAmount);
uint256 wethBefore = IERC20(wNative).balanceOf(address(this));
IUniV3SwapRouter(swapRouter).exactInputSingle(
IUniV3SwapRouter.ExactInputSingleParams({
tokenIn: token,
tokenOut: wNative,
fee: poolFee,
recipient: address(this),
amountIn: tokenAmount,
amountOutMinimum: minSwapEthOut,
sqrtPriceLimitX96: 0
})
);
uint256 wethOut = IERC20(wNative).balanceOf(address(this)) - wethBefore;
(recipientEthOut, fujinFee) = _unwrapAndPay(recipient, wethOut, minRecipientEthOut);
emit NormalSell(token, msg.sender, recipient, tokenAmount, wethOut, fujinFee, quoteId, "v3");
}
function sellV3Path(
address token,
uint256 tokenAmount,
bytes calldata path,
uint256 minSwapEthOut,
uint256 minRecipientEthOut,
address recipient,
uint256 deadline,
bytes32 quoteId
) external nonReentrant whenNotPaused returns (uint256 recipientEthOut, uint256 fujinFee) {
_checkTrade(recipient, deadline);
if (token == address(0) || token == wNative || tokenAmount == 0) revert InvalidPath();
_requireV3Path(path, token, wNative);
_safeTransferFrom(token, msg.sender, address(this), tokenAmount);
_forceApprove(token, swapRouter, tokenAmount);
uint256 wethBefore = IERC20(wNative).balanceOf(address(this));
IUniV3SwapRouter(swapRouter).exactInput(
IUniV3SwapRouter.ExactInputParams({
path: path,
recipient: address(this),
amountIn: tokenAmount,
amountOutMinimum: minSwapEthOut
})
);
uint256 wethOut = IERC20(wNative).balanceOf(address(this)) - wethBefore;
(recipientEthOut, fujinFee) = _unwrapAndPay(recipient, wethOut, minRecipientEthOut);
emit NormalSell(token, msg.sender, recipient, tokenAmount, wethOut, fujinFee, quoteId, "v3-path");
}
function sellV2(
address token,
uint256 tokenAmount,
address[] calldata path,
uint256 minSwapEthOut,
uint256 minRecipientEthOut,
address recipient,
uint256 deadline,
bytes32 quoteId
) external nonReentrant whenNotPaused returns (uint256 recipientEthOut, uint256 fujinFee) {
_checkTrade(recipient, deadline);
if (token == address(0) || token == wNative || tokenAmount == 0) revert InvalidPath();
_requireV2Path(path, token, wNative);
uint256 ethBefore = address(this).balance;
_safeTransferFrom(token, msg.sender, address(this), tokenAmount);
_forceApprove(token, v2Router, tokenAmount);
IUniV2Router(v2Router).swapExactTokensForETHSupportingFeeOnTransferTokens(
tokenAmount,
minSwapEthOut,
path,
address(this),
deadline
);
uint256 ethOut = address(this).balance - ethBefore;
(recipientEthOut, fujinFee) = _payEthAfterFee(recipient, ethOut, minRecipientEthOut);
emit NormalSell(token, msg.sender, recipient, tokenAmount, ethOut, fujinFee, quoteId, "v2");
}
function withdrawFees(uint256 amount) external onlyOwner nonReentrant {
uint256 toWithdraw = amount == 0 ? accruedEthFees : amount;
if (toWithdraw == 0 || toWithdraw > accruedEthFees) revert NoFees();
accruedEthFees -= toWithdraw;
_sendEth(feeRecipient, toWithdraw);
emit FeesWithdrawn(feeRecipient, toWithdraw);
}
function setFeeRecipient(address newFeeRecipient) external onlyOwner {
if (newFeeRecipient == address(0)) revert ZeroAddress();
emit FeeRecipientUpdated(feeRecipient, newFeeRecipient);
feeRecipient = newFeeRecipient;
}
function setPaused(bool newPaused) external onlyOwner {
paused = newPaused;
emit PausedUpdated(newPaused);
}
function transferOwnership(address newOwner) external onlyOwner {
if (newOwner == address(0)) revert ZeroAddress();
pendingOwner = newOwner;
emit OwnershipTransferStarted(owner, newOwner);
}
function acceptOwnership() external {
if (msg.sender != pendingOwner) revert NotOwner();
address oldOwner = owner;
owner = msg.sender;
pendingOwner = address(0);
emit OwnershipTransferred(oldOwner, msg.sender);
}
function _checkTrade(address recipient, uint256 deadline) internal view {
if (block.timestamp > deadline) revert DeadlineExpired();
if (recipient == address(0)) revert ZeroAddress();
}
function _takeBuyFee(uint256 ethIn) internal returns (uint256 ethToSwap) {
if (ethIn == 0) revert ZeroAmount();
uint256 fujinFee = (ethIn * feeBps) / BPS;
ethToSwap = ethIn - fujinFee;
if (ethToSwap == 0) revert ZeroAmount();
accruedEthFees += fujinFee;
}
function _unwrapAndPay(address recipient, uint256 wethOut, uint256 minRecipientEthOut)
internal
returns (uint256 recipientEthOut, uint256 fujinFee)
{
if (wethOut == 0) revert SlippageExceeded();
uint256 ethBefore = address(this).balance;
IWETH(wNative).withdraw(wethOut);
uint256 ethOut = address(this).balance - ethBefore;
return _payEthAfterFee(recipient, ethOut, minRecipientEthOut);
}
function _payEthAfterFee(address recipient, uint256 ethOut, uint256 minRecipientEthOut)
internal
returns (uint256 recipientEthOut, uint256 fujinFee)
{
if (ethOut == 0) revert SlippageExceeded();
fujinFee = (ethOut * feeBps) / BPS;
recipientEthOut = ethOut - fujinFee;
if (recipientEthOut < minRecipientEthOut || recipientEthOut == 0) revert SlippageExceeded();
accruedEthFees += fujinFee;
_sendEth(recipient, recipientEthOut);
}
function _requireV2Path(address[] calldata path, address expectedIn, address expectedOut) internal pure {
if (path.length < 2) revert InvalidPath();
if (path[0] != expectedIn || path[path.length - 1] != expectedOut) revert InvalidPath();
}
function _requireV3Path(bytes calldata path, address expectedIn, address expectedOut) internal pure {
if (path.length < 43 || (path.length - 20) % 23 != 0) revert InvalidPath();
if (_pathAddress(path, 0) != expectedIn) revert InvalidPath();
if (_pathAddress(path, path.length - 20) != expectedOut) revert InvalidPath();
}
function _pathAddress(bytes calldata path, uint256 offset) internal pure returns (address token) {
assembly {
token := shr(96, calldataload(add(path.offset, offset)))
}
}
function _sendEth(address to, uint256 amount) internal {
(bool ok,) = to.call{value: amount}("");
if (!ok) revert EthTransferFailed();
}
function _safeTransferFrom(address token, address from, address to, uint256 amount) internal {
(bool ok, bytes memory data) = token.call(abi.encodeCall(IERC20.transferFrom, (from, to, amount)));
if (!ok || (data.length != 0 && !abi.decode(data, (bool)))) revert TokenTransferFailed();
}
function _forceApprove(address token, address spender, uint256 amount) internal {
if (IERC20(token).allowance(address(this), spender) >= amount) return;
_callApprove(token, spender, 0);
_callApprove(token, spender, amount);
}
function _callApprove(address token, address spender, uint256 amount) internal {
(bool ok, bytes memory data) = token.call(abi.encodeCall(IERC20.approve, (spender, amount)));
if (!ok || (data.length != 0 && !abi.decode(data, (bool)))) revert TokenTransferFailed();
}
}
Chain explorer3013msChain node90ms