Search Apps Documentation Source Content File Folder Download Copy Actions Download

memba_quest_attestation_v1.gno

8.39 Kb · 217 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/nt/avl/v0"
 35	"gno.land/p/nt/ufmt/v0"
 36
 37	"chain/runtime"
 38	"chain/runtime/unsafe"
 39)
 40
 41// ── Constants ────────────────────────────────────────────────
 42const (
 43	// OwnerAddress is the samcrew-core-test1 multisig on test13 (realm owner /
 44	// signer-key rotator). CONFIRM before deploy.
 45	OwnerAddress = "g1x7k4628w93a7wzdhqc06atzx0v50rnshweuxu0"
 46
 47	// MaxAttestXP bounds the XP a single voucher can carry. This caps the blast
 48	// radius if the offline signer key ever leaks: a leaked key can still forge
 49	// vouchers, but cannot mint unbounded XP in one call. Generous vs the real
 50	// max single-quest XP (≤100).
 51	MaxAttestXP = 1000
 52
 53	pubKeySize = 32 // ed25519 public key length (gno crypto/ed25519 exposes no const)
 54	sigSize    = 64 // ed25519 signature length
 55	fieldSep   = "|"
 56)
 57
 58// ── State ────────────────────────────────────────────────────
 59var (
 60	// signerPubKey is the backend's offline ed25519 PUBLIC key (32 bytes). Set by
 61	// the owner via SetSigner after deploy; until then NO voucher verifies.
 62	signerPubKey []byte
 63
 64	completions *avl.Tree // addr + ":" + questId -> int64 (block height recorded)
 65	attestedXP  *avl.Tree // addr               -> int   (cumulative attested XP)
 66	usedNonce   *avl.Tree // nonce              -> true  (replay guard)
 67)
 68
 69func init() {
 70	completions = avl.NewTree()
 71	attestedXP = avl.NewTree()
 72	usedNonce = avl.NewTree()
 73}
 74
 75// ── Pure helpers (unit-tested) ───────────────────────────────
 76
 77// canonicalMsg is the EXACT byte string the backend signs offline and this realm
 78// verifies. It MUST stay byte-identical to the backend signer (ADR Track A / A.3).
 79// strconv.Itoa gives a canonical decimal with no leading zeros, so both sides
 80// agree for any xp.
 81func canonicalMsg(addr, questId string, xp int, nonce string) []byte {
 82	return []byte(addr + fieldSep + questId + fieldSep + strconv.Itoa(xp) + fieldSep + nonce)
 83}
 84
 85func completionKey(addr, questId string) string { return addr + ":" + questId }
 86
 87// validXP bounds the attestable XP (see MaxAttestXP).
 88func validXP(xp int) bool { return xp > 0 && xp <= MaxAttestXP }
 89
 90// fieldsClean rejects any voucher field containing the separator. This makes the
 91// canonical message PROVABLY unambiguous (no field-injection collision) without
 92// trusting the backend's nonce charset: with no field able to contain "|", a
 93// given canonical byte string maps to exactly one (addr, questId, xp, nonce).
 94func fieldsClean(addr, questId, nonce string) bool {
 95	return !strings.Contains(addr, fieldSep) &&
 96		!strings.Contains(questId, fieldSep) &&
 97		!strings.Contains(nonce, fieldSep)
 98}
 99
