From dc9bfd4c59e6597998bf336bd5576c26eed295f7 Mon Sep 17 00:00:00 2001 From: 0xrafasec Date: Mon, 23 Feb 2026 16:49:10 -0300 Subject: [PATCH 1/5] Add KARMA contract: Implement non-transferable ERC-20 token for tracking investment amounts - Introduced the KARMA contract as a soulbound points token. - Implemented minting functionality restricted to addresses with MINTER_ROLE. - Added pausable features with roles for pausing and unpausing the contract. - Enforced soulbound transfer restrictions to prevent wallet-to-wallet transfers. - Included error handling for invalid admin and mint inputs. --- src/tokens/Karma.sol | 85 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 src/tokens/Karma.sol diff --git a/src/tokens/Karma.sol b/src/tokens/Karma.sol new file mode 100644 index 0000000..b825397 --- /dev/null +++ b/src/tokens/Karma.sol @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.22; + +import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {ERC20Burnable} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol"; +import {ERC20Pausable} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Pausable.sol"; + +/** + * @title KARMA — Soulbound points token + * @notice Non-transferable ERC-20 that tracks investment amounts. + * @dev Minted 1:1 by the treasury on deposit; burned on future token conversion. + */ +contract KARMA is ERC20, ERC20Burnable, ERC20Pausable, AccessControl { + bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); + bytes32 private constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); + + /** + * @dev Thrown when a holder attempts a wallet-to-wallet transfer. + */ + error KarmaSoulboundTransferNotAllowed(); + + /** + * @dev Thrown when the admin address is invalid. + */ + error KarmaInvalidAdmin(); + + /** + * @dev Thrown when the mint address is == address(0) or amount is 0. + */ + error KarmaInvalidMintInput(); + + /** + * @notice Constructor for the Karma contract. + * @param admin The address of the admin. + */ + constructor(address admin) ERC20("KARMA", "KARMA") AccessControl() { + if (admin == address(0)) { + revert KarmaInvalidAdmin(); + } + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(MINTER_ROLE, admin); + _grantRole(PAUSER_ROLE, admin); + } + + /** + * @notice Mint points. Callable by treasury or any address with MINTER_ROLE. + * @param to The address to mint points to. + * @param amount The amount of points to mint. + */ + function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) { + if (to == address(0) || amount == 0) { + revert KarmaInvalidMintInput(); + } + _mint(to, amount); + } + + /** + * @notice Pause the Karma contract. + */ + function pause() external onlyRole(PAUSER_ROLE) { + _pause(); + } + + /** + * @notice Unpause the Karma contract. + */ + function unpause() external onlyRole(PAUSER_ROLE) { + _unpause(); + } + + /** + * @dev Soulbound enforcement + pausable hook. + * @param from The address from which the points are being transferred. + * @param to The address to which the points are being transferred. + * @param value The amount of points being transferred. + * @dev Only mint (from == address(0)) and burn (to == address(0)) are allowed. + */ + function _update(address from, address to, uint256 value) internal override(ERC20, ERC20Pausable) { + if (from != address(0) && to != address(0)) { + revert KarmaSoulboundTransferNotAllowed(); + } + super._update(from, to, value); + } +} From 0e18efe5ac1b9488179c86c89b4c24a8cc38d6c3 Mon Sep 17 00:00:00 2001 From: 0xrafasec Date: Mon, 23 Feb 2026 16:49:17 -0300 Subject: [PATCH 2/5] Add unit tests for KARMA contract: Comprehensive coverage for minting, burning, pausing, and access control - Introduced a new test suite for the KARMA contract to validate core functionalities. - Implemented tests for constructor behavior, minting and burning operations, and role-based access control. - Ensured proper handling of edge cases, including reverts for invalid operations and role restrictions. - Verified the soulbound nature of the token by testing transfer restrictions. --- test/foundry/unit/Karma.t.sol | 293 ++++++++++++++++++++++++++++++++++ 1 file changed, 293 insertions(+) create mode 100644 test/foundry/unit/Karma.t.sol diff --git a/test/foundry/unit/Karma.t.sol b/test/foundry/unit/Karma.t.sol new file mode 100644 index 0000000..ab4991b --- /dev/null +++ b/test/foundry/unit/Karma.t.sol @@ -0,0 +1,293 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.22; + +import "forge-std/Test.sol"; +import {KARMA} from "src/tokens/Karma.sol"; + +contract Karma_UnitTest is Test { + KARMA internal karma; + + address internal admin; + address internal minter; + address internal pauser; + address internal holder; + address internal other; + + uint256 internal constant MINT_AMOUNT = 1_000e18; + + // PAUSER_ROLE is private in KARMA; use same value for grant/check in tests + bytes32 internal constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); + + function setUp() public { + admin = makeAddr("admin"); + minter = makeAddr("minter"); + pauser = makeAddr("pauser"); + holder = makeAddr("holder"); + other = makeAddr("other"); + + vm.prank(admin); + karma = new KARMA(admin); + + // Grant roles for tests that need them (admin has all by default) + vm.startPrank(admin); + karma.grantRole(karma.MINTER_ROLE(), minter); + karma.grantRole(PAUSER_ROLE, pauser); + vm.stopPrank(); + } + + // ─── Constructor & metadata ───────────────────────────────────────────── + + function test_Constructor_SetsAdminAndRoles() public { + assertTrue(karma.hasRole(karma.DEFAULT_ADMIN_ROLE(), admin)); + assertTrue(karma.hasRole(karma.MINTER_ROLE(), admin)); + assertTrue(karma.hasRole(PAUSER_ROLE, admin)); + assertEq(karma.name(), "KARMA"); + assertEq(karma.symbol(), "KARMA"); + assertEq(karma.decimals(), 18); + } + + function test_Constructor_TotalSupplyZero() public { + assertEq(karma.totalSupply(), 0); + } + + function test_Constructor_ZeroAdmin_Reverts() public { + vm.expectRevert(KARMA.KarmaInvalidAdmin.selector); + new KARMA(address(0)); + } + + // ─── Mint ─────────────────────────────────────────────────────────────── + + function test_Mint_ByMinter_IncreasesBalance() public { + vm.prank(minter); + karma.mint(holder, MINT_AMOUNT); + assertEq(karma.balanceOf(holder), MINT_AMOUNT); + assertEq(karma.totalSupply(), MINT_AMOUNT); + } + + function test_Mint_ByAdmin_Succeeds() public { + vm.prank(admin); + karma.mint(holder, MINT_AMOUNT); + assertEq(karma.balanceOf(holder), MINT_AMOUNT); + } + + function test_Mint_ByNonMinter_Reverts() public { + vm.prank(holder); + vm.expectRevert(); + karma.mint(holder, MINT_AMOUNT); + } + + function test_Mint_ToZeroAddress_Reverts() public { + vm.prank(minter); + vm.expectRevert(KARMA.KarmaInvalidMintInput.selector); + karma.mint(address(0), MINT_AMOUNT); + } + + function test_Mint_ZeroAmount_Reverts() public { + vm.prank(minter); + vm.expectRevert(KARMA.KarmaInvalidMintInput.selector); + karma.mint(holder, 0); + } + + function test_Mint_CanMintMultipleTimes() public { + vm.startPrank(minter); + karma.mint(holder, 100e18); + karma.mint(holder, 200e18); + karma.mint(other, 50e18); + vm.stopPrank(); + assertEq(karma.balanceOf(holder), 300e18); + assertEq(karma.balanceOf(other), 50e18); + assertEq(karma.totalSupply(), 350e18); + } + + // ─── Soulbound: no transfers ──────────────────────────────────────────── + + function test_Transfer_RevertsWithSoulboundError() public { + vm.prank(minter); + karma.mint(holder, MINT_AMOUNT); + vm.prank(holder); + vm.expectRevert(KARMA.KarmaSoulboundTransferNotAllowed.selector); + karma.transfer(other, 100e18); + } + + function test_TransferFrom_RevertsWithSoulboundError() public { + vm.prank(minter); + karma.mint(holder, MINT_AMOUNT); + vm.prank(holder); + karma.approve(other, MINT_AMOUNT); + vm.prank(other); + vm.expectRevert(KARMA.KarmaSoulboundTransferNotAllowed.selector); + karma.transferFrom(holder, other, 100e18); + } + + // ─── Burn ─────────────────────────────────────────────────────────────── + + function test_Burn_ByHolder_DecreasesBalance() public { + vm.prank(minter); + karma.mint(holder, MINT_AMOUNT); + vm.prank(holder); + karma.burn(500e18); + assertEq(karma.balanceOf(holder), 500e18); + assertEq(karma.totalSupply(), 500e18); + } + + function test_Burn_AllBalance() public { + vm.prank(minter); + karma.mint(holder, MINT_AMOUNT); + vm.prank(holder); + karma.burn(MINT_AMOUNT); + assertEq(karma.balanceOf(holder), 0); + assertEq(karma.totalSupply(), 0); + } + + function test_Burn_ExceedsBalance_Reverts() public { + vm.prank(minter); + karma.mint(holder, 100e18); + vm.prank(holder); + vm.expectRevert(); + karma.burn(200e18); + } + + function test_Burn_From_ByHolder_DecreasesBalance() public { + vm.prank(minter); + karma.mint(holder, MINT_AMOUNT); + vm.prank(holder); + karma.approve(holder, 300e18); // approve self for burnFrom + vm.prank(holder); + karma.burnFrom(holder, 300e18); + assertEq(karma.balanceOf(holder), 700e18); + } + + function test_BurnFrom_WithoutAllowance_Reverts() public { + vm.prank(minter); + karma.mint(holder, MINT_AMOUNT); + vm.prank(other); + vm.expectRevert(); + karma.burnFrom(holder, 100e18); + } + + // ─── Pause / Unpause ───────────────────────────────────────────────────── + + function test_Pause_ByPauser_Succeeds() public { + vm.prank(pauser); + karma.pause(); + assertTrue(karma.paused()); + } + + function test_Pause_ByAdmin_Succeeds() public { + vm.prank(admin); + karma.pause(); + assertTrue(karma.paused()); + } + + function test_Pause_ByNonPauser_Reverts() public { + vm.prank(holder); + vm.expectRevert(); + karma.pause(); + } + + function test_Unpause_ByPauser_Succeeds() public { + vm.prank(pauser); + karma.pause(); + vm.prank(pauser); + karma.unpause(); + assertFalse(karma.paused()); + } + + function test_WhenPaused_Mint_Reverts() public { + vm.prank(pauser); + karma.pause(); + vm.prank(minter); + vm.expectRevert(); + karma.mint(holder, MINT_AMOUNT); + } + + function test_WhenPaused_Burn_Reverts() public { + vm.prank(minter); + karma.mint(holder, MINT_AMOUNT); + vm.prank(pauser); + karma.pause(); + vm.prank(holder); + vm.expectRevert(); + karma.burn(100e18); + } + + function test_Unpause_AllowsMintAndBurnAgain() public { + vm.prank(pauser); + karma.pause(); + vm.prank(pauser); + karma.unpause(); + vm.prank(minter); + karma.mint(holder, MINT_AMOUNT); + assertEq(karma.balanceOf(holder), MINT_AMOUNT); + vm.prank(holder); + karma.burn(100e18); + assertEq(karma.balanceOf(holder), MINT_AMOUNT - 100e18); + } + + // ─── AccessControl ───────────────────────────────────────────────────── + + function test_Admin_CanGrantMinterRole() public { + address newMinter = makeAddr("newMinter"); + bytes32 minterRole = karma.MINTER_ROLE(); + assertFalse(karma.hasRole(minterRole, newMinter)); + vm.prank(admin); + karma.grantRole(minterRole, newMinter); + assertTrue(karma.hasRole(minterRole, newMinter)); + vm.prank(newMinter); + karma.mint(holder, 1e18); + assertEq(karma.balanceOf(holder), 1e18); + } + + function test_Admin_CanRevokeMinterRole() public { + bytes32 minterRole = karma.MINTER_ROLE(); + vm.prank(admin); + karma.revokeRole(minterRole, minter); + vm.prank(minter); + vm.expectRevert(); + karma.mint(holder, MINT_AMOUNT); + } + + function test_NonAdmin_CannotGrantRole() public { + bytes32 minterRole = karma.MINTER_ROLE(); + vm.prank(holder); + vm.expectRevert(); + karma.grantRole(minterRole, holder); + } + + function test_Admin_CanGrantPauserRole() public { + address newPauser = makeAddr("newPauser"); + assertFalse(karma.hasRole(PAUSER_ROLE, newPauser)); + vm.prank(admin); + karma.grantRole(PAUSER_ROLE, newPauser); + assertTrue(karma.hasRole(PAUSER_ROLE, newPauser)); + vm.prank(newPauser); + karma.pause(); + assertTrue(karma.paused()); + } + + function test_Admin_CanRevokePauserRole() public { + vm.prank(admin); + karma.revokeRole(PAUSER_ROLE, pauser); + vm.prank(pauser); + vm.expectRevert(); + karma.pause(); + } + + // ─── Role constants ───────────────────────────────────────────────────── + + function test_MINTER_ROLE_MatchesExpectedHash() public { + assertEq(karma.MINTER_ROLE(), keccak256("MINTER_ROLE")); + } + + function test_PAUSER_ROLE_MatchesExpectedHash() public { + assertEq(PAUSER_ROLE, keccak256("PAUSER_ROLE")); + } + + function test_Unpause_ByNonPauser_Reverts() public { + vm.prank(pauser); + karma.pause(); + vm.prank(holder); + vm.expectRevert(); + karma.unpause(); + } +} From ed27ffa5d250602564a3fddb9bce83dc0031d26a Mon Sep 17 00:00:00 2001 From: 0xrafasec Date: Tue, 24 Feb 2026 19:47:54 -0300 Subject: [PATCH 3/5] Add IKarmaTreasury interface: Define treasury functionality for raised amount retrieval - Introduced the IKarmaTreasury interface to facilitate reading the total raised amount for token claims. - Included a function signature for getRaisedAmount() to return the total raised amount as a uint256 value. - This interface will be utilized by the KARMA contract to manage token claims effectively. --- src/interfaces/IKarmaTreasury.sol | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 src/interfaces/IKarmaTreasury.sol diff --git a/src/interfaces/IKarmaTreasury.sol b/src/interfaces/IKarmaTreasury.sol new file mode 100644 index 0000000..2396bb6 --- /dev/null +++ b/src/interfaces/IKarmaTreasury.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.22; + +/** + * @title IKarmaTreasury + * @notice Treasury interface for reading raised amount (e.g. PaymentTreasury). + * @dev Used by KARMA to determine how many tokens can be claimed via claimTokens(). + */ +interface IKarmaTreasury { + /** + * @notice Returns the total raised amount (e.g. confirmed payments, normalized). + * @return The total raised amount as a uint256 value. + */ + function getRaisedAmount() external view returns (uint256); +} From 3f97ca0fefdfd3d4989fb18c3818308fd55305d4 Mon Sep 17 00:00:00 2001 From: 0xrafasec Date: Tue, 24 Feb 2026 19:48:50 -0300 Subject: [PATCH 4/5] Add treasury management to KARMA contract: Implement treasury address and token claiming functionality - Introduced a treasury address to manage claims based on raised amounts. - Added functions to set the treasury and claim tokens, ensuring only valid operations are executed. - Implemented events for treasury updates and token claims to enhance transparency. - Included error handling for scenarios where the treasury is not set or no tokens are available to claim. --- src/tokens/Karma.sol | 78 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/src/tokens/Karma.sol b/src/tokens/Karma.sol index b825397..88f711d 100644 --- a/src/tokens/Karma.sol +++ b/src/tokens/Karma.sol @@ -5,6 +5,7 @@ import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {ERC20Burnable} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol"; import {ERC20Pausable} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Pausable.sol"; +import {IKarmaTreasury} from "../interfaces/IKarmaTreasury.sol"; /** * @title KARMA — Soulbound points token @@ -15,6 +16,32 @@ contract KARMA is ERC20, ERC20Burnable, ERC20Pausable, AccessControl { bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); bytes32 private constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); + /** + * @notice Treasury used to read raised amount for claimTokens (e.g. PaymentTreasury). + * @dev This is the address of the contract that implements the IKarmaTreasury interface. + */ + address public treasury; + + /** + * @notice Total KARMA minted so far against treasury raised amount (for incremental claiming). + * @dev This is the total amount of KARMA minted so far against the treasury raised amount. + */ + uint256 private _totalMintedAgainstRaised; + + /** + * @notice Emitted when the treasury address is set or updated. + * @param previousTreasury The previous treasury address (zero if first set). + * @param newTreasury The new treasury address. + */ + event TreasurySet(address indexed previousTreasury, address indexed newTreasury); + + /** + * @notice Emitted when tokens are claimed via claimTokens(). + * @param to The recipient of the minted tokens. + * @param amount The amount of KARMA minted (delta since last claim). + */ + event TokensClaimed(address indexed to, uint256 amount); + /** * @dev Thrown when a holder attempts a wallet-to-wallet transfer. */ @@ -30,6 +57,16 @@ contract KARMA is ERC20, ERC20Burnable, ERC20Pausable, AccessControl { */ error KarmaInvalidMintInput(); + /** + * @dev Thrown when claimTokens is called without a treasury set. + */ + error KarmaTreasuryNotSet(); + + /** + * @dev Thrown when claimTokens is called and there is nothing to claim. + */ + error KarmaNothingToClaim(); + /** * @notice Constructor for the Karma contract. * @param admin The address of the admin. @@ -43,6 +80,16 @@ contract KARMA is ERC20, ERC20Burnable, ERC20Pausable, AccessControl { _grantRole(PAUSER_ROLE, admin); } + /** + * @notice Set the treasury used for claimTokens (e.g. PaymentTreasury). Callable by admin only. + * @param _treasury Address of a contract that implements getRaisedAmount(). + */ + function setTreasury(address _treasury) external onlyRole(DEFAULT_ADMIN_ROLE) { + address previousTreasury = treasury; + treasury = _treasury; + emit TreasurySet(previousTreasury, _treasury); + } + /** * @notice Mint points. Callable by treasury or any address with MINTER_ROLE. * @param to The address to mint points to. @@ -55,6 +102,37 @@ contract KARMA is ERC20, ERC20Burnable, ERC20Pausable, AccessControl { _mint(to, amount); } + /** + * @notice Claim KARMA up to the treasury's current raised amount, minus what was already minted. + * Callable multiple times: each call mints only the delta (new raised since last claim). + * @param to Recipient of the minted tokens. + * @dev Example: raised 100 + 200, claimTokens → mints 300. Then raised +100 +100, claimTokens → mints 200. + */ + function claimTokens(address to) external onlyRole(MINTER_ROLE) whenNotPaused { + if (treasury == address(0)) { + revert KarmaTreasuryNotSet(); + } + if (to == address(0)) { + revert KarmaInvalidMintInput(); + } + + uint256 totalRaised = IKarmaTreasury(treasury).getRaisedAmount(); + uint256 delta = totalRaised > _totalMintedAgainstRaised ? totalRaised - _totalMintedAgainstRaised : 0; + if (delta == 0) { + revert KarmaNothingToClaim(); + } + _totalMintedAgainstRaised = totalRaised; + _mint(to, delta); + emit TokensClaimed(to, delta); + } + + /** + * @notice Returns the total KARMA already minted via claimTokens (raised amount covered so far). + */ + function totalMintedAgainstRaised() external view returns (uint256) { + return _totalMintedAgainstRaised; + } + /** * @notice Pause the Karma contract. */ From 65de1aad4f0f00ce625f69a916391313f1601873 Mon Sep 17 00:00:00 2001 From: 0xrafasec Date: Tue, 24 Feb 2026 19:48:59 -0300 Subject: [PATCH 5/5] Add unit tests for KARMA token claiming functionality - Introduced a mock treasury contract to facilitate testing of token claims based on raised amounts. - Implemented various test cases to validate the claimTokens function, including scenarios for invalid inputs, treasury management, and event emissions. - Ensured comprehensive coverage for edge cases, such as reverts when the treasury is not set or when claims are made to the zero address. - Verified that the total minted amount against raised funds starts at zero and increments correctly with valid claims. --- test/foundry/unit/Karma.t.sol | 151 ++++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) diff --git a/test/foundry/unit/Karma.t.sol b/test/foundry/unit/Karma.t.sol index ab4991b..aab8782 100644 --- a/test/foundry/unit/Karma.t.sol +++ b/test/foundry/unit/Karma.t.sol @@ -4,6 +4,19 @@ pragma solidity ^0.8.22; import "forge-std/Test.sol"; import {KARMA} from "src/tokens/Karma.sol"; +/// @notice Mock treasury that returns a configurable raised amount for claimTokens tests. +contract MockKarmaTreasury { + uint256 public raisedAmount; + + function setRaisedAmount(uint256 _amount) external { + raisedAmount = _amount; + } + + function getRaisedAmount() external view returns (uint256) { + return raisedAmount; + } +} + contract Karma_UnitTest is Test { KARMA internal karma; @@ -290,4 +303,142 @@ contract Karma_UnitTest is Test { vm.expectRevert(); karma.unpause(); } + + // ─── claimTokens & treasury ───────────────────────────────────────────── + + function test_ClaimTokens_WithoutTreasurySet_Reverts() public { + vm.prank(minter); + vm.expectRevert(KARMA.KarmaTreasuryNotSet.selector); + karma.claimTokens(holder); + } + + function test_ClaimTokens_ToZeroAddress_Reverts() public { + MockKarmaTreasury mockTreasury = new MockKarmaTreasury(); + vm.prank(admin); + karma.setTreasury(address(mockTreasury)); + mockTreasury.setRaisedAmount(100e18); + vm.prank(minter); + vm.expectRevert(KARMA.KarmaInvalidMintInput.selector); + karma.claimTokens(address(0)); + } + + function test_ClaimTokens_NothingToClaim_Reverts() public { + MockKarmaTreasury mockTreasury = new MockKarmaTreasury(); + vm.prank(admin); + karma.setTreasury(address(mockTreasury)); + mockTreasury.setRaisedAmount(0); + vm.prank(minter); + vm.expectRevert(KARMA.KarmaNothingToClaim.selector); + karma.claimTokens(holder); + } + + function test_ClaimTokens_MintsDelta_FirstClaim() public { + MockKarmaTreasury mockTreasury = new MockKarmaTreasury(); + vm.prank(admin); + karma.setTreasury(address(mockTreasury)); + mockTreasury.setRaisedAmount(300e18); // 100 + 200 as in example + vm.prank(minter); + karma.claimTokens(holder); + assertEq(karma.balanceOf(holder), 300e18); + assertEq(karma.totalMintedAgainstRaised(), 300e18); + } + + function test_ClaimTokens_MintsOnlyDelta_SecondClaim() public { + MockKarmaTreasury mockTreasury = new MockKarmaTreasury(); + vm.prank(admin); + karma.setTreasury(address(mockTreasury)); + mockTreasury.setRaisedAmount(300e18); + vm.prank(minter); + karma.claimTokens(holder); + assertEq(karma.balanceOf(holder), 300e18); + // Simulate more payments: raised goes to 500 (300 + 100 + 100) + mockTreasury.setRaisedAmount(500e18); + vm.prank(minter); + karma.claimTokens(holder); + assertEq(karma.balanceOf(holder), 500e18); // 300 + 200 delta + assertEq(karma.totalMintedAgainstRaised(), 500e18); + } + + function test_ClaimTokens_MultipleCalls_Incremental() public { + MockKarmaTreasury mockTreasury = new MockKarmaTreasury(); + vm.prank(admin); + karma.setTreasury(address(mockTreasury)); + mockTreasury.setRaisedAmount(100e18); + vm.prank(minter); + karma.claimTokens(holder); + assertEq(karma.balanceOf(holder), 100e18); + mockTreasury.setRaisedAmount(300e18); + vm.prank(minter); + karma.claimTokens(holder); + assertEq(karma.balanceOf(holder), 300e18); + mockTreasury.setRaisedAmount(300e18); + vm.prank(minter); + vm.expectRevert(KARMA.KarmaNothingToClaim.selector); + karma.claimTokens(holder); + } + + function test_ClaimTokens_WhenPaused_Reverts() public { + MockKarmaTreasury mockTreasury = new MockKarmaTreasury(); + vm.prank(admin); + karma.setTreasury(address(mockTreasury)); + mockTreasury.setRaisedAmount(100e18); + vm.prank(pauser); + karma.pause(); + vm.prank(minter); + vm.expectRevert(); + karma.claimTokens(holder); + } + + function test_ClaimTokens_ByNonMinter_Reverts() public { + MockKarmaTreasury mockTreasury = new MockKarmaTreasury(); + vm.prank(admin); + karma.setTreasury(address(mockTreasury)); + mockTreasury.setRaisedAmount(100e18); + vm.prank(holder); + vm.expectRevert(); + karma.claimTokens(holder); + } + + function test_SetTreasury_ByAdmin_Succeeds() public { + MockKarmaTreasury mockTreasury = new MockKarmaTreasury(); + assertEq(karma.treasury(), address(0)); + vm.prank(admin); + vm.expectEmit(true, true, false, true); + emit KARMA.TreasurySet(address(0), address(mockTreasury)); + karma.setTreasury(address(mockTreasury)); + assertEq(karma.treasury(), address(mockTreasury)); + } + + function test_SetTreasury_EmitsTreasurySet() public { + MockKarmaTreasury mockTreasury = new MockKarmaTreasury(); + vm.prank(admin); + karma.setTreasury(address(mockTreasury)); + MockKarmaTreasury mockTreasury2 = new MockKarmaTreasury(); + vm.prank(admin); + vm.expectEmit(true, true, false, true); + emit KARMA.TreasurySet(address(mockTreasury), address(mockTreasury2)); + karma.setTreasury(address(mockTreasury2)); + } + + function test_ClaimTokens_EmitsTokensClaimed() public { + MockKarmaTreasury mockTreasury = new MockKarmaTreasury(); + vm.prank(admin); + karma.setTreasury(address(mockTreasury)); + mockTreasury.setRaisedAmount(300e18); + vm.prank(minter); + vm.expectEmit(true, false, false, true); + emit KARMA.TokensClaimed(holder, 300e18); + karma.claimTokens(holder); + } + + function test_SetTreasury_ByNonAdmin_Reverts() public { + MockKarmaTreasury mockTreasury = new MockKarmaTreasury(); + vm.prank(holder); + vm.expectRevert(); + karma.setTreasury(address(mockTreasury)); + } + + function test_TotalMintedAgainstRaised_StartsZero() public { + assertEq(karma.totalMintedAgainstRaised(), 0); + } }