Skip to content

Commit a1c633f

Browse files
author
Neo
committed
Merge feat/loop-buildout: Phase 5 — governance identity + gas-sponsorship splice
P5-1 binds governance votes to the authenticated wallet (server); P5-2 splices verifying-paymaster paymasterAndData into the send path before signing (client, guarded, dormant until deploy). Gate 5: 0pnMatrx 665 / MTRX 185 (WalletTests + SigningWall green), adversarial pass = non-custodial + signing-order verified; chain_id pinning fix applied.
2 parents edcd492 + f9830de commit a1c633f

3 files changed

Lines changed: 97 additions & 7 deletions

File tree

docs/ROUTES.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@
6363
| POST | `/api/v1/governance/snapshot/vote` | `_handle_snapshot_vote` | service_routes.py:350 | |
6464
| POST | `/api/v1/governance/treasury/transfer` | `_handle_treasury_transfer` | service_routes.py:351 | |
6565
| POST | `/api/v1/governance/vote` | `_handle_governance_vote` | service_routes.py:227 | |
66-
| POST | `/api/v1/groups` | `_handle_groups_create` | service_routes.py:1969 | |
66+
| POST | `/api/v1/groups` | `_handle_groups_create` | service_routes.py:1984 | |
6767
| POST | `/api/v1/iap/asn` | `handle_iap_asn` | server.py:2056 ||
6868
| POST | `/api/v1/iap/verify` | `handle_iap_verify` | server.py:2055 ||
6969
| POST | `/api/v1/identity/create` | `_handle_did_create` | service_routes.py:201 | |
@@ -83,14 +83,14 @@
8383
| POST | `/api/v1/legal/agreement/execute` | `_handle_agreement_execute` | service_routes.py:365 | |
8484
| POST | `/api/v1/legal/dispute/file` | `_handle_legal_dispute_file` | service_routes.py:366 | |
8585
| 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:1973 | |
87-
| POST | `/api/v1/licensing/licenses` | `_handle_licensing_create_license` | service_routes.py:1975 | |
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 | |
8888
| POST | `/api/v1/loyalty/earn` | `_handle_loyalty_earn` | service_routes.py:246 | |
8989
| POST | `/api/v1/loyalty/redeem` | `_handle_loyalty_redeem` | service_routes.py:247 | |
9090
| POST | `/api/v1/marketplace/buy` | `_handle_marketplace_buy` | service_routes.py:223 | |
9191
| POST | `/api/v1/marketplace/list` | `_handle_marketplace_list` | service_routes.py:222 | |
92-
| GET | `/api/v1/messaging/conversations` | `_handle_messaging_conversations` | service_routes.py:1959 | |
93-
| GET | `/api/v1/messaging/conversations/{conversationId}/messages` | `_handle_messaging_messages` | service_routes.py:1960 | |
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 | |
9494
| POST | `/api/v1/nft/batch-mint` | `_handle_nft_batch_mint` | service_routes.py:303 | |
9595
| POST | `/api/v1/nft/bridge` | `_handle_nft_bridge` | service_routes.py:305 | |
9696
| POST | `/api/v1/nft/collection/create` | `_handle_nft_collection_create` | service_routes.py:195 | |

gateway/service_routes.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1112,13 +1112,28 @@ async def _handle_governance_create(self, request: web.Request) -> web.Response:
11121112

