core.gno
3.36 Kb · 105 lines
1// Package memba_points_migration_core_v1 holds the pure, unit-testable logic for the one-time XP
2// migration into memba_points_v1: parsing + validating the owner-submitted batch, and planning which
3// entries to award (skipping already-migrated addresses, refusing once finalized). It imports no realm
4// and holds no state — the realm (r/samcrew/memba_points_migration_v1) owns the state + the Award call.
5// Splitting it out this way is what makes the migration's risky logic testable under bare `gno test`
6// (the realm itself can't be, since it imports the unpublished points_v1).
7package memba_points_migration_core_v1
8
9import "strings"
10
11// Entry is one validated (address, amount) grant.
12type Entry struct {
13 Addr string
14 Amount int64
15}
16
17// PlanBatch parses a "addr:amount,addr:amount,..." batch and returns the entries to award:
18// - panics if finalized (migration permanently closed)
19// - panics on any malformed entry (bad format, non-address, amount outside 1..maxAward) — fail-fast,
20// so a botched export never half-applies
21// - caps the batch at maxEntries (per-tx gas bound)
22// - drops entries whose Addr already satisfies isMigrated (idempotent re-runs)
23func PlanBatch(csv string, maxAward int64, maxEntries int, finalized bool, isMigrated func(string) bool) []Entry {
24 if finalized {
25 panic("migration finalized")
26 }
27 all := ParseBatch(csv, maxAward, maxEntries)
28 out := make([]Entry, 0, len(all))
29 for _, e := range all {
30 if isMigrated(e.Addr) {
31 continue
32 }
33 out = append(out, e)
34 }
35 return out
36}
37
38// ParseBatch parses + validates the batch (no dedup against prior runs). Exposed for testing + reuse.
39func ParseBatch(csv string, maxAward int64, maxEntries int) []Entry {
40 if len(csv) == 0 {
41 panic("empty batch")
42 }
43 parts := strings.Split(csv, ",")
44 if len(parts) > maxEntries {
45 panic("batch exceeds maxEntries")
46 }
47 out := make([]Entry, 0, len(parts))
48 seen := make(map[string]bool)
49 for _, p := range parts {
50 kv := strings.Split(p, ":")
51 if len(kv) != 2 {
52 panic("entry must be addr:amount")
53 }
54 addr := kv[0]
55 if !isValidAddress(addr) {
56 panic("invalid address in batch")
57 }
58 if seen[addr] {
59 panic("duplicate address in batch")
60 }
61 amt, ok := parseAmount(kv[1])
62 if !ok || amt < 1 || amt > maxAward {
63 panic("amount out of range (1..maxAward)")
64 }
65 seen[addr] = true
66 out = append(out, Entry{Addr: addr, Amount: amt})
67 }
68 return out
69}
70
71// isValidAddress does a cheap structural check for a gno bech32 address ("g1" + 38 lowercase-alnum
72// chars). Deep checksum validation is unnecessary: the batch is owner-submitted from a trusted export,
73// and memba_points_v1.Award is the ultimate authority on the address. Loose-accepts (never false-rejects
74// a real address); a slightly-malformed one is an owner/export bug caught at the ceremony dry-run.
75func isValidAddress(s string) bool {
76 if len(s) != 40 || s[0] != 'g' || s[1] != '1' {
77 return false
78 }
79 for i := 2; i < len(s); i++ {
80 c := s[i]
81 if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'z')) {
82 return false
83 }
84 }
85 return true
86}
87
88// parseAmount parses a non-negative base-10 int64 (no strconv dep, overflow-guarded).
89func parseAmount(s string) (int64, bool) {
90 if len(s) == 0 || len(s) > 19 {
91 return 0, false
92 }
93 var n int64
94 for i := 0; i < len(s); i++ {
95 c := s[i]
96 if c < '0' || c > '9' {
97 return 0, false
98 }
99 n = n*10 + int64(c-'0')
100 if n < 0 {
101 return 0, false
102 }
103 }
104 return n, true
105}