Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions contracts/StableSwapRouter.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}
104 changes: 104 additions & 0 deletions switchboard/swap.py
Original file line number Diff line number Diff line change
@@ -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
}
96 changes: 96 additions & 0 deletions test/StableSwapRouter.t.sol
Original file line number Diff line number Diff line change
@@ -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();
}
}
33 changes: 33 additions & 0 deletions tests/test_swap.py
Original file line number Diff line number Diff line change
@@ -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"]