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