KATF Contract source

Contract source

The full code of Kata’s own contracts. Nothing is hidden and nothing can be upgraded.

  • Solidity 0.8.28, OpenZeppelin Contracts 5.4, optimizer on (10,000 runs), EVM version Cancun.
  • No proxy: the code below is the code on-chain for as long as the contracts exist.
  • Both contracts are owned by one public timelock. Every admin action waits 72 hours on-chain and can be cancelled by a separate guardian wallet.
  • KATF itself is the Argus launch token; its code is Argus’s.

KataDojo

Holds staked KATF and reports each wallet’s rank. The owner (a 72-hour timelock) can only change the rank thresholds.

Address0xD0539Da395695634Dd390F907f0e64E3Bd1e4853
VerifiedVerified on Sourcify (exact match to the deployed bytecode)
FileKataDojo.sol · 157 lines
SHA-256b9a35ab7b937c02b84946998e002c802102f8252161949117351655d970808be
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {Ownable, Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

/// @title Kata Finance Dojo
/// @notice Stake KATF to lower the Kata swap fee. The tier of a wallet depends only on its staked
///         balance; the Kata app reads `tierOf` and prices the fee from it.
/// @dev The owner (a 72-hour timelock) can only change the tier thresholds. No function lets the
///      owner or anyone else move a staker's KATF. Not upgradeable.
contract KataDojo is Ownable2Step, ReentrancyGuard {
    using SafeERC20 for IERC20;

    /// @notice Time between requesting an unstake and being able to withdraw it.
    uint256 public constant COOLDOWN = 7 days;

    IERC20 public immutable token;

    /// @notice Minimum stake for tiers 1, 2 and 3 (Kyu, Shodan, Godan). Strictly increasing.
    uint256[3] private _thresholds;

    struct Pending {
        uint256 amount;
        uint64 unlockAt;
    }

    mapping(address account => uint256) public staked;
    mapping(address account => Pending) public pending;

    uint256 public totalStaked;
    uint256 public totalPending;

    event Staked(address indexed payer, address indexed account, uint256 amount);
    event UnstakeRequested(address indexed account, uint256 amount, uint256 pendingTotal, uint64 unlockAt);
    event UnstakeCancelled(address indexed account, uint256 amount);
    event Withdrawn(address indexed account, uint256 amount);
    event ThresholdsUpdated(uint256[3] thresholds);

    error ZeroAmount();
    error ZeroAddress();
    error InsufficientStake();
    error NothingPending();
    error StillLocked(uint64 unlockAt);
    error BadThresholds();

    constructor(IERC20 token_, uint256[3] memory thresholds_, address owner_) Ownable(owner_) {
        if (address(token_) == address(0)) revert ZeroAddress();
        token = token_;
        _setThresholds(thresholds_);
    }

    // ---------------------------------------------------------------- staking

    /// @notice Stake `amount` KATF for yourself.
    function stake(uint256 amount) external nonReentrant {
        _stake(msg.sender, amount);
    }

    /// @notice Stake `amount` KATF, paid by the caller, credited to `account`.
    /// @dev Used by KataRewards to claim straight into the Dojo. Crediting someone never harms them:
    ///      they can unstake it like any other stake.
    function stakeFor(address account, uint256 amount) external nonReentrant {
        if (account == address(0)) revert ZeroAddress();
        _stake(account, amount);
    }

    /// @notice Move `amount` from staked to pending. Pending KATF no longer counts for the tier and
    ///         can be withdrawn after the cooldown. A new request restarts the cooldown for the
    ///         whole pending amount.
    function requestUnstake(uint256 amount) external nonReentrant {
        if (amount == 0) revert ZeroAmount();
        uint256 current = staked[msg.sender];
        if (amount > current) revert InsufficientStake();

        staked[msg.sender] = current - amount;
        totalStaked -= amount;

        Pending storage p = pending[msg.sender];
        p.amount += amount;
        p.unlockAt = uint64(block.timestamp + COOLDOWN);
        totalPending += amount;

        emit UnstakeRequested(msg.sender, amount, p.amount, p.unlockAt);
    }

    /// @notice Put all pending KATF back into the stake.
    function cancelUnstake() external nonReentrant {
        uint256 amount = pending[msg.sender].amount;
        if (amount == 0) revert NothingPending();
        delete pending[msg.sender];
        totalPending -= amount;

        staked[msg.sender] += amount;
        totalStaked += amount;

        emit UnstakeCancelled(msg.sender, amount);
    }

    /// @notice Withdraw all pending KATF once the cooldown has passed.
    function withdraw() external nonReentrant {
        Pending memory p = pending[msg.sender];
        if (p.amount == 0) revert NothingPending();
        if (block.timestamp < p.unlockAt) revert StillLocked(p.unlockAt);

        delete pending[msg.sender];
        totalPending -= p.amount;
        token.safeTransfer(msg.sender, p.amount);

        emit Withdrawn(msg.sender, p.amount);
    }

    // ---------------------------------------------------------------- tiers

    /// @notice 0 = no tier, 1 = Kyu, 2 = Shodan, 3 = Godan.
    function tierOf(address account) external view returns (uint8) {
        uint256 s = staked[account];
        if (s >= _thresholds[2]) return 3;
        if (s >= _thresholds[1]) return 2;
        if (s >= _thresholds[0]) return 1;
        return 0;
    }

    function thresholds() external view returns (uint256[3] memory) {
        return _thresholds;
    }

    /// @notice Change the tier thresholds. Only the owner (the timelock) can call this.
    function setThresholds(uint256[3] calldata thresholds_) external onlyOwner {
        _setThresholds(thresholds_);
    }

    // ---------------------------------------------------------------- internal

    function _stake(address account, uint256 amount) private {
        if (amount == 0) revert ZeroAmount();
        // Credit what actually arrived, so a token with a transfer fee can never break accounting.
        uint256 before = token.balanceOf(address(this));
        token.safeTransferFrom(msg.sender, address(this), amount);
        uint256 received = token.balanceOf(address(this)) - before;
        if (received == 0) revert ZeroAmount();

        staked[account] += received;
        totalStaked += received;

        emit Staked(msg.sender, account, received);
    }

    function _setThresholds(uint256[3] memory t) private {
        if (t[0] == 0 || t[0] >= t[1] || t[1] >= t[2]) revert BadThresholds();
        _thresholds = t;
        emit ThresholdsUpdated(t);
    }
}

KataRewards

Holds season rewards and pays claims against a published Merkle list. Season funds can only leave through claims.

Address0x0156b16c1Af56E9d7e71fFCA4ef06f15CD3cb0F9
VerifiedVerified on Sourcify (exact match to the deployed bytecode)
FileKataRewards.sol · 180 lines
SHA-256068c3b7e69dd838d75164b27b4b40c41af5d53850c9e756745c93370a6bfb85f
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {Ownable, Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {MerkleProof} from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";

interface IKataDojo {
    function stakeFor(address account, uint256 amount) external;
}

/// @title Kata Finance Rewards
/// @notice Seasonal KATF rewards for wallets that swap on Kata. Each season has a Merkle root of
///         (account, amount); anyone in it claims their amount, or claims straight into the Dojo
///         for a bonus paid from `bonusReserve`.
/// @dev Leaves follow OpenZeppelin's StandardMerkleTree for ["address", "uint256"]:
///      keccak256(bytes.concat(keccak256(abi.encode(account, amount)))).
///      KATF held here can only leave through claims and bonus top-ups; the owner can take back
///      only the unused bonus reserve.
contract KataRewards is Ownable2Step, ReentrancyGuard {
    using SafeERC20 for IERC20;

    /// @notice Extra stake when claiming into the Dojo, in basis points of the claim (20%).
    uint256 public constant DOJO_BONUS_BPS = 2_000;

    IERC20 public immutable token;
    IKataDojo public immutable dojo;

    struct Season {
        bytes32 root;
        uint128 total;
        uint128 claimed;
        uint64 deadline;
        bool swept;
    }

    mapping(uint32 id => Season) public seasons;
    mapping(uint32 id => mapping(address account => bool)) public hasClaimed;

    /// @notice KATF promised to open seasons and not yet claimed or swept.
    uint256 public committed;
    /// @notice KATF set aside for the claim-to-Dojo bonus.
    uint256 public bonusReserve;

    event SeasonOpened(uint32 indexed id, bytes32 root, uint256 total, uint64 deadline);
    event Claimed(uint32 indexed id, address indexed account, uint256 amount, bool toDojo, uint256 bonus);
    event SeasonSwept(uint32 indexed id, uint256 unclaimed);
    event BonusFunded(address indexed from, uint256 amount);
    event BonusWithdrawn(address indexed to, uint256 amount);

    error ZeroAddress();
    error ZeroAmount();
    error SeasonExists();
    error UnknownSeason();
    error BadDeadline();
    error Underfunded(uint256 available, uint256 needed);
    error SeasonClosed();
    error SeasonStillOpen();
    error AlreadyClaimed();
    error AlreadySwept();
    error InvalidProof();
    error ExceedsReserve();
    error ExceedsSeason();

    constructor(IERC20 token_, IKataDojo dojo_, address owner_) Ownable(owner_) {
        if (address(token_) == address(0) || address(dojo_) == address(0)) revert ZeroAddress();
        token = token_;
        dojo = dojo_;
    }

    // ---------------------------------------------------------------- owner

    /// @notice Open season `id`. The contract must already hold `total` KATF that is not committed
    ///         to another season or to the bonus reserve.
    function openSeason(uint32 id, bytes32 root, uint128 total, uint64 deadline) external onlyOwner {
        if (seasons[id].root != bytes32(0)) revert SeasonExists();
        if (root == bytes32(0) || total == 0) revert ZeroAmount();
        if (deadline <= block.timestamp) revert BadDeadline();

        uint256 free = available();
        if (free < total) revert Underfunded(free, total);

        seasons[id] = Season({root: root, total: total, claimed: 0, deadline: deadline, swept: false});
        committed += total;

        emit SeasonOpened(id, root, total, deadline);
    }

    /// @notice After the deadline, release the unclaimed part of a season for future seasons.
    function sweep(uint32 id) external onlyOwner {
        Season storage s = seasons[id];
        if (s.root == bytes32(0)) revert UnknownSeason();
        if (block.timestamp <= s.deadline) revert SeasonStillOpen();
        if (s.swept) revert AlreadySwept();

        s.swept = true;
        uint256 unclaimed = s.total - s.claimed;
        committed -= unclaimed;

        emit SeasonSwept(id, unclaimed);
    }

    /// @notice Take back part of the unused bonus reserve.
    function withdrawBonus(address to, uint256 amount) external onlyOwner nonReentrant {
        if (to == address(0)) revert ZeroAddress();
        if (amount > bonusReserve) revert ExceedsReserve();
        bonusReserve -= amount;
        token.safeTransfer(to, amount);
        emit BonusWithdrawn(to, amount);
    }

    // ---------------------------------------------------------------- anyone

    /// @notice Add KATF to the claim-to-Dojo bonus reserve.
    function fundBonus(uint256 amount) external nonReentrant {
        if (amount == 0) revert ZeroAmount();
        uint256 before = token.balanceOf(address(this));
        token.safeTransferFrom(msg.sender, address(this), amount);
        uint256 received = token.balanceOf(address(this)) - before;
        bonusReserve += received;
        emit BonusFunded(msg.sender, received);
    }

    /// @notice Claim your reward for season `id` to your wallet.
    function claim(uint32 id, uint256 amount, bytes32[] calldata proof) external nonReentrant {
        _consume(id, amount, proof);
        token.safeTransfer(msg.sender, amount);
        emit Claimed(id, msg.sender, amount, false, 0);
    }

    /// @notice Claim your reward for season `id` straight into the Dojo, plus the bonus while the
    ///         reserve lasts. The staked KATF follows the Dojo's normal 7-day unstake cooldown.
    function claimToDojo(uint32 id, uint256 amount, bytes32[] calldata proof) external nonReentrant {
        _consume(id, amount, proof);

        uint256 bonus = (amount * DOJO_BONUS_BPS) / 10_000;
        if (bonus > bonusReserve) bonus = bonusReserve;
        bonusReserve -= bonus;

        uint256 total = amount + bonus;
        token.forceApprove(address(dojo), total);
        dojo.stakeFor(msg.sender, total);

        emit Claimed(id, msg.sender, amount, true, bonus);
    }

    // ---------------------------------------------------------------- views

    /// @notice KATF held here that is free to fund a new season.
    function available() public view returns (uint256) {
        uint256 balance = token.balanceOf(address(this));
        uint256 locked = committed + bonusReserve;
        return balance > locked ? balance - locked : 0;
    }

    function leaf(address account, uint256 amount) public pure returns (bytes32) {
        return keccak256(bytes.concat(keccak256(abi.encode(account, amount))));
    }

    // ---------------------------------------------------------------- internal

    function _consume(uint32 id, uint256 amount, bytes32[] calldata proof) private {
        Season storage s = seasons[id];
        if (s.root == bytes32(0)) revert UnknownSeason();
        if (s.swept || block.timestamp > s.deadline) revert SeasonClosed();
        if (amount == 0) revert ZeroAmount();
        if (hasClaimed[id][msg.sender]) revert AlreadyClaimed();
        if (!MerkleProof.verifyCalldata(proof, s.root, leaf(msg.sender, amount))) revert InvalidProof();

        // A bad root can never pay out more than the season was funded with.
        if (amount > s.total - s.claimed) revert ExceedsSeason();

        hasClaimed[id][msg.sender] = true;
        s.claimed += uint128(amount);
        committed -= amount;
    }
}

Connect a wallet

Kata Finance never holds your funds. You sign every swap in your own wallet.

Wallet

Connected with wallet
Profile and history