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.
| Address | 0xD0539Da395695634Dd390F907f0e64E3Bd1e4853 |
| Verified | Verified on Sourcify (exact match to the deployed bytecode) |
| File | KataDojo.sol · 157 lines |
| SHA-256 | b9a35ab7b937c02b84946998e002c802102f8252161949117351655d970808be |
// 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.
| Address | 0x0156b16c1Af56E9d7e71fFCA4ef06f15CD3cb0F9 |
| Verified | Verified on Sourcify (exact match to the deployed bytecode) |
| File | KataRewards.sol · 180 lines |
| SHA-256 | 068c3b7e69dd838d75164b27b4b40c41af5d53850c9e756745c93370a6bfb85f |
// 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;
}
}