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.

checkpoint.gno

15.34 Kb · 383 lines
  1// Package checkpoint remembers what a number used to be.
  2//
  3// A Series is one value's history. It answers what that value held during a
  4// past epoch, which is the question governance asks: voting weight has to be
  5// read as of the moment a question was put, or it can be borrowed for the
  6// length of one transaction and voted twice.
  7//
  8// It was written for a token's balances and its total supply, and there is
  9// nothing about it that knows what a token is. Anything a realm wants to look
 10// back at — a fee, a quorum, a member count — is a Series.
 11//
 12// # What it costs
 13//
 14// One fact about gno shapes the whole thing: storage deposit is charged per
 15// OBJECT, on the DELTA of its encoded size (gnovm/pkg/gnolang/store.go,
 16// LastObjectSize, at 100ugnot/byte). A field added to a record that was going
 17// to be written anyway is paid for once, at creation, and costs nothing on
 18// every write after. On the EVM every extra word touched is a fresh SSTORE
 19// forever, which is the entire reason OpenZeppelin's ERC20Votes appends to an
 20// array per block — and the reason that shape does not have to be inherited.
 21//
 22// So: two points inline, the rest paged into an archive. The overwhelmingly
 23// common query is answered without touching the archive at all, and a value
 24// that changes several times within one epoch coalesces into a single update
 25// of a record already being written.
 26//
 27// # Epochs, not blocks
 28//
 29// The caller supplies the epoch and this package never asks what one is. That
 30// is deliberate — the clock is policy. Quantising to something coarser than a
 31// block is what makes the cost work, because a checkpoint per block maximises
 32// the one thing gno charges dearly for, a brand new key.
 33//
 34// It also turns the anti-flash-loan property from a convention into an
 35// invariant, if the caller refuses to answer for the current epoch: a
 36// transaction cannot outlive its block and a block cannot outlive its epoch,
 37// so a borrowed balance cannot be voted and returned. This package cannot
 38// enforce that, because it does not know what time it is. It is the caller's
 39// half of the bargain and worth writing down where the caller will read it.
 40package checkpoint
 41
 42import (
 43	"strings"
 44
 45	bptree "gno.land/p/nt/bptree/v0"
 46)
 47
 48// Sep separates a caller's key from this package's own suffix.
 49//
 50// Exported because a caller sharing one Archive between several keyspaces
 51// usually wants the same byte for its own compound keys, and two constants
 52// with the same value in different files eventually disagree.
 53//
 54// A key may not contain it. That is checked rather than documented, because
 55// the failure is silent: one key's range scan would reach into another's and
 56// return a plausible number belonging to somebody else.
 57const Sep = "\x00"
 58
 59// MaxKey bounds a key.
 60//
 61// A key is not stored once. It is the prefix of every page key this series ever
 62// writes, so its length multiplies by the number of pages a long-lived value
 63// accumulates — and the pages are what the design buys with the deposit it
 64// saves elsewhere.
 65//
 66// Exported so a caller can check before calling rather than discover by
 67// panicking. A hundred and twenty-eight is room for a bech32 address twice over
 68// with a separator between, which is the largest sensible key the realm this
 69// came out of could form.
 70const MaxKey = 128
 71
 72// PageEpochs is how many epochs one archive page covers.
 73//
 74// It bounds the archive in both directions, and both matter.
 75//
 76// Downwards: one new key buys this many checkpoints instead of one, which is
 77// what keeps the marginal cost of history near zero.
 78//
 79// Upwards: a page cannot grow past one bucket. Keeping every checkpoint for a
 80// key in a single page would be CHEAPER in keys — one per key, forever — and
 81// that is exactly its problem. Appending stays cheap, since the deposit is
 82// charged on the size delta, but the whole object is deserialised to answer
 83// anything about it. A key with years behind it would pay to load all of them
 84// to answer about one epoch.
 85const PageEpochs = uint32(32)
 86
 87// Archive is the shared store the older points live in.
 88//
 89// One Archive can hold any number of Series, told apart by key. Bounding each
 90// scan to its own key's prefix is what keeps them apart, and it is the reason
 91// a key may not contain Sep.
 92type Archive struct {
 93	t *bptree.BPTree
 94}
 95
 96// NewArchive returns an empty archive.
 97func NewArchive() *Archive { return &Archive{t: bptree.NewBPTree32()} }
 98
 99// Size is the number of pages stored, across every key.