100// verifyVoucher reports whether sigHex is a valid ed25519 signature by pub over
101// the canonical voucher message. Pure (no state) so it is unit-tested directly
102// with offline-generated test vectors.
103func verifyVoucher(pub []byte, addr, questId string, xp int, nonce, sigHex string) bool {
104	if len(pub) != pubKeySize {
105		return false
106	}
107	sig, err := hex.DecodeString(sigHex)
108	if err != nil || len(sig) != sigSize {
109		return false
110	}
111	return ed25519.Verify(pub, canonicalMsg(addr, questId, xp, nonce), sig)
112}
113
114// ── Owner: rotate the signer key ─────────────────────────────
115
116// SetSigner installs/rotates the backend's offline signer PUBLIC key (32-byte
117// hex). Owner-multisig only. This is a crossing function, so PreviousRealm()
118// correctly identifies the immediate caller (see gno interrealm semantics).
119func SetSigner(cur realm, pubKeyHex string) {
120	if unsafe.PreviousRealm().Address() != address(OwnerAddress) {
121		panic("unauthorized: owner multisig only")
122	}
123	pub, err := hex.DecodeString(pubKeyHex)
124	if err != nil || len(pub) != pubKeySize {
125		panic("signer pubkey must be 32-byte hex")
126	}
127	signerPubKey = pub
128}
129
130// ── User-broadcast: record a signed completion voucher ───────
131
132// RecordCompletion verifies a backend-signed voucher and records the completion
133// + its XP on-chain. Idempotent per (addr, questId); replay-proof per nonce.
134// Panics (reverting the tx) on any invalid/again-used/out-of-range voucher.
135func RecordCompletion(cur realm, addr, questId string, xp int, nonce, sigHex string) {
136	if len(signerPubKey) != pubKeySize {
137		panic("attestation signer not configured")
138	}
139	if addr == "" || questId == "" || nonce == "" {
140		panic("missing voucher field")
141	}
142	if !fieldsClean(addr, questId, nonce) {
143		panic("voucher field must not contain the separator")
144	}
145	if !validXP(xp) {
146		panic("xp out of range")
147	}
148	if _, used := usedNonce.Get(nonce); used {
149		panic("nonce already used")
150	}
151	if !verifyVoucher(signerPubKey, addr, questId, xp, nonce, sigHex) {
152		panic("invalid voucher signature")
153	}
154
155	// Consume the nonce (replay guard) before the idempotency early-return below,
156	// so a replayed voucher always fails even for an already-recorded completion.
157	usedNonce.Set(nonce, true)
158
159	// Idempotent on (addr, questId): a second, distinct-nonce voucher for an
160	// already-recorded completion must NOT double-count XP.
161	ck := completionKey(addr, questId)
162	if _, exists := completions.Get(ck); exists {
163		return
164	}
165	completions.Set(ck, runtime.ChainHeight())
166
167	prev := 0
168	if v, ok := attestedXP.Get(addr); ok {
169		prev = v.(int)
170	}
171	attestedXP.Set(addr, prev+xp)
172}
173
174// ── Public reads ─────────────────────────────────────────────
175
176// GetAttestedXP returns addr's cumulative on-chain attested XP (0 if none).
177func GetAttestedXP(addr string) int {
178	if v, ok := attestedXP.Get(addr); ok {
179		return v.(int)
180	}
181	return 0
182}
183
184// GetRecordedCompletions returns addr's attested quest IDs as a comma-separated
185// list (empty string if none), in ascending key order.
186func GetRecordedCompletions(addr string) string {
187	prefix := addr + ":"
188	out := []string{}
189	completions.Iterate(prefix, addr+";", func(key string, _ interface{}) bool {
190		out = append(out, strings.TrimPrefix(key, prefix))
191		return false
192	})
193	return strings.Join(out, ",")
194}
195
196// Render is the human/gnoweb view; the authoritative reads are the exported
197// Get* funcs (queried via vm/qeval).
198func Render(path string) string {
199	if path == "" {
200		signer := "not configured"
201		if len(signerPubKey) == pubKeySize {
202			signer = "configured"
203		}
204		return ufmt.Sprintf(
205			"# 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",
206			completions.Size(), attestedXP.Size(), signer)
207	}
208	if strings.HasPrefix(path, "user/") {
209		addr := strings.TrimPrefix(path, "user/")
210		comps := GetRecordedCompletions(addr)
211		if comps == "" {
212			comps = "(none)"
213		}
214		return ufmt.Sprintf("# %s\n\n- Attested XP: %d\n- Completions: %s\n", addr, GetAttestedXP(addr), comps)
215	}
216	return "404"
217}