@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.
- package/LICENSE +21 -0
- package/contracts/CreditAccount.sol +271 -0
- package/contracts/DisputeResolution.sol +221 -0
- package/contracts/JobEscrow.sol +307 -0
- package/contracts/NodeRegistry.sol +322 -0
- package/contracts/ProtocolTreasury.sol +157 -0
- package/contracts/QUAISToken.sol +27 -0
- package/contracts/StakingRewards.sol +136 -0
- package/contracts/mocks/ReentrantBatchToken.sol +47 -0
- package/contracts/mocks/ReentrantToken.sol +45 -0
- package/deployments/addresses.arbitrumSepolia.json +20 -0
- package/deployments/addresses.localhost.json +20 -0
- package/dist/abis.d.ts +4327 -0
- package/dist/abis.d.ts.map +1 -0
- package/dist/abis.js +5593 -0
- package/dist/abis.js.map +1 -0
- package/dist/addresses.d.ts +30 -0
- package/dist/addresses.d.ts.map +1 -0
- package/dist/addresses.js +23 -0
- package/dist/addresses.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/package.json +58 -0
|
@@ -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
|
+
}
|