gabriel / musehub public
musehub_mpay.py python
188 lines 6.1 KB
Raw
sha256:73f24973678ceefd56628ee17c6d0535d86e33533828af6c63348f50941515ad Merge branch 'fix/smoke-test-tag-label' into dev Human 15 days ago
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 )
File History 12 commits
sha256:cfefc25a166c3c3eed8ea3529aee19ea350bc05f2954d007420e924133b7d8ce chore: pivot to nightly channel — bump version to 0.2.0.dev… Sonnet 5 patch 13 days ago
sha256:d035733f21ccff27735fddebfbbe0ed24565a32a22db8de5885402262671ecd2 chore: bump version to 0.2.0rc15 for musehub#113 fix release Sonnet 4.6 patch 15 days ago
sha256:0032d6cfa33bc3c8367436ad768e7dd0e339b4332153160247da8266cb5fa352 Merge branch 'task/version-tags-phase3-server' into dev Human 18 days ago
sha256:4669620efda9ff41c55bdefd1f7bfe1c239d468428744c84ead9957e5a003a53 merge: rescue snapshot-recovery hardening (c00aa21d) into d… Opus 4.8 minor 30 days ago
sha256:a59da49c4611b970fc4b6ae48678ce4943261c213a07ddbd73ce9201df869b4a fix: remove false-positive proposal_comments index drop fro… Sonnet 4.6 patch 34 days ago
sha256:0a240d6dbff234f07d98a28a4a9a68db702f3f9ff9260196f24219bdb1c0b6f3 feat: render markdown mists as HTML with heading anchor links Sonnet 4.6 patch 35 days ago
sha256:24a7d47486ebc4ebd1832830580e177ec6f877b48dced8c000e198cdec4ce9d6 Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 36 days ago
sha256:b9ff931d147e0114a1f17060f415b89ed551c170a91ff226c70437aa5c85f9ee Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 36 days ago
sha256:d1122d21e73471879b460037b22c0b50fded7c423444a176f248428f75dac39c Merge 'task/fix-issue-pagination-cursor' into 'dev' — propo… Human 36 days ago
sha256:01e18975e73d2b3cd5b6db7929c895bef9aa6e0d4391dc5b2adfc548b41318dd Merge 'feat/adding-debug-logs-to-staging' into 'dev' — prop… Human 36 days ago
sha256:6b1949fc2797ca4c1936a637a4cbfec828ef56cf52398a2e74ca3c4f494e728f fix: use wire_bytes not mpack_bytes_raw in compute_object_b… Sonnet 4.6 patch 49 days ago
sha256:b99f2455dc346966d040133f5203297e6e3ef5803a93728a2c30568d0a0f7583 rename: delta_add → delta_upsert across wire format, models… Sonnet 4.6 patch 51 days ago