@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 QueraIS contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,271 @@
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 {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
10
+ import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
11
+
12
+ /**
13
+ * @title CreditAccount
14
+ * @notice Session-deposit settlement for QueraIS. A requester pre-funds a $QAIS balance
15
+ * once, signs an EIP-712 spending cap off-chain (zero gas), and the gateway then
16
+ * serves many inference jobs and settles them in a single `batchSettle` tx —
17
+ * turning per-call cost from 1–4 on-chain txs into ≈ 0. Each debit splits payment
18
+ * 95% provider / 5% treasury, exactly like JobEscrow.
19
+ *
20
+ * @dev Trust model (Phase 1): the gateway holds SETTLER_ROLE and pays settlement gas. The
21
+ * requester's signed `SpendingCap` is the on-chain authorization that BOUNDS what the
22
+ * gateway can ever debit: settlement is capped at `maxSpendWei` per (requester, nonce),
23
+ * can only pay the providers/amounts in the signed batch, and never touches principal
24
+ * beyond the cap. Funds leave only via `batchSettle` (to providers/treasury) or
25
+ * withdraw-after-notice (back to the requester). A compromised settler cannot steal
26
+ * deposited principal — worst case is settling up to already-signed caps.
27
+ *
28
+ * Replay/idempotency: every debit carries a `jobId`; a job settles at most once
29
+ * (`settledJob`). `spentAgainst[requester][nonce]` accumulates across batches so one
30
+ * signature funds incremental settlement until the cap or the deposit is exhausted.
31
+ *
32
+ * Invariants asserted in tests:
33
+ * sum(providerPay) + protocolFee == sum(debit.amountWei) (per batch)
34
+ * balanceAfter + totalSettled == balanceBefore (conservation)
35
+ * spentAgainst[r][n] <= cap.maxSpendWei
36
+ */
37
+ contract CreditAccount is AccessControl, Pausable, ReentrancyGuard, EIP712 {
38
+ using SafeERC20 for IERC20;
39
+
40
+ bytes32 public constant SETTLER_ROLE = keccak256("SETTLER_ROLE");
41
+ bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");
42
+
43
+ /// @notice Protocol fee in basis points (500 == 5%). Bounded by MAX_FEE_RATE.
44
+ uint16 public protocolFeeRate = 500;
45
+ uint16 public constant MAX_FEE_RATE = 1000; // 10% hard cap
46
+ uint16 public constant BPS_DENOMINATOR = 10000;
47
+
48
+ /// @notice Delay between initiating and completing a withdrawal, so the gateway can
49
+ /// flush any pending signed debits before the requester pulls funds out.
50
+ uint64 public constant WITHDRAWAL_NOTICE = 1 days;
51
+
52
+ /// @dev EIP-712 typehash for the spending cap a requester signs off-chain.
53
+ bytes32 public constant SPENDING_CAP_TYPEHASH = keccak256(
54
+ "SpendingCap(address requester,address settler,uint256 maxSpendWei,uint256 nonce,uint256 deadline)"
55
+ );
56
+
57
+ struct SpendingCap {
58
+ address requester; // who is authorizing the spend (and signs)
59
+ address settler; // the only address allowed to submit batches for this cap
60
+ uint256 maxSpendWei; // cumulative ceiling across all batches for this (requester, nonce)
61
+ uint256 nonce; // namespaces independent sessions for the same requester
62
+ uint256 deadline; // unix seconds; batches rejected after this
63
+ }
64
+
65
+ struct Debit {
66
+ bytes32 jobId; // unique per job; settled at most once
67
+ address provider; // node that served the job
68
+ uint256 amountWei; // gross payment (split 95/5 here)
69
+ }
70
+
71
+ IERC20 public immutable token;
72
+ address public treasury;
73
+
74
+ /// @notice Deposited, unspent $QAIS per requester.
75
+ mapping(address => uint256) public balanceOf;
76
+ /// @notice Cumulative gross settled against a (requester, nonce) cap.
77
+ mapping(address => mapping(uint256 => uint256)) public spentAgainst;
78
+ /// @notice Jobs already settled (replay/idempotency guard).
79
+ mapping(bytes32 => bool) public settledJob;
80
+ /// @notice Earliest timestamp a requester may complete a withdrawal (0 == none pending).
81
+ mapping(address => uint64) public withdrawableAt;
82
+
83
+ // ─── Events ─────────────────────────────────────────────────────────────────
84
+ event Deposited(address indexed requester, uint256 amount, uint256 newBalance);
85
+ event BatchSettled(
86
+ address indexed requester,
87
+ address indexed settler,
88
+ uint256 nonce,
89
+ uint256 jobCount,
90
+ uint256 totalPaid,
91
+ uint256 protocolFee
92
+ );
93
+ event DebitSettled(
94
+ bytes32 indexed jobId, address indexed provider, uint256 providerPay, uint256 protocolFee
95
+ );
96
+ event WithdrawalInitiated(address indexed requester, uint64 availableAt);
97
+ event WithdrawalCompleted(address indexed requester, uint256 amount);
98
+ event TreasuryUpdated(address indexed oldTreasury, address indexed newTreasury);
99
+ event ProtocolFeeRateUpdated(uint16 oldRate, uint16 newRate);
100
+
101
+ // ─── Errors ──────────────────────────────────────────────────────────────────
102
+ error ZeroAddress();
103
+ error ZeroAmount();
104
+ error EmptyBatch();
105
+ error CapExpired(uint256 deadline);
106
+ error WrongSettler(address expected, address actual);
107
+ error BadSignature(address recovered, address requester);
108
+ error CapExceeded(uint256 wouldSpend, uint256 maxSpendWei);
109
+ error InsufficientBalance(uint256 needed, uint256 available);
110
+ error JobAlreadySettled(bytes32 jobId);
111
+ error NoWithdrawalPending();
112
+ error WithdrawalNotReady(uint64 availableAt);
113
+ error NothingToWithdraw();
114
+ error FeeRateTooHigh(uint16 rate);
115
+
116
+ constructor(IERC20 token_, address treasury_, address admin)
117
+ EIP712("QueraIS CreditAccount", "1")
118
+ {
119
+ if (address(token_) == address(0) || treasury_ == address(0) || admin == address(0)) {
120
+ revert ZeroAddress();
121
+ }
122
+ token = token_;
123
+ treasury = treasury_;
124
+ _grantRole(DEFAULT_ADMIN_ROLE, admin);
125
+ _grantRole(PAUSER_ROLE, admin);
126
+ }
127
+
128
+ // ─── Deposit / withdraw ────────────────────────────────────────────────────────
129
+
130
+ /// @notice Pre-fund a credit balance. The caller must have approved this contract.
131
+ function deposit(uint256 amount) external nonReentrant whenNotPaused {
132
+ if (amount == 0) revert ZeroAmount();
133
+ // Effect first (CEI); SafeERC20 reverts on transfer failure so balance stays sound.
134
+ balanceOf[msg.sender] += amount;
135
+ // Re-depositing cancels any pending withdrawal (the requester is committing funds).
136
+ if (withdrawableAt[msg.sender] != 0) withdrawableAt[msg.sender] = 0;
137
+ token.safeTransferFrom(msg.sender, address(this), amount);
138
+ emit Deposited(msg.sender, amount, balanceOf[msg.sender]);
139
+ }
140
+
141
+ /// @notice Start the withdrawal notice window. Settlement of already-signed debits can
142
+ /// still happen during the window; after it, the residual balance is withdrawable.
143
+ function initiateWithdrawal() external {
144
+ uint64 at = uint64(block.timestamp) + WITHDRAWAL_NOTICE;
145
+ withdrawableAt[msg.sender] = at;
146
+ emit WithdrawalInitiated(msg.sender, at);
147
+ }
148
+
149
+ /// @notice Withdraw the entire remaining balance once the notice window has elapsed.
150
+ function completeWithdrawal() external nonReentrant {
151
+ uint64 at = withdrawableAt[msg.sender];
152
+ if (at == 0) revert NoWithdrawalPending();
153
+ if (block.timestamp < at) revert WithdrawalNotReady(at);
154
+ uint256 amount = balanceOf[msg.sender];
155
+ if (amount == 0) revert NothingToWithdraw();
156
+
157
+ // Effects
158
+ balanceOf[msg.sender] = 0;
159
+ withdrawableAt[msg.sender] = 0;
160
+
161
+ // Interaction
162
+ token.safeTransfer(msg.sender, amount);
163
+ emit WithdrawalCompleted(msg.sender, amount);
164
+ }
165
+
166
+ // ─── Settlement ──────────────────────────────────────────────────────────────
167
+
168
+ /// @notice Settle a batch of debits against a requester's signed spending cap. Pays each
169
+ /// provider 95% and the treasury 5%, all in one tx. Only the cap's named settler
170
+ /// may submit it, and only up to the signed ceiling / deposited balance.
171
+ function batchSettle(SpendingCap calldata cap, bytes calldata signature, Debit[] calldata debits)
172
+ external
173
+ onlyRole(SETTLER_ROLE)
174
+ nonReentrant
175
+ whenNotPaused
176
+ {
177
+ if (debits.length == 0) revert EmptyBatch();
178
+ if (block.timestamp > cap.deadline) revert CapExpired(cap.deadline);
179
+ if (cap.settler != msg.sender) revert WrongSettler(cap.settler, msg.sender);
180
+
181
+ // Verify the EIP-712 signature authorizes this cap.
182
+ address signer = ECDSA.recover(_hashCap(cap), signature);
183
+ if (signer != cap.requester) revert BadSignature(signer, cap.requester);
184
+
185
+ // Sum the batch and guard against double-settling any job (CEI: mark before transfer).
186
+ uint256 total;
187
+ for (uint256 i = 0; i < debits.length; i++) {
188
+ Debit calldata d = debits[i];
189
+ if (d.provider == address(0)) revert ZeroAddress();
190
+ if (d.amountWei == 0) revert ZeroAmount();
191
+ if (settledJob[d.jobId]) revert JobAlreadySettled(d.jobId);
192
+ settledJob[d.jobId] = true;
193
+ total += d.amountWei;
194
+ }
195
+
196
+ uint256 wouldSpend = spentAgainst[cap.requester][cap.nonce] + total;
197
+ if (wouldSpend > cap.maxSpendWei) revert CapExceeded(wouldSpend, cap.maxSpendWei);
198
+ if (total > balanceOf[cap.requester]) {
199
+ revert InsufficientBalance(total, balanceOf[cap.requester]);
200
+ }
201
+
202
+ // Effects: debit the requester before any payout.
203
+ balanceOf[cap.requester] -= total;
204
+ spentAgainst[cap.requester][cap.nonce] = wouldSpend;
205
+
206
+ // Interactions: pay providers individually, accrue the fee, then one treasury transfer.
207
+ uint256 totalFee;
208
+ for (uint256 i = 0; i < debits.length; i++) {
209
+ Debit calldata d = debits[i];
210
+ uint256 fee = (d.amountWei * protocolFeeRate) / BPS_DENOMINATOR;
211
+ uint256 providerPay = d.amountWei - fee;
212
+ totalFee += fee;
213
+ if (providerPay > 0) token.safeTransfer(d.provider, providerPay);
214
+ emit DebitSettled(d.jobId, d.provider, providerPay, fee);
215
+ }
216
+ if (totalFee > 0) token.safeTransfer(treasury, totalFee);
217
+
218
+ emit BatchSettled(cap.requester, msg.sender, cap.nonce, debits.length, total, totalFee);
219
+ }
220
+
221
+ // ─── Admin ───────────────────────────────────────────────────────────────────
222
+
223
+ function setTreasury(address newTreasury) external onlyRole(DEFAULT_ADMIN_ROLE) {
224
+ if (newTreasury == address(0)) revert ZeroAddress();
225
+ address old = treasury;
226
+ treasury = newTreasury;
227
+ emit TreasuryUpdated(old, newTreasury);
228
+ }
229
+
230
+ function setProtocolFeeRate(uint16 newRate) external onlyRole(DEFAULT_ADMIN_ROLE) {
231
+ if (newRate > MAX_FEE_RATE) revert FeeRateTooHigh(newRate);
232
+ uint16 old = protocolFeeRate;
233
+ protocolFeeRate = newRate;
234
+ emit ProtocolFeeRateUpdated(old, newRate);
235
+ }
236
+
237
+ function pause() external onlyRole(PAUSER_ROLE) {
238
+ _pause();
239
+ }
240
+
241
+ function unpause() external onlyRole(PAUSER_ROLE) {
242
+ _unpause();
243
+ }
244
+
245
+ // ─── Views ───────────────────────────────────────────────────────────────────
246
+
247
+ /// @notice The EIP-712 digest a requester signs for `cap` (exposed for off-chain parity).
248
+ function hashSpendingCap(SpendingCap calldata cap) external view returns (bytes32) {
249
+ return _hashCap(cap);
250
+ }
251
+
252
+ /// @notice The EIP-712 domain separator (for off-chain signing parity / tests).
253
+ function domainSeparator() external view returns (bytes32) {
254
+ return _domainSeparatorV4();
255
+ }
256
+
257
+ function _hashCap(SpendingCap calldata cap) internal view returns (bytes32) {
258
+ return _hashTypedDataV4(
259
+ keccak256(
260
+ abi.encode(
261
+ SPENDING_CAP_TYPEHASH,
262
+ cap.requester,
263
+ cap.settler,
264
+ cap.maxSpendWei,
265
+ cap.nonce,
266
+ cap.deadline
267
+ )
268
+ )
269
+ );
270
+ }
271
+ }
@@ -0,0 +1,221 @@
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
+ import {NodeRegistry} from "./NodeRegistry.sol";
11
+
12
+ /**
13
+ * @title DisputeResolution
14
+ * @notice The Slice-5B challenge hook: the FAST track of the dispute design
15
+ * (querais_smart_contracts.md §5). A challenger posts a 50-QAIS bond against a
16
+ * provider; the provider may submit counter-evidence within 24h; the trusted
17
+ * verification oracle resolves clear-cut cases (`autoResolve`) — challenger
18
+ * wins → 20%-of-stake slash routed 50% burn / 30% challenger / 20% treasury and
19
+ * the bond returns; provider wins → the bond burns (deters frivolous disputes).
20
+ *
21
+ * @dev Deliberately minimal: the arbitration panel, commit-reveal voting, and
22
+ * escalation tracks are Phase 5. Evidence is content hashes only (IPFS later) —
23
+ * the chain stores commitments, never prompt/output text. Disputes act on STAKE,
24
+ * not escrow: Layer-A samples settled jobs (both venues), so the payment already
25
+ * moved; the deterrent is the slash. Pause semantics follow the protocol rule —
26
+ * value inflows (raiseDispute) and settlement (autoResolve) freeze, while the
27
+ * defendant's counter-evidence and the challenger's timeout reclaim stay open
28
+ * (a pause can never trap funds or silence a defense).
29
+ */
30
+ contract DisputeResolution is AccessControl, Pausable, ReentrancyGuard {
31
+ using SafeERC20 for IERC20;
32
+
33
+ // ─── Roles ────────────────────────────────────────────────────────────────
34
+ bytes32 public constant ORACLE_ROLE = keccak256("ORACLE_ROLE");
35
+ bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");
36
+
37
+ // ─── Economics (spec §5) ──────────────────────────────────────────────────
38
+ /// @notice QAIS a challenger must post to raise a dispute.
39
+ uint256 public constant CHALLENGER_BOND = 50 ether;
40
+ /// @notice Fraction of the defendant's stake slashed on a lost dispute (bps).
41
+ uint16 public constant SLASH_BPS = 2000; // 20%
42
+ /// @notice Of the slashed amount: burned / to the challenger; remainder → treasury.
43
+ uint16 public constant BURN_SHARE_BPS = 5000; // 50%
44
+ uint16 public constant CHALLENGER_SHARE_BPS = 3000; // 30% (treasury gets the rest)
45
+
46
+ /// @notice Window for the defendant to commit counter-evidence.
47
+ uint64 public constant COUNTER_EVIDENCE_WINDOW = 24 hours;
48
+ /// @notice After this long unresolved, the challenger can reclaim the bond
49
+ /// (the no-trapped-funds escape hatch; resolution should take ~days).
50
+ uint64 public constant RECLAIM_AFTER = 30 days;
51
+
52
+ enum DisputeStatus {
53
+ NONE,
54
+ OPEN,
55
+ COUNTERED,
56
+ RESOLVED
57
+ }
58
+
59
+ struct Dispute {
60
+ address challenger;
61
+ address defendant;
62
+ uint256 bond;
63
+ bytes32 evidenceHash; // content hash (IPFS in later phases)
64
+ bytes32 counterEvidenceHash;
65
+ uint64 raisedAt;
66
+ DisputeStatus status;
67
+ bool challengerWon;
68
+ }
69
+
70
+ ERC20Burnable public immutable token;
71
+ NodeRegistry public immutable registry;
72
+ address public immutable treasury;
73
+
74
+ /// @dev Keyed by jobId — one dispute per job, ever (matches the escrow's identity).
75
+ mapping(bytes32 => Dispute) public disputes;
76
+
77
+ // ─── Events ───────────────────────────────────────────────────────────────
78
+ event DisputeRaised(
79
+ bytes32 indexed jobId, address indexed challenger, address indexed defendant, uint256 bond
80
+ );
81
+ event CounterEvidenceSubmitted(bytes32 indexed jobId, address indexed defendant);
82
+ event DisputeResolved(bytes32 indexed jobId, bool challengerWon, uint256 slashAmount);
83
+ event BondReclaimed(bytes32 indexed jobId, address indexed challenger, uint256 bond);
84
+
85
+ // ─── Errors ───────────────────────────────────────────────────────────────
86
+ error ZeroAddress();
87
+ error ZeroEvidence();
88
+ error DisputeExists(bytes32 jobId);
89
+ error NoSuchDispute(bytes32 jobId);
90
+ error NotANode(address defendant);
91
+ error NotDefendant();
92
+ error NotChallenger();
93
+ error AlreadyCountered();
94
+ error AlreadyResolved(bytes32 jobId);
95
+ error CounterWindowClosed(uint64 closedAt);
96
+ error ReclaimNotReady(uint64 readyAt);
97
+
98
+ constructor(ERC20Burnable token_, NodeRegistry registry_, address treasury_, address admin) {
99
+ if (
100
+ address(token_) == address(0) || address(registry_) == address(0)
101
+ || treasury_ == address(0) || admin == address(0)
102
+ ) revert ZeroAddress();
103
+ token = token_;
104
+ registry = registry_;
105
+ treasury = treasury_;
106
+ _grantRole(DEFAULT_ADMIN_ROLE, admin);
107
+ _grantRole(PAUSER_ROLE, admin);
108
+ }
109
+
110
+ // ─── Challenge ────────────────────────────────────────────────────────────
111
+
112
+ /// @notice Raise a dispute against a provider for a job, posting the bond.
113
+ /// Caller must `approve` CHALLENGER_BOND first.
114
+ function raiseDispute(bytes32 jobId, address defendant, bytes32 evidenceHash)
115
+ external
116
+ nonReentrant
117
+ whenNotPaused
118
+ {
119
+ if (evidenceHash == bytes32(0)) revert ZeroEvidence();
120
+ Dispute storage d = disputes[jobId];
121
+ if (d.status != DisputeStatus.NONE) revert DisputeExists(jobId);
122
+ if (!registry.exists(defendant)) revert NotANode(defendant);
123
+
124
+ // Effects
125
+ d.challenger = msg.sender;
126
+ d.defendant = defendant;
127
+ d.bond = CHALLENGER_BOND;
128
+ d.evidenceHash = evidenceHash;
129
+ d.raisedAt = uint64(block.timestamp);
130
+ d.status = DisputeStatus.OPEN;
131
+
132
+ // Interaction
133
+ IERC20(address(token)).safeTransferFrom(msg.sender, address(this), CHALLENGER_BOND);
134
+
135
+ emit DisputeRaised(jobId, msg.sender, defendant, CHALLENGER_BOND);
136
+ }
137
+
138
+ /// @notice The defendant commits counter-evidence within the 24h window.
139
+ /// Deliberately NOT pausable — a pause must never silence a defense.
140
+ function submitCounterEvidence(bytes32 jobId, bytes32 counterEvidenceHash) external {
141
+ if (counterEvidenceHash == bytes32(0)) revert ZeroEvidence();
142
+ Dispute storage d = disputes[jobId];
143
+ if (d.status == DisputeStatus.NONE) revert NoSuchDispute(jobId);
144
+ if (d.status == DisputeStatus.RESOLVED) revert AlreadyResolved(jobId);
145
+ if (d.status == DisputeStatus.COUNTERED) revert AlreadyCountered();
146
+ if (msg.sender != d.defendant) revert NotDefendant();
147
+ uint64 closesAt = d.raisedAt + COUNTER_EVIDENCE_WINDOW;
148
+ if (block.timestamp > closesAt) revert CounterWindowClosed(closesAt);
149
+
150
+ d.counterEvidenceHash = counterEvidenceHash;
151
+ d.status = DisputeStatus.COUNTERED;
152
+ emit CounterEvidenceSubmitted(jobId, msg.sender);
153
+ }
154
+
155
+ // ─── Resolution (FAST track: trusted oracle; the panel is Phase 5) ────────
156
+
157
+ /// @notice Oracle resolves a clear-cut case (its re-run confirmed the outcome).
158
+ function autoResolve(bytes32 jobId, bool challengerWins)
159
+ external
160
+ onlyRole(ORACLE_ROLE)
161
+ nonReentrant
162
+ whenNotPaused
163
+ {
164
+ Dispute storage d = disputes[jobId];
165
+ if (d.status == DisputeStatus.NONE) revert NoSuchDispute(jobId);
166
+ if (d.status == DisputeStatus.RESOLVED) revert AlreadyResolved(jobId);
167
+
168
+ d.status = DisputeStatus.RESOLVED;
169
+ d.challengerWon = challengerWins;
170
+
171
+ uint256 slashAmount = 0;
172
+ if (challengerWins) {
173
+ uint256 stake = registry.getNode(d.defendant).stakeAmount;
174
+ slashAmount = (stake * SLASH_BPS) / 10000;
175
+ uint256 challengerCut = 0;
176
+ if (slashAmount > 0) {
177
+ // Pull the slashed stake here, then split it 50/30/20.
178
+ registry.slashTo(d.defendant, slashAmount, address(this), "dispute lost");
179
+ uint256 burnAmount = (slashAmount * BURN_SHARE_BPS) / 10000;
180
+ challengerCut = (slashAmount * CHALLENGER_SHARE_BPS) / 10000;
181
+ uint256 treasuryCut = slashAmount - burnAmount - challengerCut; // absorbs rounding
182
+ token.burn(burnAmount);
183
+ IERC20(address(token)).safeTransfer(treasury, treasuryCut);
184
+ }
185
+ // Bond returns to the winning challenger alongside their cut.
186
+ IERC20(address(token)).safeTransfer(d.challenger, d.bond + challengerCut);
187
+ } else {
188
+ // Frivolous-challenge deterrent: the losing challenger's bond burns.
189
+ token.burn(d.bond);
190
+ }
191
+
192
+ emit DisputeResolved(jobId, challengerWins, slashAmount);
193
+ }
194
+
195
+ /// @notice Escape hatch: if a dispute sits unresolved past RECLAIM_AFTER, the
196
+ /// challenger recovers the bond (no slash, no winner). NOT pausable —
197
+ /// the protocol's pause rule forbids trapping funds.
198
+ function reclaimBond(bytes32 jobId) external nonReentrant {
199
+ Dispute storage d = disputes[jobId];
200
+ if (d.status == DisputeStatus.NONE) revert NoSuchDispute(jobId);
201
+ if (d.status == DisputeStatus.RESOLVED) revert AlreadyResolved(jobId);
202
+ if (msg.sender != d.challenger) revert NotChallenger();
203
+ uint64 readyAt = d.raisedAt + RECLAIM_AFTER;
204
+ if (block.timestamp < readyAt) revert ReclaimNotReady(readyAt);
205
+
206
+ d.status = DisputeStatus.RESOLVED;
207
+ d.challengerWon = false;
208
+ IERC20(address(token)).safeTransfer(d.challenger, d.bond);
209
+ emit BondReclaimed(jobId, d.challenger, d.bond);
210
+ }
211
+
212
+ // ─── Admin ────────────────────────────────────────────────────────────────
213
+
214
+ function pause() external onlyRole(PAUSER_ROLE) {
215
+ _pause();
216
+ }
217
+
218
+ function unpause() external onlyRole(PAUSER_ROLE) {
219
+ _unpause();
220
+ }
221
+ }