@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
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
|
+
}
|