@querais/contracts 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,157 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.28;
3
+
4
+ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
5
+ import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
6
+ import {ERC20Burnable} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol";
7
+ import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
8
+ import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol";
9
+ import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
10
+
11
+ /**
12
+ * @title ProtocolTreasury
13
+ * @notice Slice 6A: protocol fees accumulate here as plain ERC-20 transfers (this
14
+ * contract simply replaces the treasury EOA as the fee recipient — settlement
15
+ * code is untouched), and a keeper-called `distribute()` executes the
16
+ * tokenomics' 60/20/20 ops/staker/burn split in ONE tx per epoch.
17
+ *
18
+ * @dev Accumulate-and-sweep, NOT the spec's per-settlement `receiveFee`: splitting +
19
+ * burning on every settlement would put token ops on the hot `batchSettle` path
20
+ * for identical economics at far more gas. Until the staking pool exists (6B:
21
+ * node-operator stakes — Option 1), the staker share parks here under
22
+ * `stakerEarmarkWei`, which `allocate()` can never touch. Pausing blocks
23
+ * `distribute()`/`allocate()`; the treasury holds protocol funds ONLY, so there
24
+ * is deliberately no user exit path to keep open while paused.
25
+ */
26
+ contract ProtocolTreasury is AccessControl, Pausable, ReentrancyGuard {
27
+ using SafeERC20 for IERC20;
28
+
29
+ // ─── Roles ────────────────────────────────────────────────────────────────
30
+ /// @notice May call distribute() — the gateway's hot key (the daily epoch keeper).
31
+ bytes32 public constant KEEPER_ROLE = keccak256("KEEPER_ROLE");
32
+ bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");
33
+
34
+ // ─── Split rates (tokenomics: 20% burn / 20% stakers / 60% ops) ───────────
35
+ uint16 public burnRateBps = 2000;
36
+ uint16 public stakerRateBps = 2000;
37
+
38
+ ERC20Burnable public immutable token;
39
+
40
+ /// @notice The 6B staking pool; address(0) until it exists (share parks here).
41
+ address public stakerPool;
42
+ /// @notice Staker share accrued while no pool is set. Never spendable via allocate().
43
+ uint256 public stakerEarmarkWei;
44
+ /// @notice The retained ops share (the 60%) — the only funds allocate() may spend.
45
+ /// Tracked explicitly so a sweep never re-splits what an earlier sweep kept.
46
+ uint256 public opsRetainedWei;
47
+
48
+ // Audit counters (monotonic).
49
+ uint256 public totalDistributed;
50
+ uint256 public totalBurned;
51
+ uint256 public totalToStakers;
52
+ uint256 public totalAllocated;
53
+
54
+ // ─── Events ───────────────────────────────────────────────────────────────
55
+ event Distributed(uint256 pending, uint256 burned, uint256 toStakers, uint256 opsRetained);
56
+ event Allocated(address indexed recipient, uint256 amount, string purpose);
57
+ event RatesUpdated(uint16 burnRateBps, uint16 stakerRateBps);
58
+ event StakerPoolSet(address indexed pool, uint256 earmarkFlushed);
59
+
60
+ // ─── Errors ───────────────────────────────────────────────────────────────
61
+ error ZeroAddress();
62
+ error ZeroAmount();
63
+ error NothingToDistribute();
64
+ error RatesExceedTotal(uint16 burnBps, uint16 stakerBps);
65
+ error ExceedsSpendable(uint256 requested, uint256 spendable);
66
+
67
+ constructor(ERC20Burnable token_, address admin) {
68
+ if (address(token_) == address(0) || admin == address(0)) revert ZeroAddress();
69
+ token = token_;
70
+ _grantRole(DEFAULT_ADMIN_ROLE, admin);
71
+ _grantRole(PAUSER_ROLE, admin);
72
+ }
73
+
74
+ // ─── Views ────────────────────────────────────────────────────────────────
75
+
76
+ /// @notice Fees that arrived since the last sweep (earmark + retained ops are the
77
+ /// already-swept remainder of the balance).
78
+ function pendingDistribution() public view returns (uint256) {
79
+ return token.balanceOf(address(this)) - stakerEarmarkWei - opsRetainedWei;
80
+ }
81
+
82
+ // ─── The epoch sweep ──────────────────────────────────────────────────────
83
+
84
+ /// @notice Execute the burn/staker/ops split over everything accrued since the
85
+ /// last sweep. Ops = remainder (absorbs rounding — conservation is exact).
86
+ function distribute() external onlyRole(KEEPER_ROLE) nonReentrant whenNotPaused {
87
+ uint256 pending = pendingDistribution();
88
+ if (pending == 0) revert NothingToDistribute();
89
+
90
+ uint256 burnAmount = (pending * burnRateBps) / 10000;
91
+ uint256 stakerAmount = (pending * stakerRateBps) / 10000;
92
+ uint256 opsRetained = pending - burnAmount - stakerAmount;
93
+ bool payPool = stakerPool != address(0) && stakerAmount > 0;
94
+
95
+ // Effects — ALL state before any external call (CEI, the pinned design rule).
96
+ totalDistributed += pending;
97
+ totalBurned += burnAmount;
98
+ totalToStakers += stakerAmount;
99
+ opsRetainedWei += opsRetained;
100
+ if (!payPool && stakerAmount > 0) stakerEarmarkWei += stakerAmount; // parks until 6B
101
+
102
+ // Interactions. opsRetained simply stays in the balance, spendable via allocate().
103
+ if (burnAmount > 0) token.burn(burnAmount);
104
+ if (payPool) IERC20(address(token)).safeTransfer(stakerPool, stakerAmount);
105
+
106
+ emit Distributed(pending, burnAmount, stakerAmount, opsRetained);
107
+ }
108
+
109
+ // ─── Ops spending ─────────────────────────────────────────────────────────
110
+
111
+ /// @notice Spend from the retained ops share (grants, incentives, marketing).
112
+ /// Can never dip into the staker earmark.
113
+ function allocate(address recipient, uint256 amount, string calldata purpose)
114
+ external
115
+ onlyRole(DEFAULT_ADMIN_ROLE)
116
+ nonReentrant
117
+ whenNotPaused
118
+ {
119
+ if (recipient == address(0)) revert ZeroAddress();
120
+ if (amount == 0) revert ZeroAmount();
121
+ if (amount > opsRetainedWei) revert ExceedsSpendable(amount, opsRetainedWei);
122
+
123
+ opsRetainedWei -= amount;
124
+ totalAllocated += amount;
125
+ IERC20(address(token)).safeTransfer(recipient, amount);
126
+ emit Allocated(recipient, amount, purpose);
127
+ }
128
+
129
+ // ─── Admin ────────────────────────────────────────────────────────────────
130
+
131
+ function setRates(uint16 burnBps, uint16 stakerBps) external onlyRole(DEFAULT_ADMIN_ROLE) {
132
+ if (uint32(burnBps) + uint32(stakerBps) > 10000) revert RatesExceedTotal(burnBps, stakerBps);
133
+ burnRateBps = burnBps;
134
+ stakerRateBps = stakerBps;
135
+ emit RatesUpdated(burnBps, stakerBps);
136
+ }
137
+
138
+ /// @notice Wire the 6B staking pool; any parked earmark flushes to it immediately.
139
+ function setStakerPool(address pool) external onlyRole(DEFAULT_ADMIN_ROLE) nonReentrant {
140
+ if (pool == address(0)) revert ZeroAddress();
141
+ stakerPool = pool;
142
+ uint256 flushed = stakerEarmarkWei;
143
+ if (flushed > 0) {
144
+ stakerEarmarkWei = 0;
145
+ IERC20(address(token)).safeTransfer(pool, flushed);
146
+ }
147
+ emit StakerPoolSet(pool, flushed);
148
+ }
149
+
150
+ function pause() external onlyRole(PAUSER_ROLE) {
151
+ _pause();
152
+ }
153
+
154
+ function unpause() external onlyRole(PAUSER_ROLE) {
155
+ _unpause();
156
+ }
157
+ }
@@ -0,0 +1,27 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.28;
3
+
4
+ import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
5
+ import {ERC20Burnable} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol";
6
+
7
+ /**
8
+ * @title QUAISToken
9
+ * @notice The $QAIS utility token: fixed supply, no mint function after deployment.
10
+ * Used as the payment medium (escrow) and node stake/collateral.
11
+ * @dev Burnable (holders and the protocol can permanently reduce supply). The full
12
+ * 60/20/20 treasury split is deferred; for the MVP the burn primitive exists and
13
+ * protocol fees accrue to a treasury address handled by JobEscrow.
14
+ *
15
+ * Total supply (1,000,000,000 QAIS) is minted once to `initialHolder` at
16
+ * construction. There is intentionally no `mint` — supply can only ever decrease.
17
+ */
18
+ contract QUAISToken is ERC20Burnable {
19
+ /// @notice Fixed total supply minted at construction: 1,000,000,000 * 1e18.
20
+ uint256 public constant INITIAL_SUPPLY = 1_000_000_000 ether;
21
+
22
+ /// @param initialHolder Receives the entire initial supply (the deployer/treasury bootstrap).
23
+ constructor(address initialHolder) ERC20("QueraIS Token", "QAIS") {
24
+ require(initialHolder != address(0), "QAIS: zero holder");
25
+ _mint(initialHolder, INITIAL_SUPPLY);
26
+ }
27
+ }
@@ -0,0 +1,136 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.28;
3
+
4
+ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
5
+ import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
6
+ import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
7
+ import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol";
8
+ import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
9
+ import {NodeRegistry} from "./NodeRegistry.sol";
10
+
11
+ /**
12
+ * @title StakingRewards
13
+ * @notice Slice 6B (Option 1: node-operator stakes ARE the stakers). The treasury's
14
+ * 20% staker share flows here (`ProtocolTreasury.setStakerPool`); a keeper
15
+ * epoch call credits it pro-rata to the ACTIVE staked nodes in `NodeRegistry`;
16
+ * operators pull their earnings with `claim()`.
17
+ *
18
+ * @dev Discrete epoch crediting, computed fully on-chain: stakes change in the registry
19
+ * without notifying anyone (register/addStake/slash/unbond), so continuous
20
+ * Synthetix-style accrual would need registry hooks. Instead the active node set —
21
+ * small and enumerable on-chain — is walked in one keeper tx. Known trade-off:
22
+ * whoever is staked at sweep time gets that epoch's full pro-rata share (no
23
+ * intra-epoch time-weighting); acceptable at daily epochs, revisit with Phase-4
24
+ * trustlessness. Known scale limit: O(n) registry reads per epoch; the scale-out
25
+ * path is a Merkle-epoch distributor, deferred with horizontal scale.
26
+ * Pausing blocks crediting; `claim()` is deliberately NOT pausable — earned
27
+ * rewards are a user exit and a pause can never trap funds.
28
+ */
29
+ contract StakingRewards is AccessControl, Pausable, ReentrancyGuard {
30
+ using SafeERC20 for IERC20;
31
+
32
+ // ─── Roles ────────────────────────────────────────────────────────────────
33
+ /// @notice May call distributeEpoch() — the gateway's hot key (the epoch keeper).
34
+ bytes32 public constant KEEPER_ROLE = keccak256("KEEPER_ROLE");
35
+ bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");
36
+
37
+ IERC20 public immutable token;
38
+ NodeRegistry public immutable registry;
39
+
40
+ /// @notice Earned, unclaimed rewards per node-operator wallet. A token debt —
41
+ /// it survives later slashes/unbonding (earned while staked).
42
+ mapping(address => uint256) public claimable;
43
+
44
+ // Audit counters (monotonic). balance − (credited − claimed) = pending rewards.
45
+ uint256 public totalCredited;
46
+ uint256 public totalClaimed;
47
+
48
+ // ─── Events ───────────────────────────────────────────────────────────────
49
+ event EpochDistributed(uint256 credited, uint256 activeNodes, uint256 totalActiveStake);
50
+ event Claimed(address indexed operator, uint256 amount);
51
+
52
+ // ─── Errors ───────────────────────────────────────────────────────────────
53
+ error ZeroAddress();
54
+ error NothingToCredit();
55
+ error NoActiveNodes();
56
+ error NothingToClaim();
57
+
58
+ constructor(IERC20 token_, NodeRegistry registry_, address admin) {
59
+ if (address(token_) == address(0) || address(registry_) == address(0) || admin == address(0))
60
+ {
61
+ revert ZeroAddress();
62
+ }
63
+ token = token_;
64
+ registry = registry_;
65
+ _grantRole(DEFAULT_ADMIN_ROLE, admin);
66
+ _grantRole(PAUSER_ROLE, admin);
67
+ }
68
+
69
+ // ─── Views ────────────────────────────────────────────────────────────────
70
+
71
+ /// @notice Staker-share funds that arrived but are not yet credited to operators.
72
+ function pendingRewards() public view returns (uint256) {
73
+ return token.balanceOf(address(this)) - (totalCredited - totalClaimed);
74
+ }
75
+
76
+ // ─── Epoch crediting ──────────────────────────────────────────────────────
77
+
78
+ /// @notice Credit everything pending pro-rata to the currently ACTIVE staked nodes.
79
+ /// Division dust stays pending and rolls into the next epoch — the credited
80
+ /// amounts always conserve exactly against what was swept in.
81
+ function distributeEpoch() external onlyRole(KEEPER_ROLE) nonReentrant whenNotPaused {
82
+ uint256 pending = pendingRewards();
83
+ if (pending == 0) revert NothingToCredit();
84
+ uint256 n = registry.activeNodeCount();
85
+ if (n == 0) revert NoActiveNodes(); // funds simply wait for the next epoch
86
+
87
+ // Read phase: collect the active set + stakes (registry views), THEN write —
88
+ // strict CEI even though the only external calls are reads of our own registry.
89
+ address[] memory wallets = new address[](n);
90
+ uint256[] memory stakes = new uint256[](n);
91
+ uint256 totalActiveStake = 0;
92
+ for (uint256 i; i < n; ++i) {
93
+ address wallet = registry.activeNodeAt(i);
94
+ uint256 stake = registry.getNode(wallet).stakeAmount;
95
+ wallets[i] = wallet;
96
+ stakes[i] = stake;
97
+ totalActiveStake += stake;
98
+ }
99
+ if (totalActiveStake == 0) revert NoActiveNodes();
100
+
101
+ uint256 credited = 0;
102
+ for (uint256 i; i < n; ++i) {
103
+ uint256 share = (pending * stakes[i]) / totalActiveStake;
104
+ if (share > 0) {
105
+ claimable[wallets[i]] += share;
106
+ credited += share;
107
+ }
108
+ }
109
+ totalCredited += credited;
110
+
111
+ emit EpochDistributed(credited, n, totalActiveStake);
112
+ }
113
+
114
+ // ─── Operator claims ──────────────────────────────────────────────────────
115
+
116
+ /// @notice Pull all earned rewards. Deliberately NOT pausable (user exit).
117
+ function claim() external nonReentrant {
118
+ uint256 amount = claimable[msg.sender];
119
+ if (amount == 0) revert NothingToClaim();
120
+
121
+ claimable[msg.sender] = 0;
122
+ totalClaimed += amount;
123
+ token.safeTransfer(msg.sender, amount);
124
+ emit Claimed(msg.sender, amount);
125
+ }
126
+
127
+ // ─── Admin ────────────────────────────────────────────────────────────────
128
+
129
+ function pause() external onlyRole(PAUSER_ROLE) {
130
+ _pause();
131
+ }
132
+
133
+ function unpause() external onlyRole(PAUSER_ROLE) {
134
+ _unpause();
135
+ }
136
+ }
@@ -0,0 +1,47 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.28;
3
+
4
+ import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
5
+
6
+ /**
7
+ * @title ReentrantBatchToken
8
+ * @notice Test-only malicious ERC-20: on its first outbound `transfer` it re-enters a target
9
+ * with pre-armed calldata (e.g. CreditAccount.batchSettle) and bubbles up the nested
10
+ * revert. Used to prove CreditAccount's nonReentrant guard + CEI ordering stop a
11
+ * re-entrant settlement from double-spending a deposit.
12
+ * @dev NOT part of the deployed system — lives under contracts/mocks for tests only.
13
+ */
14
+ contract ReentrantBatchToken is ERC20 {
15
+ address public target;
16
+ bytes public reentryData;
17
+ bool public armed;
18
+
19
+ constructor() ERC20("ReentrantBatch", "RB") {
20
+ _mint(msg.sender, 1_000_000_000 ether);
21
+ }
22
+
23
+ function mintTo(address to, uint256 amount) external {
24
+ _mint(to, amount);
25
+ }
26
+
27
+ /// @notice Arm the attack: the next `transfer` re-enters `target_` with `data_` once.
28
+ function arm(address target_, bytes calldata data_) external {
29
+ target = target_;
30
+ reentryData = data_;
31
+ armed = true;
32
+ }
33
+
34
+ function transfer(address to, uint256 amount) public override returns (bool) {
35
+ if (armed) {
36
+ armed = false; // single shot
37
+ (bool ok, bytes memory ret) = target.call(reentryData);
38
+ if (!ok) {
39
+ // Bubble the nested revert so the outer settlement reverts too.
40
+ assembly {
41
+ revert(add(ret, 0x20), mload(ret))
42
+ }
43
+ }
44
+ }
45
+ return super.transfer(to, amount);
46
+ }
47
+ }
@@ -0,0 +1,45 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.28;
3
+
4
+ import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
5
+
6
+ interface IReentryTarget {
7
+ function verifyAndRelease(bytes32 jobId) external;
8
+ }
9
+
10
+ /**
11
+ * @title ReentrantToken
12
+ * @notice Test-only malicious ERC-20: on its first outbound `transfer` it attempts to
13
+ * re-enter JobEscrow.verifyAndRelease for the same job. Used to prove the
14
+ * nonReentrant guard + CEI ordering prevent double settlement / draining.
15
+ * @dev NOT part of the deployed system — lives under contracts/mocks for tests only.
16
+ */
17
+ contract ReentrantToken is ERC20 {
18
+ IReentryTarget public target;
19
+ bytes32 public jobId;
20
+ bool public armed;
21
+
22
+ constructor() ERC20("Reentrant", "RENT") {
23
+ _mint(msg.sender, 1_000_000_000 ether);
24
+ }
25
+
26
+ /// @notice Mint helper so tests can fund arbitrary accounts.
27
+ function mintTo(address to, uint256 amount) external {
28
+ _mint(to, amount);
29
+ }
30
+
31
+ /// @notice Arm the attack: the next `transfer` will re-enter `target` once.
32
+ function arm(address target_, bytes32 jobId_) external {
33
+ target = IReentryTarget(target_);
34
+ jobId = jobId_;
35
+ armed = true;
36
+ }
37
+
38
+ function transfer(address to, uint256 amount) public override returns (bool) {
39
+ if (armed) {
40
+ armed = false; // single shot
41
+ target.verifyAndRelease(jobId); // expected to revert via ReentrancyGuard
42
+ }
43
+ return super.transfer(to, amount);
44
+ }
45
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "chainId": 421614,
3
+ "rpcUrl": "https://sepolia-rollup.arbitrum.io/rpc",
4
+ "contracts": {
5
+ "token": "0x5532663d4d4560d9923e30fb7230b82edcb25531",
6
+ "nodeRegistry": "0xe9674474f7450b8fdc88895f7646d0d5fc34e99a",
7
+ "jobEscrow": "0x9a8be9ad9f980e828757163780aea1ca46303267",
8
+ "creditAccount": "0xc148e3d305a35876d9df211dbc9ef944ab4c8191",
9
+ "disputeResolution": "0x546b548bf5401aad0a21e85ce750aad5e58d8013",
10
+ "protocolTreasury": "0x83acf7b9a8182a6398c1fd80d0e237011e903fa2",
11
+ "stakingRewards": "0x8fa6ec119ae18f0793d1ec0eb0525e9f6f6b648f"
12
+ },
13
+ "treasury": "0x83acf7b9a8182a6398c1fd80d0e237011e903fa2",
14
+ "accounts": {
15
+ "deployer": "0xc80a8137e57d494b195eda12f74d7df324f5b9d6",
16
+ "gateway": "0xc80a8137e57d494b195eda12f74d7df324f5b9d6",
17
+ "node": "0xc80a8137e57d494b195eda12f74d7df324f5b9d6",
18
+ "requester": "0xc80a8137e57d494b195eda12f74d7df324f5b9d6"
19
+ }
20
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "chainId": 31337,
3
+ "rpcUrl": "http://127.0.0.1:8545",
4
+ "contracts": {
5
+ "token": "0x5fbdb2315678afecb367f032d93f642f64180aa3",
6
+ "nodeRegistry": "0x9fe46736679d2d9a65f0992f2272de9f3c7fa6e0",
7
+ "jobEscrow": "0xcf7ed3acca5a467e9e704c703e8d87f634fb0fc9",
8
+ "creditAccount": "0xdc64a140aa3e981100a9beca4e685f962f0cf6c9",
9
+ "disputeResolution": "0x5fc8d32690cc91d4c39d9d3abcbd16989f875707",
10
+ "protocolTreasury": "0xe7f1725e7734ce288f8367e1bb143e90bb3f0512",
11
+ "stakingRewards": "0x0165878a594ca255338adfa4d48449f69242eb8f"
12
+ },
13
+ "treasury": "0xe7f1725e7734ce288f8367e1bb143e90bb3f0512",
14
+ "accounts": {
15
+ "deployer": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266",
16
+ "gateway": "0x70997970c51812dc3a010c7d01b50e0d17dc79c8",
17
+ "node": "0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc",
18
+ "requester": "0x90f79bf6eb2c4f870365e785982e1f101e93b906"
19
+ }
20
+ }