memba_quest_attestation_v1.gno
8.31 Kb · 215 lines
1// Package memba_quest_attestation_v1 is the on-chain quest/XP attestation realm
2// for Memba (audit Q-05, Track A — "Model B" offline-signed voucher).
3//
4// WHY: Memba quest XP lives in a centralized backend DB; the chain holds no
5// verifiable record. This realm makes a quest completion + its XP an independently
6// verifiable on-chain fact, WITHOUT giving the backend a hot key that can write
7// arbitrary state.
8//
9// HOW (Model B — offline voucher):
10// - The backend holds an OFFLINE ed25519 signing key; it NEVER broadcasts.
11// - For a server-verified completion it issues a voucher
12// (address, questId, xp, nonce) + ed25519 signature
13// over the canonical message
14// addr "|" questId "|" itoa(xp) "|" nonce (UTF-8 bytes)
15// - The USER broadcasts RecordCompletion(...) with the voucher (they pay gas).
16// - This realm verifies the signature against the configured signer pubkey,
17// rejects reused nonces (replay), bounds xp, and records the completion +
18// cumulative attested XP.
19//
20// TRUST MODEL: the signature is the authority, NOT the caller — anyone may
21// broadcast a valid voucher (it can only record the backend-attested fact). The
22// only privileged action is rotating the signer pubkey (owner multisig).
23//
24// The canonical message format is a CONTRACT with the backend signer — it MUST
25// stay byte-identical on both sides. See canonicalMsg.
26package memba_quest_attestation_v1
27
28import (
29 "crypto/ed25519"
30 "encoding/hex"
31 "strconv"
32 "strings"
33
34 "gno.land/p/samcrew/avl"
35 "gno.land/p/nt/ufmt/v0"
36
37 "chain/runtime"
38)
39
40// ── Constants ────────────────────────────────────────────────
41const (
42 // The realm owner is not a constant: admin.gno seeds it from the publisher
43 // at package load and rotates it via TransferOwnership/AcceptOwnership.
44
45 // MaxAttestXP bounds the XP a single voucher can carry. This caps the blast
46 // radius if the offline signer key ever leaks: a leaked key can still forge
47 // vouchers, but cannot mint unbounded XP in one call. Generous vs the real
48 // max single-quest XP (≤100).
49 MaxAttestXP = 1000
50
51 pubKeySize = 32 // ed25519 public key length (gno crypto/ed25519 exposes no const)
52 sigSize = 64 // ed25519 signature length
53 fieldSep = "|"
54)
55
56// ── State ────────────────────────────────────────────────────
57var (
58 // signerPubKey is the backend's offline ed25519 PUBLIC key (32 bytes). Set by
59 // the owner via SetSigner after deploy; until then NO voucher verifies.
60 signerPubKey []byte
61
62 completions *avl.Tree // addr + ":" + questId -> int64 (block height recorded)
63 attestedXP *avl.Tree // addr -> int (cumulative attested XP)
64 usedNonce *avl.Tree // nonce -> true (replay guard)
65)
66
67func init() {
68 completions = avl.NewTree()
69 attestedXP = avl.NewTree()
70 usedNonce = avl.NewTree()
71}
72
73// ── Pure helpers (unit-tested) ───────────────────────────────
74
75// canonicalMsg is the EXACT byte string the backend signs offline and this realm
76// verifies. It MUST stay byte-identical to the backend signer (ADR Track A / A.3).
77// strconv.Itoa gives a canonical decimal with no leading zeros, so both sides
78// agree for any xp.
79func canonicalMsg(addr, questId string, xp int, nonce string) []byte {
80 return []byte(addr + fieldSep + questId + fieldSep + strconv.Itoa(xp) + fieldSep + nonce)
81}
82
83func completionKey(addr, questId string) string { return addr + ":" + questId }
84
85// validXP bounds the attestable XP (see MaxAttestXP).
86func validXP(xp int) bool { return xp > 0 && xp <= MaxAttestXP }
87
88// fieldsClean rejects any voucher field containing the separator. This makes the
89// canonical message PROVABLY unambiguous (no field-injection collision) without
90// trusting the backend's nonce charset: with no field able to contain "|", a
91// given canonical byte string maps to exactly one (addr, questId, xp, nonce).
92func fieldsClean(addr, questId, nonce string) bool {
93 return !strings.Contains(addr, fieldSep) &&
94 !strings.Contains(questId, fieldSep) &&
95 !strings.Contains(nonce, fieldSep)
96}
97
98// verifyVoucher reports whether sigHex is a valid ed25519 signature by pub over
99// the canonical voucher message. Pure (no state) so it is unit-tested directly
100// with offline-generated test vectors.
101func verifyVoucher(pub []byte, addr, questId string, xp int, nonce, sigHex string) bool {
102 if len(pub) != pubKeySize {
103 return false
104 }
105 sig, err := hex.DecodeString(sigHex)
106 if err != nil || len(sig) != sigSize {
107 return false
108 }
109 return ed25519.Verify(pub, canonicalMsg(addr, questId, xp, nonce), sig)
110}
111
112// ── Owner: rotate the signer key ─────────────────────────────
113
114// SetSigner installs/rotates the backend's offline signer PUBLIC key (32-byte
115// hex). The current owner is authenticated using its live crossing frame.
116func SetSigner(cur realm, pubKeyHex string) {
117 assertOwner(cur)
118 pub, err := hex.DecodeString(pubKeyHex)
119 if err != nil || len(pub) != pubKeySize {
120 panic("signer pubkey must be 32-byte hex")
121 }
122 signerPubKey = pub
123}
124
125// ── User-broadcast: record a signed completion voucher ───────
126
127// RecordCompletion verifies a backend-signed voucher and records the completion
128// + its XP on-chain. Idempotent per (addr, questId); replay-proof per nonce.
129// Panics (reverting the tx) on any invalid/again-used/out-of-range voucher.
130func RecordCompletion(cur realm, addr, questId string, xp int, nonce, sigHex string) {
131 if len(signerPubKey) != pubKeySize {
132 panic("attestation signer not configured")
133 }
134 if addr == "" || questId == "" || nonce == "" {
135 panic("missing voucher field")
136 }
137 if !fieldsClean(addr, questId, nonce) {
138 panic("voucher field must not contain the separator")
139 }
140 if !validXP(xp) {
141 panic("xp out of range")
142 }
143 if _, used := usedNonce.Get(nonce); used {
144 panic("nonce already used")
145 }
146 if !verifyVoucher(signerPubKey, addr, questId, xp, nonce, sigHex) {
147 panic("invalid voucher signature")
148 }
149
150 // Consume the nonce (replay guard) before the idempotency early-return below,
151 // so a replayed voucher always fails even for an already-recorded completion.
152 usedNonce.Set(nonce, true)
153
154 // Idempotent on (addr, questId): a second, distinct-nonce voucher for an
155 // already-recorded completion must NOT double-count XP.
156 ck := completionKey(addr, questId)
157 if _, exists := completions.Get(ck); exists {
158 return
159 }
160 completions.Set(ck, runtime.ChainHeight())
161
162 prev := 0
163 if v, ok := attestedXP.Get(addr); ok {
164 prev = v.(int)
165 }
166 attestedXP.Set(addr, prev+xp)
167}
168
169// ── Public reads ─────────────────────────────────────────────
170
171// GetSigner returns a copied canonical public-key string, or empty before setup.
172func GetSigner() string { return hex.EncodeToString(signerPubKey) }
173
174// GetAttestedXP returns addr's cumulative on-chain attested XP (0 if none).
175func GetAttestedXP(addr string) int {
176 if v, ok := attestedXP.Get(addr); ok {
177 return v.(int)
178 }
179 return 0
180}
181
182// GetRecordedCompletions returns addr's attested quest IDs as a comma-separated
183// list (empty string if none), in ascending key order.
184func GetRecordedCompletions(addr string) string {
185 prefix := addr + ":"
186 out := []string{}
187 completions.Iterate(prefix, addr+";", func(key string, _ interface{}) bool {
188 out = append(out, strings.TrimPrefix(key, prefix))
189 return false
190 })
191 return strings.Join(out, ",")
192}
193
194// Render is the human/gnoweb view; the authoritative reads are the exported
195// Get* funcs (queried via vm/qeval).
196func Render(path string) string {
197 if path == "" {
198 signer := "not configured"
199 if len(signerPubKey) == pubKeySize {
200 signer = "configured"
201 }
202 return ufmt.Sprintf(
203 "# Memba Quest Attestation\n\nVerifiable on-chain quest/XP records (offline-signed vouchers).\n\n- Completions attested: %d\n- Accounts with attested XP: %d\n- Signer: %s\n",
204 completions.Size(), attestedXP.Size(), signer)
205 }
206 if strings.HasPrefix(path, "user/") {
207 addr := strings.TrimPrefix(path, "user/")
208 comps := GetRecordedCompletions(addr)
209 if comps == "" {
210 comps = "(none)"
211 }
212 return ufmt.Sprintf("# %s\n\n- Attested XP: %d\n- Completions: %s\n", addr, GetAttestedXP(addr), comps)
213 }
214 return "404"
215}