Skip to content

Commit d178ada

Browse files
author
Neo
committed
Merge feat/loop-buildout: Part-2 deploy-independent hardening
DAO proposals live-read (route emits client Proposal shape through _call) and OpenMatrixPaymaster agent value-call policy (per-agent daily cap + target allowlist, secure-by-default). Adversarial pass: no violations; forge 21 green, gateway suite 667 green.
2 parents a1c633f + 1609995 commit d178ada

6 files changed

Lines changed: 268 additions & 6 deletions

File tree

contracts/OpenMatrixPaymaster.sol

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,26 @@ contract OpenMatrixPaymaster is ReentrancyGuard {
2222
uint256 public totalSponsored;
2323
uint256 public totalTransactions;
2424

25+
// ── Value-call policy (mirrors the server paymaster policy.allowed_actions) ──
26+
// A compromised/authorized AGENT key must not be able to drain funds or hit
27+
// an arbitrary target via sponsoredCallWithValue. The owner is trusted and
28+
// exempt; agents are constrained by a per-agent daily value cap and an
29+
// optional target allowlist. Both default to the tightest setting
30+
// (cap = 0 → agents cannot move value until the owner grants an allowance).
31+
uint256 public agentDailyCap;
32+
bool public targetAllowlistEnabled;
33+
mapping(address => bool) public allowedTargets;
34+
mapping(address => uint256) public agentSpentToday;
35+
mapping(address => uint256) public agentDayStart;
36+
2537
event GasSponsored(address indexed user, uint256 amount, string action);
2638
event AgentAuthorized(address indexed agent);
2739
event AgentRevoked(address indexed agent);
2840
event FundsDeposited(address indexed from, uint256 amount);
2941
event FundsWithdrawn(address indexed to, uint256 amount);
42+
event AgentDailyCapSet(uint256 cap);
43+
event TargetAllowed(address indexed target, bool allowed);
44+
event TargetAllowlistEnabledSet(bool enabled);
3045

3146
modifier onlyOwner() {
3247
require(msg.sender == owner, "Only owner");
@@ -69,15 +84,51 @@ contract OpenMatrixPaymaster is ReentrancyGuard {
6984
}
7085

7186
/// @notice Execute a sponsored call with ETH value
87+
/// @dev The owner is unrestricted. A non-owner AGENT is bound by the target
88+
/// allowlist (when enabled) and a per-agent daily value cap, so a
89+
/// compromised agent key cannot drain funds or reach an arbitrary
90+
/// target. The cap resets on a rolling 1-day bucket.
7291
function sponsoredCallWithValue(address target, bytes calldata data, uint256 value) external onlyAuthorized nonReentrant returns (bytes memory) {
7392
require(address(this).balance >= value, "Insufficient balance");
93+
if (msg.sender != owner && value > 0) {
94+
if (targetAllowlistEnabled) {
95+
require(allowedTargets[target], "Target not allowlisted");
96+
}
97+
uint256 dayStart = block.timestamp - (block.timestamp % 1 days);
98+
if (agentDayStart[msg.sender] != dayStart) {
99+
agentDayStart[msg.sender] = dayStart;
100+
agentSpentToday[msg.sender] = 0;
101+
}
102+
require(agentSpentToday[msg.sender] + value <= agentDailyCap, "Agent daily cap exceeded");
103+
agentSpentToday[msg.sender] += value;
104+
}
74105
totalTransactions++;
75106
totalSponsored += tx.gasprice * gasleft();
76107
(bool success, bytes memory result) = target.call{value: value}(data);
77108
require(success, "Sponsored call failed");
78109
return result;
79110
}
80111

112+
/// @notice Set the per-agent daily value cap (wei). Default 0 blocks all
113+
/// agent value-calls until the owner grants an allowance.
114+
function setAgentDailyCap(uint256 cap) external onlyOwner {
115+
agentDailyCap = cap;
116+
emit AgentDailyCapSet(cap);
117+
}
118+
119+
/// @notice Allow/deny a target for agent value-calls (used when the
120+
/// allowlist is enabled).
121+
function setTargetAllowed(address target, bool allowed) external onlyOwner {
122+
allowedTargets[target] = allowed;
123+
emit TargetAllowed(target, allowed);
124+
}
125+
126+
/// @notice Enable/disable the target allowlist for agent value-calls.
127+
function setTargetAllowlistEnabled(bool enabled) external onlyOwner {
128+
targetAllowlistEnabled = enabled;
129+
emit TargetAllowlistEnabledSet(enabled);
130+
}
131+
81132
/// @notice Authorize an agent address to sponsor gas
82133
function authorizeAgent(address agent) external onlyOwner {
83134
authorizedAgents[agent] = true;

contracts/test/OpenMatrixPaymaster.t.sol

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,79 @@ contract OpenMatrixPaymasterTest is Test {
146146
assertEq(txs, 0);
147147
assertEq(bal, 2 ether);
148148
}
149+
150+
// ── sponsoredCallWithValue agent policy (per-agent daily cap + allowlist) ──
151+
152+
function test_AgentValueCall_BlockedByDefault() public {
153+
// Default cap is 0 → a compromised/authorized agent cannot move value.
154+
vm.deal(address(paymaster), 1 ether);
155+
paymaster.authorizeAgent(agent);
156+
address target = makeAddr("target");
157+
vm.prank(agent);
158+
vm.expectRevert("Agent daily cap exceeded");
159+
paymaster.sponsoredCallWithValue(target, "", 0.1 ether);
160+
}
161+
162+
function test_AgentValueCall_WithinCapSucceeds_ThenCumulativeCapReverts() public {
163+
vm.deal(address(paymaster), 2 ether);
164+
paymaster.authorizeAgent(agent);
165+
paymaster.setAgentDailyCap(1 ether);
166+
address target = makeAddr("target");
167+
vm.prank(agent);
168+
paymaster.sponsoredCallWithValue(target, "", 0.6 ether); // ok
169+
assertEq(paymaster.agentSpentToday(agent), 0.6 ether);
170+
vm.prank(agent);
171+
vm.expectRevert("Agent daily cap exceeded"); // 0.6 + 0.6 > 1.0
172+
paymaster.sponsoredCallWithValue(target, "", 0.6 ether);
173+
}
174+
175+
function test_AgentValueCall_DailyCapResetsNextDay() public {
176+
vm.deal(address(paymaster), 3 ether);
177+
paymaster.authorizeAgent(agent);
178+
paymaster.setAgentDailyCap(1 ether);
179+
address target = makeAddr("target");
180+
vm.prank(agent);
181+
paymaster.sponsoredCallWithValue(target, "", 1 ether); // fills the cap
182+
vm.warp(block.timestamp + 1 days + 1);
183+
vm.prank(agent);
184+
paymaster.sponsoredCallWithValue(target, "", 1 ether); // new day, ok
185+
assertEq(paymaster.agentSpentToday(agent), 1 ether);
186+
}
187+
188+
function test_AgentValueCall_TargetAllowlistEnforced() public {
189+
vm.deal(address(paymaster), 2 ether);
190+
paymaster.authorizeAgent(agent);
191+
paymaster.setAgentDailyCap(1 ether);
192+
paymaster.setTargetAllowlistEnabled(true);
193+
address bad = makeAddr("bad");
194+
address good = makeAddr("good");
195+
vm.prank(agent);
196+
vm.expectRevert("Target not allowlisted");
197+
paymaster.sponsoredCallWithValue(bad, "", 0.1 ether);
198+
paymaster.setTargetAllowed(good, true);
199+
vm.prank(agent);
200+
paymaster.sponsoredCallWithValue(good, "", 0.1 ether); // ok
201+
}
202+
203+
function test_OwnerValueCall_IsUnrestricted() public {
204+
// The owner is trusted: no cap, no allowlist — even with defaults.
205+
vm.deal(address(paymaster), 1 ether);
206+
address target = makeAddr("target");
207+
paymaster.sponsoredCallWithValue(target, "", 0.5 ether); // owner, ok
208+
assertEq(target.balance, 0.5 ether);
209+
}
210+
211+
function test_PolicySetters_OnlyOwner() public {
212+
vm.prank(stranger);
213+
vm.expectRevert("Only owner");
214+
paymaster.setAgentDailyCap(1 ether);
215+
vm.prank(stranger);
216+
vm.expectRevert("Only owner");
217+
paymaster.setTargetAllowlistEnabled(true);
218+
vm.prank(stranger);
219+
vm.expectRevert("Only owner");
220+
paymaster.setTargetAllowed(makeAddr("t"), true);
221+
}
149222
}
150223

151224
/// Owner stand-in whose receive() costs more than the 2300-gas transfer

docs/ROUTES.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
> Run `python scripts/generate_route_table.py` after adding a route;
55
> CI runs it with `--check` and fails if this file is stale.
66
7-
**196 routes.** A **public** route requires no API key (its own auth applies — e.g. a signed JWS, SIWE, or per-IP caps).
7+
**197 routes.** A **public** route requires no API key (its own auth applies — e.g. a signed JWS, SIWE, or per-IP caps).
88

99
| Method | Path | Handler | Source | Public |
1010
|---|---|---|---|---|
@@ -57,13 +57,14 @@
5757
| POST | `/api/v1/fundraising/campaign/create` | `_handle_fundraising_create` | service_routes.py:239 | |
5858
| POST | `/api/v1/fundraising/contribute` | `_handle_fundraising_contribute` | service_routes.py:240 | |
5959
| POST | `/api/v1/gaming/register` | `_handle_gaming_register` | service_routes.py:268 | |
60+
| GET | `/api/v1/governance/daos/{daoId}/proposals` | `_handle_dao_proposals` | service_routes.py:2003 | |
6061
| POST | `/api/v1/governance/multisig/approve` | `_handle_multisig_approve` | service_routes.py:349 | |
6162
| POST | `/api/v1/governance/multisig/propose` | `_handle_multisig_propose` | service_routes.py:348 | |
6263
| POST | `/api/v1/governance/proposal/create` | `_handle_governance_create` | service_routes.py:226 | |
6364
| POST | `/api/v1/governance/snapshot/vote` | `_handle_snapshot_vote` | service_routes.py:350 | |
6465
| POST | `/api/v1/governance/treasury/transfer` | `_handle_treasury_transfer` | service_routes.py:351 | |
6566
| POST | `/api/v1/governance/vote` | `_handle_governance_vote` | service_routes.py:227 | |
66-
| POST | `/api/v1/groups` | `_handle_groups_create` | service_routes.py:1984 | |
67+
| POST | `/api/v1/groups` | `_handle_groups_create` | service_routes.py:1993 | |
6768
| POST | `/api/v1/iap/asn` | `handle_iap_asn` | server.py:2056 ||
6869
| POST | `/api/v1/iap/verify` | `handle_iap_verify` | server.py:2055 ||
6970
| POST | `/api/v1/identity/create` | `_handle_did_create` | service_routes.py:201 | |
@@ -83,14 +84,14 @@
8384
| POST | `/api/v1/legal/agreement/execute` | `_handle_agreement_execute` | service_routes.py:365 | |
8485
| POST | `/api/v1/legal/dispute/file` | `_handle_legal_dispute_file` | service_routes.py:366 | |
8586
| POST | `/api/v1/legal/license/grant` | `_handle_license_grant` | service_routes.py:364 | |
86-
| POST | `/api/v1/licensing/ip` | `_handle_licensing_register_ip` | service_routes.py:1988 | |
87-
| POST | `/api/v1/licensing/licenses` | `_handle_licensing_create_license` | service_routes.py:1990 | |
87+
| POST | `/api/v1/licensing/ip` | `_handle_licensing_register_ip` | service_routes.py:1997 | |
88+
| POST | `/api/v1/licensing/licenses` | `_handle_licensing_create_license` | service_routes.py:1999 | |
8889
| POST | `/api/v1/loyalty/earn` | `_handle_loyalty_earn` | service_routes.py:246 | |
8990
| POST | `/api/v1/loyalty/redeem` | `_handle_loyalty_redeem` | service_routes.py:247 | |
9091
| POST | `/api/v1/marketplace/buy` | `_handle_marketplace_buy` | service_routes.py:223 | |
9192
| POST | `/api/v1/marketplace/list` | `_handle_marketplace_list` | service_routes.py:222 | |
92-
| GET | `/api/v1/messaging/conversations` | `_handle_messaging_conversations` | service_routes.py:1974 | |
93-
| GET | `/api/v1/messaging/conversations/{conversationId}/messages` | `_handle_messaging_messages` | service_routes.py:1975 | |
93+
| GET | `/api/v1/messaging/conversations` | `_handle_messaging_conversations` | service_routes.py:1983 | |
94+
| GET | `/api/v1/messaging/conversations/{conversationId}/messages` | `_handle_messaging_messages` | service_routes.py:1984 | |
9495
| POST | `/api/v1/nft/batch-mint` | `_handle_nft_batch_mint` | service_routes.py:303 | |
9596
| POST | `/api/v1/nft/bridge` | `_handle_nft_bridge` | service_routes.py:305 | |
9697
| POST | `/api/v1/nft/collection/create` | `_handle_nft_collection_create` | service_routes.py:195 | |

gateway/service_routes.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1952,6 +1952,15 @@ async def _handle_licensing_create_license(self, request: web.Request) -> web.Re
19521952
)
19531953
return self._ok(result)
19541954

1955+
async def _handle_dao_proposals(self, request: web.Request) -> web.Response:
1956+
# DAO proposals live-read (was blocked: no route emitted the client's
1957+
# Proposal shape). list_proposals_detailed returns exactly the client's
1958+
# DAO tab shape (proposal_id/title/description/status/votes_for/
1959+
# votes_against/quorum/end_time) through the _call seam.
1960+
dao_id = request.match_info.get("daoId", "")
1961+
result = await self._call("governance", "list_proposals_detailed", dao_id=dao_id)
1962+
return self._ok(result)
1963+
19551964
def _p2_route_specs(self) -> List[Tuple[str, str, Callable[..., Awaitable[web.Response]]]]:
19561965
"""The P2 route-completion table (built once, shared by both routers).
19571966
@@ -1990,6 +1999,8 @@ def _p2_route_specs(self) -> List[Tuple[str, str, Callable[..., Awaitable[web.Re
19901999
("POST", "/api/v1/licensing/licenses", self._handle_licensing_create_license),
19912000
("POST", "/api/v1/licensing/licenses/purchase", NI("licensing.purchase_license")),
19922001
("GET", "/api/v1/licensing/marketplace", NI("licensing.marketplace")),
2002+
# ── DAO proposals live-read (WIRE → governance.list_proposals_detailed) ──
2003+
("GET", "/api/v1/governance/daos/{daoId}/proposals", self._handle_dao_proposals),
19932004
# ── events (no backing service yet → honest 501) ──
19942005
("GET", "/api/v1/events", NI("events.list")),
19952006
("GET", "/api/v1/events/tickets", NI("events.user_tickets")),

runtime/blockchain/services/governance/service.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,71 @@ async def list_proposals(self, status: str | None = None) -> list:
341341
results.sort(key=lambda p: p["created_at"], reverse=True)
342342
return results
343343

344+
async def list_proposals_detailed(self, dao_id: str | None = None) -> list:
345+
"""List proposals in the MTRX client's ``Proposal`` shape.
346+
347+
The client's DAO tab needs ``proposal_id, title, description, status,
348+
votes_for, votes_against, quorum, end_time`` (end_time as an ISO-8601
349+
string). The tally + quorum are extracted HERE — the voting-model
350+
knowledge lives on the server, never the client. Unknown tallies are an
351+
honest 0.0, never fabricated. ``dao_id`` is accepted for the route shape
352+
(proposals are global today) and reserved for per-DAO scoping.
353+
"""
354+
from datetime import datetime, timezone
355+
356+
out: list = []
357+
now = int(time.time())
358+
for pid, proposal in self._proposals.items():
359+
if proposal["status"] == "active" and now > proposal["ends_at"]:
360+
proposal["status"] = "expired"
361+
362+
votes = self._votes.get(pid, [])
363+
model = get_voting_model(proposal["voting_model"])
364+
totals = (model.tally(votes) or {}).get("totals", {})
365+
366+
def _pick(*keys: str) -> float:
367+
for k in keys:
368+
if k in totals:
369+
return float(totals[k])
370+
return 0.0
371+
372+
votes_for = _pick("for", "yes", "approve", "aye")
373+
votes_against = _pick("against", "no", "reject", "nay")
374+
375+
quorum_val = 0.0
376+
try:
377+
q = await self._quorum.check_quorum(
378+
pid, votes=votes,
379+
proposal_type=proposal.get("proposal_type", "standard"),
380+
voting_model=model,
381+
)
382+
if isinstance(q, dict):
383+
quorum_val = float(
384+
q.get("required")
385+
or q.get("threshold")
386+
or q.get("quorum_required")
387+
or q.get("total_required")
388+
or 0.0
389+
)
390+
except Exception: # pragma: no cover — quorum is best-effort here
391+
quorum_val = 0.0
392+
393+
out.append({
394+
"proposal_id": pid,
395+
"title": proposal.get("title", ""),
396+
"description": proposal.get("description", ""),
397+
"status": proposal["status"],
398+
"votes_for": votes_for,
399+
"votes_against": votes_against,
400+
"quorum": quorum_val,
401+
"end_time": datetime.fromtimestamp(
402+
proposal.get("ends_at", now), tz=timezone.utc
403+
).isoformat(),
404+
})
405+
406+
out.sort(key=lambda p: p["end_time"], reverse=True)
407+
return out
408+
344409
# ------------------------------------------------------------------
345410
# Internals
346411
# ------------------------------------------------------------------

tests/test_p6_dao_proposals.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""DAO proposals live-read: the gateway now emits the client's Proposal shape.
2+
3+
Was BLOCKED — no route returned number/proposer/votes/quorum in the shape the
4+
MTRX DAO tab decodes. GET /api/v1/governance/daos/{daoId}/proposals now wires
5+
through _call to governance.list_proposals_detailed, returning
6+
proposal_id/title/description/status/votes_for/votes_against/quorum/end_time
7+
(end_time as an ISO-8601 string the client date decoder accepts).
8+
"""
9+
10+
import pytest
11+
from aiohttp import web
12+
from aiohttp.test_utils import TestClient, TestServer
13+
14+
from gateway.service_routes import ServiceRoutes
15+
16+
17+
@pytest.fixture
18+
async def env():
19+
routes = ServiceRoutes(config={})
20+
app = web.Application()
21+
routes.register_routes(app)
22+
async with TestClient(TestServer(app)) as c:
23+
yield routes, c
24+
25+
26+
async def test_dao_proposals_returns_client_shape(env):
27+
routes, client = env
28+
gov = routes._get_registry().get("governance")
29+
prop = await gov.create_proposal(
30+
proposer="0xabc",
31+
title="Fund the treasury",
32+
description="Allocate 10 ETH to grants",
33+
voting_model="token_weighted",
34+
options=["for", "against"],
35+
)
36+
pid = prop["proposal_id"]
37+
await gov.vote(proposal_id=pid, voter="0xv1", choice="for", weight=3.0)
38+
39+
resp = await client.get("/api/v1/governance/daos/dao1/proposals")
40+
assert resp.status == 200, await resp.text()
41+
data = (await resp.json())["data"]
42+
assert isinstance(data, list) and data, "expected a non-empty proposal list"
43+
44+
row = next(r for r in data if r["proposal_id"] == pid)
45+
# Every field the client's Proposal struct decodes must be present + typed.
46+
assert row["title"] == "Fund the treasury"
47+
assert row["description"] == "Allocate 10 ETH to grants"
48+
assert isinstance(row["status"], str)
49+
assert row["votes_for"] >= 3.0, "the for-vote weight must be tallied server-side"
50+
assert row["votes_against"] == 0.0
51+
assert isinstance(row["quorum"], (int, float))
52+
# end_time is an ISO-8601 string (client decoder needs a string, not epoch).
53+
assert isinstance(row["end_time"], str) and "T" in row["end_time"]
54+
55+
56+
async def test_dao_proposals_empty_is_honest_empty_list(env):
57+
# No proposals -> an honest empty list, not a fabrication or a 500.
58+
routes, client = env
59+
resp = await client.get("/api/v1/governance/daos/dao1/proposals")
60+
assert resp.status == 200, await resp.text()
61+
assert (await resp.json())["data"] == []

0 commit comments

Comments
 (0)