11131113
async def _handle_governance_vote(self, request: web.Request) -> web.Response:
11141114
body = await self._parse_body(request)
1115-
self._require(body, "proposal_id", "voter", "support")
1115+
self._require(body, "proposal_id", "support")
1116+
# P5-1: bind the vote to the wallet the security middleware authenticated
1117+
# for THIS request — not a spoofable body field. An authenticated identity
1118+
# always wins, so a mismatched body ``voter`` is simply ignored (the vote
1119+
# is recorded under the real wallet). The body voter is a testnet/dev
1120+
# fallback only, used when no identity was bound. The 400 for a
1121+
# fully-absent voter just mirrors the prior _require("voter") — no gate
1122+
# stricter than before, and the Morpheus OBSERVE gate itself is untouched.
1123+
from gateway.security_gate import current_request_security
1124+
authed = str((current_request_security() or {}).get("wallet") or "")
1125+
voter = authed or str(body.get("voter") or "")
1126+
if not voter:
1127+
raise web.HTTPBadRequest(
1128+
text=json.dumps({"error": "voter identity required"}),
1129+
content_type="application/json",
1130+
)
11161131
# governance.vote's parameter is `choice`, not `support` — passing
11171132
# support= raised TypeError (500) on every live vote. Map it here.
11181133
result = await self._call(
11191134
"governance", "vote",
11201135
proposal_id=body["proposal_id"],
1121-
voter=body["voter"],
1136+
voter=voter,
11221137
choice=body["support"],
11231138
)
11241139
return self._ok(result)

tests/test_p5_identity.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""P5-1 regression tests: governance vote binds to the authenticated wallet.
2+
3+
A vote must be attributed to the wallet the security middleware authenticated
4+
for the request — not a spoofable ``voter`` body field. When an identity is
5+
bound, it wins over the body; with no identity the body voter is a testnet/dev
6+
fallback; with neither, the request is an honest 400.
7+
"""
8+
9+
import pytest
10+
from aiohttp import web
11+
from aiohttp.test_utils import TestClient, TestServer
12+
13+
from gateway.service_routes import ServiceRoutes
14+
from gateway.security_gate import bind_request_security
15+
16+
17+
@pytest.fixture
18+
async def env():
19+
routes = ServiceRoutes(config={})
20+
app = web.Application()
21+
22+
@web.middleware
23+
async def _bind_wallet(request, handler):
24+
w = request.headers.get("X-Test-Wallet")
25+
if w:
26+
bind_request_security(identity=w)
27+
return await handler(request)
28+
29+
app.middlewares.append(_bind_wallet)
30+
routes.register_routes(app)
31+
async with TestClient(TestServer(app)) as c:
32+
yield routes, c
33+
34+
35+
def _spy_vote(routes, captured):
36+
async def spy(service, method, **kwargs):
37+
if method == "vote":
38+
captured.update(kwargs)
39+
return {"status": "cast", **kwargs}
40+
routes._call = spy # type: ignore[assignment]
41+
42+
43+
async def test_authenticated_wallet_wins_over_body_voter(env):
44+
routes, client = env
45+
captured: dict = {}
46+
_spy_vote(routes, captured)
47+
resp = await client.post(
48+
"/api/v1/governance/vote",
49+
headers={"X-Test-Wallet": "0xAUTH"},
50+
json={"proposal_id": "p1", "voter": "0xSPOOF", "support": "yes"},
51+
)
52+
assert resp.status == 200, await resp.text()
53+
assert captured.get("voter") == "0xAUTH", "the authenticated wallet must win, not the body voter"
54+
55+
56+
async def test_body_voter_used_when_unauthenticated(env):
57+
routes, client = env
58+
captured: dict = {}
59+
_spy_vote(routes, captured)
60+
resp = await client.post(
61+
"/api/v1/governance/vote",
62+
json={"proposal_id": "p1", "voter": "0xbob", "support": "yes"},
63+
)
64+
assert resp.status == 200, await resp.text()
65+
assert captured.get("voter") == "0xbob", "the body voter is the testnet fallback"
66+
67+
68+
async def test_no_identity_at_all_is_honest_400(env):
69+
routes, client = env
70+
resp = await client.post(
71+
"/api/v1/governance/vote",
72+
json={"proposal_id": "p1", "support": "yes"},
73+
)
74+
assert resp.status == 400, await resp.text()
75+
assert "voter" in (await resp.json()).get("error", "").lower()

0 commit comments

Comments
 (0)