@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,307 @@
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
+
10
+ /**
11
+ * @title JobEscrow
12
+ * @notice The on-chain heart of QueraIS. For each inference job it locks the
13
+ * requester's $QAIS, records the matched provider and agreed price, and on
14
+ * verification atomically splits payment: 95% to the provider, 5% to the
15
+ * protocol treasury, and refunds the unused remainder to the requester.
16
+ *
17
+ * @dev MVP scope:
18
+ * - Per-job lock/settle (no session deposits / batching — gas is free locally).
19
+ * - The gateway holds ORACLE_ROLE (verification) and MATCHING_ENGINE_ROLE
20
+ * (job creation/assignment). Token counts written by the oracle are the
21
+ * gateway's independently-counted authoritative value (see settlement design).
22
+ * - Disputes are deferred; the fail path issues a full refund.
23
+ *
24
+ * JobEscrow is the authoritative job registry: `jobs[jobId]` is the single source
25
+ * of truth for every job's data and status (no separate JobRegistry contract).
26
+ *
27
+ * Invariants enforced by construction and asserted in tests:
28
+ * providerPay + protocolFee == actualPayment
29
+ * actualPayment + refund == lockedAmount
30
+ */
31
+ contract JobEscrow is AccessControl, Pausable, ReentrancyGuard {
32
+ using SafeERC20 for IERC20;
33
+
34
+ bytes32 public constant ORACLE_ROLE = keccak256("ORACLE_ROLE");
35
+ bytes32 public constant MATCHING_ENGINE_ROLE = keccak256("MATCHING_ENGINE_ROLE");
36
+ bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");
37
+
38
+ /// @notice Protocol fee in basis points (500 == 5%). Bounded by MAX_FEE_RATE.
39
+ uint16 public protocolFeeRate = 500;
40
+ uint16 public constant MAX_FEE_RATE = 1000; // 10% hard cap
41
+ uint16 public constant BPS_DENOMINATOR = 10000;
42
+
43
+ enum JobStatus {
44
+ NONE, // 0 — never created
45
+ PENDING, // 1 — funds locked, awaiting assignment
46
+ ASSIGNED, // 2 — provider matched, executing
47
+ COMPLETED, // 3 — provider reported result, awaiting verification
48
+ VERIFIED, // 4 — verified & paid out
49
+ FAILED, // 5 — verification failed or timed out; requester refunded
50
+ CANCELLED // 6 — cancelled before assignment; requester refunded
51
+ }
52
+
53
+ struct Job {
54
+ address requester;
55
+ address provider;
56
+ uint256 lockedAmount;
57
+ uint256 maxPricePerToken;
58
+ uint256 agreedPricePerToken;
59
+ uint256 maxTokens;
60
+ uint256 actualTokens;
61
+ uint64 lockedAt;
62
+ uint64 deadline;
63
+ bytes32 resultHash;
64
+ JobStatus status;
65
+ }
66
+
67
+ IERC20 public immutable token;
68
+ address public treasury;
69
+ mapping(bytes32 => Job) public jobs;
70
+
71
+ // ─── Events ─────────────────────────────────────────────────────────────────
72
+ event JobCreated(
73
+ bytes32 indexed jobId, address indexed requester, uint256 lockedAmount, uint64 deadline
74
+ );
75
+ event JobAssigned(bytes32 indexed jobId, address indexed provider, uint256 pricePerToken);
76
+ event JobCompleted(bytes32 indexed jobId, uint256 actualTokens, bytes32 resultHash);
77
+ event JobVerified(
78
+ bytes32 indexed jobId, uint256 providerPay, uint256 protocolFee, uint256 refund
79
+ );
80
+ event JobFailed(bytes32 indexed jobId, string reason, uint256 refund);
81
+ event JobCancelled(bytes32 indexed jobId, uint256 refund);
82
+ event TreasuryUpdated(address indexed oldTreasury, address indexed newTreasury);
83
+ event ProtocolFeeRateUpdated(uint16 oldRate, uint16 newRate);
84
+
85
+ // ─── Errors ──────────────────────────────────────────────────────────────────
86
+ error ZeroAddress();
87
+ error JobAlreadyExists(bytes32 jobId);
88
+ error UnexpectedStatus(bytes32 jobId, JobStatus have, JobStatus want);
89
+ error ZeroAmount();
90
+ error DeadlineInPast();
91
+ error PriceAboveMax(uint256 agreed, uint256 max);
92
+ error TokensAboveMax(uint256 actual, uint256 max);
93
+ error DeadlineNotReached(uint64 deadline);
94
+ error NotRequester();
95
+ error FeeRateTooHigh(uint16 rate);
96
+
97
+ constructor(IERC20 token_, address treasury_, address admin) {
98
+ if (address(token_) == address(0) || treasury_ == address(0) || admin == address(0)) {
99
+ revert ZeroAddress();
100
+ }
101
+ token = token_;
102
+ treasury = treasury_;
103
+ _grantRole(DEFAULT_ADMIN_ROLE, admin);
104
+ _grantRole(PAUSER_ROLE, admin);
105
+ }
106
+
107
+ // ─── Job lifecycle ───────────────────────────────────────────────────────────
108
+
109
+ /// @notice Create a job and lock `maxPricePerToken * maxTokens` from the requester.
110
+ /// The requester must have approved this contract for that amount.
111
+ function createJob(
112
+ bytes32 jobId,
113
+ address requester,
114
+ uint256 maxPricePerToken,
115
+ uint256 maxTokens,
116
+ uint64 deadline
117
+ ) external onlyRole(MATCHING_ENGINE_ROLE) nonReentrant whenNotPaused {
118
+ if (requester == address(0)) revert ZeroAddress();
119
+ if (maxPricePerToken == 0 || maxTokens == 0) revert ZeroAmount();
120
+ if (deadline <= block.timestamp) revert DeadlineInPast();
121
+ Job storage j = jobs[jobId];
122
+ if (j.status != JobStatus.NONE) revert JobAlreadyExists(jobId);
123
+
124
+ uint256 locked = maxPricePerToken * maxTokens;
125
+
126
+ // Effects
127
+ j.requester = requester;
128
+ j.lockedAmount = locked;
129
+ j.maxPricePerToken = maxPricePerToken;
130
+ j.maxTokens = maxTokens;
131
+ j.lockedAt = uint64(block.timestamp);
132
+ j.deadline = deadline;
133
+ j.status = JobStatus.PENDING;
134
+
135
+ // Interaction
136
+ token.safeTransferFrom(requester, address(this), locked);
137
+
138
+ emit JobCreated(jobId, requester, locked, deadline);
139
+ }
140
+
141
+ /// @notice Assign a matched provider and the agreed (winning) price.
142
+ function assignJob(bytes32 jobId, address provider, uint256 agreedPricePerToken)
143
+ external
144
+ onlyRole(MATCHING_ENGINE_ROLE)
145
+ whenNotPaused
146
+ {
147
+ if (provider == address(0)) revert ZeroAddress();
148
+ if (agreedPricePerToken == 0) revert ZeroAmount();
149
+ Job storage j = jobs[jobId];
150
+ if (j.status != JobStatus.PENDING) {
151
+ revert UnexpectedStatus(jobId, j.status, JobStatus.PENDING);
152
+ }
153
+ if (agreedPricePerToken > j.maxPricePerToken) {
154
+ revert PriceAboveMax(agreedPricePerToken, j.maxPricePerToken);
155
+ }
156
+
157
+ j.provider = provider;
158
+ j.agreedPricePerToken = agreedPricePerToken;
159
+ j.status = JobStatus.ASSIGNED;
160
+
161
+ emit JobAssigned(jobId, provider, agreedPricePerToken);
162
+ }
163
+
164
+ /// @notice Record the provider's result. `actualTokens` is the gateway's
165
+ /// authoritative (independently counted) token total.
166
+ function completeJob(bytes32 jobId, uint256 actualTokens, bytes32 resultHash)
167
+ external
168
+ onlyRole(ORACLE_ROLE)
169
+ whenNotPaused
170
+ {
171
+ if (actualTokens == 0) revert ZeroAmount();
172
+ Job storage j = jobs[jobId];
173
+ if (j.status != JobStatus.ASSIGNED) {
174
+ revert UnexpectedStatus(jobId, j.status, JobStatus.ASSIGNED);
175
+ }
176
+ if (actualTokens > j.maxTokens) revert TokensAboveMax(actualTokens, j.maxTokens);
177
+
178
+ j.actualTokens = actualTokens;
179
+ j.resultHash = resultHash;
180
+ j.status = JobStatus.COMPLETED;
181
+
182
+ emit JobCompleted(jobId, actualTokens, resultHash);
183
+ }
184
+
185
+ /// @notice Verify a completed job and atomically settle: 95% provider, 5% treasury,
186
+ /// remainder refunded to the requester.
187
+ function verifyAndRelease(bytes32 jobId)
188
+ external
189
+ onlyRole(ORACLE_ROLE)
190
+ nonReentrant
191
+ whenNotPaused
192
+ {
193
+ Job storage j = jobs[jobId];
194
+ if (j.status != JobStatus.COMPLETED) {
195
+ revert UnexpectedStatus(jobId, j.status, JobStatus.COMPLETED);
196
+ }
197
+
198
+ uint256 actualPayment = j.actualTokens * j.agreedPricePerToken;
199
+ uint256 fee = (actualPayment * protocolFeeRate) / BPS_DENOMINATOR;
200
+ uint256 providerPay = actualPayment - fee;
201
+ uint256 refund = j.lockedAmount - actualPayment; // actualPayment <= locked by construction
202
+
203
+ address provider = j.provider;
204
+ address requester = j.requester;
205
+
206
+ // Effects
207
+ j.status = JobStatus.VERIFIED;
208
+
209
+ // Interactions (CEI): pay provider, fee, then refund.
210
+ if (providerPay > 0) token.safeTransfer(provider, providerPay);
211
+ if (fee > 0) token.safeTransfer(treasury, fee);
212
+ if (refund > 0) token.safeTransfer(requester, refund);
213
+
214
+ emit JobVerified(jobId, providerPay, fee, refund);
215
+ }
216
+
217
+ /// @notice Fail a job (e.g. Layer-B verification failure) and fully refund the
218
+ /// requester. Callable from ASSIGNED or COMPLETED.
219
+ function failJob(bytes32 jobId, string calldata reason)
220
+ external
221
+ onlyRole(ORACLE_ROLE)
222
+ nonReentrant
223
+ {
224
+ Job storage j = jobs[jobId];
225
+ if (j.status != JobStatus.ASSIGNED && j.status != JobStatus.COMPLETED) {
226
+ revert UnexpectedStatus(jobId, j.status, JobStatus.ASSIGNED);
227
+ }
228
+ uint256 refund = j.lockedAmount;
229
+ address requester = j.requester;
230
+
231
+ j.status = JobStatus.FAILED;
232
+ if (refund > 0) token.safeTransfer(requester, refund);
233
+
234
+ emit JobFailed(jobId, reason, refund);
235
+ }
236
+
237
+ /// @notice Cancel a job that was never assigned, refunding the requester. Callable
238
+ /// by the requester or the matching engine.
239
+ function cancelJob(bytes32 jobId) external nonReentrant {
240
+ Job storage j = jobs[jobId];
241
+ if (j.status != JobStatus.PENDING) {
242
+ revert UnexpectedStatus(jobId, j.status, JobStatus.PENDING);
243
+ }
244
+ if (msg.sender != j.requester && !hasRole(MATCHING_ENGINE_ROLE, msg.sender)) {
245
+ revert NotRequester();
246
+ }
247
+ uint256 refund = j.lockedAmount;
248
+ address requester = j.requester;
249
+
250
+ j.status = JobStatus.CANCELLED;
251
+ if (refund > 0) token.safeTransfer(requester, refund);
252
+
253
+ emit JobCancelled(jobId, refund);
254
+ }
255
+
256
+ /// @notice After the deadline, anyone can time out an unfinished ASSIGNED job and
257
+ /// refund the requester. Provider slashing is handled off-chain in the MVP.
258
+ function timeoutJob(bytes32 jobId) external nonReentrant {
259
+ Job storage j = jobs[jobId];
260
+ if (j.status != JobStatus.ASSIGNED) {
261
+ revert UnexpectedStatus(jobId, j.status, JobStatus.ASSIGNED);
262
+ }
263
+ if (block.timestamp <= j.deadline) revert DeadlineNotReached(j.deadline);
264
+
265
+ uint256 refund = j.lockedAmount;
266
+ address requester = j.requester;
267
+
268
+ j.status = JobStatus.FAILED;
269
+ if (refund > 0) token.safeTransfer(requester, refund);
270
+
271
+ emit JobFailed(jobId, "timeout", refund);
272
+ }
273
+
274
+ // ─── Admin ───────────────────────────────────────────────────────────────────
275
+
276
+ function setTreasury(address newTreasury) external onlyRole(DEFAULT_ADMIN_ROLE) {
277
+ if (newTreasury == address(0)) revert ZeroAddress();
278
+ address old = treasury;
279
+ treasury = newTreasury;
280
+ emit TreasuryUpdated(old, newTreasury);
281
+ }
282
+
283
+ function setProtocolFeeRate(uint16 newRate) external onlyRole(DEFAULT_ADMIN_ROLE) {
284
+ if (newRate > MAX_FEE_RATE) revert FeeRateTooHigh(newRate);
285
+ uint16 old = protocolFeeRate;
286
+ protocolFeeRate = newRate;
287
+ emit ProtocolFeeRateUpdated(old, newRate);
288
+ }
289
+
290
+ function pause() external onlyRole(PAUSER_ROLE) {
291
+ _pause();
292
+ }
293
+
294
+ function unpause() external onlyRole(PAUSER_ROLE) {
295
+ _unpause();
296
+ }
297
+
298
+ // ─── Views ───────────────────────────────────────────────────────────────────
299
+
300
+ function getJob(bytes32 jobId) external view returns (Job memory) {
301
+ return jobs[jobId];
302
+ }
303
+
304
+ function statusOf(bytes32 jobId) external view returns (JobStatus) {
305
+ return jobs[jobId].status;
306
+ }
307
+ }
@@ -0,0 +1,322 @@
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
+
10
+ /**
11
+ * @title NodeRegistry
12
+ * @notice Tracks node operators: their $QAIS stake, tier, reputation, and lifecycle
13
+ * (active / unbonding / suspended). Stake is collateral that backs honest
14
+ * inference; reputation is computed off-chain (EMA) and pushed on-chain by the
15
+ * oracle. This is the MVP slice — disputes are deferred; slashing is gated to a
16
+ * SLASHER_ROLE held by the protocol/gateway rather than a DisputeResolution
17
+ * contract.
18
+ *
19
+ * @dev Reputation is a uint16 in basis points of [0,1]: 10000 == 1.0000. New nodes
20
+ * start at 7000 (0.70), matching the reputation system's onboarding baseline.
21
+ */
22
+ contract NodeRegistry is AccessControl, Pausable, ReentrancyGuard {
23
+ using SafeERC20 for IERC20;
24
+
25
+ // ─── Roles ────────────────────────────────────────────────────────────────
26
+ bytes32 public constant ORACLE_ROLE = keccak256("ORACLE_ROLE");
27
+ bytes32 public constant SLASHER_ROLE = keccak256("SLASHER_ROLE");
28
+ bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");
29
+
30
+ // ─── Tier thresholds (in QAIS wei) ──────────────────────────────────────────
31
+ uint256 public constant BRONZE_THRESHOLD = 100 ether;
32
+ uint256 public constant SILVER_THRESHOLD = 500 ether;
33
+ uint256 public constant GOLD_THRESHOLD = 2_500 ether;
34
+ uint256 public constant PLATINUM_THRESHOLD = 10_000 ether;
35
+
36
+ /// @notice Reputation assigned to a freshly registered node (0.70 in bps).
37
+ uint16 public constant INITIAL_REPUTATION = 7000;
38
+ uint16 public constant MAX_REPUTATION = 10000;
39
+
40
+ /// @notice Time a node must wait after initiating unbonding before withdrawing stake.
41
+ uint64 public constant UNBONDING_PERIOD = 7 days;
42
+
43
+ struct NodeInfo {
44
+ bytes32 nodeId; // libp2p-style peer id hash (informational in MVP)
45
+ uint256 stakeAmount; // current staked QAIS (wei)
46
+ uint16 reputationScore; // 0..10000 == 0.0..1.0
47
+ uint8 tier; // 0=Bronze 1=Silver 2=Gold 3=Platinum
48
+ uint64 registeredAt; // for off-chain longevity scoring
49
+ uint64 unbondingStartedAt; // 0 unless unbonding
50
+ uint64 suspendedAt; // 0 unless suspended by a sub-minimum slash
51
+ bool isActive; // visible & able to accept jobs
52
+ bool isUnbonding; // withdrawing stake
53
+ bool exists; // registered at all
54
+ }
55
+
56
+ IERC20 public immutable token;
57
+
58
+ mapping(address => NodeInfo) private _nodes;
59
+ mapping(bytes32 => address) public nodeIdToWallet;
60
+ address[] private _activeNodes;
61
+ mapping(address => uint256) private _activeIndex; // 1-based; 0 == not in array
62
+ uint256 public totalStaked;
63
+
64
+ // ─── Events ─────────────────────────────────────────────────────────────────
65
+ event NodeRegistered(address indexed wallet, bytes32 indexed nodeId, uint256 stake, uint8 tier);
66
+ event StakeAdded(address indexed wallet, uint256 newTotal, uint8 newTier);
67
+ event NodeUnbonding(address indexed wallet, uint64 unbondingCompleteAt);
68
+ event NodeUnbonded(address indexed wallet, uint256 returnedAmount);
69
+ event ReputationUpdated(address indexed wallet, uint16 oldScore, uint16 newScore);
70
+ event NodeSlashed(address indexed wallet, uint256 amount, string reason, uint256 remainingStake);
71
+ event NodeSuspended(address indexed wallet, uint64 suspendedAt);
72
+ event NodeReactivated(address indexed wallet, uint8 tier);
73
+
74
+ // ─── Errors ──────────────────────────────────────────────────────────────────
75
+ error ZeroAddress();
76
+ error AlreadyRegistered();
77
+ error NotRegistered();
78
+ error StakeBelowMinimum(uint256 provided, uint256 required);
79
+ error NodeIdTaken(bytes32 nodeId);
80
+ error NotActive();
81
+ error AlreadyUnbonding();
82
+ error NotUnbonding();
83
+ error UnbondingNotComplete(uint64 readyAt);
84
+ error InvalidReputation(uint16 score);
85
+ error AmountExceedsStake(uint256 amount, uint256 stake);
86
+ error ZeroAmount();
87
+
88
+ constructor(IERC20 token_, address admin) {
89
+ if (address(token_) == address(0) || admin == address(0)) revert ZeroAddress();
90
+ token = token_;
91
+ _grantRole(DEFAULT_ADMIN_ROLE, admin);
92
+ _grantRole(PAUSER_ROLE, admin);
93
+ }
94
+
95
+ // ─── Registration & staking ──────────────────────────────────────────────────
96
+
97
+ /// @notice Register the caller as a node by staking QAIS. Caller must `approve` first.
98
+ function registerNode(bytes32 nodeId, uint256 stake) external nonReentrant whenNotPaused {
99
+ NodeInfo storage n = _nodes[msg.sender];
100
+ if (n.exists) revert AlreadyRegistered();
101
+ if (stake < BRONZE_THRESHOLD) revert StakeBelowMinimum(stake, BRONZE_THRESHOLD);
102
+ if (nodeId == bytes32(0)) revert ZeroAmount();
103
+ if (nodeIdToWallet[nodeId] != address(0)) revert NodeIdTaken(nodeId);
104
+
105
+ // Effects
106
+ n.nodeId = nodeId;
107
+ n.stakeAmount = stake;
108
+ n.reputationScore = INITIAL_REPUTATION;
109
+ n.tier = _tierFor(stake);
110
+ n.registeredAt = uint64(block.timestamp);
111
+ n.isActive = true;
112
+ n.exists = true;
113
+ nodeIdToWallet[nodeId] = msg.sender;
114
+ totalStaked += stake;
115
+ _addActive(msg.sender);
116
+
117
+ // Interaction
118
+ token.safeTransferFrom(msg.sender, address(this), stake);
119
+
120
+ emit NodeRegistered(msg.sender, nodeId, stake, n.tier);
121
+ }
122
+
123
+ /// @notice Add stake to an existing node. May promote tier and/or reactivate a
124
+ /// suspended node whose stake returns above the bronze minimum.
125
+ function addStake(uint256 amount) external nonReentrant whenNotPaused {
126
+ if (amount == 0) revert ZeroAmount();
127
+ NodeInfo storage n = _nodes[msg.sender];
128
+ if (!n.exists) revert NotRegistered();
129
+
130
+ n.stakeAmount += amount;
131
+ n.tier = _tierFor(n.stakeAmount);
132
+ totalStaked += amount;
133
+
134
+ // Reactivate a suspended (but not unbonding) node that is now solvent again.
135
+ if (!n.isActive && !n.isUnbonding && n.stakeAmount >= BRONZE_THRESHOLD) {
136
+ n.isActive = true;
137
+ n.suspendedAt = 0;
138
+ _addActive(msg.sender);
139
+ emit NodeReactivated(msg.sender, n.tier);
140
+ }
141
+
142
+ token.safeTransferFrom(msg.sender, address(this), amount);
143
+ emit StakeAdded(msg.sender, n.stakeAmount, n.tier);
144
+ }
145
+
146
+ // ─── Unbonding / withdrawal ──────────────────────────────────────────────────
147
+
148
+ /// @notice Begin the unbonding countdown; node leaves the marketplace immediately.
149
+ function initiateUnbonding() external whenNotPaused {
150
+ NodeInfo storage n = _nodes[msg.sender];
151
+ if (!n.exists) revert NotRegistered();
152
+ if (n.isUnbonding) revert AlreadyUnbonding();
153
+
154
+ n.isUnbonding = true;
155
+ n.unbondingStartedAt = uint64(block.timestamp);
156
+ if (n.isActive) {
157
+ n.isActive = false;
158
+ _removeActive(msg.sender);
159
+ }
160
+ emit NodeUnbonding(msg.sender, uint64(block.timestamp) + UNBONDING_PERIOD);
161
+ }
162
+
163
+ /// @notice After the unbonding period, return the full stake and delete the node.
164
+ function completeUnbonding() external nonReentrant {
165
+ NodeInfo storage n = _nodes[msg.sender];
166
+ if (!n.exists) revert NotRegistered();
167
+ if (!n.isUnbonding) revert NotUnbonding();
168
+ uint64 readyAt = n.unbondingStartedAt + UNBONDING_PERIOD;
169
+ if (block.timestamp < readyAt) revert UnbondingNotComplete(readyAt);
170
+
171
+ uint256 amount = n.stakeAmount;
172
+ bytes32 nodeId = n.nodeId;
173
+
174
+ // Effects: wipe state before transferring out.
175
+ totalStaked -= amount;
176
+ delete nodeIdToWallet[nodeId];
177
+ delete _nodes[msg.sender];
178
+
179
+ // Interaction
180
+ if (amount > 0) token.safeTransfer(msg.sender, amount);
181
+ emit NodeUnbonded(msg.sender, amount);
182
+ }
183
+
184
+ // ─── Oracle / slasher actions ────────────────────────────────────────────────
185
+
186
+ /// @notice Push an off-chain-computed reputation score on-chain.
187
+ function updateReputation(address wallet, uint16 newScore) external onlyRole(ORACLE_ROLE) {
188
+ NodeInfo storage n = _nodes[wallet];
189
+ if (!n.exists) revert NotRegistered();
190
+ if (newScore > MAX_REPUTATION) revert InvalidReputation(newScore);
191
+ uint16 old = n.reputationScore;
192
+ n.reputationScore = newScore;
193
+ emit ReputationUpdated(wallet, old, newScore);
194
+ }
195
+
196
+ /// @notice Slash a node's stake. Proceeds go to the treasury (burn/dispute split
197
+ /// deferred). A slash that drops stake below the bronze minimum suspends
198
+ /// the node; it can recover by adding stake or unbond after the period.
199
+ function slash(address wallet, uint256 amount, string calldata reason)
200
+ external
201
+ onlyRole(SLASHER_ROLE)
202
+ nonReentrant
203
+ {
204
+ _slash(wallet, amount, reason);
205
+ // Slashed tokens remain in the contract balance (conceptually the treasury's);
206
+ // the per-incident gateway slash keeps this legacy routing.
207
+ }
208
+
209
+ /// @notice Slash a node's stake and route the proceeds to `recipient` — the
210
+ /// DisputeResolution contract distributes them per the spec's
211
+ /// 50% burn / 30% challenger / 20% treasury split (Slice 5B).
212
+ function slashTo(address wallet, uint256 amount, address recipient, string calldata reason)
213
+ external
214
+ onlyRole(SLASHER_ROLE)
215
+ nonReentrant
216
+ {
217
+ if (recipient == address(0)) revert ZeroAddress();
218
+ _slash(wallet, amount, reason);
219
+ token.safeTransfer(recipient, amount);
220
+ }
221
+
222
+ function _slash(address wallet, uint256 amount, string calldata reason) internal {
223
+ if (amount == 0) revert ZeroAmount();
224
+ NodeInfo storage n = _nodes[wallet];
225
+ if (!n.exists) revert NotRegistered();
226
+ if (amount > n.stakeAmount) revert AmountExceedsStake(amount, n.stakeAmount);
227
+
228
+ n.stakeAmount -= amount;
229
+ totalStaked -= amount;
230
+ n.tier = _tierFor(n.stakeAmount);
231
+
232
+ if (n.isActive && n.stakeAmount < BRONZE_THRESHOLD) {
233
+ n.isActive = false;
234
+ n.suspendedAt = uint64(block.timestamp);
235
+ _removeActive(wallet);
236
+ emit NodeSuspended(wallet, n.suspendedAt);
237
+ }
238
+
239
+ emit NodeSlashed(wallet, amount, reason, n.stakeAmount);
240
+ }
241
+
242
+ // ─── Admin ───────────────────────────────────────────────────────────────────
243
+
244
+ function pause() external onlyRole(PAUSER_ROLE) {
245
+ _pause();
246
+ }
247
+
248
+ function unpause() external onlyRole(PAUSER_ROLE) {
249
+ _unpause();
250
+ }
251
+
252
+ // ─── Views ───────────────────────────────────────────────────────────────────
253
+
254
+ function getNode(address wallet) external view returns (NodeInfo memory) {
255
+ return _nodes[wallet];
256
+ }
257
+
258
+ function exists(address wallet) external view returns (bool) {
259
+ return _nodes[wallet].exists;
260
+ }
261
+
262
+ /// @notice True if the node is active and meets the reputation floor — the cheap
263
+ /// on-chain check the gateway uses to validate an off-chain match.
264
+ function isEligible(address wallet, uint16 minReputation) public view returns (bool) {
265
+ NodeInfo storage n = _nodes[wallet];
266
+ return n.exists && n.isActive && n.reputationScore >= minReputation;
267
+ }
268
+
269
+ function activeNodeCount() external view returns (uint256) {
270
+ return _activeNodes.length;
271
+ }
272
+
273
+ function activeNodeAt(uint256 index) external view returns (address) {
274
+ return _activeNodes[index];
275
+ }
276
+
277
+ /// @notice All active nodes meeting the reputation floor. Off-chain matching does
278
+ /// the model/price/region filtering against capability data it holds.
279
+ function getEligibleNodes(uint16 minReputation) external view returns (address[] memory) {
280
+ uint256 len = _activeNodes.length;
281
+ address[] memory tmp = new address[](len);
282
+ uint256 count;
283
+ for (uint256 i; i < len; ++i) {
284
+ address w = _activeNodes[i];
285
+ if (_nodes[w].reputationScore >= minReputation) {
286
+ tmp[count++] = w;
287
+ }
288
+ }
289
+ // shrink to fit
290
+ assembly {
291
+ mstore(tmp, count)
292
+ }
293
+ return tmp;
294
+ }
295
+
296
+ // ─── Internal helpers ─────────────────────────────────────────────────────────
297
+
298
+ function _tierFor(uint256 stake) internal pure returns (uint8) {
299
+ if (stake >= PLATINUM_THRESHOLD) return 3;
300
+ if (stake >= GOLD_THRESHOLD) return 2;
301
+ if (stake >= SILVER_THRESHOLD) return 1;
302
+ return 0;
303
+ }
304
+
305
+ function _addActive(address wallet) internal {
306
+ _activeNodes.push(wallet);
307
+ _activeIndex[wallet] = _activeNodes.length; // 1-based
308
+ }
309
+
310
+ function _removeActive(address wallet) internal {
311
+ uint256 idx = _activeIndex[wallet];
312
+ if (idx == 0) return; // not present
313
+ uint256 last = _activeNodes.length;
314
+ if (idx != last) {
315
+ address moved = _activeNodes[last - 1];
316
+ _activeNodes[idx - 1] = moved;
317
+ _activeIndex[moved] = idx;
318
+ }
319
+ _activeNodes.pop();
320
+ _activeIndex[wallet] = 0;
321
+ }
322
+ }