diff --git a/contracts/StableSwapRouter.sol b/contracts/StableSwapRouter.sol new file mode 100644 index 0000000..ae31062 --- /dev/null +++ b/contracts/StableSwapRouter.sol @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +/** + * @title StableSwapRouter + * @notice Parity swap router for CR8-USD and MUSD. + */ +contract StableSwapRouter is Ownable, ReentrancyGuard { + using SafeERC20 for IERC20; + + IERC20 public immutable cr8usd; + IERC20 public immutable musd; + + address public treasury; + uint256 public feeBps; // Default 5 bps + + uint256 public constant MAX_FEE_BPS = 1000; // 10% max fee + + uint256 public epochDuration = 1 days; + uint256 public defaultEpochLimit = 100_000 * 10**18; // $100K default limit + + // integrator address -> epoch limit (if 0, uses defaultEpochLimit) + mapping(address => uint256) public customLimits; + + // address -> epoch index -> swapped amount + mapping(address => mapping(uint256 => uint256)) public epochVolume; + + event Swap(address indexed user, address indexed tokenIn, address indexed tokenOut, uint256 amountIn, uint256 amountOut, uint256 fee); + event TreasuryUpdated(address newTreasury); + event FeeUpdated(uint256 newFeeBps); + event CustomLimitUpdated(address indexed integrator, uint256 newLimit); + + constructor( + address _cr8usd, + address _musd, + address _treasury, + uint256 _feeBps + ) Ownable(msg.sender) { + require(_cr8usd != address(0) && _musd != address(0), "Zero address"); + require(_treasury != address(0), "Zero treasury address"); + require(_feeBps <= MAX_FEE_BPS, "Fee too high"); + + cr8usd = IERC20(_cr8usd); + musd = IERC20(_musd); + treasury = _treasury; + feeBps = _feeBps; + } + + function _getCurrentEpoch() internal view returns (uint256) { + return block.timestamp / epochDuration; + } + + function _checkAndRecordLimit(address user, uint256 amount) internal { + uint256 epoch = _getCurrentEpoch(); + uint256 limit = customLimits[user] > 0 ? customLimits[user] : defaultEpochLimit; + + epochVolume[user][epoch] += amount; + require(epochVolume[user][epoch] <= limit, "Epoch volume limit exceeded"); + } + + function swapCR8USDtoMUSD(uint256 amount, address to) external nonReentrant { + require(amount > 0, "Zero amount"); + _checkAndRecordLimit(msg.sender, amount); + + uint256 fee = (amount * feeBps) / 10000; + uint256 outAmount = amount - fee; + + cr8usd.safeTransferFrom(msg.sender, address(this), amount); + if (fee > 0) { + cr8usd.safeTransfer(treasury, fee); + } + + musd.safeTransfer(to, outAmount); + + emit Swap(msg.sender, address(cr8usd), address(musd), amount, outAmount, fee); + } + + function swapMUSDtoCR8USD(uint256 amount, address to) external nonReentrant { + require(amount > 0, "Zero amount"); + _checkAndRecordLimit(msg.sender, amount); + + uint256 fee = (amount * feeBps) / 10000; + uint256 outAmount = amount - fee; + + musd.safeTransferFrom(msg.sender, address(this), amount); + if (fee > 0) { + musd.safeTransfer(treasury, fee); + } + + cr8usd.safeTransfer(to, outAmount); + + emit Swap(msg.sender, address(musd), address(cr8usd), amount, outAmount, fee); + } + + function setTreasury(address _treasury) external onlyOwner { + require(_treasury != address(0), "Zero address"); + treasury = _treasury; + emit TreasuryUpdated(_treasury); + } + + function setFeeBps(uint256 _feeBps) external onlyOwner { + require(_feeBps <= MAX_FEE_BPS, "Fee too high"); + feeBps = _feeBps; + emit FeeUpdated(_feeBps); + } + + function setCustomLimit(address integrator, uint256 limit) external onlyOwner { + customLimits[integrator] = limit; + emit CustomLimitUpdated(integrator, limit); + } +} diff --git a/switchboard/swap.py b/switchboard/swap.py new file mode 100644 index 0000000..4de8285 --- /dev/null +++ b/switchboard/swap.py @@ -0,0 +1,104 @@ +"""Thin client wrapper for StableSwapRouter. + +Allows AgentEscrow and other agent infrastructure to call the CR8-USD <-> MUSD +parity swap router inline as part of the settle flow. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any, Dict + + +class StableSwapError(Exception): + """Base error for StableSwap operations.""" + + +@dataclass +class SwapQuote: + """A quote for a parity swap.""" + token_in: str + token_out: str + amount_in: float + amount_out: float + fee: float + fee_bps: int + + +class StableSwapRouterClient: + """Client wrapper for StableSwapRouter contract. + + Generates payload data for interacting with the on-chain router. + """ + + # Standard function selectors (keccak256 signatures) + # swapCR8USDtoMUSD(uint256,address) -> 0x82f254e0 + # swapMUSDtoCR8USD(uint256,address) -> 0x5a18a994 + SWAP_CR8USD_TO_MUSD_SELECTOR = "0x82f254e0" + SWAP_MUSD_TO_CR8USD_SELECTOR = "0x5a18a994" + + def __init__( + self, + router_address: str, + cr8usd_address: str, + musd_address: str, + fee_bps: int = 5, + default_limit: float = 100_000.0 + ): + self.router_address = router_address + self.cr8usd_address = cr8usd_address + self.musd_address = musd_address + self.fee_bps = fee_bps + self.default_limit = default_limit + + def quote_swap(self, token_in: str, amount: float) -> SwapQuote: + """Calculate expected output and fee for a parity swap.""" + if amount <= 0: + raise StableSwapError("Amount must be positive") + + fee = amount * (self.fee_bps / 10000.0) + out = amount - fee + + token_out = self.musd_address if token_in.lower() == self.cr8usd_address.lower() else self.cr8usd_address + + return SwapQuote( + token_in=token_in, + token_out=token_out, + amount_in=amount, + amount_out=out, + fee=fee, + fee_bps=self.fee_bps + ) + + def _pad_hex(self, val: str | int, length: int = 64) -> str: + if isinstance(val, int): + val = hex(val) + val = val.replace("0x", "") + return val.zfill(length) + + def build_swap_cr8usd_to_musd_tx(self, amount_wei: int, recipient: str) -> Dict[str, Any]: + """Build transaction payload to swap CR8-USD to MUSD.""" + amount_hex = self._pad_hex(amount_wei) + recipient_hex = self._pad_hex(recipient) + + data = f"{self.SWAP_CR8USD_TO_MUSD_SELECTOR}{amount_hex}{recipient_hex}" + + return { + "to": self.router_address, + "data": data, + "value": 0 + } + + def build_swap_musd_to_cr8usd_tx(self, amount_wei: int, recipient: str) -> Dict[str, Any]: + """Build transaction payload to swap MUSD to CR8-USD.""" + amount_hex = self._pad_hex(amount_wei) + recipient_hex = self._pad_hex(recipient) + + data = f"{self.SWAP_MUSD_TO_CR8USD_SELECTOR}{amount_hex}{recipient_hex}" + + return { + "to": self.router_address, + "data": data, + "value": 0 + } diff --git a/test/StableSwapRouter.t.sol b/test/StableSwapRouter.t.sol new file mode 100644 index 0000000..cdd2242 --- /dev/null +++ b/test/StableSwapRouter.t.sol @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test, console2} from "forge-std/Test.sol"; +import {StableSwapRouter} from "../contracts/StableSwapRouter.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +contract MockToken is ERC20 { + constructor(string memory name, string memory symbol) ERC20(name, symbol) {} + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} + +contract StableSwapRouterTest is Test { + StableSwapRouter router; + MockToken cr8usd; + MockToken musd; + address treasury = address(0x111); + address user1 = address(0x222); + address user2 = address(0x333); + + function setUp() public { + cr8usd = new MockToken("CR8-USD", "CR8-USD"); + musd = new MockToken("MUSD", "MUSD"); + + router = new StableSwapRouter(address(cr8usd), address(musd), treasury, 5); + + // Mint tokens to router to act as reserve + cr8usd.mint(address(router), 1_000_000 * 10**18); + musd.mint(address(router), 1_000_000 * 10**18); + } + + function test_constructor() public view { + assertEq(address(router.cr8usd()), address(cr8usd)); + assertEq(address(router.musd()), address(musd)); + assertEq(router.treasury(), treasury); + assertEq(router.feeBps(), 5); + } + + function test_swapCR8USDtoMUSD() public { + uint256 swapAmount = 1000 * 10**18; + cr8usd.mint(user1, swapAmount); + + vm.startPrank(user1); + cr8usd.approve(address(router), swapAmount); + + uint256 expectedFee = swapAmount * 5 / 10000; + uint256 expectedOut = swapAmount - expectedFee; + + router.swapCR8USDtoMUSD(swapAmount, user2); + vm.stopPrank(); + + assertEq(cr8usd.balanceOf(user1), 0); + assertEq(cr8usd.balanceOf(treasury), expectedFee); + assertEq(cr8usd.balanceOf(address(router)), 1_000_000 * 10**18 + swapAmount - expectedFee); + + assertEq(musd.balanceOf(user2), expectedOut); + assertEq(musd.balanceOf(address(router)), 1_000_000 * 10**18 - expectedOut); + } + + function test_swapMUSDtoCR8USD() public { + uint256 swapAmount = 2000 * 10**18; + musd.mint(user1, swapAmount); + + vm.startPrank(user1); + musd.approve(address(router), swapAmount); + + uint256 expectedFee = swapAmount * 5 / 10000; + uint256 expectedOut = swapAmount - expectedFee; + + router.swapMUSDtoCR8USD(swapAmount, user2); + vm.stopPrank(); + + assertEq(musd.balanceOf(user1), 0); + assertEq(musd.balanceOf(treasury), expectedFee); + assertEq(musd.balanceOf(address(router)), 1_000_000 * 10**18 + swapAmount - expectedFee); + + assertEq(cr8usd.balanceOf(user2), expectedOut); + assertEq(cr8usd.balanceOf(address(router)), 1_000_000 * 10**18 - expectedOut); + } + + function test_rate_limit() public { + uint256 limit = router.defaultEpochLimit(); + uint256 overLimit = limit + 1; + + cr8usd.mint(user1, overLimit); + vm.startPrank(user1); + cr8usd.approve(address(router), overLimit); + + vm.expectRevert("Epoch volume limit exceeded"); + router.swapCR8USDtoMUSD(overLimit, user1); + vm.stopPrank(); + } +} diff --git a/tests/test_swap.py b/tests/test_swap.py new file mode 100644 index 0000000..efd27a2 --- /dev/null +++ b/tests/test_swap.py @@ -0,0 +1,33 @@ +import pytest +from switchboard.swap import StableSwapRouterClient, SwapQuote, StableSwapError + +def test_quote_swap(): + client = StableSwapRouterClient( + router_address="0x111", + cr8usd_address="0x222", + musd_address="0x333", + fee_bps=5 + ) + + quote = client.quote_swap(token_in="0x222", amount=100000.0) + assert quote.token_out == "0x333" + assert quote.fee == 50.0 + assert quote.amount_out == 99950.0 + +def test_quote_swap_zero_amount(): + client = StableSwapRouterClient("0x111", "0x222", "0x333") + with pytest.raises(StableSwapError): + client.quote_swap(token_in="0x222", amount=0) + +def test_build_swap_tx(): + client = StableSwapRouterClient( + router_address="0x111", + cr8usd_address="0x222", + musd_address="0x333" + ) + tx = client.build_swap_cr8usd_to_musd_tx(amount_wei=10**18, recipient="0x444") + assert tx["to"] == "0x111" + assert "0x82f254e0" in tx["data"] + assert "0x444".replace("0x", "").zfill(64) in tx["data"] + assert hex(10**18).replace("0x", "").zfill(64) in tx["data"] +