Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

memba_quest_attestation_v1.gno

8.34 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/g1a9g8nu3m0xgx58hh36fph49e2xkqm9axlxuf5z/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}