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.

twap.gno

12.02 Kb · 310 lines
  1// Package twap is a fixed-window trailing average of an integer series: cheap to
  2// keep, cheap to read, and hard to move with a one-block spike.
  3//
  4// It answers one question — "what has this number averaged over the last W?" —
  5// which is what an on-chain market wants from a price oracle and what a token
  6// vote wants from a supply-or-turnout figure: a value that a flash spike cannot
  7// shift, because moving the average requires HOLDING the pushed value for a real
  8// fraction of the window.
  9//
 10// # Not checkpoint
 11//
 12// The sibling p/kourt/checkpoint answers a different question — "what was
 13// this value at sealed epoch N?" — from unbounded, paged history. This is a
 14// bounded rolling window with no archive: the last N buckets and nothing older.
 15// Different query, different structure, no overlap.
 16//
 17// # A value, not an object
 18//
 19// A Ring is a plain value the caller stores inline on a record it was going to
 20// write anyway — a market's own row. It is never a heap object of its own, and
 21// never held by pointer in realm state: every method takes a Ring by value and
 22// returns an updated one. The reason to keep it a value is purely GAS, not
 23// safety: gno charges per object touched, so a Ring that rides its host's write
 24// costs nothing extra to keep, whereas a *Ring would be a second object (a second
 25// key) — and it would buy nothing, since every method is a value receiver that
 26// hands back a copy anyway. There is no mutator to borrow and so no security
 27// question here at all; the value shape is a cost decision.
 28//
 29//	r = r.Observe(height, price)   // store r back onto your own record
 30//	avg, ok := r.Average(height, window)
 31//
 32// # Buckets, and why a spike does not move the average
 33//
 34// Time is quantised into fixed-width buckets. Each bucket remembers the last
 35// value seen in it; a bucket with no observation carries the previous value
 36// forward, so a quiet series reads as "unchanged", not as a gap. The average
 37// over a window of W is the mean of the W/width most recent buckets.
 38//
 39// A spike therefore lands in at most one bucket — one part in W/width of the
 40// average — and only if it is still the last value in that bucket when the bucket
 41// closes. To move the average by a fraction f of the series' range you must keep
 42// it moved across about f·(W/width) buckets, i.e. hold the pushed value for f of
 43// the whole window against everyone trading back. One block is ~1/(W/width) of
 44// that, which for a week of hourly buckets is under a thousandth of a spike.
 45//
 46// # The freshness contract
 47//
 48// That guarantee holds only while observations keep arriving. An empty bucket
 49// carries the last value forward, so if the caller STOPS observing, the last
 50// value — a spike included — persists across the whole window and the average
 51// becomes that single value, still reported mature. So the caller must Observe on
 52// every change to the tracked quantity, OR Observe at the height it later reads.
 53// StaleBy reports how far a read has drifted from the newest observation, for a
 54// caller that cannot guarantee the former. `mature` means "the window is
 55// covered", never "the data is recent".
 56package twap
 57
 58import "math/overflow"
 59
 60// Ring is a fixed-window trailing average. The zero value is not usable; build
 61// one with New.
 62//
 63// bpp is bytes per sample: 1 for a 0..255 value such as a percentage price, up
 64// to 8 for a full int64. Samples are packed big-endian into buf so the whole
 65// history is one []byte-shaped field rather than N times the 40 bytes gno spends
 66// on a slice element of any wider type.
 67type Ring struct {
 68	width  int64  // bucket width, in the caller's height units
 69	n      int    // number of buckets
 70	bpp    int    // bytes per sample
 71	head   int    // ring index of the newest bucket
 72	base   int64  // bucket number (height/width) at head
 73	last   int64  // most recent value, carried across empty buckets
 74	filled int    // buckets ever written, for maturity
 75	buf    string // n*bpp packed big-endian samples
 76}
 77
 78// New builds an empty ring of n buckets, each width height-units wide, storing
 79// bpp bytes per sample.
 80//
 81// The window a caller later asks Average for must be at most n*width, or the ring
 82// cannot cover it and the read is reported immature. Size n to the longest window
 83// you will read.
 84//
 85// width is the resolution-versus-cost trade: a finer width puts more buckets in a
 86// window (more storage, a longer Average scan) but resists a spike that is held
 87// for less real time; a coarser width is cheaper and blunter. n*width fixes the
 88// span, so the two are chosen together — e.g. a week resisted at hourly grain is
 89// width=1h, n=168.
 90func New(width int64, n, bpp int) Ring {
 91	mustSane(width, n, bpp)
 92	return Ring{
 93		width: width, n: n, bpp: bpp,
 94		head: 0, base: 0, last: 0, filled: 0,
 95		buf: string(make([]byte, n*bpp)),
 96	}
 97}
 98
 99// Load reconstructs a ring from a previously stored header and its buf.
