gabriel / musehub public

musehub_mpay.py file-level

at sha256:c · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:e fix(9A-4 F7): env alias + staging secrets for overseer provenance gate.… · aaronrene · Jul 13, 2026
1 """MPay service — record, verify, and query nanoMUSE micropayment claims.
2
3 Canonical MPay message (UTF-8, newline-separated):
4 MPAY
5 {sender}
6 {recipient}
7 {amount_nano}
8 {nonce_hex}
9
10 The "MPAY" prefix domain-separates this from MSign auth and ATTEST messages.
11 """
12
13 import logging
14 from datetime import datetime, timezone
15
16 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
17 from muse.core.types import decode_sig, decode_pubkey
18 from sqlalchemy import text
19 from sqlalchemy.ext.asyncio import AsyncSession
20
21 from musehub.core.genesis import compute_mpay_claim_id
22 from musehub.types.json_types import ReadOnlyJSONObject
23 from musehub.models.musehub import (
24 MPayClaimRequest,
25 MPayClaimResponse,
26 MPayLedgerResponse,
27 )
28
29 logger = logging.getLogger(__name__)
30
31 def _canonical_message(
32 sender: str, recipient: str, amount_nano: int, nonce_hex: str
33 ) -> bytes:
34 return f"MPAY\n{sender}\n{recipient}\n{amount_nano}\n{nonce_hex}".encode()
35
36 def verify_mpay_signature(
37 sender: str,
38 recipient: str,
39 amount_nano: int,
40 nonce_hex: str,
41 signature: str,
42 sender_public_key: str,
43 ) -> tuple[bool, str]:
44 """Verify an Ed25519 MPay signature.
45
46 Returns ``(True, "")`` on success; ``(False, reason)`` on failure.
47 """
48 if not signature.startswith("ed25519:"):
49 return False, "signature must start with 'ed25519:'"
50 if not sender_public_key.startswith("ed25519:"):
51 return False, "public key must start with 'ed25519:'"
52 try:
53 _, sig_bytes = decode_sig(signature)
54 _, key_bytes = decode_pubkey(sender_public_key)
55 pubkey = Ed25519PublicKey.from_public_bytes(key_bytes)
56 msg = _canonical_message(sender, recipient, amount_nano, nonce_hex)
57 pubkey.verify(sig_bytes, msg)
58 return True, ""
59 except Exception as exc:
60 return False, str(exc)
61
62 def _row_to_claim(r: ReadOnlyJSONObject) -> MPayClaimResponse:
63 return MPayClaimResponse(
64 claim_id=r["claim_id"],
65 sender=r["sender"],
66 recipient=r["recipient"],
67 amount_nano=r["amount_nano"],
68 nonce_hex=r["nonce_hex"],
69 signature=r["signature"],
70 sender_public_key=r["sender_public_key"],
71 memo=r.get("memo"),
72 created_at=r["created_at"],
73 confirmed_at=r.get("confirmed_at"),
74 voided_at=r.get("voided_at"),
75 )
76
77 async def record_mpay_claim(
78 db: AsyncSession,
79 req: MPayClaimRequest,
80 ) -> MPayClaimResponse:
81 """Verify and persist an MPay claim.
82
83 Idempotent: if the nonce is already recorded, returns the existing claim.
84 Raises ``ValueError`` if the signature is invalid.
85 """
86 ok, reason = verify_mpay_signature(
87 req.sender,
88 req.recipient,
89 req.amount_nano,
90 req.nonce_hex,
91 req.signature,
92 req.sender_public_key,
93 )
94 if not ok:
95 raise ValueError(f"Invalid MPay signature: {reason}")
96
97 claim_id = compute_mpay_claim_id(
98 req.sender, req.recipient, req.amount_nano, req.nonce_hex
99 )
100
101 # Idempotent — nonce uniqueness constraint guarantees one record per payment.
102 row = await db.execute(
103 text(
104 "SELECT claim_id, sender, recipient, amount_nano, nonce_hex, "
105 "signature, sender_public_key, memo, created_at, confirmed_at, voided_at "
106 "FROM musehub_mpay_claims WHERE nonce_hex = :nonce"
107 ),
108 {"nonce": req.nonce_hex},
109 )
110 existing = row.mappings().one_or_none()
111 if existing is not None:
112 return _row_to_claim(dict(existing))
113
114 now = datetime.now(timezone.utc)
115 await db.execute(
116 text(
117 "INSERT INTO musehub_mpay_claims "
118 "(claim_id, sender, recipient, amount_nano, nonce_hex, "
119 "signature, sender_public_key, memo, created_at) "
120 "VALUES (:cid, :sender, :recipient, :amount, :nonce, "
121 ":sig, :pubkey, :memo, :created_at)"
122 ),
123 {
124 "cid": claim_id,
125 "sender": req.sender,
126 "recipient": req.recipient,
127 "amount": req.amount_nano,
128 "nonce": req.nonce_hex,
129 "sig": req.signature,
130 "pubkey": req.sender_public_key,
131 "memo": req.memo,
132 "created_at": now,
133 },
134 )
135 await db.commit()
136 logger.info(
137 "MPay claim recorded: %s → %s, %d nanoMUSE (id=%s)",
138 req.sender, req.recipient, req.amount_nano, claim_id,
139 )
140 return MPayClaimResponse(
141 claim_id=claim_id,
142 sender=req.sender,
143 recipient=req.recipient,
144 amount_nano=req.amount_nano,
145 nonce_hex=req.nonce_hex,
146 signature=req.signature,
147 sender_public_key=req.sender_public_key,
148 memo=req.memo,
149 created_at=now,
150 confirmed_at=None,
151 voided_at=None,
152 )
153
154 async def get_mpay_ledger(
155 db: AsyncSession,
156 handle: str,
157 limit: int = 100,
158 ) -> MPayLedgerResponse:
159 """Return the MPay ledger for a handle — all sent and received claims."""
160 sent_rows = await db.execute(
161 text(
162 "SELECT claim_id, sender, recipient, amount_nano, nonce_hex, "
163 "signature, sender_public_key, memo, created_at, confirmed_at, voided_at "
164 "FROM musehub_mpay_claims WHERE sender = :handle AND voided_at IS NULL "
165 "ORDER BY created_at DESC LIMIT :limit"
166 ),
167 {"handle": handle, "limit": limit},
168 )
169 recv_rows = await db.execute(
170 text(
171 "SELECT claim_id, sender, recipient, amount_nano, nonce_hex, "
172 "signature, sender_public_key, memo, created_at, confirmed_at, voided_at "
173 "FROM musehub_mpay_claims WHERE recipient = :handle AND voided_at IS NULL "
174 "ORDER BY created_at DESC LIMIT :limit"
175 ),
176 {"handle": handle, "limit": limit},
177 )
178
179 sent = [_row_to_claim(dict(r)) for r in sent_rows.mappings().all()]
180 received = [_row_to_claim(dict(r)) for r in recv_rows.mappings().all()]
181
182 return MPayLedgerResponse(
183 handle=handle,
184 sent=sent,
185 received=received,
186 total_sent_nano=sum(c.amount_nano for c in sent),
187 total_received_nano=sum(c.amount_nano for c in received),
188 )