Skip to content

Commit fba1a7a

Browse files
author
Neo
committed
[RP-verify] fix holes from adversarial pass: NaN stake, appeal-claims, juror parties, gov vote kwarg, paymaster ReentrancyGuard
Adversarial multi-lens review of the return-pass changes surfaced real holes; fixed with proof (0pnMatrx pytest 547 green, forge 28/28 green): - P1 NaN/Infinity stake bypass (introduced by RP-5's float() cast): file_dispute now rejects non-finite stake BEFORE the floor check (every NaN/inf comparison against the floor is False, so it would slip past the anti-spam guard). - P2 stale claims survive appeal: appeal() now clears dispute['claims'] so an overturned round's stake_return / juror_reward can't drive a double payout. - P2 juror party-exclusion: select_jurors takes exclude={parties}; resolve() passes both parties so neither can occupy a slot on their own case (vote() already blocked their ballot; this stops quorum dilution + the claimant tie-break bias). - P2 governance vote kwarg: _handle_governance_vote passed support= into a service expecting choice= (500 on every live vote) — mapped correctly. - Paymaster ReentrancyGuard: withdraw/sponsoredCall/sponsoredCallWithValue now nonReentrant, matching every sibling contract; the RP-4 .transfer->.call change forwards full gas, so the guard closes the surface that opens. sponsoredCallWithValue's agent-drain primitive is flagged as a pre-existing design decision (per-agent cap / target allowlist) in POST_DEPLOY_WIRING_UNIT.md — not silently changed.
1 parent 7d61a77 commit fba1a7a

5 files changed

Lines changed: 80 additions & 9 deletions

File tree

contracts/OpenMatrixPaymaster.sol

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
// SPDX-License-Identifier: MIT
22
pragma solidity ^0.8.20;
33

4+
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
5+
46
/**
57
* @title OpenMatrixPaymaster
68
* @notice Gas sponsorship contract for the 0pnMatrx platform.
79
* The platform covers all gas fees for users — users never pay gas.
810
* This contract holds ETH and sponsors transactions on behalf of users.
11+
* @dev All ETH-moving functions are nonReentrant, matching every sibling
12+
* contract. This matters because withdraw/sponsoredCall* now use `.call`
13+
* (which forwards all gas) instead of `.transfer` (2300-gas stipend):
14+
* the guard closes the reentrancy surface that the gas-forwarding opens.
915
*/
10-
contract OpenMatrixPaymaster {
16+
contract OpenMatrixPaymaster is ReentrancyGuard {
1117
address public owner;
1218
address public platform;
1319

@@ -54,7 +60,7 @@ contract OpenMatrixPaymaster {
5460
/// @notice Execute a sponsored call to a target contract
5561
/// @param target Contract to call
5662
/// @param data Calldata to send
57-
function sponsoredCall(address target, bytes calldata data) external onlyAuthorized returns (bytes memory) {
63+
function sponsoredCall(address target, bytes calldata data) external onlyAuthorized nonReentrant returns (bytes memory) {
5864
totalTransactions++;
5965
totalSponsored += tx.gasprice * gasleft();
6066
(bool success, bytes memory result) = target.call(data);
@@ -63,7 +69,7 @@ contract OpenMatrixPaymaster {
6369
}
6470

6571
/// @notice Execute a sponsored call with ETH value
66-
function sponsoredCallWithValue(address target, bytes calldata data, uint256 value) external onlyAuthorized returns (bytes memory) {
72+
function sponsoredCallWithValue(address target, bytes calldata data, uint256 value) external onlyAuthorized nonReentrant returns (bytes memory) {
6773
require(address(this).balance >= value, "Insufficient balance");
6874
totalTransactions++;
6975
totalSponsored += tx.gasprice * gasleft();
@@ -88,7 +94,7 @@ contract OpenMatrixPaymaster {
8894
/// @dev Uses call, not transfer: the 2300-gas stipend would permanently
8995
/// strand funds if ownership moves to a smart-contract wallet
9096
/// (multisig / ERC-4337 account) whose receive needs more gas.
91-
function withdraw(uint256 amount) external onlyOwner {
97+
function withdraw(uint256 amount) external onlyOwner nonReentrant {
9298
require(address(this).balance >= amount, "Insufficient balance");
9399
(bool ok, ) = payable(owner).call{value: amount}("");
94100
require(ok, "Withdraw failed");

gateway/service_routes.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1029,11 +1029,13 @@ async def _handle_governance_create(self, request: web.Request) -> web.Response:
10291029
async def _handle_governance_vote(self, request: web.Request) -> web.Response:
10301030
body = await self._parse_body(request)
10311031
self._require(body, "proposal_id", "voter", "support")
1032+
# governance.vote's parameter is `choice`, not `support` — passing
1033+
# support= raised TypeError (500) on every live vote. Map it here.
10321034
result = await self._call(
10331035
"governance", "vote",
10341036
proposal_id=body["proposal_id"],
10351037
voter=body["voter"],
1036-
support=body["support"],
1038+
choice=body["support"],
10371039
)
10381040
return self._ok(result)
10391041

runtime/blockchain/services/dispute_resolution/juror_pool.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,8 @@ async def remove_juror(self, address: str) -> dict:
105105
# ------------------------------------------------------------------
106106

107107
async def select_jurors(
108-
self, dispute_id: str, count: int = 5, category: str | None = None
108+
self, dispute_id: str, count: int = 5, category: str | None = None,
109+
exclude: set[str] | None = None,
109110
) -> list[dict]:
110111
"""Select *count* jurors for a dispute using Chainlink VRF.
111112
@@ -117,14 +118,21 @@ async def select_jurors(
117118
dispute_id: Identifier of the dispute requiring jurors.
118119
count: Number of jurors to select (default 5).
119120
category: Optional dispute category for expertise weighting.
121+
exclude: Addresses barred from the panel (the dispute's own
122+
parties) so a party cannot occupy a juror slot on their own
123+
case — which would dilute the effective quorum even though
124+
the vote() layer separately blocks them from casting.
120125
121126
Returns:
122127
List of selected juror records.
123128
124129
Raises:
125130
RuntimeError: If there are fewer eligible jurors than *count*.
126131
"""
127-
eligible = [j for j in self._jurors.values() if j["active"]]
132+
barred = exclude or set()
133+
eligible = [
134+
j for j in self._jurors.values() if j["active"] and j["address"] not in barred
135+
]
128136
if len(eligible) < count:
129137
raise RuntimeError(
130138
f"Not enough eligible jurors: need {count}, have {len(eligible)}"

runtime/blockchain/services/dispute_resolution/service.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"""
1616

1717
import logging
18+
import math
1819
import time
1920
import uuid
2021
from typing import Any
@@ -103,6 +104,12 @@ async def file_dispute(
103104
f"Invalid category '{category}'. Must be one of: {sorted(VALID_CATEGORIES)}"
104105
)
105106

107+
# Reject NaN/Infinity BEFORE the floor check: every comparison against
108+
# NaN is False and inf<floor is False, so a non-finite stake would slip
109+
# past `stake_amount < base_stake` and defeat the anti-spam economic guard.
110+
if not math.isfinite(stake_amount):
111+
raise ValueError(f"Stake amount must be a finite number, got {stake_amount}")
112+
106113
if stake_amount < self._base_stake:
107114
raise ValueError(
108115
f"Stake {stake_amount} below minimum {self._base_stake}"
@@ -342,7 +349,8 @@ async def resolve(self, dispute_id: str) -> dict:
342349
juror_count = latest["juror_count"]
343350

344351
jurors = await self.juror_pool.select_jurors(
345-
dispute_id, count=juror_count, category=dispute["category"]
352+
dispute_id, count=juror_count, category=dispute["category"],
353+
exclude={dispute["claimant"], dispute["respondent"]},
346354
)
347355
dispute["jurors"] = [j["address"] for j in jurors]
348356

@@ -413,11 +421,15 @@ async def appeal(
413421
new_evidence=new_evidence,
414422
)
415423

416-
# Reset dispute for re-adjudication
424+
# Reset dispute for re-adjudication. Clearing claims is REQUIRED: any
425+
# stake_return / juror_reward recorded against the now-overturned round
426+
# must not survive into settlement, or both the round-1 and round-2
427+
# "winners" would hold a valid entitlement against the same dispute.
417428
dispute["status"] = "appealed"
418429
dispute["appeal_count"] += 1
419430
dispute["jurors"] = [] # will be re-selected with larger panel
420431
dispute["outcome"] = None
432+
dispute["claims"] = {}
421433

422434
logger.info(
423435
"Dispute appealed — id=%s appellant=%s level=%d",

tests/test_dispute_vote_claim.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,49 @@ async def test_majority_juror_claims_minority_and_stranger_rejected():
105105
await svc.claim(d["dispute_id"], "0xstranger")
106106

107107

108+
@pytest.mark.asyncio
109+
@pytest.mark.parametrize("bad", ["nan", "inf", "-inf", "Infinity", "NaN", float("nan"), float("inf")])
110+
async def test_nonfinite_stake_rejected(bad):
111+
"""NaN/Infinity must not slip past the base-stake floor (all NaN/inf
112+
comparisons against the floor are False)."""
113+
svc = DisputeResolution(config={})
114+
stake = float(bad) if isinstance(bad, str) else bad
115+
with pytest.raises(ValueError, match="finite"):
116+
await svc.file_dispute(
117+
claimant="0xa", respondent="0xb", category="fraud",
118+
evidence={"description": "x"}, stake_amount=stake)
119+
120+
121+
@pytest.mark.asyncio
122+
async def test_appeal_clears_stale_claims():
123+
"""A recorded entitlement must not survive re-adjudication."""
124+
svc = DisputeResolution(config={})
125+
d = await _filed_dispute(svc)
126+
d["status"] = "resolved"
127+
d["outcome"] = {"winner": "claimant", "juror_results": {}}
128+
await svc.claim(d["dispute_id"], "0xclaimant")
129+
assert d.get("claims") # recorded
130+
# Respondent must have staked before appeal is allowed.
131+
d["respondent_stake"] = 100.0
132+
await svc.appeal(d["dispute_id"], appellant="0xrespondent", new_evidence={})
133+
assert d["claims"] == {} # cleared — no stale entitlement into round 2
134+
assert d["outcome"] is None and d["status"] == "appealed"
135+
136+
137+
@pytest.mark.asyncio
138+
async def test_juror_pool_excludes_parties():
139+
"""A dispute party cannot occupy a juror slot on their own case."""
140+
from runtime.blockchain.services.dispute_resolution.juror_pool import JurorPool
141+
pool = JurorPool(config={})
142+
for addr in ("0xclaimant", "0xj1", "0xj2", "0xj3", "0xj4", "0xj5"):
143+
await pool.register_juror(addr, expertise=["fraud"], stake=pool._min_stake)
144+
selected = await pool.select_jurors("d1", count=5, category="fraud",
145+
exclude={"0xclaimant", "0xrespondent"})
146+
addrs = {j["address"] for j in selected}
147+
assert "0xclaimant" not in addrs
148+
assert len(addrs) == 5
149+
150+
108151
@pytest.mark.asyncio
109152
async def test_routes_registered_and_validate(aiohttp_client, tmp_path):
110153
"""400 (required-field validation), never 404 — proves registration."""

0 commit comments

Comments
 (0)