100//
101// The three shape numbers are the caller's own constants; only buf is data, so a
102// caller that keeps width/n/bpp as package constants stores just the head fields
103// and the bytes.
104//
105// Load validates SHAPE — buf length, and head/filled in range — and panics on a
106// mismatch. It TRUSTS base and last: a wrong value there only skews a later
107// average (never reads out of bounds), so pass back exactly what the accessors
108// returned rather than hand-built numbers.
109func Load(width int64, n, bpp int, head int, base, last int64, filled int, buf string) Ring {
110	mustSane(width, n, bpp)
111	if len(buf) != n*bpp {
112		panic("twap: buf length does not match n*bpp")
113	}
114	if head < 0 || head >= n || filled < 0 || filled > n {
115		panic("twap: head or filled out of range")
116	}
117	return Ring{width: width, n: n, bpp: bpp, head: head, base: base, last: last, filled: filled, buf: buf}
118}
119
120// Observe records value as of height and returns the updated ring.
121//
122// Height must not go backwards below the newest bucket already written; a chain
123// height only ever increases, and a caller that resets it (a test harness) is the
124// one case this refuses, loudly, because a backwards write would corrupt every
125// later average.
126func (r Ring) Observe(height, value int64) Ring {
127	if r.n == 0 {
128		panic("twap: zero-value ring; use New")
129	}
130	bn := height / r.width
131	b := []byte(r.buf)
132	if r.filled == 0 {
133		// Seed on the FIRST real observation, wherever it lands. A market is
134		// created at some height H and its first Observe is at H, not at 0 — so
135		// base must start at H's bucket, not at zero. Seeding at zero and then
136		// advancing to H would carry the zero-value `last` into every bucket in
137		// between and count them in `filled`, and the ring would then report a
138		// week of history averaging zero as mature and true when the only value
139		// ever seen was, say, 50. That is a wrong average handed to a quorum, and
140		// it is exactly the case no test with a height-0 start ever reaches.
141		r.base = bn
142		put(b, r.head*r.bpp, r.bpp, value)
143		r.filled, r.last, r.buf = 1, value, string(b)
144		return r
145	}
146	if bn < r.base {
147		panic("twap: height went backwards")
148	}
149	if bn == r.base {
150		// Same bucket: overwrite its representative value.
151		put(b, r.head*r.bpp, r.bpp, value)
152		r.last = value
153		r.buf = string(b)
154		return r
155	}
156	// Advance across the gap, carrying `last` into every skipped bucket and
157	// `value` into the newest. More than n steps just laps the ring, so cap the
158	// work at n — the older laps are overwritten anyway.
159	steps := bn - r.base
160	if steps > int64(r.n) {
161		steps = int64(r.n)
162	}
163	for i := int64(0); i < steps; i++ {
164		r.head++
165		if r.head == r.n {
166			r.head = 0
167		}
168		fill := r.last
169		if i == steps-1 {
170			fill = value
171		}
172		put(b, r.head*r.bpp, r.bpp, fill)
173		if r.filled < r.n {
174			r.filled++
175		}
176	}
177	r.base = bn
178	r.last = value
179	r.buf = string(b)
180	return r
181}
182
183// Average returns the trailing average over [height-window, height] and whether
184// the ring held enough history to cover the whole window.
185//
186// mature means the window is COVERED by real buckets — not that those buckets are
187// RECENT. An empty tail carries the last value forward (see StaleBy and the
188// freshness contract), so a lone spike that is the last observation before a quiet
189// spell fills the window and still reads mature. A caller acting on the average
190// against manipulation — gating a vote, pricing a payout — must BOTH refuse to act
191// while mature is false AND keep the read fresh: Observe at (or near) the height
192// it reads, or gate on StaleBy. Reading is pure — a transient decode, no object
193// touched — so it is safe inside a Render.
194func (r Ring) Average(height, window int64) (avg int64, mature bool) {
195	if r.n == 0 {
196		panic("twap: zero-value ring; use New")
197	}
198	if window < r.width {
199		window = r.width
200	}
201	// want is how many buckets the window asks for, UNCAPPED. Maturity is measured
202	// against it, so a window wider than the ring can ever hold (want > n) reads
203	// immature — rather than silently returning the ring's shorter average stamped
204	// as the requested one. k is want capped to what the ring can scan.
205	want := int(window / r.width)
206	k := want
207	if k > r.n {
208		k = r.n
209	}
210	bn := height / r.width
211	var sum int64
212	count := 0
213	for i := 0; i < k; i++ {
214		bkt := bn - int64(i)
215		if bkt < 0 || bkt <= r.base-int64(r.filled) {
216			break // older than the ring has ever held real data
217		}
218		var v int64
219		if bkt > r.base {
220			v = r.last // beyond the last observation: value has persisted
221		} else {
222			idx := r.head - int(r.base-bkt)
223			for idx < 0 {
224				idx += r.n
225			}
226			v = get(r.buf, idx*r.bpp, r.bpp) // read the string directly; no []byte copy
227		}
228		// Checked, not assumed. Values are non-negative, so a wrapped sum goes
229		// negative and would hand a quorum a negative "average"; refuse loudly
230		// instead. Trips only when the ring holds more large buckets than fit in
231		// int64 (e.g. per-minute buckets at the supply cap) — size the ring or
232		// narrow the value range.
233		next, ok := overflow.Add64(sum, v)
234		if !ok {
235			panic("twap: window sum overflowed int64")
236		}
237		sum = next
238		count++
239	}
240	if count == 0 {
241		return 0, false
242	}
243	return sum / int64(count), count == want && r.filled >= want
244}
245
246// Head, Base, Last, Filled, Bytes expose the fields a caller stores alongside buf
247// to reconstruct the ring with Load.
248func (r Ring) Head() int     { return r.head }
249func (r Ring) Base() int64   { return r.base }
250func (r Ring) Last() int64   { return r.last }
251func (r Ring) Filled() int   { return r.filled }
252func (r Ring) Bytes() string { return r.buf }
253
254// StaleBy reports how many buckets the read height lies beyond the newest
255// observation. Zero means the newest real value sits in height's own bucket; a
256// larger number means the window a caller is about to Average is that many buckets
257// of carried-forward value rather than fresh data. Because `mature` only says the
258// window is covered, a caller acting against manipulation should require StaleBy
259// to be small — ideally 0, i.e. Observe at the height it reads (see the freshness
260// contract on the package).
261func (r Ring) StaleBy(height int64) int64 {
262	if r.n == 0 {
263		panic("twap: zero-value ring; use New")
264	}
265	bn := height / r.width
266	if bn <= r.base {
267		return 0
268	}
269	return bn - r.base
270}
271
272func mustSane(width int64, n, bpp int) {
273	if width <= 0 {
274		panic("twap: width must be positive")
275	}
276	if n <= 0 {
277		panic("twap: n must be positive")
278	}
279	if bpp < 1 || bpp > 8 {
280		panic("twap: bpp must be 1..8")
281	}
282}
283
284// put writes v big-endian into b[off:off+bpp]. The caller guarantees v fits in
285// bpp bytes; a value wider than the sample width is a caller bug, and truncating
286// it silently is the one behaviour this must not have — so the top bytes are
287// asserted zero.
288func put(b []byte, off, bpp int, v int64) {
289	if v < 0 {
290		panic("twap: negative value")
291	}
292	if bpp < 8 && v>>(uint(bpp)*8) != 0 {
293		panic("twap: value does not fit in bpp bytes")
294	}
295	for i := bpp - 1; i >= 0; i-- {
296		b[off+i] = byte(v & 0xff)
297		v >>= 8
298	}
299}
300
301// get reads a big-endian sample straight from the buf STRING, so a read never
302// allocates a []byte copy. Only Observe needs the mutable []byte (for put); a
303// read (Average) is the hot path and stays allocation-free.
304func get(s string, off, bpp int) int64 {
305	var v int64
306	for i := 0; i < bpp; i++ {
307		v = (v << 8) | int64(s[off+i])
308	}
309	return v
310}