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

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}