1// Package storagecost answers one question: is it worth paying gas to delete2// on-chain state?3//4// On gno.land every byte of realm state locks GNOT, and the lock is refunded5// to whoever signs the transaction that frees the byte. Deleting state is6// therefore paid work, and whether a given deletion pays depends on two prices7// that move independently: the storage price, a chain parameter, and the gas8// price of the day. A contract cannot know the second one, so it cannot decide9// on its own behalf whether to compact, reindex or reap. It can only publish10// 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 chain13// imports, so a realm can call it inside a Render and an off-chain bot can14// reuse the identical formula.15//16// The single fact worth remembering is the ratio. One byte freed refunds 10017// ugnot, and at the lowest gas price mainnet has actually accepted one ugnot18// buys 1000 gas. So a byte is worth 100,000 gas, and any deletion costing less19// than that per byte pays for itself.20//21// Demo realms: gno.land/r/moul/x/reaper for reaping expired state, and22// gno.land/r/moul/x/compact for the compaction half.23package storagecost2425import (26 "gno.land/p/moul/kit/num/v0"27 "gno.land/p/nt/ufmt/v0"28)2930const (31 // DefaultStoragePrice is the ugnot locked per byte of realm state, the32 // default of the chain's vm:p:storage_price parameter. It is governance33 // settable, so read it from the chain rather than trusting this constant34 // when real money depends on the answer.35 DefaultStoragePrice int64 = 1003637 // GasPerUgnotFloor is how much gas one ugnot buys at the lowest gas price38 // 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 for40 // more headroom raises the fee proportionally.41 GasPerUgnotFloor int64 = 10004243 // payloadOverheadNum/payloadOverheadDen approximate what a payload really44 // costs once the realm has wrapped it in an object. Measured at 1.85x: ten45 // 1,024-byte strings in a realm slice cost 18,984 bytes of state, 1,89846 // each. It is an estimate and nothing more. The authoritative number is47 // the chain's own, from the vm/qstorage query or a StorageDepositEvent.48 payloadOverheadNum int64 = 18549 payloadOverheadDen int64 = 1005051 // BytesPerTreeNode is the realm state one balanced-tree container node52 // occupies, for the tree containers in this namespace (p/moul/ulist and53 // what is built on it).54 //55 // Measured twice, agreeing exactly. Compacting 8 dead nodes on mainnet56 // freed 6,848 bytes (2026-09-23), and 31 nodes in the local integration57 // harness freed 26,536. Both give 856.58 //59 // It is a property of the node, not of the element: the node costs this60 // whether the value it carried was 8 bytes or 1,024. That is what makes61 // EstimateNodes accurate where EstimateBytes is only indicative.62 BytesPerTreeNode int64 = 8566364 ugnotPerGNOT int64 = 1_000_00065)6667// Refund is the deposit returned for freeing bytes at the given price per68// byte. Returns 0 for non-positive inputs rather than panicking, so a Render69// on a realm with no state still works.70func Refund(bytes, pricePerByte int64) int64 {71 if bytes <= 0 || pricePerByte <= 0 {72 return 073 }74 return bytes * pricePerByte75}7677// BreakEvenBytes is the fewest bytes whose refund covers a fee: the point78// where a cleanup stops costing money and starts making it. Below this many79// bytes the transaction is charity.80func BreakEvenBytes(feeUgnot, pricePerByte int64) int64 {81 if pricePerByte <= 0 {82 return 083 }84 if feeUgnot <= 0 {85 return 086 }87 // Round up: freeing exactly feeUgnot/pricePerByte bytes must cover the fee.88 return (feeUgnot + pricePerByte - 1) / pricePerByte89}9091// GasFee is the fee a transaction asking for gasWanted must pay at a gas price92// of num/den ugnot per gas, rounded up.93//94// The fee tracks gas_wanted rather than gas_used, so unused headroom is paid95// for. Pass the ceiling you will actually put in the transaction, not what you96// expect to burn.97func GasFee(gasWanted, num, den int64) int64 {98 if gasWanted <= 0 || num <= 0 || den <= 0 {99 return 0100 }101 return (gasWanted*num + den - 1) / den102}103104// FloorGasFee is GasFee at the lowest gas price mainnet has accepted.105func FloorGasFee(gasWanted int64) int64 {106 return GasFee(gasWanted, 1, GasPerUgnotFloor)107}108109// EstimateBytes guesses the realm state a payload of this many bytes will110// occupy, applying the measured object overhead.111//112// It is for sizing a bounty in a Render, where being within a factor of two113// beats reporting the raw payload length. Never settle an accounting question114// with it.115func EstimateBytes(payloadBytes int64) int64 {116 if payloadBytes <= 0 {117 return 0118 }119 return payloadBytes * payloadOverheadNum / payloadOverheadDen120}121122// 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 a126// compaction it always can. EstimateBytes scales a payload length by a ratio127// measured at one size and is wrong at the others: a per-entry floor dominates128// at the small end, and a realm advertising a bounty that way under-reported129// 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 exact131// number, and every node costs the same.132//133// Still an estimate. BytesPerTreeNode is measured rather than derived, and the134// chain's own StorageDepositEvent remains the only settlement.135func EstimateNodes(nodes int64) int64 {136 if nodes <= 0 {137 return 0138 }139 return nodes * BytesPerTreeNode140}141142// Quote is a complete answer for one candidate cleanup.143type Quote struct {144 Bytes int64 // bytes the cleanup would free145 Refund int64 // ugnot returned for them146 Fee int64 // ugnot the transaction will cost147 Net int64 // Refund - Fee; negative means it costs more than it pays148 BreakEven int64 // bytes needed to cover Fee149}150151// Worth reports whether the cleanup pays for itself.152func (q Quote) Worth() bool { return q.Net > 0 }153154// 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}165166// Evaluate prices one cleanup: freeing bytes in a transaction asking for167// gasWanted, at a storage price of pricePerByte and a gas price of num/den168// 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}180181// EvaluateAtFloor is Evaluate at the default storage price and the floor gas182// 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}186187// FormatGNOT renders ugnot as GNOT with trailing zeros trimmed, because a188// 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 by191// hand with `amount = -amount`, and since that leaves math.MinInt64 negative192// both the whole and the fractional part then carried their own sign:193// FormatGNOT(math.MinInt64) returned "--9223372036854.-775808 GNOT". No caller194// in this repo can reach it, but the function is exported and a formatter that195// can return a non-number is not one.196func FormatGNOT(amount int64) string {197 return num.GNOTf(amount)198}199Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.