100//
101// Exported for the caller that wants to assert what its own history costs —
102// the count is the number of KEYS bought, which is the expensive quantity.
103func (a *Archive) Size() int { return a.t.Size() }
104
105// page is one bucket of archived checkpoints for one key, ascending.
106//
107// One packed string, not slices: gno stores a []int64 or []uint32 as an array of
108// TypedValues at ~40 bytes an element, but a string (like a []byte) at one byte
109// each. So the entries pack big-endian into a single string — 4 bytes of epoch
110// then 8 of value, 12 bytes per entry — which is ~3x smaller than the two-slice
111// form and is what the archive's locked deposit is charged on. Appending
112// reassigns this *page's field (no tree Set), so a write still dirties exactly one
113// object.
114type page struct {
115	packed string // ascending [epoch:4 BE][value:8 BE] entries, 12 bytes each
116}
117
118// Series is one value's history: the two most recent points inline, everything
119// older in the archive.
120//
121//	at >= e0        -> cur
122//	e1 <= at < e0   -> prev
123//	at < e1         -> the archive
124//
125// e1 == 0 means the second slot was never used, and prev is then zero, which
126// is the correct answer for every epoch below e0 — so a key that has held a
127// steady value since it was created never touches the archive at all. That
128// case is the overwhelming majority of queries.
129//
130// That sentinel is why epochs are 1-BASED and SetAt refuses zero. Zero has to
131// mean "before this series existed" and cannot also be an epoch somebody wrote
132// in, or the two readings collide: a value written at epoch 0 leaves e1 == 0
133// after the next change, the roll after that reads it as an empty slot, and the
134// point is dropped instead of archived. Silently — the archive stays empty and
135// every query below the newest two points answers zero.
136//
137// The realm this came out of never could have hit it, because its clock is
138// 1-based for exactly this reason. The assumption did not travel with the code,
139// which is the whole hazard of moving code into a package.
140//
141// Hold it as a VALUE field in whatever record the caller already persists.
142// Mutating it then dirties only that record. A pointer would make it a second
143// object, which is a second key, which is the cost this design exists to
144// avoid.
145type Series struct {
146	cur  int64
147	prev int64
148	e0   uint32
149	e1   uint32
150}
151
152// Value is what the series holds now, without consulting anything.
153func (s *Series) Value() int64 { return s.cur }
154
155// Prev is the older of the two inline points: its epoch and value. A zero
156// epoch means that slot was never used (the series has at most one point).
157// Callers assembling a full history read the archive, then Prev, then
158// Since/Value — ascending by construction, since points only roll outward.
159func (s *Series) Prev() (uint32, int64) { return s.e1, s.prev }
160
161// Since is the epoch the current value has held from.
162//
163// Useful for asserting that something did NOT write — a caller that expects an
164// operation to be free can check this did not move.
165func (s *Series) Since() uint32 { return s.e0 }
166
167// SetAt records v from epoch e onward, rolling whatever falls out of the two
168// inline slots into the archive.
169//
170// e must be at least 1, and must not be below the newest epoch already
171// recorded. Callers pass a chain height quantised to an epoch, which only ever
172// increases.
173func (s *Series) SetAt(a *Archive, key string, e uint32, v int64) {
174	mustBeUsable(key)
175	if e == 0 {
176		// Refused rather than documented, because the damage is silent and
177		// arrives two changes later. See the note on Series: zero is the
178		// sentinel for an unused slot, so a real checkpoint written there is
179		// read as an empty one and dropped on the roll after next.
180		panic("checkpoint: epochs are 1-based; 0 means before the series existed")
181	}
182	if e < s.e0 {
183		// The clock went backwards. Impossible on a chain, so this means the
184		// caller is not on one — a test harness that resets the height between
185		// cases produces exactly this.
186		//
187		// Worth a panic rather than a comment because the failure it prevents
188		// is invisible: a checkpoint written below the newest one pushes a
189		// stale value into the previous slot and moves e0 backwards, and every
190		// later query returns a plausible, wrong number.
191		panic("checkpoint: the clock went backwards")
192	}
193	if e == s.e0 {
194		// The second and later change within one epoch. No new key, and the
195		// object was going to be written anyway: free, in the sense that
196		// matters.
197		s.cur = v
198		return
199	}
200	if s.e1 != 0 {
201		a.append(key, s.e1, s.prev)
202	}
203	s.prev, s.e1 = s.cur, s.e0
204	s.cur, s.e0 = v, e
205}
206
207// ValueAt answers what the series held during epoch at.
208//
209// Zero for an epoch before the series began, which is exact rather than a
210// miss: the key held nothing then.
211func (s *Series) ValueAt(a *Archive, key string, at uint32) int64 {
212	if at >= s.e0 {
213		return s.cur
214	}
215	// Covers e1 == 0 as well, where prev is zero and zero is exact.
216	if at >= s.e1 {
217		return s.prev
218	}
219	mustBeUsable(key)
220	out := int64(0)
221	// Bounded below by this key's own prefix so the descent cannot walk back
222	// into the previous key's pages. ReverseIterate is [start, end] descending
223	// with end inclusive (bptree tree.gno, "ReverseIterate calls cb"), which is
224	// the floor query — no binary search of our own.
225	a.t.ReverseIterate(key+Sep, pageKey(key, at/PageEpochs), func(_ string, pv any) bool {
226		s := pv.(*page).packed
227		for i := len(s)/12 - 1; i >= 0; i-- {
228			off := i * 12
229			if rd32(s, off) <= at {
230				out = rd64(s, off+4)
231				return true
232			}
233		}
234		// Pages bucket EPOCHS, not checkpoints, so the page a query lands in
235		// may hold only points later than the epoch being asked about. Keep
236		// walking down; the answer is in an older page.
237		return false
238	})
239	return out
240}
241
242func (a *Archive) append(key string, e uint32, v int64) {
243	pk := pageKey(key, e/PageEpochs)
244	entry := be32(e) + be64(v) // 12 bytes: epoch then value
245	if pv := a.t.Get(pk); pv != nil {
246		p := pv.(*page)
247		// Reassign the field on the pointer the tree already holds. No Set, so the
248		// leaf and its whole inner path stay clean and the write dirties only this
249		// *page. Entries arrive ascending by construction, so there is nothing to
250		// sort.
251		p.packed += entry
252		return
253	}
254	a.t.Set(pk, &page{packed: entry})
255}
256
257// mustBeUsable refuses a key that would collide with another key's pages.
258//
259// A p/ package's callers are not the people who wrote it, so this is a case
260// that can happen rather than defensive decoration. The cost is a scan of a
261// short key against the tree operations it precedes, which is nothing.
262func mustBeUsable(key string) {
263	if strings.Contains(key, Sep) {
264		panic("checkpoint: a key may not contain checkpoint.Sep")
265	}
266	if len(key) > MaxKey {
267		panic("checkpoint: that key is too long")
268	}
269	if key == "" {
270		// An empty key is not wrong so much as unusable: its page range is a
271		// prefix of every other key's, so it would scan into whatever sorts
272		// first. Refused for the same reason as one containing the separator.
273		panic("checkpoint: a key may not be empty")
274	}
275}
276
277// be32 is big-endian and fixed width, so byte order is numeric order.
278//
279// The floor query is a range scan over these keys and nothing else puts them
280// in order. Little-endian would sort pages 0, 1 and 2 correctly and come apart
281// at 256.
282func be32(u uint32) string {
283	b := [4]byte{byte(u >> 24), byte(u >> 16), byte(u >> 8), byte(u)}
284	return string(b[:])
285}
286
287// be64 packs an int64 value big-endian (via its uint64 bit pattern, so a negative
288// value round-trips through rd64). Together with be32 it forms a page's 12-byte
289// entry.
290func be64(v int64) string {
291	u := uint64(v)
292	var b [8]byte
293	for i := 7; i >= 0; i-- {
294		b[i] = byte(u & 0xff)
295		u >>= 8
296	}
297	return string(b[:])
298}
299
300// rd32 and rd64 read a packed entry's epoch and value back from a page string.
301func rd32(s string, off int) uint32 {
302	return uint32(s[off])<<24 | uint32(s[off+1])<<16 | uint32(s[off+2])<<8 | uint32(s[off+3])
303}
304
305func rd64(s string, off int) int64 {
306	var u uint64
307	for i := 0; i < 8; i++ {
308		u = (u << 8) | uint64(s[off+i])
309	}
310	return int64(u)
311}
312
313func pageKey(key string, page uint32) string { return key + Sep + be32(page) }
314
315// Trim deletes archive pages of key that lie entirely below keepFrom — except
316// the newest such page — oldest-first, at most maxPages per call. It returns
317// how many pages were removed.
318//
319// The exception is the floor-preservation invariant: a value set below the
320// horizon and unchanged since is still the correct answer for epochs INSIDE
321// the kept window, and its floor entry lives in the newest all-below page.
322// Sparing that one page keeps ValueAt exact at and above keepFrom through
323// every intermediate state of a bounded, multi-call trim. A page straddling
324// the horizon is never a candidate (it is not entirely below), so up to
325// PageEpochs-1 epochs of over-retention are kept deliberately.
326//
327// Deletion frees the page object's bytes; on chains with storage deposits the
328// locked amount is released by the same mechanism that charged it. Trim never
329// touches the two inline points — recent reads stay exact by construction.
330//
331// The walk is collect-then-remove because the tree forbids mutation from an
332// iteration callback, and it is bounded to maxPages+1 keys of collection, so
333// a call's cost is a constant of the caller's choosing.
334func (a *Archive) Trim(key string, keepFrom uint32, maxPages int) int {
335	mustBeUsable(key)
336	if maxPages <= 0 {
337		return 0
338	}
339	// Candidates are pages strictly below the horizon's own page: page P
340	// covers epochs [P*PageEpochs, P*PageEpochs+PageEpochs-1], so P <
341	// keepFrom/PageEpochs puts every epoch in P below keepFrom. Iterate is
342	// [start, end) ascending, which is exactly the candidate range.
343	end := pageKey(key, keepFrom/PageEpochs)
344	keys := []string{}
345	a.t.Iterate(key+Sep, end, func(k string, _ any) bool {
346		keys = append(keys, k)
347		return len(keys) >= maxPages+1
348	})
349	var del []string
350	if len(keys) == maxPages+1 {
351		// The budget's worth, oldest-first. The newest collected candidate —
352		// and anything beyond it the bounded walk never reached — survives,
353		// so the newest all-below page survives.
354		del = keys[:maxPages]
355	} else if len(keys) > 0 {
356		// The walk exhausted the candidates: the last one collected IS the
357		// newest all-below page. Spare it.
358		del = keys[:len(keys)-1]
359	}
360	for _, k := range del {
361		a.t.Remove(k)
362	}
363	return len(del)
364}
365
366// WalkDesc visits key's ARCHIVED checkpoints newest-first, stopping when fn
367// returns true. The two inline points are the Series' own fields and are not
368// visited — a caller assembling a history appends them itself (they are always
369// newer than anything archived, because points only ever roll OUT of the
370// inline slots). Bounded by the key's page prefix like every other walk here.
371func (a *Archive) WalkDesc(key string, fn func(e uint32, v int64) bool) {
372	mustBeUsable(key)
373	a.t.ReverseIterate(key+Sep, pageKey(key, ^uint32(0)), func(_ string, pv any) bool {
374		s := pv.(*page).packed
375		for i := len(s)/12 - 1; i >= 0; i-- {
376			off := i * 12
377			if fn(rd32(s, off), rd64(s, off+4)) {
378				return true
379			}
380		}
381		return false
382	})
383}