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

storagecost.gno

7.76 Kb · 198 lines
  1// Package storagecost answers one question: is it worth paying gas to delete
  2// on-chain state?
  3//
  4// On gno.land every byte of realm state locks GNOT, and the lock is refunded
  5// to whoever signs the transaction that frees the byte. Deleting state is
  6// therefore paid work, and whether a given deletion pays depends on two prices
  7// that move independently: the storage price, a chain parameter, and the gas
  8// price of the day. A contract cannot know the second one, so it cannot decide
  9// on its own behalf whether to compact, reindex or reap. It can only publish
 10// the size of the prize and let a caller do the arithmetic.
 11//
 12// This package is that arithmetic. It is pure integer maths with no chain
 13// imports, so a realm can call it inside a Render and an off-chain bot can
 14// reuse the identical formula.
 15//
 16// The single fact worth remembering is the ratio. One byte freed refunds 100
 17// ugnot, and at the lowest gas price mainnet has actually accepted one ugnot
 18// buys 1000 gas. So a byte is worth 100,000 gas, and any deletion costing less
 19// than that per byte pays for itself.
 20//
 21// Demo realms: gno.land/r/moul/x/reaper for reaping expired state, and
 22// gno.land/r/moul/x/compact for the compaction half.
 23package storagecost
 24
 25import (
 26	"gno.land/p/moul/kit/num/v0"
 27	"gno.land/p/nt/ufmt/v0"
 28)
 29
 30const (
 31	// DefaultStoragePrice is the ugnot locked per byte of realm state, the
 32	// default of the chain's vm:p:storage_price parameter. It is governance
 33	// settable, so read it from the chain rather than trusting this constant
 34	// when real money depends on the answer.
 35	DefaultStoragePrice int64 = 100
 36
 37	// GasPerUgnotFloor is how much gas one ugnot buys at the lowest gas price
 38	// mainnet has been observed to accept, 0.001 ugnot per gas. It is a floor,
 39	// not a promise: the fee a node requires tracks gas_wanted, so asking for
 40	// more headroom raises the fee proportionally.
 41	GasPerUgnotFloor int64 = 1000
 42
 43	// payloadOverheadNum/payloadOverheadDen approximate what a payload really
 44	// costs once the realm has wrapped it in an object. Measured at 1.85x: ten
 45	// 1,024-byte strings in a realm slice cost 18,984 bytes of state, 1,898
 46	// each. It is an estimate and nothing more. The authoritative number is
 47	// the chain's own, from the vm/qstorage query or a StorageDepositEvent.
 48	payloadOverheadNum int64 = 185
 49	payloadOverheadDen int64 = 100
 50
 51	// BytesPerTreeNode is the realm state one balanced-tree container node
 52	// occupies, for the tree containers in this namespace (p/moul/ulist and
 53	// what is built on it).
 54	//
 55	// Measured twice, agreeing exactly. Compacting 8 dead nodes on mainnet
 56	// freed 6,848 bytes (2026-09-23), and 31 nodes in the local integration
 57	// harness freed 26,536. Both give 856.
 58	//
 59	// It is a property of the node, not of the element: the node costs this
 60	// whether the value it carried was 8 bytes or 1,024. That is what makes
 61	// EstimateNodes accurate where EstimateBytes is only indicative.
 62	BytesPerTreeNode int64 = 856
 63
 64	ugnotPerGNOT int64 = 1_000_000
 65)
 66
 67// Refund is the deposit returned for freeing bytes at the given price per
 68// byte. Returns 0 for non-positive inputs rather than panicking, so a Render
 69// on a realm with no state still works.
 70func Refund(bytes, pricePerByte int64) int64 {
 71	if bytes <= 0 || pricePerByte <= 0 {
 72		return 0
 73	}
 74	return bytes * pricePerByte
 75}
 76
 77// BreakEvenBytes is the fewest bytes whose refund covers a fee: the point
 78// where a cleanup stops costing money and starts making it. Below this many
 79// bytes the transaction is charity.
 80func BreakEvenBytes(feeUgnot, pricePerByte int64) int64 {
 81	if pricePerByte <= 0 {
 82		return 0
 83	}
 84	if feeUgnot <= 0 {
 85		return 0
 86	}
 87	// Round up: freeing exactly feeUgnot/pricePerByte bytes must cover the fee.
 88	return (feeUgnot + pricePerByte - 1) / pricePerByte
 89}
 90
 91// GasFee is the fee a transaction asking for gasWanted must pay at a gas price
 92// of num/den ugnot per gas, rounded up.
 93//
 94// The fee tracks gas_wanted rather than gas_used, so unused headroom is paid
 95// for. Pass the ceiling you will actually put in the transaction, not what you
 96// expect to burn.
 97func GasFee(gasWanted, num, den int64) int64 {
 98	if gasWanted <= 0 || num <= 0 || den <= 0 {
 99		return 0
100	}
101	return (gasWanted*num + den - 1) / den
102}
103
104// FloorGasFee is GasFee at the lowest gas price mainnet has accepted.
105func FloorGasFee(gasWanted int64) int64 {
106	return GasFee(gasWanted, 1, GasPerUgnotFloor)
107}
108
109// EstimateBytes guesses the realm state a payload of this many bytes will
110// occupy, applying the measured object overhead.
111//
112// It is for sizing a bounty in a Render, where being within a factor of two
113// beats reporting the raw payload length. Never settle an accounting question
114// with it.
115func EstimateBytes(payloadBytes int64) int64 {
116	if payloadBytes <= 0 {
117		return 0
118	}
119	return payloadBytes * payloadOverheadNum / payloadOverheadDen
120}
121
122// EstimateNodes is the realm state a count of dead container nodes occupies,
123// and so what compacting them frees.
124//
125// Prefer it to EstimateBytes wherever the caller can count, which for a
126// compaction it always can. EstimateBytes scales a payload length by a ratio
127// measured at one size and is wrong at the others: a per-entry floor dominates
128// at the small end, and a realm advertising a bounty that way under-reported
129// by 25x against what the cleanup actually returned on chain (2026-09-23).
130// Counting nodes has no such failure mode. The container reports the exact
131// number, and every node costs the same.
132//
133// Still an estimate. BytesPerTreeNode is measured rather than derived, and the
134// chain's own StorageDepositEvent remains the only settlement.
135func EstimateNodes(nodes int64) int64 {
136	if nodes <= 0 {
137		return 0
138	}
139	return nodes * BytesPerTreeNode
140}
141
142// Quote is a complete answer for one candidate cleanup.
143type Quote struct {
144	Bytes     int64 // bytes the cleanup would free
145	Refund    int64 // ugnot returned for them
146	Fee       int64 // ugnot the transaction will cost
147	Net       int64 // Refund - Fee; negative means it costs more than it pays
148	BreakEven int64 // bytes needed to cover Fee
149}
150
151// Worth reports whether the cleanup pays for itself.
152func (q Quote) Worth() bool { return q.Net > 0 }
153
154// String renders the quote as one line of markdown-safe text, for a Render.
155func (q Quote) String() string {
156	verdict := "not worth it yet"
157	if q.Worth() {
158		verdict = "worth " + FormatGNOT(q.Net)
159	}
160	return ufmt.Sprintf(
161		"%d bytes, refunds %s against %s of gas, break-even %d bytes: %s",
162		q.Bytes, FormatGNOT(q.Refund), FormatGNOT(q.Fee), q.BreakEven, verdict,
163	)
164}
165
166// Evaluate prices one cleanup: freeing bytes in a transaction asking for
167// gasWanted, at a storage price of pricePerByte and a gas price of num/den
168// ugnot per gas.
169func Evaluate(bytes, pricePerByte, gasWanted, num, den int64) Quote {
170	fee := GasFee(gasWanted, num, den)
171	refund := Refund(bytes, pricePerByte)
172	return Quote{
173		Bytes:     bytes,
174		Refund:    refund,
175		Fee:       fee,
176		Net:       refund - fee,
177		BreakEven: BreakEvenBytes(fee, pricePerByte),
178	}
179}
180
181// EvaluateAtFloor is Evaluate at the default storage price and the floor gas
182// price: the best case, and the one to quote when advertising a bounty.
183func EvaluateAtFloor(bytes, gasWanted int64) Quote {
184	return Evaluate(bytes, DefaultStoragePrice, gasWanted, 1, GasPerUgnotFloor)
185}
186
187// FormatGNOT renders ugnot as GNOT with trailing zeros trimmed, because a
188// bounty shown in ugnot is unreadable and gno has no floats.
189//
190// It delegates to [p/moul/kit/num], which owns amount formatting. v0 did it by
191// hand with `amount = -amount`, and since that leaves math.MinInt64 negative
192// both the whole and the fractional part then carried their own sign:
193// FormatGNOT(math.MinInt64) returned "--9223372036854.-775808 GNOT". No caller
194// in this repo can reach it, but the function is exported and a formatter that
195// can return a non-number is not one.
196func FormatGNOT(amount int64) string {
197	return num.GNOTf(amount)
198}