Transaction
87E588E5FDDFCE…DF90BCFC8798
Block 410,018 · index 0 · indexed
Summary
- Hash
- 87E588E5FDDFCEFC93B49681B3266A0772274202720B65663B8ADF90BCFC8798
- Block
- 410,018
- Size
- 147426 bytes
- Gas used
- 163,877,406 / 533,405,600
- Fee
- 1600216ugnot
- Status
- success
Messages
- Attached funds
- 8000000ugnot
Arguments · 9
- #1storagecost
- #2README.md
- #3# `gno.land/p/moul/x/storagecost/v1` Is it worth paying gas to delete on-chain state? On gno.land every byte of realm state locks GNOT, and the lock is refunded to whoever signs the transaction that frees the byte. Deleting state is therefore paid work. Whether a particular deletion pays depends on two prices that move independently: the storage price, a chain parameter, and the gas price of the day. **A contract cannot know the second one**, so it cannot decide on its own behalf whether to compact, reindex or reap. It can only publish the size of the prize and let a caller do the arithmetic. This package is that arithmetic: pure integer maths, no chain imports, so a realm can call it inside a `Render` and an off-chain bot can reuse the identical formula. ## The one number to remember One byte freed refunds 100 ugnot. At the lowest gas price mainnet has actually accepted, one ugnot buys 1000 gas. **So a byte is worth 100,000 gas, and any deletion costing less than that per byte pays for itself.** ```go q := storagecost.EvaluateAtFloor(17835, 5_000_000) q.Refund // 1783500 ugnot q.Fee // 5000 ugnot q.Net // 1778500 ugnot q.Worth() // true q.String() // "17835 bytes, refunds 1.7835 GNOT against 0.005 GNOT of gas, break-even 50 bytes: worth 1.7785 GNOT" ``` ## What is in here | | | |---|---| | `Refund` · `BreakEvenBytes` · `Net` | the core arithmetic. `BreakEvenBytes` rounds up, so a quoted threshold always covers the fee | | `GasFee` · `FloorGasFee` | a fee from a gas ceiling and a rational gas price. The fee tracks `gas_wanted`, not `gas_used`, so unused headroom is paid for | | `Evaluate` · `EvaluateAtFloor` · `Quote` | a whole verdict for one candidate cleanup, with a `String()` fit for a `Render` | | `EstimateBytes` | what a payload really costs once a realm has wrapped it in an object, at the measured 1.85x | | `FormatGNOT` | ugnot as readable GNOT, because gno has no floats and a bounty quoted in ugnot is unreadable | ## Two honesty notes **`DefaultStoragePrice` is a default, not a fact.** `vm:p:storage_price` is governance settable. Read it from the chain when real money depends on the answer; the constant is for sizing and display. **`EstimateBytes` is an estimate.** It exists so a bounty shown on a page is within a factor of two instead of reporting raw payload length. No stdlib call exposes a realm's real locked storage, so the authoritative numbers are the chain's: the `vm/qstorage` query, or the `StorageDepositEvent` and `StorageUnlockEvent` every transaction emits. Never settle an accounting question with a guess. Demo realm: [`r/moul/x/reaper`](https://github.com/moul/gno-contracts/tree/main/r/moul/x/reaper). ## v1: `FormatGNOT` delegates to `p/moul/kit/num` `v0` formatted by hand with `amount = -amount`, which leaves `math.MinInt64` negative, so both the whole and the fractional part then carried their own sign: `FormatGNOT(math.MinInt64)` returned `--9223372036854.-775808 GNOT`. No caller in this repo can reach a negative (`q.Net` is the only one that can be, and it is printed only inside `if q.Worth()`), but the function is exported and a formatter that can return a non-number is not one. `v0` stays resolvable. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:**  > 🧪 **Highly experimental — potentially vibe-coded.** Not audited; may break, change, or be removed at any time. Do not use with anything of value. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
- #4gnomod.toml
- #5module = "gno.land/p/moul/x/storagecost/v1" gno = "0.9"
- #6storagecost.gno
- #7// Package storagecost answers one question: is it worth paying gas to delete // on-chain state? // // On gno.land every byte of realm state locks GNOT, and the lock is refunded // to whoever signs the transaction that frees the byte. Deleting state is // therefore paid work, and whether a given deletion pays depends on two prices // that move independently: the storage price, a chain parameter, and the gas // price of the day. A contract cannot know the second one, so it cannot decide // on its own behalf whether to compact, reindex or reap. It can only publish // the size of the prize and let a caller do the arithmetic. // // This package is that arithmetic. It is pure integer maths with no chain // imports, so a realm can call it inside a Render and an off-chain bot can // reuse the identical formula. // // The single fact worth remembering is the ratio. One byte freed refunds 100 // ugnot, and at the lowest gas price mainnet has actually accepted one ugnot // buys 1000 gas. So a byte is worth 100,000 gas, and any deletion costing less // than that per byte pays for itself. // // Demo realms: gno.land/r/moul/x/reaper for reaping expired state, and // gno.land/r/moul/x/compact for the compaction half. package storagecost import ( "gno.land/p/moul/kit/num/v0" "gno.land/p/nt/ufmt/v0" ) const ( // DefaultStoragePrice is the ugnot locked per byte of realm state, the // default of the chain's vm:p:storage_price parameter. It is governance // settable, so read it from the chain rather than trusting this constant // when real money depends on the answer. DefaultStoragePrice int64 = 100 // GasPerUgnotFloor is how much gas one ugnot buys at the lowest gas price // mainnet has been observed to accept, 0.001 ugnot per gas. It is a floor, // not a promise: the fee a node requires tracks gas_wanted, so asking for // more headroom raises the fee proportionally. GasPerUgnotFloor int64 = 1000 // payloadOverheadNum/payloadOverheadDen approximate what a payload really // costs once the realm has wrapped it in an object. Measured at 1.85x: ten // 1,024-byte strings in a realm slice cost 18,984 bytes of state, 1,898 // each. It is an estimate and nothing more. The authoritative number is // the chain's own, from the vm/qstorage query or a StorageDepositEvent. payloadOverheadNum int64 = 185 payloadOverheadDen int64 = 100 // BytesPerTreeNode is the realm state one balanced-tree container node // occupies, for the tree containers in this namespace (p/moul/ulist and // what is built on it). // // Measured twice, agreeing exactly. Compacting 8 dead nodes on mainnet // freed 6,848 bytes (2026-09-23), and 31 nodes in the local integration // harness freed 26,536. Both give 856. // // It is a property of the node, not of the element: the node costs this // whether the value it carried was 8 bytes or 1,024. That is what makes // EstimateNodes accurate where EstimateBytes is only indicative. BytesPerTreeNode int64 = 856 ugnotPerGNOT int64 = 1_000_000 ) // Refund is the deposit returned for freeing bytes at the given price per // byte. Returns 0 for non-positive inputs rather than panicking, so a Render // on a realm with no state still works. func Refund(bytes, pricePerByte int64) int64 { if bytes <= 0 || pricePerByte <= 0 { return 0 } return bytes * pricePerByte } // BreakEvenBytes is the fewest bytes whose refund covers a fee: the point // where a cleanup stops costing money and starts making it. Below this many // bytes the transaction is charity. func BreakEvenBytes(feeUgnot, pricePerByte int64) int64 { if pricePerByte <= 0 { return 0 } if feeUgnot <= 0 { return 0 } // Round up: freeing exactly feeUgnot/pricePerByte bytes must cover the fee. return (feeUgnot + pricePerByte - 1) / pricePerByte } // GasFee is the fee a transaction asking for gasWanted must pay at a gas price // of num/den ugnot per gas, rounded up. // // The fee tracks gas_wanted rather than gas_used, so unused headroom is paid // for. Pass the ceiling you will actually put in the transaction, not what you // expect to burn. func GasFee(gasWanted, num, den int64) int64 { if gasWanted <= 0 || num <= 0 || den <= 0 { return 0 } return (gasWanted*num + den - 1) / den } // FloorGasFee is GasFee at the lowest gas price mainnet has accepted. func FloorGasFee(gasWanted int64) int64 { return GasFee(gasWanted, 1, GasPerUgnotFloor) } // EstimateBytes guesses the realm state a payload of this many bytes will // occupy, applying the measured object overhead. // // It is for sizing a bounty in a Render, where being within a factor of two // beats reporting the raw payload length. Never settle an accounting question // with it. func EstimateBytes(payloadBytes int64) int64 { if payloadBytes <= 0 { return 0 } return payloadBytes * payloadOverheadNum / payloadOverheadDen } // EstimateNodes is the realm state a count of dead container nodes occupies, // and so what compacting them frees. // // Prefer it to EstimateBytes wherever the caller can count, which for a // compaction it always can. EstimateBytes scales a payload length by a ratio // measured at one size and is wrong at the others: a per-entry floor dominates // at the small end, and a realm advertising a bounty that way under-reported // by 25x against what the cleanup actually returned on chain (2026-09-23). // Counting nodes has no such failure mode. The container reports the exact // number, and every node costs the same. // // Still an estimate. BytesPerTreeNode is measured rather than derived, and the // chain's own StorageDepositEvent remains the only settlement. func EstimateNodes(nodes int64) int64 { if nodes <= 0 { return 0 } return nodes * BytesPerTreeNode } // Quote is a complete answer for one candidate cleanup. type Quote struct { Bytes int64 // bytes the cleanup would free Refund int64 // ugnot returned for them Fee int64 // ugnot the transaction will cost Net int64 // Refund - Fee; negative means it costs more than it pays BreakEven int64 // bytes needed to cover Fee } // Worth reports whether the cleanup pays for itself. func (q Quote) Worth() bool { return q.Net > 0 } // String renders the quote as one line of markdown-safe text, for a Render. func (q Quote) String() string { verdict := "not worth it yet" if q.Worth() { verdict = "worth " + FormatGNOT(q.Net) } return ufmt.Sprintf( "%d bytes, refunds %s against %s of gas, break-even %d bytes: %s", q.Bytes, FormatGNOT(q.Refund), FormatGNOT(q.Fee), q.BreakEven, verdict, ) } // Evaluate prices one cleanup: freeing bytes in a transaction asking for // gasWanted, at a storage price of pricePerByte and a gas price of num/den // ugnot per gas. func Evaluate(bytes, pricePerByte, gasWanted, num, den int64) Quote { fee := GasFee(gasWanted, num, den) refund := Refund(bytes, pricePerByte) return Quote{ Bytes: bytes, Refund: refund, Fee: fee, Net: refund - fee, BreakEven: BreakEvenBytes(fee, pricePerByte), } } // EvaluateAtFloor is Evaluate at the default storage price and the floor gas // price: the best case, and the one to quote when advertising a bounty. func EvaluateAtFloor(bytes, gasWanted int64) Quote { return Evaluate(bytes, DefaultStoragePrice, gasWanted, 1, GasPerUgnotFloor) } // FormatGNOT renders ugnot as GNOT with trailing zeros trimmed, because a // bounty shown in ugnot is unreadable and gno has no floats. // // It delegates to [p/moul/kit/num], which owns amount formatting. v0 did it by // hand with `amount = -amount`, and since that leaves math.MinInt64 negative // both the whole and the fractional part then carried their own sign: // FormatGNOT(math.MinInt64) returned "--9223372036854.-775808 GNOT". No caller // in this repo can reach it, but the function is exported and a formatter that // can return a non-number is not one. func FormatGNOT(amount int64) string { return num.GNOTf(amount) }
- #8storagecost_test.gno
- #9package storagecost import ( "testing" "gno.land/p/nt/uassert/v0" ) func TestRefund(t *testing.T) { tests := []struct { name string bytes int64 price int64 want int64 }{ {"one byte at the default price", 1, DefaultStoragePrice, 100}, {"the measured reap", 17835, DefaultStoragePrice, 1783500}, {"the measured compact", 830, DefaultStoragePrice, 83000}, {"zero bytes", 0, DefaultStoragePrice, 0}, {"negative bytes are not a credit", -100, DefaultStoragePrice, 0}, {"a free chain refunds nothing", 1000, 0, 0}, {"a ten-fold price rise", 1000, 1000, 1000000}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { uassert.Equal(t, tt.want, Refund(tt.bytes, tt.price)) }) } } func TestBreakEvenBytes(t *testing.T) { tests := []struct { name string fee int64 want int64 }{ // A 2.6M gas call at the floor costs 2,600 ugnot, so 26 bytes. {"the canonical 2.6M gas call", 2600, 26}, {"rounds up, never down", 101, 2}, {"exactly one byte", 100, 1}, {"a fee below one byte still needs a byte", 1, 1}, {"no fee, no threshold", 0, 0}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := BreakEvenBytes(tt.fee, DefaultStoragePrice) uassert.Equal(t, tt.want, got) // The definition: this many bytes covers the fee, one fewer does not. if tt.want > 0 { uassert.True(t, Refund(got, DefaultStoragePrice) >= tt.fee) uassert.False(t, Refund(got-1, DefaultStoragePrice) >= tt.fee) } }) } uassert.Equal(t, int64(0), BreakEvenBytes(1000, 0)) } func TestGasFee(t *testing.T) { // The floor mainnet has actually accepted: 1 ugnot per 1000 gas. uassert.Equal(t, int64(5000), FloorGasFee(5_000_000)) uassert.Equal(t, int64(2500), FloorGasFee(2_500_000)) // Rounds up, so a fee is never short. uassert.Equal(t, int64(1), FloorGasFee(1)) uassert.Equal(t, int64(0), FloorGasFee(0)) // The harness price used in the measurements, 1.2 ugnot per gas. uassert.Equal(t, int64(6_000_000), GasFee(5_000_000, 12, 10)) uassert.Equal(t, int64(0), GasFee(5_000_000, 0, 10)) uassert.Equal(t, int64(0), GasFee(5_000_000, 1, 0)) } func TestEvaluateReproducesTheMeasuredReap(t *testing.T) { // The measured run: 17,835 bytes freed by a call that used 4,120,876 gas, // so gas_wanted 5,000,000 at the floor. Net +1,778,500 ugnot. q := EvaluateAtFloor(17835, 5_000_000) uassert.Equal(t, int64(1783500), q.Refund) uassert.Equal(t, int64(5000), q.Fee) uassert.Equal(t, int64(1778500), q.Net) uassert.Equal(t, int64(50), q.BreakEven) uassert.True(t, q.Worth()) } func TestEvaluateReproducesTheMeasuredCompact(t *testing.T) { q := EvaluateAtFloor(830, 2_500_000) uassert.Equal(t, int64(83000), q.Refund) uassert.Equal(t, int64(2500), q.Fee) uassert.Equal(t, int64(80500), q.Net) uassert.True(t, q.Worth()) } func TestEvaluateAtTheHarnessPriceIsALoss(t *testing.T) { // The same reap priced at 1.2 ugnot/gas, 1200x the floor, loses money. // This is why the gas price cannot be baked into a contract. q := Evaluate(17835, DefaultStoragePrice, 5_000_000, 12, 10) uassert.Equal(t, int64(6_000_000), q.Fee) uassert.False(t, q.Worth()) uassert.True(t, q.Net < 0) } func TestQuoteString(t *testing.T) { worth := EvaluateAtFloor(17835, 5_000_000) uassert.Equal(t, "17835 bytes, refunds 1.7835 GNOT against 0.005 GNOT of gas, break-even 50 bytes: worth 1.7785 GNOT", worth.String()) loss := Evaluate(10, DefaultStoragePrice, 5_000_000, 12, 10) uassert.Equal(t, "10 bytes, refunds 0.001 GNOT against 6 GNOT of gas, break-even 60000 bytes: not worth it yet", loss.String()) } func TestFormatGNOT(t *testing.T) { tests := []struct { amount int64 want string }{ {0, "0 GNOT"}, {1, "0.000001 GNOT"}, {100, "0.0001 GNOT"}, {1_000_000, "1 GNOT"}, {1_500_000, "1.5 GNOT"}, {1_783_500, "1.7835 GNOT"}, {978_743_800, "978.7438 GNOT"}, {-2_100, "-0.0021 GNOT"}, {1_000_001, "1.000001 GNOT"}, } for _, tt := range tests { uassert.Equal(t, tt.want, FormatGNOT(tt.amount)) } } func TestEstimateBytes(t *testing.T) { // Measured: a 1,024-byte payload occupied 1,898 bytes of realm state. uassert.Equal(t, int64(1894), EstimateBytes(1024)) uassert.Equal(t, int64(0), EstimateBytes(0)) uassert.Equal(t, int64(0), EstimateBytes(-5)) // Monotonic, which is all a bounty estimate really needs. uassert.True(t, EstimateBytes(2048) > EstimateBytes(1024)) } // TestFormatGNOTIsTotal pins what v0 could not do. v0 negated in int64, so at // math.MinInt64 both halves kept a sign and it returned // "--9223372036854.-775808 GNOT". Delegating to kit/num is the fix; this // asserts the contract rather than the delegation. func TestFormatGNOTIsTotal(t *testing.T) { const minInt64 = -9223372036854775808 uassert.Equal(t, "-9223372036854.775808 GNOT", FormatGNOT(minInt64)) uassert.Equal(t, "-1.5 GNOT", FormatGNOT(-1500000)) uassert.Equal(t, "-0.000001 GNOT", FormatGNOT(-1)) uassert.Equal(t, "1.5 GNOT", FormatGNOT(1500000)) uassert.Equal(t, "0 GNOT", FormatGNOT(0)) } func TestEstimateNodes(t *testing.T) { cases := []struct { name string nodes int64 want int64 }{ {"none", 0, 0}, {"negative is not an error", -5, 0}, // The mainnet measurement this constant comes from: compacting a // drained 8-node board returned 6,848 bytes. {"the mainnet sample", 8, 6848}, // And the local harness, which agreed to the byte. {"the local harness sample", 31, 26536}, } for _, tc := range cases { uassert.Equal(t, tc.want, EstimateNodes(tc.nodes), tc.name) } } // TestEstimateNodesBeatsEstimateBytesOnTheCaseThatBurnedUs pins the reason // EstimateNodes exists, not just what it computes. // // A realm advertised a bounty by running the payload length of five small // entries through EstimateBytes and got 481 bytes. Reaping and compacting them // actually returned 11,972. The payload path cannot see either the per-entry // floor or the container, and no existing test could catch that because they // all fed EstimateBytes the 1,024-byte payloads its ratio was measured on. func TestEstimateNodesBeatsEstimateBytesOnTheCaseThatBurnedUs(t *testing.T) { const payload = 8 + 231 + 7 + 5 + 9 // the five note bodies, 260 bytes const reallyFreed = 11972 guess := EstimateBytes(payload) uassert.True(t, guess*20 < reallyFreed, "EstimateBytes is still supposed to be the bad one here") // The container half is the part that is countable, and counting it gets // most of the way there on its own: 8 nodes were the larger share. counted := EstimateNodes(8) uassert.True(t, counted > guess*10, "counting nodes should dominate guessing from payload") uassert.True(t, counted <= reallyFreed, "counting nodes must never over-advertise") }
- Attached funds
- 14000000ugnot
Arguments · 15
- #1registry
- #2README.md
- #3# `gno.land/r/moul/gnopm/registry/v0` An open, on-chain map from a deployed gno package path back to the source that produced it: repository, commit, directory. The domain model lives in [`gno.land/p/moul/gnopm/v0`](../../../../p/moul/gnopm), which is also where the reasoning is written down. This realm is the wiring: the routes, the authority rule and the events. ## It cannot promise, and does not pretend to Nothing here is verified and nothing here **can** be. A realm cannot clone a repository, so it cannot check that the bytes at the claimed commit are the bytes deployed at the claimed path. What it stores is testimony under a signature: *this address says this package came from that source.* That is still worth storing, for two reasons. **It is testimony in the shape a verifier needs.** Path, repository, commit and directory are precisely the four inputs to "hash the `addpkg` payload of that directory and compare it with what the chain hands back", which [gnopm](https://github.com/moul/gnopm) already does against a local tree (`gnopm verify -deployed`). The realm does not answer the question; it makes the question answerable by anything that can clone. **The chain knows who signed.** A claim from the address that owns the package path's namespace comes from the party that controls the path. That is not proof the source matches, it is proof of who is speaking, and it is the strongest signal available without a chain-level feature. ## Open, and tagged Anyone may claim any path, including one they had nothing to do with. A claim from an address that does not own the namespace is **not hidden**: it renders under its own heading, below the owner's, and says so. Suppressing it was the alternative and it is worse. A registry that only accepts self-registrations is empty on day one, when almost nothing has been registered by its own deployer, and an empty registry teaches nobody anything. A claimant may always withdraw their own claim, and may never touch anyone else's. ## Routes | path | page | | --- | --- | | `/` | every claimed package path, paginated with `?page=N` | | `/<package path>` | the claims about one package, owner first | | `/help` | what this realm is and how to write to it | A package path contains slashes, so it arrives at `Render` already split; the routing is the rejoin. ## Writing ```sh gnokey maketx call -pkgpath gno.land/r/moul/gnopm/registry/v0 \ -func Register \ -args "gno.land/p/moul/md/v1" \ -args "https://github.com/moul/gno-contracts" \ -args "<40 or 64 char lowercase hex commit>" \ -args "p/moul/md" \ -args "refs/tags/v1.0.0" \ -gas-fee 1000000ugnot -gas-wanted 5000000 \ -broadcast -chainid <chain> -remote <rpc> moul ``` `dir` is empty for a package at the repository root. `ref` is optional and is never the thing verified: a ref moves, a commit does not. It is recorded so a reader can tell a claim pinned to a released tag from one pinned to a commit on nobody's branch, and so a verifier can report a commit since orphaned by a force-push. Calling `Register` again for the same path replaces your own claim and nobody else's, which is how a claim moves to a new commit after a redeploy. `Withdraw` takes it back. ## Reading, from another realm or an indexer ```go registry.HasClaims(pkgPath) // has anyone said anything repo, commit, dir, ok := registry.OwnerClaim(pkgPath) // the namespace holder's claim registry.PackageCount() registry.ClaimCount() ``` `OwnerClaim` is the only read that filters, and it filters on the one thing the chain can prove. Ownership is recomputed on every call rather than stored, because a name can be transferred. Two events carry the same information to an indexer, which is how an explorer follows the registry without polling: `SourceClaimed` (`pkgpath`, `claimant`, `repo`, `commit`) and `SourceWithdrawn` (`pkgpath`, `claimant`). ## Why `private = true` Standard for a new realm here: it can be redeployed at this path by its creator instead of burning a `/v1`. The cost is real and worth stating, because this realm holds data other people wrote: **a redeploy wipes every package-level variable**, so every claim in it goes with it. Claims are cheap to re-make and each one is a signed statement its author can reissue, which is what makes the trade acceptable here and would not make it acceptable for a realm holding balances. ## What is deliberately not here - **No verification**, per the top of this file. The check that actually proves something needs to clone a repository and hash a tree, which is a service, not a realm. gnopm already holds the whole of that logic (`hashPayload` hashes exactly what `addpkg` would upload; `gnomodnorm` handles the fact that the chain **rewrites** `gnomod.toml` on publish, appending an `[addpkg]` table, so a naive byte comparison reports every package as differing forever). - **No curation, no voting.** Whose claim to believe is a judgement that wants the deployer identity from chain history, which an explorer has and a realm does not. - **No compare-and-swap on update.** `r/moul/forge` takes the expected previous object id when moving a ref, because two maintainers racing on a branch is a real lost-update. Here a claimant only ever overwrites their own claim, so there is nobody to race. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:**  > ⚠️ **Disclaimer:** provided as-is, without warranty; not security-audited. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
- #4gnomod.toml
- #5module = "gno.land/r/moul/gnopm/registry/v0" gno = "0.9" private = true
- #6helpers_test.gno
- #7package registry import ( pm "gno.land/p/moul/gnopm/v0" "gno.land/p/nt/testutils/v0" ) // Deterministic, checksum-valid test addresses. Made rather than typed: the // address-namespace rule turns on the bech32 alphabet, which drops 1, b, i and // o, so a plausible-looking hand-written "g1..." fails the check for a reason // that has nothing to do with what is under test. var ( alice = testutils.TestAddress("alice") bob = testutils.TestAddress("bob") // alicePkg sits in alice's OWN address namespace, which is the only // ownership a test can create: r/sys/users is not writable from a unit // test, so a name namespace can never resolve to a test account. alicePkg = "gno.land/r/" + alice.String() + "/scratch/v0" ) const ( repoURL = "https://github.com/moul/gno-contracts" sha1Zero = "0000000000000000000000000000000000000000" sha1One = "1111111111111111111111111111111111111111" otherPkg = "gno.land/p/moul/md/v1" ) // reset puts the realm back to its initial state. Realm globals persist for the // whole test binary and examples run after every Test, so anything that renders // starts from a known tree or its pinned output silently depends on test order. func reset() { reg = pm.New() } // seedFixture builds a deterministic registry straight through the library: no // chain context needed, which is what lets an Example use it. Going through the // library rather than through Register also skips the crossing call, which an // example cannot make. func seedFixture() { reset() reg.Register(alice, 100, alicePkg, repoURL, sha1Zero, "scratch", "refs/tags/v0.1.0") reg.Register(bob, 110, alicePkg, "https://github.com/bob/impostor", sha1One, "", "") reg.Register(alice, 120, otherPkg, repoURL, sha1One, "p/moul/md", "") }
- #8registry.gno
- #9// Package registry is an open, on-chain map from a deployed gno package path // back to the source that produced it: repository, commit, directory. // // It is the realm half of gno.land/p/moul/gnopm/v0, which holds the domain // model and the whole of the reasoning; this file is wiring. Every exported // mutation is a crossing function that resolves the caller, forwards to the // library, aborts on error (the only way to revert state in gno) and emits an // event for indexers. // // # It cannot promise, and does not pretend to // // Nothing here is verified and nothing here can be. A realm cannot clone a // repository, so it cannot check that the bytes at the claimed commit are the // bytes deployed at the claimed path. What it stores is testimony under a // signature: this address says this package came from that source. // // That is still worth storing, for two reasons. // // First, it is testimony in the exact shape a verifier needs. Path, repository, // commit and directory are precisely the four inputs to "hash the addpkg // payload of that directory and compare it with what the chain hands back", // which gnopm already does against a local tree (`gnopm verify -deployed`). The // registry does not answer the question; it makes the question answerable by // anything that can clone. // // Second, the chain knows who signed. A claim from the address that owns the // package path's namespace comes from the party that controls the path, and // OwnedBy reports that. It is computed on every read, never stored, because a // name can be transferred and a stored answer would rot into exactly the kind // of stale claim this realm exists to distinguish from a live one. // // # Open, and tagged // // Anyone may claim any path, including one they had nothing to do with. That is // deliberate: gating registration on namespace ownership would leave the map // empty on day one, when almost nothing has been registered by its own // deployer, and an empty registry teaches nobody anything. A stranger's claim // is not suppressed, it is labelled and ranked below the owner's, and Render // never presents an unverified claim as a fact. // // A claimant may always withdraw their own claim, and may never touch anyone // else's. package registry import ( "chain" "chain/runtime" pm "gno.land/p/moul/gnopm/v0" "gno.land/r/sys/users" ) var reg = pm.New() // caller is the address that crossed into this realm. Deliberately not // restricted to end users: a realm may hold a namespace, and a DAO registering // the source of the packages it governs is the same operation. func caller(cur realm) address { if !cur.IsCurrent() { panic("gnopm/registry: spoofed realm") } return cur.Previous().Address() } func must(err error) { if err != nil { panic(err) } } // resolveName is the r/sys/users half of the ownership rule, passed to the // library so the library stays pure. // // isCurrent is deliberately ignored, matching r/moul/forge: a name that // resolves through an alias still resolves to the same holder, so a namespace // survives its owner renaming. IsDeleted folds the nil check in, which is what // its own doc comment asks call sites to rely on. func resolveName(name string) (address, bool) { data, _ := users.ResolveName(name) if data.IsDeleted() { return "", false } return data.Addr(), true } // Register records that pkgPath was built from dir of repo at commit. // // Calling it again for the same path replaces the caller's own claim and // nobody else's, so moving a claim to a new commit after a redeploy is one // call with no read first. // // ref is optional and is never the thing verified: a ref moves, a commit does // not. It is recorded so a reader can tell a claim pinned to a released tag // from one pinned to a commit on nobody's branch, and so a verifier can report // a commit that has since been orphaned by a force-push. // // dir is empty when the package sits at the repository root. func Register(cur realm, pkgPath, repo, commit, dir, ref string) { a := caller(cur) _, err := reg.Register(a, runtime.ChainHeight(), pkgPath, repo, commit, dir, ref) must(err) chain.Emit("SourceClaimed", "pkgpath", pkgPath, "claimant", a.String(), "repo", repo, "commit", commit, ) } // Withdraw removes the caller's own claim about pkgPath. func Withdraw(cur realm, pkgPath string) { a := caller(cur) must(reg.Withdraw(a, pkgPath)) chain.Emit("SourceWithdrawn", "pkgpath", pkgPath, "claimant", a.String()) } // PackageCount is how many package paths carry at least one claim. func PackageCount() int { return reg.Size() } // ClaimCount is the total number of claims across every path. func ClaimCount() int { return reg.Claims() } // HasClaims reports whether anything has been said about pkgPath. Cheap enough // for another realm or an indexer to ask per package. func HasClaims(pkgPath string) bool { return reg.Package(pkgPath) != nil } // OwnerClaim returns the repository, commit and directory claimed by the party // that owns pkgPath's namespace, and whether such a claim exists. // // This is the only read that filters, and it filters on the one signal the // chain can actually prove. Everything else a caller wants is in Render or in // the events. func OwnerClaim(pkgPath string) (repo, commit, dir string, ok bool) { p := reg.Package(pkgPath) if p == nil { return "", "", "", false } p.IterateClaims(func(c *pm.Claim) bool { if c.OwnedBy(resolveName) { repo, commit, dir, ok = c.Repo, c.Commit, c.Dir, true return true // stop } return false }) return repo, commit, dir, ok }
- #10registry_test.gno
- #11package registry import ( "strings" "testing" "gno.land/p/nt/uassert/v0" ) func TestRegisterAndWithdraw(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(alice)) Register(cross(cur), otherPkg, repoURL, sha1Zero, "p/moul/md", "refs/heads/main") uassert.Equal(t, 1, PackageCount()) uassert.Equal(t, 1, ClaimCount()) uassert.True(t, HasClaims(otherPkg)) uassert.False(t, HasClaims("gno.land/p/moul/nope/v0")) testing.SetRealm(testing.NewUserRealm(alice)) Withdraw(cross(cur), otherPkg) uassert.Equal(t, 0, PackageCount()) uassert.False(t, HasClaims(otherPkg)) } // A claim is the claimant's own. Nobody else may take it back, and the refusal // has to abort rather than silently do nothing: a Withdraw that quietly did // nothing reads to the caller exactly like one that worked. func TestWithdrawIsNotTransferable(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(alice)) Register(cross(cur), otherPkg, repoURL, sha1Zero, "", "") testing.SetRealm(testing.NewUserRealm(bob)) uassert.AbortsContains(t, cur, "no such claim", func() { Withdraw(cross(cur), otherPkg) }) uassert.True(t, HasClaims(otherPkg), "alice's claim survived bob") } func TestRegisterAborts(cur realm, t *testing.T) { reset() cases := []struct { name string pkgPath, repo, commit, dir, ref string contains string }{ {"bad path", "nonsense", repoURL, sha1Zero, "", "", "invalid package path"}, {"non-https repo", otherPkg, "javascript:alert(1)", sha1Zero, "", "", "invalid repository URL"}, {"abbreviated commit", otherPkg, repoURL, "0123456", "", "", "invalid commit id"}, {"absolute dir", otherPkg, repoURL, sha1Zero, "/etc", "", "invalid directory"}, {"unqualified ref", otherPkg, repoURL, sha1Zero, "", "main", "invalid ref name"}, } for _, tc := range cases { testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsContains(t, cur, tc.contains, func() { Register(cross(cur), tc.pkgPath, tc.repo, tc.commit, tc.dir, tc.ref) }, tc.name) } uassert.Equal(t, 0, ClaimCount(), "nothing was stored") } // OwnerClaim is the only read that filters, and it filters on the one thing // about a claim the chain can prove. An address namespace belongs to that // account with nothing registered anywhere, so it is testable without // r/sys/users, which a test cannot write to. func TestOwnerClaim(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(bob)) Register(cross(cur), alicePkg, "https://github.com/bob/impostor", sha1One, "", "") _, _, _, ok := OwnerClaim(alicePkg) uassert.False(t, ok, "a stranger's claim is not the owner's") testing.SetRealm(testing.NewUserRealm(alice)) Register(cross(cur), alicePkg, repoURL, sha1Zero, "scratch", "") repo, commit, dir, ok := OwnerClaim(alicePkg) uassert.True(t, ok, "the namespace holder's claim is found") uassert.Equal(t, repoURL, repo) uassert.Equal(t, sha1Zero, commit) uassert.Equal(t, "scratch", dir) _, _, _, ok = OwnerClaim("gno.land/p/moul/nothing/v0") uassert.False(t, ok, "an unclaimed path has no owner claim") } // Re-registering moves the caller's own claim and creates no second one. func TestReregister(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(alice)) Register(cross(cur), otherPkg, repoURL, sha1Zero, "p/moul/md", "") testing.SkipHeights(10) testing.SetRealm(testing.NewUserRealm(alice)) Register(cross(cur), otherPkg, repoURL, sha1One, "p/moul/md", "refs/tags/v1") uassert.Equal(t, 1, ClaimCount()) got := Render(otherPkg) uassert.True(t, strings.Contains(got, sha1One[:12]), "the new commit renders") uassert.False(t, strings.Contains(got, sha1Zero[:12]), "the old one is gone") uassert.True(t, strings.Contains(got, "| last updated | block"), "an updated claim says when") } // A package path arrives at Render already split on "/", so the routing is the // rejoin. Anything that does not reassemble into a path is a miss, and a miss // must not render as an empty package page. func TestRenderRouting(t *testing.T) { reset() uassert.True(t, strings.Contains(Render("nonsense"), "Not found"), "one bad element") uassert.True(t, strings.Contains(Render("a/b/c"), "Not found"), "too few elements") uassert.True(t, strings.Contains(Render("help"), "How this registry works"), "help page") uassert.True(t, strings.Contains(Render(""), "gnopm source registry"), "home") uassert.True(t, strings.Contains(Render(otherPkg), "Nobody has claimed"), "valid but unclaimed") } // The page ranks the owner's claim above a stranger's and says which is which. // Rendering them identically is the whole mistake this realm exists to avoid, // so the separation is pinned rather than left to review. func TestRenderSeparatesOwnerFromStranger(t *testing.T) { seedFixture() got := Render(alicePkg) iOwner := strings.Index(got, "Claimed by the namespace owner") iOthers := strings.Index(got, "Claimed by others") uassert.True(t, iOwner >= 0, "the owner section exists") uassert.True(t, iOthers >= 0, "the others section exists") uassert.True(t, iOwner < iOthers, "the owner is ranked first") uassert.True(t, strings.Contains(got, "impostor"), "a stranger's claim is shown, not hidden") uassert.True(t, strings.Contains(got, "do not own"), "and is labelled as not the owner's") }
- #12render.gno
- #13package registry import ( "chain/runtime" "chain/runtime/unsafe" "strconv" "strings" pm "gno.land/p/moul/gnopm/v0" "gno.land/p/moul/md/v0" "gno.land/p/moul/realmpath/v0" "gno.land/p/moul/txlink/v0" ) // untrusted-render: every STORED field is charset-validated at write time by // gno.land/p/moul/gnopm/v0 (ValidPkgPath, ValidRepo, ValidCommit, ValidDir, // ValidRef). Each is an allowlist, so a stored field cannot hold a backtick, a // pipe, a bracket, a control character or a bidi override, and therefore needs // no escaping at any of the several places one claim is shown. Validating once // at the write beats escaping at every read, and it is why ValidRef is narrower // than git's own rule: one denylist field would have undone it for all of them. // // The one string here that is NOT validated is the render path itself, which // arrives from the URL and is echoed back by notFound. That one is escaped. // pageSize bounds every listing: a Render that walks unbounded state is a // Render that eventually stops rendering. const pageSize = 20 // Render routes gnoweb paths: // // / every claimed package path // /<package path> the claims about one package // /help what this realm is, and how to write to it func Render(path string) string { req := realmpath.Parse(path) parts := req.PathParts() if len(parts) == 0 || parts[0] == "" { return renderHome(pageOf(req)) } if len(parts) == 1 && parts[0] == "help" { return renderHelp() } // A package path contains slashes, so it arrives split. Rejoining is the // whole of the routing: anything that reassembles into a valid path is a // package page, anything else is a miss. pkgPath := strings.Join(parts, "/") if !pm.ValidPkgPath(pkgPath) { return notFound(pkgPath + " is not a package path") } return renderPackage(pkgPath) } func base() string { return strings.TrimPrefix(unsafe.CurrentRealm().PkgPath(), runtime.ChainDomain()) } func pkgURL(pkgPath string) string { return base() + ":" + pkgPath } // link does not escape its text, because every call site passes a field that is // charset-validated at write time (see the note above). Escaping here would // turn "gno.land/p/moul/md/v1" into "gno\.land/p/moul/md/v1" on every row of // every listing, for no safety anyone gains. func link(text, url string) string { return "[" + text + "](" + url + ")" } // join glues blocks with exactly one blank line between them. // // gno collapses two consecutive blank lines inside an example's // Output: // block, exactly as Go does, so a Render that ever emits them cannot be pinned // by an example at all. Appending "\n" by hand as sections are built is how a // Render acquires them; assembling a list of blocks and joining once is how it // stops. Every block arrives without its own leading or trailing blank. func join(blocks []string) string { for i, b := range blocks { blocks[i] = strings.Trim(b, "\n") } return strings.Join(blocks, "\n\n") + "\n" } func pageOf(req *realmpath.Request) int { n, err := strconv.Atoi(req.Query.Get("page")) if err != nil || n < 1 { return 1 } return n } // notFound echoes the requested path, which is the only untrusted string this // file handles: it comes from the URL and has already failed validation by the // time we are here. It is escaped rather than dropped, because "that is not a // package path" without saying which one is an unhelpful error. func notFound(why string) string { return md.H1("Not found") + "\n" + md.EscapeText(why) + ".\n\n" + link("Back to the registry", base()) + "\n" } func renderHome(page int) string { var b strings.Builder b.WriteString(md.H1("gnopm source registry")) b.WriteString("\nWhere a deployed package says it came from. Every entry is a claim made by whoever signed it, never a verified fact: this realm cannot clone a repository, so it records testimony in the shape a verifier needs and labels who said it.\n") b.WriteString("\n**Packages:** " + strconv.Itoa(reg.Size()) + " · **Claims:** " + strconv.Itoa(reg.Claims()) + "\n") if reg.Size() == 0 { b.WriteString("\nNothing claimed yet. " + link("Register the first package", txlink.Call("Register")) + "\n") b.WriteString("\n" + link("How this works", base()+":help") + "\n") return b.String() } b.WriteString("\n| package | claims | owner has claimed |\n") b.WriteString("| --- | ---: | --- |\n") rows := 0 reg.IteratePackages((page-1)*pageSize, pageSize, func(p *pm.Package) bool { rows++ owner := "no" if _, _, _, ok := OwnerClaim(p.PkgPath); ok { owner = "yes" } b.WriteString("| " + link(p.PkgPath, pkgURL(p.PkgPath)) + " | " + strconv.Itoa(p.Count()) + " | " + owner + " |\n") return false }) if p := pager(page, rows); p != "" { b.WriteString("\n" + p + "\n") } b.WriteString("\n" + link("How this works", base()+":help") + "\n") return b.String() } func pager(page, rows int) string { var parts []string if page > 1 { parts = append(parts, link("previous", base()+"?page="+strconv.Itoa(page-1))) } if rows == pageSize { parts = append(parts, link("next", base()+"?page="+strconv.Itoa(page+1))) } return strings.Join(parts, " · ") } func renderPackage(pkgPath string) string { p := reg.Package(pkgPath) if p == nil { return join([]string{ md.H1(pkgPath), "Nobody has claimed a source for this package.", link("Claim it", txlink.Call("Register", "pkgPath", pkgPath)), link("Back to the registry", base()), }) } // The namespace owner first, then everyone else. The ordering IS the // tagging: no ranking is stored, it is recomputed here from the one thing // about a claim that the chain can actually prove. var owner, others []*pm.Claim p.IterateClaims(func(c *pm.Claim) bool { if c.OwnedBy(resolveName) { owner = append(owner, c) } else { others = append(others, c) } return false }) ns := md.InlineCode(pm.Namespace(pkgPath)) blocks := []string{ md.H1(pkgPath), strconv.Itoa(p.Count()) + " claim(s). A claim is what an address said, not what anyone checked.", } if len(owner) > 0 { blocks = append(blocks, md.H2("Claimed by the namespace owner"), "The signer owns "+ns+", so this claim comes from the party that controls the path. That is not a proof the source matches. It is a proof of who is speaking.") for _, c := range owner { blocks = append(blocks, claimBlock(c)) } } if len(others) > 0 { blocks = append(blocks, md.H2("Claimed by others"), "These addresses do not own "+ns+". A third party may be filling in the map honestly, or pointing at a repository that has nothing to do with this package. Read the source before trusting either.") for _, c := range others { blocks = append(blocks, claimBlock(c)) } } return join(append(blocks, link("Back to the registry", base()))) } func claimBlock(c *pm.Claim) string { var b strings.Builder b.WriteString("| field | value |\n| --- | --- |\n") b.WriteString("| claimant | " + md.InlineCode(c.Claimant.String()) + " |\n") b.WriteString("| commit | " + link(c.Commit[:12], c.SourceURL()) + " |\n") b.WriteString("| repository | " + link(c.Repo, c.Repo) + " |\n") dir := c.Dir if dir == "" { dir = "(repository root)" } b.WriteString("| directory | " + md.InlineCode(dir) + " |\n") if c.Ref != "" { b.WriteString("| ref | " + md.InlineCode(c.Ref) + " |\n") } b.WriteString("| claimed at | block " + strconv.FormatInt(c.Height, 10) + " |\n") updated := "never" if c.UpdatedAt != c.Height { updated = "block " + strconv.FormatInt(c.UpdatedAt, 10) } b.WriteString("| last updated | " + updated + " |") return b.String() } func renderHelp() string { var b strings.Builder b.WriteString(md.H1("How this registry works")) b.WriteString("\nA gno import path is a chain address, not a repository URL, so nothing on chain says where a deployed package came from. This realm is where an address can say so.\n") b.WriteString("\n" + md.H2("What it proves")) b.WriteString("\nNothing on its own, and that is the honest answer. A realm cannot clone a repository, so it cannot check that the code at a commit is the code at a path. Three claims get confused with each other and only two of them can ever be settled:\n\n") b.WriteString("| claim | provable |\n| --- | --- |\n") b.WriteString("| this package came from that repository | no. Not here and not anywhere: anyone may deploy any bytes and claim any repository |\n") b.WriteString("| the deployed bytes equal the addpkg payload of that directory at that commit | yes, by hashing both sides. Off chain, by anything that can clone |\n") b.WriteString("| the claimant owns this path's namespace | yes, from chain data, and it is recomputed on every read |\n") b.WriteString("\n" + md.H2("Writing to it")) b.WriteString("\nRegister the source of a package you deployed. Calling it again replaces your own claim and nobody else's, which is how you move a claim to a new commit after a redeploy.\n\n") b.WriteString(link("Register", txlink.Call("Register")) + " · " + link("Withdraw", txlink.Call("Withdraw")) + "\n") b.WriteString("\nFields: the full package path, an https repository URL, a 40 or 64 character lowercase hex commit, the subdirectory holding the package (empty for the repository root), and optionally a fully-qualified ref such as " + md.InlineCode("refs/tags/v1.2.0") + ".\n") b.WriteString("\nAnyone may claim any path. A claim from an address that does not own the namespace is not hidden: it is shown under its own heading and ranked below the owner's.\n") b.WriteString("\n" + link("Back to the registry", base()) + "\n") return b.String() }
- #14render_test.gno
- #15package registry // ExampleRender pins the realm's Render output, which is its whole public // surface. Each example calls seedFixture first: realm globals persist for the // whole test binary and examples run after every Test, so without the reset the // pinned output would silently depend on test order. func ExampleRender() { seedFixture() print(Render("")) // Output: // # gnopm source registry // // Where a deployed package says it came from. Every entry is a claim made by whoever signed it, never a verified fact: this realm cannot clone a repository, so it records testimony in the shape a verifier needs and labels who said it. // // **Packages:** 2 · **Claims:** 3 // // | package | claims | owner has claimed | // | --- | ---: | --- | // | [gno.land/p/moul/md/v1](/r/moul/gnopm/registry/v0:gno.land/p/moul/md/v1) | 1 | no | // | [gno.land/r/g1v9kxjcm9ta047h6lta047h6lta047h6lzd40gh/scratch/v0](/r/moul/gnopm/registry/v0:gno.land/r/g1v9kxjcm9ta047h6lta047h6lta047h6lzd40gh/scratch/v0) | 2 | yes | // // [How this works](/r/moul/gnopm/registry/v0:help) } // The package page is the one that matters: an owner's claim and a stranger's // claim about the same path, under separate headings, owner first. The pinned // output is what stops that distinction being quietly refactored away. func ExampleRender_package() { seedFixture() print(Render(alicePkg)) // Output: // # gno.land/r/g1v9kxjcm9ta047h6lta047h6lta047h6lzd40gh/scratch/v0 // // 2 claim(s). A claim is what an address said, not what anyone checked. // // ## Claimed by the namespace owner // // The signer owns `g1v9kxjcm9ta047h6lta047h6lta047h6lzd40gh`, so this claim comes from the party that controls the path. That is not a proof the source matches. It is a proof of who is speaking. // // | field | value | // | --- | --- | // | claimant | `g1v9kxjcm9ta047h6lta047h6lta047h6lzd40gh` | // | commit | [000000000000](https://github.com/moul/gno-contracts/tree/0000000000000000000000000000000000000000/scratch) | // | repository | [https://github.com/moul/gno-contracts](https://github.com/moul/gno-contracts) | // | directory | `scratch` | // | ref | `refs/tags/v0.1.0` | // | claimed at | block 100 | // | last updated | never | // // ## Claimed by others // // These addresses do not own `g1v9kxjcm9ta047h6lta047h6lta047h6lzd40gh`. A third party may be filling in the map honestly, or pointing at a repository that has nothing to do with this package. Read the source before trusting either. // // | field | value | // | --- | --- | // | claimant | `g1vfhkyh6lta047h6lta047h6lta047h6l03vdhu` | // | commit | [111111111111](https://github.com/bob/impostor/tree/1111111111111111111111111111111111111111) | // | repository | [https://github.com/bob/impostor](https://github.com/bob/impostor) | // | directory | `(repository root)` | // | claimed at | block 110 | // | last updated | never | // // [Back to the registry](/r/moul/gnopm/registry/v0) } func ExampleRender_unclaimed() { seedFixture() print(Render("gno.land/p/moul/nothing/v0")) // Output: // # gno.land/p/moul/nothing/v0 // // Nobody has claimed a source for this package. // // [Claim it](/r/moul/gnopm/registry/v0$help&func=Register&pkgPath=gno.land%2Fp%2Fmoul%2Fnothing%2Fv0) // // [Back to the registry](/r/moul/gnopm/registry/v0) } func ExampleRender_help() { seedFixture() print(Render("help")) // Output: // # How this registry works // // A gno import path is a chain address, not a repository URL, so nothing on chain says where a deployed package came from. This realm is where an address can say so. // // ## What it proves // // Nothing on its own, and that is the honest answer. A realm cannot clone a repository, so it cannot check that the code at a commit is the code at a path. Three claims get confused with each other and only two of them can ever be settled: // // | claim | provable | // | --- | --- | // | this package came from that repository | no. Not here and not anywhere: anyone may deploy any bytes and claim any repository | // | the deployed bytes equal the addpkg payload of that directory at that commit | yes, by hashing both sides. Off chain, by anything that can clone | // | the claimant owns this path's namespace | yes, from chain data, and it is recomputed on every read | // // ## Writing to it // // Register the source of a package you deployed. Calling it again replaces your own claim and nobody else's, which is how you move a claim to a new commit after a redeploy. // // [Register](/r/moul/gnopm/registry/v0$help&func=Register) · [Withdraw](/r/moul/gnopm/registry/v0$help&func=Withdraw) // // Fields: the full package path, an https repository URL, a 40 or 64 character lowercase hex commit, the subdirectory holding the package (empty for the repository root), and optionally a fully-qualified ref such as `refs/tags/v1.2.0`. // // Anyone may claim any path. A claim from an address that does not own the namespace is not hidden: it is shown under its own heading and ranked below the owner's. // // [Back to the registry](/r/moul/gnopm/registry/v0) }
- Attached funds
- 12000000ugnot
Arguments · 13
- #1idle
- #2README.md
- #3# `gno.land/r/moul/x/games/idle/v0` **A mine that keeps working while you are not looking at it**: `Start`, `Claim`, `Upgrade`, `Prestige`. ``` maketx call ... -func Start # open a mine at level 1 maketx call ... -func Claim # bank what it dug maketx call ... -func Upgrade # spend the bank to dig faster maketx call ... -func Prestige # at level 10: reset for a permanent multiplier ``` Ore accrues at a rate set by your level. There are no turns, no block hook and no keeper: the realm stores what you had and when, and everything else is arithmetic done at the moment somebody asks. `Render("")` is the board, `Render("<address>")` is one mine's card. **The numbers move on their own.** Refresh the page a minute later and the mine has dug more, with no transaction in between, because every figure comes from the read-only projection rather than from stored state. That is a live dashboard with no frontend, no indexer and no JavaScript, which is the one thing this chain gives you that the others charge for separately. Three decisions make it a game rather than a counter: - **The mine is small.** It holds 120 periods of production and then spills. Without a ceiling the optimal play is to never log in and claim once at the end, which is a savings account. The ceiling scales with the rate, so the fill time stays two hours at every level and showing up is the only skill. Removing it turns five tests red. - **Upgrades are quadratic against linear production.** Level `L` costs `10*L*L` and pays `L` per period, so the next level always takes about `10*L` periods to afford. Linear against linear never ends; exponential against linear dies at level four. - **Prestige exists.** Without a reset the leaderboard is settled by whoever started first and nobody else has a reason to join. Trading a level-10 mine for a permanent multiplier means a player starting today has a reachable ceiling. Banked and pending ore burn; the lifetime total does not. **Nothing rolls dice.** Every outcome is a pure function of when you acted, so there is no block hash to grind and no ordering to front-run, and the card can tell you exactly what an action produces before you sign it. Combat games reach for randomness and inherit a validator-manipulable seed; this one does not need to. Time is unix seconds from the block, not block height: a height-denominated economy silently reprices itself whenever the chain's block time moves. The arithmetic is [p/moul/x/games/accrual](/p/moul/x/games/accrual/v0), which owns the rule that makes lazy time honest. Claiming every period and claiming once pay exactly the same, right up to the point where the mine fills, and that limit is the design rather than the arithmetic. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:**  > 🧪 **Highly experimental — potentially vibe-coded.** Not audited; may break, change, or be removed at any time. Do not use with anything of value. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
- #4gnomod.toml
- #5module = "gno.land/r/moul/x/games/idle/v0" gno = "0.9" private = true
- #6idle.gno
- #7// Package idle is a mine that keeps working while you are not looking at it. // // Ore accrues at a rate set by your level. Claim banks it, Upgrade spends the // bank to dig faster, and at level 10 you may Prestige: start over at level 1 // carrying a permanent multiplier. There are no turns, no block hook and no // keeper. The realm stores what you had and when, and everything else is // arithmetic done at the moment somebody asks. // // The three decisions that make it a game rather than a counter: // // 1. THE MINE IS SMALL. It holds two hours of production and then spills. // Without a ceiling the optimal play is to never log in and claim once at // the end, which is not a game, it is a savings account. The ceiling is // what makes showing up the skill, and it scales with the rate, so two // hours stays two hours at every level. // 2. UPGRADES ARE QUADRATIC AGAINST LINEAR PRODUCTION. Level L costs // UpgradeBase*L*L and pays L per period, so the next level always takes // about UpgradeBase*L periods to afford: the curve bends without ever // stalling. Linear against linear never ends, and exponential against // linear dies at level four. // 3. PRESTIGE EXISTS. Without a reset the leaderboard is settled by whoever // started first and nobody else has a reason to join. Trading a level-10 // mine for a permanent multiplier means a player who starts today has a // reachable ceiling. // // Time is unix seconds from the block, not block height. A height-denominated // economy silently repriced itself every time the chain's block time moved, // and gno.land's has moved from about 4.1s to 3.405s inside a month. // // Nothing here rolls dice. Everything a player can do is a pure function of // when they did it, so there is no block hash to grind and no ordering to // front-run, and the page can tell you exactly what an action will produce // before you sign it. // // The arithmetic is [p/moul/x/games/accrual](/p/moul/x/games/accrual/v0), // which owns the one rule that makes lazy time honest: how often you claim // must not change what you end up with. package idle import ( "errors" "strconv" "time" "gno.land/p/moul/x/games/accrual/v0" "gno.land/p/nt/avl/v0" ) // The economy. Ore is a bare integer: there is no token here, and nothing // leaves the realm. const ( // Period is one minute of unix time. A level-1 mine digs one ore a minute. Period = int64(60) // CapPeriods is how much production the mine holds before it spills, in // periods. It multiplies the rate, so the fill time is level-independent. CapPeriods = int64(120) // UpgradeBase sets the cost curve: level L costs UpgradeBase*L*L to leave. UpgradeBase = int64(10) // PrestigeAt is the level at which a reset becomes available. PrestigeAt = int64(10) // MaxLevel is a safety rail on the cost curve, not a mechanic: it is far // beyond anything reachable, and keeps every product inside int64. MaxLevel = int64(1000) // MaxPrestige bounds the multiplier for the same reason. MaxPrestige = int64(100) ) var errNoMine = errors.New("idle: you have no mine yet, call Start first") // mine is one player's state. pending and anchor are the accrual pair: what // was in the mine, and as of when. Everything else is bookkeeping. type mine struct { owner address level int64 prestige int64 pending int64 // ore in the mine at anchor, capped anchor int64 // unix seconds; the accrual clock, not "last seen" banked int64 // claimed ore, spendable, uncapped lifetime int64 // every ore ever claimed, never reset by Prestige claims int64 started int64 } var mines avl.Tree // address string -> *mine // now is the chain's own clock in unix seconds. time.Now() in a realm returns // the block timestamp, so it is consensus-deterministic rather than wall time. func now() int64 { return time.Now().Unix() } // rate is the accrual Rate for a level and prestige. It is rebuilt on each // call rather than stored, so a mine's stored state cannot disagree with the // economy after a redeploy changes a constant. func rate(level, prestige int64) accrual.Rate { perPeriod := level * (1 + prestige) r, err := accrual.New(perPeriod, Period, CapPeriods*perPeriod) if err != nil { panic("idle: " + err.Error()) } return r } // upgradeCost is what it costs to leave the given level. func upgradeCost(level int64) int64 { return UpgradeBase * level * level } func get(owner address) (*mine, bool) { v := mines.Get(owner.String()) if v == nil { return nil, false } return v.(*mine), true } // settle moves the mine's clock to at, banking nothing. It is the only place // that writes the accrual pair, and it is deliberately the same call Render // makes read-only, so the page can never promise a number an action will not // honour. func (m *mine) settle(at int64) { pending, anchor, err := rate(m.level, m.prestige).Advance(m.pending, m.anchor, at) if err != nil { panic("idle: " + err.Error()) } m.pending, m.anchor = pending, anchor } // view is settle without the write, for Render and for the read helpers. func (m *mine) view(at int64) int64 { pending, err := rate(m.level, m.prestige).At(m.pending, m.anchor, at) if err != nil { panic("idle: " + err.Error()) } return pending } // fullAt is the unix second at which this mine starts wasting production. func (m *mine) fullAt(at int64) int64 { pending := m.view(at) full, err := rate(m.level, m.prestige).Full(pending, at) if err != nil { panic("idle: " + err.Error()) } return full } // start opens a mine for owner. Non-crossing so tests can drive the clock. func start(owner address, at int64) string { if _, ok := get(owner); ok { panic("idle: you already have a mine") } mines.Set(owner.String(), &mine{ owner: owner, level: 1, anchor: at, started: at, }) return "Mine opened at level 1. It digs " + strconv.FormatInt(Period, 10) + "s per ore and holds " + strconv.FormatInt(CapPeriods, 10) + " before it spills." } func mustGet(owner address) *mine { m, ok := get(owner) if !ok { panic(errNoMine.Error()) } return m } // claim banks everything the mine has dug. func claim(owner address, at int64) string { m := mustGet(owner) m.settle(at) if m.pending == 0 { panic("idle: nothing to claim yet") } got := m.pending m.pending = 0 m.banked += got m.lifetime += got m.claims++ return "Claimed " + strconv.FormatInt(got, 10) + " ore. Banked: " + strconv.FormatInt(m.banked, 10) + "." } // upgrade spends the bank to raise the level. func upgrade(owner address, at int64) string { m := mustGet(owner) m.settle(at) if m.level >= MaxLevel { panic("idle: this mine is at the maximum level") } cost := upgradeCost(m.level) if m.banked < cost { panic("idle: upgrade costs " + strconv.FormatInt(cost, 10) + " ore, you have " + strconv.FormatInt(m.banked, 10)) } m.banked -= cost m.level++ return "Level " + strconv.FormatInt(m.level, 10) + ". Digging " + strconv.FormatInt(m.level*(1+m.prestige), 10) + " ore per period." } // prestige trades the mine for a permanent multiplier. func prestige(owner address, at int64) string { m := mustGet(owner) m.settle(at) if m.level < PrestigeAt { panic("idle: prestige unlocks at level " + strconv.FormatInt(PrestigeAt, 10) + ", you are at " + strconv.FormatInt(m.level, 10)) } if m.prestige >= MaxPrestige { panic("idle: this mine is at the maximum prestige") } m.prestige++ m.level = 1 m.pending = 0 m.banked = 0 m.anchor = at return "Prestige " + strconv.FormatInt(m.prestige, 10) + ". Back to level 1, digging " + strconv.FormatInt(1+m.prestige, 10) + " ore per period. Lifetime ore: " + strconv.FormatInt(m.lifetime, 10) + "." } // Start opens a mine for the caller. One per address, forever: Prestige is the // only way to start over, and it keeps your lifetime total. func Start(cur realm) string { return start(cur.Previous().Address(), now()) } // Claim banks whatever the mine has dug since the last claim. Claiming often // and claiming rarely pay exactly the same, by construction: see accrual. func Claim(cur realm) string { return claim(cur.Previous().Address(), now()) } // Upgrade spends banked ore to raise the level, which raises both the rate and // the ceiling. func Upgrade(cur realm) string { return upgrade(cur.Previous().Address(), now()) } // Prestige resets a level-10 mine to level 1 for a permanent +1 multiplier. // Banked and pending ore are burned; the lifetime total is not. func Prestige(cur realm) string { return prestige(cur.Previous().Address(), now()) } // Pending reports what is sitting in an address's mine right now, unclaimed. func Pending(owner address) int64 { m, ok := get(owner) if !ok { return 0 } return m.view(now()) } // Banked reports an address's spendable ore. func Banked(owner address) int64 { m, ok := get(owner) if !ok { return 0 } return m.banked } // Level reports an address's level and prestige. func Level(owner address) (int64, int64) { m, ok := get(owner) if !ok { return 0, 0 } return m.level, m.prestige } // FullAt reports the unix second at which an address's mine starts wasting // what it digs, which is the only deadline in this game. func FullAt(owner address) int64 { m, ok := get(owner) if !ok { return 0 } return m.fullAt(now()) }
- #8idle_test.gno
- #9package idle import ( "strings" "testing" "gno.land/p/nt/avl/v0" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) // reset clears the realm between tests. Package state carries over from one // test to the next even though the block height does not, so every test that // looks at the board has to start from a known tree. func reset() { mines = avl.Tree{} } // t0 is an arbitrary unix second to hang the tests off. Nothing asserts an // absolute time, only differences from this one. // // It is only legal for the non-crossing helpers, which take the time as an // argument. Anything reaching now() (Render and the read helpers) reads the // BLOCK timestamp, which in a test is far below t0, so a mine anchored here // would make the chain's clock look like it ran backwards. Those tests anchor // at now() instead. const t0 = int64(1700000000) func TestStartOpensALevelOneMine(t *testing.T) { reset() alice := testutils.TestAddress("alice") out := start(alice, t0) uassert.True(t, strings.Contains(out, "level 1"), "expected the opening message") m, ok := get(alice) uassert.True(t, ok, "expected a mine") uassert.Equal(t, int64(1), m.level) uassert.Equal(t, int64(0), m.prestige) uassert.Equal(t, t0, m.anchor) uassert.Equal(t, int64(0), m.pending) } func TestStartTwicePanics(cur realm, t *testing.T) { reset() alice := testutils.TestAddress("alice") start(alice, t0) uassert.PanicsContains(t, cur, "already have a mine", func() { start(alice, t0) }) } func TestActingWithoutAMinePanics(cur realm, t *testing.T) { reset() bob := testutils.TestAddress("bob") uassert.PanicsContains(t, cur, "no mine yet", func() { claim(bob, t0) }) uassert.PanicsContains(t, cur, "no mine yet", func() { upgrade(bob, t0) }) uassert.PanicsContains(t, cur, "no mine yet", func() { prestige(bob, t0) }) } func TestAccrualAndClaim(t *testing.T) { reset() alice := testutils.TestAddress("alice") start(alice, t0) m, _ := get(alice) uassert.Equal(t, int64(0), m.view(t0+Period-1), "nothing before the first whole period") uassert.Equal(t, int64(1), m.view(t0+Period), "one ore after one period") uassert.Equal(t, int64(30), m.view(t0+30*Period)) out := claim(alice, t0+30*Period) uassert.True(t, strings.Contains(out, "Claimed 30 ore"), "got: "+out) uassert.Equal(t, int64(30), m.banked) uassert.Equal(t, int64(30), m.lifetime) uassert.Equal(t, int64(0), m.pending) uassert.Equal(t, int64(1), m.claims) } func TestClaimingNothingPanics(cur realm, t *testing.T) { reset() alice := testutils.TestAddress("alice") start(alice, t0) uassert.PanicsContains(t, cur, "nothing to claim", func() { claim(alice, t0+Period-1) }) } // TestClaimCadenceDoesNotMatter is the game-level statement of the invariant // the accrual package exists for: two players who dig for the same span end up // with the same ore whatever their claiming habits. A realm that re-anchored // to now would pay the busy player less, and a realm that decayed would pay // them more. // // "Whatever their habits" has exactly one limit, and it is the design rather // than the arithmetic: a cadence longer than the mine takes to fill spills, // and is meant to. Every cadence here stays under that, and // TestTheMineSpills covers the other side. func TestClaimCadenceDoesNotMatter(t *testing.T) { const span = 600 * Period fill := CapPeriods * Period for _, cadence := range []int64{Period, 2*Period + 1, 7 * Period, 599, 6001, fill} { if cadence > fill { t.Fatalf("cadence %d would spill, which is not what this test is about", cadence) } reset() alice := testutils.TestAddress("alice") start(alice, t0) m, _ := get(alice) for at := t0 + cadence; at <= t0+span; at += cadence { if m.view(at) > 0 { claim(alice, at) } } if m.view(t0+span) > 0 { claim(alice, t0+span) } uassert.Equal(t, int64(600), m.lifetime, "cadence changed the payout") } } // TestTheMineSpills pins the ceiling: production past the cap is lost, and // coming back late is what costs you, which is decision 1 of the design. func TestTheMineSpills(t *testing.T) { reset() alice := testutils.TestAddress("alice") start(alice, t0) m, _ := get(alice) uassert.Equal(t, CapPeriods, m.view(t0+CapPeriods*Period), "full exactly at the cap") uassert.Equal(t, CapPeriods, m.view(t0+10*CapPeriods*Period), "and no more, ever") claim(alice, t0+10*CapPeriods*Period) uassert.Equal(t, CapPeriods, m.lifetime, "ten cap-fulls of absence paid one") } func TestFullAtIsTheOnlyDeadline(t *testing.T) { reset() alice := testutils.TestAddress("alice") start(alice, t0) m, _ := get(alice) uassert.Equal(t, t0+CapPeriods*Period, m.fullAt(t0), "an empty mine fills in CapPeriods") half := t0 + (CapPeriods/2)*Period uassert.Equal(t, half+(CapPeriods/2)*Period, m.fullAt(half), "half full, half the time left") late := t0 + 99*CapPeriods*Period uassert.Equal(t, late, m.fullAt(late), "already full reads as now") } func TestUpgrade(t *testing.T) { reset() alice := testutils.TestAddress("alice") start(alice, t0) m, _ := get(alice) uassert.Equal(t, UpgradeBase, upgradeCost(1)) uassert.Equal(t, 4*UpgradeBase, upgradeCost(2)) claim(alice, t0+UpgradeBase*Period) out := upgrade(alice, t0+UpgradeBase*Period) uassert.True(t, strings.Contains(out, "Level 2"), "got: "+out) uassert.Equal(t, int64(2), m.level) uassert.Equal(t, int64(0), m.banked, "the upgrade spent the bank") // The rate and the ceiling both doubled, so the fill time did not move. uassert.Equal(t, int64(2), rate(m.level, m.prestige).Amount) uassert.Equal(t, 2*CapPeriods, rate(m.level, m.prestige).Cap) } func TestUpgradeTooPoorPanics(cur realm, t *testing.T) { reset() alice := testutils.TestAddress("alice") start(alice, t0) uassert.PanicsContains(t, cur, "upgrade costs 10 ore, you have 0", func() { upgrade(alice, t0) }) } func TestPrestigeIsLockedUntilLevelTen(cur realm, t *testing.T) { reset() alice := testutils.TestAddress("alice") start(alice, t0) uassert.PanicsContains(t, cur, "prestige unlocks at level 10", func() { prestige(alice, t0) }) } func TestPrestigeResetsButKeepsLifetime(t *testing.T) { reset() alice := testutils.TestAddress("alice") start(alice, t0) m, _ := get(alice) // Hand the mine to level 10 rather than grinding it, then bank something // so the reset has ore to burn. m.level = PrestigeAt claim(alice, t0+50*Period) banked, lifetime := m.banked, m.lifetime uassert.True(t, banked > 0, "expected ore to burn") out := prestige(alice, t0+50*Period) uassert.True(t, strings.Contains(out, "Prestige 1"), "got: "+out) uassert.Equal(t, int64(1), m.prestige) uassert.Equal(t, int64(1), m.level) uassert.Equal(t, int64(0), m.banked, "prestige burns the bank") uassert.Equal(t, int64(0), m.pending) uassert.Equal(t, lifetime, m.lifetime, "lifetime survives the reset") // A level-1 prestige-1 mine digs what a level-2 mine does. uassert.Equal(t, int64(2), rate(m.level, m.prestige).Amount) } func TestReadHelpersAreSafeOnAStranger(t *testing.T) { reset() carol := testutils.TestAddress("carol") uassert.Equal(t, int64(0), Pending(carol)) uassert.Equal(t, int64(0), Banked(carol)) uassert.Equal(t, int64(0), FullAt(carol)) lvl, pre := Level(carol) uassert.Equal(t, int64(0), lvl) uassert.Equal(t, int64(0), pre) } func TestSpan(t *testing.T) { cases := []struct { secs int64 want string }{ {0, "0s"}, {-5, "0s"}, {1, "1s"}, {59, "59s"}, {60, "1m"}, {61, "1m 1s"}, {3600, "1h"}, {3661, "1h 1m"}, {86400, "1d"}, {90061, "1d 1h"}, {7200, "2h"}, } for _, tc := range cases { uassert.Equal(t, tc.want, span(tc.secs), "span("+tc.want+")") } } func TestRenderBoard(t *testing.T) { reset() uassert.True(t, strings.Contains(board(t0), "Nobody is digging yet"), "expected the empty placeholder") alice := testutils.TestAddress("alice") start(alice, t0) out := board(t0 + 30*Period) uassert.True(t, strings.Contains(out, "The Mine"), "expected the title") uassert.True(t, strings.Contains(out, "30"), "expected the live pending figure") uassert.True(t, strings.Contains(out, "to spill"), "expected the spill countdown") } func TestRenderCard(t *testing.T) { reset() alice := testutils.TestAddress("alice") start(alice, t0) out := card(alice, t0+30*Period) uassert.True(t, strings.Contains(out, "Level **1**"), "got: "+out) uassert.True(t, strings.Contains(out, "30 / 120"), "expected stock over cap") uassert.True(t, strings.Contains(out, "1h 30m"), "expected the spill countdown") uassert.True(t, strings.Contains(out, "10 short"), "expected the shortfall") uassert.True(t, strings.Contains(out, "9 to go"), "expected the prestige distance") full := card(alice, t0+999*Period) uassert.True(t, strings.Contains(full, "already full"), "expected the waste warning") bob := testutils.TestAddress("bob") uassert.True(t, strings.Contains(card(bob, t0), "No mine at"), "expected the stranger card") } // TestRenderIsLive is the property the whole design is for: the page changes // with nothing but the passage of time, so gnoweb serves a live dashboard and // no transaction was needed to move it. func TestRenderIsLive(t *testing.T) { reset() alice := testutils.TestAddress("alice") start(alice, t0) early := board(t0 + 10*Period) later := board(t0 + 20*Period) uassert.NotEqual(t, early, later, "the board did not move with time") uassert.Equal(t, int64(0), Pending(testutils.TestAddress("nobody")), "and reading it wrote nothing") }
- #10render.gno
- #11package idle import ( "sort" "strconv" "strings" "gno.land/p/moul/kit/ui/v0" ) // Render shows every mine, or one mine's card at /r/.../idle:<address>. // // The numbers move on their own: the same URL renders differently a minute // later because the mine kept digging, and nothing wrote to the chain in // between. That is the whole point of the accrual pair, and it is why every // figure here comes from the read-only view rather than from stored state. func Render(path string) string { at := now() if addr := strings.TrimSpace(path); addr != "" { return card(address(addr), at) } return board(at) } // byLifetimeThenOwner ranks by lifetime ore, then by address so the order is // identical on every node. type byLifetimeThenOwner []*mine func (r byLifetimeThenOwner) Len() int { return len(r) } func (r byLifetimeThenOwner) Swap(i, j int) { r[i], r[j] = r[j], r[i] } func (r byLifetimeThenOwner) Less(i, j int) bool { if r[i].prestige != r[j].prestige { return r[i].prestige > r[j].prestige } if r[i].lifetime != r[j].lifetime { return r[i].lifetime > r[j].lifetime } return r[i].owner.String() < r[j].owner.String() } func board(at int64) string { var b strings.Builder b.WriteString("# The Mine\n\n") b.WriteString("Ore accrues while you are away. The mine holds " + strconv.FormatInt(CapPeriods, 10) + " periods of production and then spills, so the only thing that costs you anything is not showing up.\n\n") all := []*mine{} mines.Iterate("", "", func(_ string, v any) bool { all = append(all, v.(*mine)) return false }) sort.Sort(byLifetimeThenOwner(all)) t := ui.NewTable("#", "Miner", "Lv", "Prestige", "In the mine", "Banked", "Lifetime") for i, m := range all { pending := m.view(at) full := "" if r := m.fullAt(at); r > at { full = " (" + span(r-at) + " to spill)" } else { full = " (**full**)" } t.Row( ui.Podium(i)+strconv.Itoa(i+1), ui.Addr(m.owner), strconv.FormatInt(m.level, 10), strconv.FormatInt(m.prestige, 10), strconv.FormatInt(pending, 10)+full, strconv.FormatInt(m.banked, 10), strconv.FormatInt(m.lifetime, 10), ) } b.WriteString(t.OrEmpty("Nobody is digging yet.")) b.WriteString("\n") b.WriteString(ui.Join(" | ", ui.Action("Open a mine", "Start"), ui.Action("Claim", "Claim"), ui.Action("Upgrade", "Upgrade"), ui.Action("Prestige", "Prestige"), )) b.WriteString("\n") return b.String() } func card(owner address, at int64) string { m, ok := get(owner) if !ok { return "# The Mine\n\n" + ui.Empty("No mine at "+ui.Addr(owner)+".") } pending := m.view(at) r := rate(m.level, m.prestige) var b strings.Builder b.WriteString("# " + ui.Addr(m.owner) + "'s mine\n\n") b.WriteString("Level **" + strconv.FormatInt(m.level, 10) + "**") if m.prestige > 0 { b.WriteString(", prestige **" + strconv.FormatInt(m.prestige, 10) + "**") } b.WriteString(", digging **" + strconv.FormatInt(r.Amount, 10) + "** ore every " + span(Period) + ".\n\n") t := ui.NewTable("", "") t.Row("In the mine", strconv.FormatInt(pending, 10)+" / "+strconv.FormatInt(r.Cap, 10)) if full := m.fullAt(at); full > at { t.Row("Spills in", span(full-at)) } else { t.Row("Spills in", "**already full, every period is being wasted**") } t.Row("Banked", strconv.FormatInt(m.banked, 10)) t.Row("Lifetime ore", strconv.FormatInt(m.lifetime, 10)) t.Row("Claims", strconv.FormatInt(m.claims, 10)) t.Row("Digging for", span(at-m.started)) if m.level < MaxLevel { cost := upgradeCost(m.level) short := cost - m.banked next := strconv.FormatInt(cost, 10) + " ore" if short > 0 { next += " (" + strconv.FormatInt(short, 10) + " short)" } else { next += " (**affordable now**)" } t.Row("Next level", next) } if m.prestige < MaxPrestige { if m.level >= PrestigeAt { t.Row("Prestige", "**available**, and it burns "+ strconv.FormatInt(m.banked+pending, 10)+" ore") } else { t.Row("Prestige", "at level "+strconv.FormatInt(PrestigeAt, 10)+ ", "+strconv.FormatInt(PrestigeAt-m.level, 10)+" to go") } } b.WriteString(t.String()) b.WriteString("\n") b.WriteString(ui.Join(" | ", ui.Action("Claim", "Claim"), ui.Action("Upgrade", "Upgrade"), ui.Action("Prestige", "Prestige"), )) b.WriteString("\n") return b.String() } // span renders a number of seconds the way a player reads it. It rounds down // and stops at two units, because "2h 3m 11s to spill" is noise when the // question is whether to claim now or after lunch. func span(secs int64) string { if secs <= 0 { return "0s" } units := []struct { size int64 name string }{ {86400, "d"}, {3600, "h"}, {60, "m"}, {1, "s"}, } parts := []string{} for i, u := range units { if secs < u.size { continue } n := secs / u.size secs -= n * u.size parts = append(parts, strconv.FormatInt(n, 10)+u.name) if len(parts) == 2 || i == len(units)-1 { break } } return strings.Join(parts, " ") }
- #12render_example_test.gno
- #13package idle import ( "strings" "testing" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) // TestRenderRoutes pins that Render dispatches on the path: empty is the // board, anything else is read as an address. func TestRenderRoutes(t *testing.T) { reset() alice := testutils.TestAddress("alice") // Anchored at the block clock, not at t0: Render reads now(). start(alice, now()) uassert.True(t, strings.Contains(Render(""), "The Mine"), "empty path is the board") uassert.True(t, strings.Contains(Render(alice.String()), "'s mine"), "an address is a card") uassert.True(t, strings.Contains(Render(" "+alice.String()+" "), "'s mine"), "a padded address is still a card") uassert.True(t, strings.Contains(Render("g1nobody"), "No mine at"), "a stranger is handled") } // ExampleRender pins the realm's empty board. It resets the tree first because // examples run after every Test in the package and would otherwise render // whatever they left behind. func ExampleRender() { reset() print(Render("")) // Output: // # The Mine // // Ore accrues while you are away. The mine holds 120 periods of production and then spills, so the only thing that costs you anything is not showing up. // // *Nobody is digging yet.* // // [Open a mine](/r/moul/x/games/idle/v0$help&func=Start) | [Claim](/r/moul/x/games/idle/v0$help&func=Claim) | [Upgrade](/r/moul/x/games/idle/v0$help&func=Upgrade) | [Prestige](/r/moul/x/games/idle/v0$help&func=Prestige) }
- Attached funds
- 12000000ugnot
Arguments · 11
- #1nativeify
- #2README.md
- #3# `gno.land/r/moul/x/nativeify/v0` **wugnot, backwards: escrow a GRC20, issue a native coin against it 1:1.** wugnot takes GNOT, a coin the bank holds, and issues a GRC20 receipt, because a GRC20 can be pulled by a contract and a native coin cannot. This realm runs the other direction, because a native coin can do four things a GRC20 cannot: - move with no realm call at all, from any wallet - ride the `-send` envelope of a call, so it can **pay** for something - sit beside GNOT on an account page, with nothing registered anywhere - be named as the gas denom by a chain whose genesis says so ## What you give up, and it is not small A native coin cannot be pulled. `banker.SendCoins` refuses any `from` that is not the banker's own realm address, so there is no allowance, no `TransferFrom`, and no way for an AMM, an escrow or a subscription to take what you approved. Everything downstream of `Approve` stops working the moment a coin becomes native. A test in this package pins it: the realm holds the strongest banker the chain offers, the one that can mint and destroy this denom at will, and it still cannot move a unit it does not already hold. And the issuer of a native coin can always delete a balance, because `RemoveCoin` takes an arbitrary address. This realm never calls it on an address other than its own, and it is deployed **public** rather than `private = true` so that the code saying so can never be replaced. Those two together are the entire guarantee; there is no on-chain way to prove a negative about code. ## Use it ``` # 1. play money maketx call -pkgpath gno.land/r/moul/x/grc20faucet/v0 -func Claim # 2. let this realm pull your RED (Home() prints the address) maketx call -pkgpath gno.land/r/moul/x/grc20faucet/v0 -func Approve \ -args RED -args <home> -args 1000000 # 3. create the pair once, then wrap into it maketx call -pkgpath gno.land/r/moul/x/nativeify/v0 -func Nativeify \ -args gno.land/r/moul/x/grc20faucet/v0.RED -args nred maketx call -pkgpath gno.land/r/moul/x/nativeify/v0 -func Wrap -args nred -args 500000 # 4. it is an ordinary coin now maketx send -to <address> -send 1000/gno.land/r/moul/x/nativeify/v0:nred # 5. redeem, by sending the coins back maketx call -pkgpath gno.land/r/moul/x/nativeify/v0 -func Unwrap -args nred \ -send 1000/gno.land/r/moul/x/nativeify/v0:nred ``` Step 5 is the interesting one. This realm could delete your coins directly and save you a flag, and it does not: a realm whose normal operation is to remove coins from addresses that never handed them over has no way left to demonstrate that it only ever does so consensually. ## The rate is 1, and solvency is checkable without trusting this realm One smallest unit of the underlying is one unit of the coin. Not `10^decimals`: the bank has no notion of divisibility, so a token with 6 decimals produces a coin counted in the token's own smallest units, and the 6 travels to [`nativereg`](https://gno.land/r/moul/x/nativereg/v0) as a display hint and nowhere else. `Solvency` reads the escrow from the GRC20 ledger and the supply from the bank, and consults this realm's own bookkeeping for neither. If the two ever disagree, the disagreement is the answer. One native denom per underlying token: a second would double the chain's per-denom storage for no new capability, and realm-denom balances live under their own store keys, which are not gas-metered. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:**  > 🧪 **Highly experimental — potentially vibe-coded.** Not audited; may break, change, or be removed at any time. Do not use with anything of value. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
- #4gnomod.toml
- #5module = "gno.land/r/moul/x/nativeify/v0" gno = "0.9" # public: it issues coins, and an issuing realm must be immutable. private = true # buys a redeploy, and a redeploy of an issuer replaces the code that decided not # to claw anybody's balance back while the coins themselves survive. Holders of a # coin from here are trusting this code, so this path can never be rewritten.
- #6nativeify.gno
- #7// Package nativeify turns a GRC20 into a native coin, which is wugnot run // backwards. // // wugnot takes GNOT, a coin the bank holds, and issues a GRC20 receipt for it, // because a GRC20 can be pulled by a contract and a native coin cannot. This // realm takes a GRC20, escrows it, and issues a native denom against it 1:1, // because a native coin can do four things a GRC20 cannot: // // - move with no realm call at all (`gnokey maketx send`, from any wallet) // - ride the `-send` envelope of a call, so it can PAY for something // - sit beside GNOT on an account page, with nothing registered anywhere // - be named as the gas denom by a chain whose genesis says so // // # What you give up, and it is not small // // A native coin cannot be pulled. `banker.SendCoins` refuses any `from` that is // not the banker's own realm address, so there is no allowance, no // `TransferFrom`, and no way for an AMM, an escrow or a subscription to take // what you approved. Everything downstream of `Approve` stops working the moment // a coin becomes native. That is not a gap in this realm; it is the property // that makes the wrapping worth doing in the other direction. // // And the issuer of a native coin can always delete a balance: `RemoveCoin` // takes an arbitrary address. This realm never calls it on an address other than // its own, [Unwrap] explains why in detail, and the gnomod.toml explains why this // path is public rather than redeployable. Those three together are the entire // guarantee. There is no on-chain way to prove a negative about code. // // # The exchange rate is 1, always // // One smallest unit of the underlying is one unit of the coin. Not 10^decimals: // the bank has no notion of divisibility, so a token with 6 decimals wrapped // here produces a coin counted in the token's own smallest units, and the 6 is // carried to `r/moul/x/nativereg` as a display hint and nowhere else. // // Solvency is therefore a plain equality that anybody can check without trusting // this realm's bookkeeping: the GRC20 balance at this realm's address equals the // total supply of the denom. [Solvency] computes it from both sides. // // Play money to try it on: `r/moul/x/grc20faucet/v0`. package nativeify import ( "chain" "chain/banker" "chain/runtime" "strings" "gno.land/p/moul/x/envelope/v0" "gno.land/p/nt/avl/v0" "gno.land/p/nt/grc20/v0" "gno.land/p/nt/ufmt/v0" "gno.land/r/nt/grc20reg/v0" "gno.land/r/moul/x/nativereg/v0" ) // Path is this realm's package path, which every denom it issues embeds. const Path = "gno.land/r/moul/x/nativeify/v0" // MinBase and MaxBase bound a base denom name. The chain enforces exactly this // (`isValidBaseDenom` in the banker stdlib): a lowercase letter, then 2 to 15 // more lowercase letters or digits. Validating here rather than letting the // banker abort is the difference between a usable error and a stack trace. const ( MinBase = 3 MaxBase = 16 ) type pair struct { base string // base denom name, unique in this realm denom string // the full chain denom key string // grc20reg key of the underlying creator address height int64 } var ( byBase = avl.NewTree() // base name -> *pair byKey = avl.NewTree() // grc20reg key -> *pair bases []string // creation order, for a stable Render home address // this realm's address: where every escrow sits ) func init(cur realm) { home = cur.Address() } // Home is the address to approve on the underlying token before wrapping. Every // escrow this realm holds sits there. func Home() address { return home } // Nativeify creates the native counterpart of a GRC20 registered in // `r/nt/grc20reg`, and returns its denom. It issues nothing: [Wrap] does that. // // `base` is the part of the denom after the colon, yours to choose, unique in // this realm and in the chain's charset. One native denom per underlying token: // a second one would double the chain's per-denom storage for no new capability, // and realm-denom balances live under their own store keys, which are not // gas-metered. func Nativeify(cur realm, tokenKey, base string) string { under := grc20reg.MustGet(tokenKey) assertBase(base) if byBase.Get(base) != nil { panic("this realm already issues " + base) } if v := byKey.Get(tokenKey); v != nil { panic(ufmt.Sprintf("%s is already native here as %s", under.GetSymbol(), v.(*pair).denom)) } p := &pair{ base: base, denom: chain.CoinDenom(Path, base), key: tokenKey, creator: cur.Previous().Address(), height: runtime.ChainHeight(), } byBase.Set(base, p) byKey.Set(tokenKey, p) bases = append(bases, base) // This realm is the issuer named inside the denom, so it is the only thing // on the chain that can describe it. The decimals travel as a display hint; // the bank still counts whole units. nativereg.Register(cross(cur), p.denom, under.GetName()+" (native)", "n"+under.GetSymbol(), under.GetDecimals(), "1:1 native wrapper of "+tokenKey+", redeemable at "+Path) return p.denom } // Wrap escrows `amount` of the underlying and issues the same number of native // coins to the caller. // // You must `Approve` this realm's address on the underlying token first, through // that token's own realm. Until you do, this realm has no authority over your // balance at all, and Wrap fails with "insufficient allowance", which is the // system working. func Wrap(cur realm, base string, amount int64) int64 { p := mustPair(base) if amount <= 0 { panic("wrap a positive amount") } who := cur.Previous().Address() // Last point at which nothing has moved. under := grc20reg.MustGet(p.key) if err := under.RealmTeller(0, cur).TransferFrom(0, cur, who, home, amount); err != nil { panic(err) } banker.NewBanker(banker.BankerTypeRealmIssue, cur).IssueCoin(who, p.denom, amount) return amount } // Unwrap destroys the native coins attached to this call and releases the same // number of underlying GRC20 units to the caller. // // # Why you have to send the coins rather than let this realm take them // // This realm holds a `BankerTypeRealmIssue` banker, and `RemoveCoin` takes an // arbitrary address, so it could delete the caller's coins directly and save // them a flag. It does not, and the reason is worth stating where somebody // copying this will read it: a realm whose normal operation is to remove coins // from addresses that did not hand them over has no way left to demonstrate that // it only ever does so consensually. Every `RemoveCoin` here names this realm's // own address, which anybody can check by reading the source, and the coins get // to that address the only way a native coin ever moves, by their holder pushing // them. // // So: attach them. // // -send <amount>/gno.land/r/moul/x/nativeify/v0:<base> func Unwrap(cur realm, base string) int64 { p := mustPair(base) // Reading the envelope is also what makes this call payable at all: MsgCall // rejects a message whose coins no code looked at. amount := envelope.Require(p.denom) who := cur.Previous().Address() // Destroy the receipt before releasing the backing, so a failure between the // two can only ever leave the pair over-collateralized. banker.NewBanker(banker.BankerTypeRealmIssue, cur).RemoveCoin(home, p.denom, amount) under := grc20reg.MustGet(p.key) if err := under.RealmTeller(0, cur).Transfer(0, cur, who, amount); err != nil { panic(err) } return amount } // Denom returns the denom this realm issues for a base name. func Denom(base string) string { return mustPair(base).denom } // TokenKey returns the grc20reg key of the underlying behind a base name. func TokenKey(base string) string { return mustPair(base).key } // Bases returns every base name this realm issues, in creation order. func Bases() []string { out := make([]string, len(bases)) copy(out, bases) return out } // Solvency returns what is escrowed and what is issued for a base name. They are // read from two different places on the chain, the GRC20 ledger and the bank, // and this realm's own bookkeeping is not consulted for either: if they ever // disagree, the disagreement is the answer. func Solvency(base string) (escrowed, issued int64) { p := mustPair(base) escrowed = grc20reg.MustGet(p.key).BalanceOf(home) issued = banker.NewReadonlyBanker().TotalCoin(p.denom) return escrowed, issued } // IsSolvent reports whether every coin issued for a base name is backed. // // It allows escrowed > issued, which is what a donation to this realm's address // looks like and cannot hurt anybody, and refuses the reverse. func IsSolvent(base string) bool { escrowed, issued := Solvency(base) return escrowed >= issued } // Underlying returns the underlying token behind a base name, for a caller that // wants its name, symbol or decimals. func Underlying(base string) *grc20.Token { return grc20reg.MustGet(mustPair(base).key) } func mustPair(base string) *pair { v := byBase.Get(base) if v == nil { panic("this realm does not issue " + base) } return v.(*pair) } // assertBase applies the chain's own base-denom rule before the banker does, so // the caller reads a sentence instead of a stack trace. func assertBase(base string) { if len(base) < MinBase || len(base) > MaxBase { panic(ufmt.Sprintf("base %q is %d characters, must be %d to %d", base, len(base), MinBase, MaxBase)) } for i := 0; i < len(base); i++ { c := base[i] lower := c >= 'a' && c <= 'z' digit := c >= '0' && c <= '9' if lower || (i > 0 && digit) { continue } panic(ufmt.Sprintf("base %q: a base denom is a lowercase letter followed by "+ "lowercase letters or digits, and %q is not", base, string(c))) } if strings.Contains(base, ":") { // unreachable past the charset loop; belt and braces panic("a base denom cannot contain a colon") } }
- #8nativeify_test.gno
- #9package nativeify import ( "chain" "chain/banker" "strings" "testing" "gno.land/p/moul/x/envelope/v0" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" "gno.land/r/moul/x/grc20faucet/v0" "gno.land/r/moul/x/nativereg/v0" ) // Two things about the harness shape every test below. // // testing.SetRealm governs only the crossing calls made from the frame that // called it, so every account switch is inline in the test body. // // The BANK does not carry over between test functions while realm state does, // so native balances, TotalCoin and the escrowed GRC20 cannot be compared // across tests. Everything here is asserted as a delta inside one test. func TestNativeifyCreatesThePair(cur realm, t *testing.T) { alice := testutils.TestAddress("nf-alice") testing.SetRealm(testing.NewUserRealm(alice)) denom := Nativeify(cross(cur), grc20faucet.RedKey, "nred") uassert.Equal(t, "/gno.land/r/moul/x/nativeify/v0:nred", denom) uassert.Equal(t, denom, Denom("nred")) uassert.Equal(t, grc20faucet.RedKey, TokenKey("nred")) // The denom names this realm, so this realm is the only thing on the chain // that can describe it, and it did. e := nativereg.MustGet(denom) uassert.Equal(t, Path, e.Issuer) uassert.Equal(t, "nRED", e.Symbol) uassert.Equal(t, 4, e.Decimals, "the underlying's decimals travel as a display hint") uassert.AbortsContains(t, cur, "already issues nred", func() { Nativeify(cross(cur), grc20faucet.BlueKey, "nred") }) uassert.AbortsContains(t, cur, "already native here", func() { Nativeify(cross(cur), grc20faucet.RedKey, "nred2") }) uassert.AbortsContains(t, cur, "unknown token", func() { Nativeify(cross(cur), "gno.land/r/nope/nope.NOPE", "nnope") }) } func TestBaseNameFollowsTheChainsRule(cur realm, t *testing.T) { bob := testutils.TestAddress("nf-bob") testing.SetRealm(testing.NewUserRealm(bob)) // The chain's own rule, applied here so the caller reads a sentence instead // of a banker stack trace: a lowercase letter, then 2 to 15 more lowercase // letters or digits. uassert.AbortsContains(t, cur, "is 2 characters", func() { Nativeify(cross(cur), grc20faucet.BlueKey, "ab") }) uassert.AbortsContains(t, cur, "is 17 characters", func() { Nativeify(cross(cur), grc20faucet.BlueKey, strings.Repeat("a", 17)) }) uassert.AbortsContains(t, cur, "is not", func() { Nativeify(cross(cur), grc20faucet.BlueKey, "nBLUE") }) uassert.AbortsContains(t, cur, "is not", func() { Nativeify(cross(cur), grc20faucet.BlueKey, "1blue") }) uassert.AbortsContains(t, cur, "is not", func() { Nativeify(cross(cur), grc20faucet.BlueKey, "n-blue") }) // And one that is legal all the way through. uassert.Equal(t, "/gno.land/r/moul/x/nativeify/v0:nblue1", Nativeify(cross(cur), grc20faucet.BlueKey, "nblue1")) } func TestWrapNeedsAnAllowanceFirst(cur realm, t *testing.T) { carol := testutils.TestAddress("nf-carol") testing.SetRealm(testing.NewUserRealm(carol)) grc20faucet.Claim(cross(cur)) // Until the holder names this realm on the underlying token, this realm has // no authority over their balance whatsoever. That is the GRC20 half of the // trade, and it is the half a native coin does not have. uassert.AbortsContains(t, cur, "allowance", func() { Wrap(cross(cur), "nred", 1_000) }) uassert.Equal(t, int64(0), envelope.BalanceOf(carol, Denom("nred"))) } func TestWrapEscrowsAndIssues(cur realm, t *testing.T) { dave := testutils.TestAddress("nf-dave") testing.SetRealm(testing.NewUserRealm(dave)) grc20faucet.Claim(cross(cur)) grc20faucet.Approve(cross(cur), "RED", Home(), 1_000_000) denom := Denom("nred") redBefore := grc20faucet.BalanceOf("RED", dave) escrowBefore, issuedBefore := Solvency("nred") got := Wrap(cross(cur), "nred", 400_000) uassert.Equal(t, int64(400_000), got) uassert.Equal(t, redBefore-400_000, grc20faucet.BalanceOf("RED", dave), "the GRC20 left the holder") uassert.Equal(t, escrowBefore+400_000, grc20faucet.BalanceOf("RED", Home()), "and landed in escrow") uassert.Equal(t, int64(400_000), envelope.BalanceOf(dave, denom), "the native coin is at the bank, 1:1") escrowAfter, issuedAfter := Solvency("nred") uassert.Equal(t, escrowBefore+400_000, escrowAfter) uassert.Equal(t, issuedBefore+400_000, issuedAfter) uassert.True(t, IsSolvent("nred"), "every coin issued is backed") uassert.AbortsContains(t, cur, "positive amount", func() { Wrap(cross(cur), "nred", 0) }) uassert.AbortsContains(t, cur, "does not issue", func() { Wrap(cross(cur), "ghost", 1) }) } func TestUnwrapDestroysTheCoinAndReleasesTheBacking(cur realm, t *testing.T) { erin := testutils.TestAddress("nf-erin") denom := Denom("nred") testing.SetRealm(testing.NewUserRealm(erin)) grc20faucet.Claim(cross(cur)) grc20faucet.Approve(cross(cur), "RED", Home(), 1_000_000) Wrap(cross(cur), "nred", 300_000) // On chain the -send envelope moves the coins to this realm's address before // Unwrap runs. The harness has no bank-send primitive, so the credit is // issued here; SetOriginSend is the half the realm actually reads. sent := chain.NewCoins(chain.NewCoin(denom, 120_000)) testing.IssueCoins(Home(), sent) testing.SetOriginSend(sent) redBefore := grc20faucet.BalanceOf("RED", erin) escrowBefore := grc20faucet.BalanceOf("RED", Home()) _, issuedBefore := Solvency("nred") got := Unwrap(cross(cur), "nred") uassert.Equal(t, int64(120_000), got) uassert.Equal(t, redBefore+120_000, grc20faucet.BalanceOf("RED", erin), "the backing came back") uassert.Equal(t, escrowBefore-120_000, grc20faucet.BalanceOf("RED", Home()), "escrow shrank by the same") _, issuedAfter := Solvency("nred") uassert.Equal(t, issuedBefore-120_000, issuedAfter, "the coins were destroyed, not moved: total supply fell") testing.SetOriginSend(nil) uassert.AbortsContains(t, cur, "must be paid", func() { Unwrap(cross(cur), "nred") }) } func TestRender(cur realm, t *testing.T) { index := Render("") uassert.True(t, strings.Contains(index, "# nativeify"), index) uassert.True(t, strings.Contains(index, Home().String()), "the escrow address is on the page") uassert.True(t, strings.Contains(index, "| [nred](/r/moul/x/nativeify/v0:nred) |"), "the pair is listed:\n"+index) one := Render("nred") uassert.True(t, strings.Contains(one, "/gno.land/r/moul/x/nativeify/v0:nred"), one) uassert.True(t, strings.Contains(one, "a display hint only"), one) uassert.True(t, strings.Contains(Render("ghost"), "404"), "an unknown base renders a 404") } // attemptPull is the thing a GRC20 facade over a native coin would have to do, // written out so it can be shown failing. It is in the test file, so it is not // part of the deployed package. func attemptPull(cur realm, from address, denom string, amount int64) { banker.NewBanker(banker.BankerTypeRealmIssue, cur). SendCoins(from, Home(), chain.NewCoins(chain.NewCoin(denom, amount))) } func TestARealmCannotPullANativeCoin(cur realm, t *testing.T) { // The claim the package doc rests on, pinned rather than asserted: this // realm holds the strongest banker the chain offers, the one that can mint // and destroy this denom at will, and it still cannot MOVE a unit it does // not already hold. There is no allowance to grant and no TransferFrom to // call, which is why a grc20.Teller over a native coin cannot be written and // why wugnot wraps in the direction it does. frank := testutils.TestAddress("nf-frank") denom := Denom("nred") testing.IssueCoins(frank, chain.NewCoins(chain.NewCoin(denom, 1_000))) msg := pullError(cur, frank, denom, 1_000) uassert.True(t, strings.Contains(msg, "can only send coins from realm that created banker"), "got: "+msg) uassert.Equal(t, int64(1_000), envelope.BalanceOf(frank, denom), "and nothing moved") } // pullError runs attemptPull and returns the refusal as a string. // // uassert.PanicsContains cannot read this one. banker.SendCoins builds its // message as `"..." + b.pkgAddr + "..."`, and in gno a string concatenated with // an `address` has type `address`, so the panic value is an address rather than // a string and the helper reports "recover: unsupported type". Measured // 2026-09-28 against gno master; the message itself is correct, only its type is // surprising. func pullError(cur realm, from address, denom string, amount int64) (msg string) { defer func() { switch r := recover().(type) { case nil: msg = "it did not fail at all" case address: msg = string(r) case string: msg = r case error: msg = r.Error() default: msg = "panicked with an unexpected type" } }() attemptPull(cur, from, denom, amount) return "" } // --------------------------------------------------------------------------- // p/moul/x/envelope's Forward is exercised here rather than in its own package: // minting a BankerTypeOriginSend banker calls rlm.Previous(), and a p/ package's // test has no realm frame to walk, so it dies with "frame not found: cannot seek // beyond origin caller override". A p/ helper that needs a realm handle can only // be tested from a realm. func forwardDirect(cur realm, to address, coins chain.Coins) { envelope.Forward(0, cur, to, coins) } func forwardOneDeeper(cur realm, to address, coins chain.Coins) { forwardDirect(cur, to, coins) } func TestForwardIsBoundedByTheEnvelope(cur realm, t *testing.T) { to := testutils.TestAddress("nf-forward-to") denom := Denom("nred") // The realm holds 5,000 of its own, and the envelope carried 250. A // RealmSend banker could spend all 5,250; this one may spend 250. testing.IssueCoins(Home(), chain.NewCoins(chain.NewCoin(denom, 5_000))) sent := chain.NewCoins(chain.NewCoin(denom, 250)) testing.IssueCoins(Home(), sent) testing.SetOriginSend(sent) heldBefore := envelope.BalanceOf(Home(), denom) forwardDirect(cur, to, sent) uassert.Equal(t, int64(250), envelope.BalanceOf(to, denom)) uassert.Equal(t, heldBefore-250, envelope.BalanceOf(Home(), denom), "exactly the envelope left, and none of the realm's own balance") // Spending past the envelope is refused by the VM, not by any check here. uassert.PanicsContains(t, cur, "limit", func() { forwardDirect(cur, to, chain.NewCoins(chain.NewCoin(denom, 5_000))) }) testing.SetOriginSend(nil) } func TestForwardFromOneFrameDeeper(cur realm, t *testing.T) { // The frame-depth rule, measured rather than assumed: NewBanker refuses // BankerTypeOriginSend unless rlm.Previous().IsUserCall(), and IsUserCall // counts at most two call-boundary frames. Whatever this run reports is what // the package doc says. to := testutils.TestAddress("nf-deep-to") denom := Denom("nred") sent := chain.NewCoins(chain.NewCoin(denom, 90)) testing.IssueCoins(Home(), sent) testing.SetOriginSend(sent) forwardOneDeeper(cur, to, sent) uassert.Equal(t, int64(90), envelope.BalanceOf(to, denom), "one extra frame between the entry point and Forward") testing.SetOriginSend(nil) }
- #10render.gno
- #11package nativeify import ( "strings" "gno.land/p/moul/kit/ui/v0" "gno.land/p/moul/md/v0" "gno.land/p/nt/ufmt/v0" ) // Render lists the pairs. `?<base>` shows one. func Render(path string) string { if path == "" { return renderIndex() } return renderOne(path) } func renderIndex() string { var sb strings.Builder sb.WriteString(md.H1("nativeify: wugnot, backwards")) sb.WriteString("\nwugnot takes a native coin and issues a GRC20, because a GRC20 can be " + "pulled by a contract and a native coin cannot. This realm does the other one: escrow " + "a GRC20, issue a native denom against it 1:1, and hand back something that moves with " + "no realm call, rides the `-send` envelope of a transaction, and shows up beside GNOT " + "on an account page.\n\n") sb.WriteString(md.Blockquote("What you give up is everything downstream of `Approve`. A " + "native coin has no allowance, so nothing can pull it: not an AMM, not an escrow, not " + "a subscription. And its issuer can always delete a balance.")) sb.WriteString("\n") sb.WriteString(ufmt.Sprintf("Escrow account to approve: `%s`\n\n", home.String())) sb.WriteString(md.H2("Pairs") + "\n") t := ui.NewTable("base", "underlying", "denom", "escrowed", "issued", "solvent") for _, b := range bases { p := mustPair(b) escrowed, issued := Solvency(b) solvent := "yes" if escrowed < issued { solvent = "**NO**" } t.Row(md.Link(b, "/r/moul/x/nativeify/v0:"+b), ui.Cell(Underlying(b).GetSymbol()), md.InlineCode(ui.ShortN(p.denom, 20, 10)), ufmt.Sprintf("%d", escrowed), ufmt.Sprintf("%d", issued), solvent) } sb.WriteString(t.OrEmpty("Nothing wrapped yet.") + "\n") sb.WriteString(md.H2("Try it") + "\n") sb.WriteString("Claim play money, let this realm pull it, wrap, then send the result " + "around like any other coin:\n\n") sb.WriteString(md.LanguageCodeBlock("", "# 1. get RED\n"+ "maketx call -pkgpath gno.land/r/moul/x/grc20faucet/v0 -func Claim\n\n"+ "# 2. let this realm pull your RED\n"+ "maketx call -pkgpath gno.land/r/moul/x/grc20faucet/v0 -func Approve \\\n"+ ufmt.Sprintf(" -args RED -args %s -args 1000000\n\n", home.String())+ "# 3. create the pair once, then wrap into it\n"+ "maketx call -pkgpath "+Path+" -func Nativeify \\\n"+ " -args gno.land/r/moul/x/grc20faucet/v0.RED -args nred\n"+ "maketx call -pkgpath "+Path+" -func Wrap -args nred -args 500000\n\n"+ "# 4. it is an ordinary coin now: no realm call, no approval\n"+ "maketx send -to <address> -send 1000/"+Path+":nred\n\n"+ "# 5. redeem, by sending the coins back\n"+ "maketx call -pkgpath "+Path+" -func Unwrap -args nred \\\n"+ " -send 1000/"+Path+":nred")) sb.WriteString("\nEvery denom here is described in [nativereg](/r/moul/x/nativereg/v0), " + "which is where a native coin says what it is, since the chain stores no name for it.\n") return sb.String() } func renderOne(base string) string { v := byBase.Get(base) if v == nil { return md.H1("404") + "\nThis realm does not issue " + md.InlineCode(base) + ".\n" } p := v.(*pair) under := Underlying(base) escrowed, issued := Solvency(base) t := ui.NewTable("", "") t.Row("denom", md.InlineCode(p.denom)) t.Row("underlying", ui.Cell(under.GetName())+" ("+ui.Cell(under.GetSymbol())+")") t.Row("registry key", md.InlineCode(p.key)) t.Row("decimals", ufmt.Sprintf("%d, a display hint only", under.GetDecimals())) t.Row("escrowed", ufmt.Sprintf("%d", escrowed)) t.Row("issued", ufmt.Sprintf("%d", issued)) t.Row("solvent", ufmt.Sprintf("%t", escrowed >= issued)) t.Row("created at block", ufmt.Sprintf("%d", p.height)) t.Row("created by", ui.Addr(p.creator)) s := md.H1(ui.Inline(base)) + "\n" + t.String() + "\n" s += "Escrowed and issued are read from the GRC20 ledger and from the bank, not from " + "this realm's own bookkeeping. If they disagree, the disagreement is the answer.\n\n" s += md.LanguageCodeBlock("", "maketx call -pkgpath "+Path+" -func Wrap -args "+base+" -args <amount>\n"+ "maketx call -pkgpath "+Path+" -func Unwrap -args "+base+" \\\n"+ " -send <amount>"+p.denom) return s }
- Attached funds
- 5000000ugnot
Arguments · 7
- #1impl
- #2README.md
- #3# `gno.land/r/moul/x/upgrade/adminreg/impl/v0` Version 0 of the **implementation realm** of pattern F. Nominates itself with [the facade](../../facade) from `init`, and serves nothing until the owner accepts its path. See [the pattern](../../README.md) and [the exploration](../../../README.md). <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:**  > 🧪 **Highly experimental — potentially vibe-coded.** Not audited; may break, change, or be removed at any time. Do not use with anything of value. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
- #4gnomod.toml
- #5module = "gno.land/r/moul/x/upgrade/adminreg/impl/v0" gno = "0.9" # public: hands its own greeter to the facade; private aborts with "cannot persist object from the private realm"
- #6impl.gno
- #7// Package impl is version 0 of the implementation realm of the "propose and // accept" upgrade pattern (pattern F; see ../../README.md). // // Deploying it only nominates it. It serves nothing until the facade's owner // accepts this path. package impl import ( facade "gno.land/r/moul/x/upgrade/adminreg/facade/v0" ) type greeter struct{} func (greeter) Greet(name string) string { return "hello, " + name } func (greeter) Version() string { return "impl/v0" } // Instance exposes the singleton. func Instance() facade.Impl { return greeter{} } func init(cur realm) { facade.Propose(cross(cur), Instance()) } // Propose re-nominates this implementation. init does it at deploy; exposing it // as a transaction lets a candidate be restored without redeploying. func Propose(cur realm) { facade.Propose(cross(cur), Instance()) } // Path is this realm's own package path, the string the facade owner accepts. const Path = "gno.land/r/moul/x/upgrade/adminreg/impl/v0"
- Attached funds
- 5000000ugnot
Arguments · 9
- #1impl
- #2README.md
- #3# `gno.land/r/moul/x/upgrade/adminreg/impl/v1` Version 1 of the **implementation realm** of pattern F. Same as [`v0`](../gen0), different behaviour, and equally inert until accepted. See [the pattern](../../README.md) and [the exploration](../../../README.md). <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:**  > 🧪 **Highly experimental — potentially vibe-coded.** Not audited; may break, change, or be removed at any time. Do not use with anything of value. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
- #4gnomod.toml
- #5module = "gno.land/r/moul/x/upgrade/adminreg/impl/v1" gno = "0.9" # public: hands its own greeter to the facade; private aborts with "cannot persist object from the private realm"
- #6impl.gno
- #7// Package impl is version 1 of the implementation realm of the "propose and // accept" upgrade pattern (pattern F; see ../../README.md). // // Deploying it only nominates it. It serves nothing until the facade owner // accepts this path. package impl import ( facade "gno.land/r/moul/x/upgrade/adminreg/facade/v0" ) type greeter struct{} func (greeter) Greet(name string) string { return "HELLO, " + name + "!" } func (greeter) Version() string { return "impl/v1" } // Instance exposes the singleton. func Instance() facade.Impl { return greeter{} } func init(cur realm) { facade.Propose(cross(cur), Instance()) } // Propose re-nominates this implementation. init does it at deploy; exposing it // as a transaction lets a candidate be restored without redeploying. func Propose(cur realm) { facade.Propose(cross(cur), Instance()) } // Path is this realm's own package path, the string the facade owner accepts. const Path = "gno.land/r/moul/x/upgrade/adminreg/impl/v1"
- #8impl_test.gno
- #9package impl import ( "testing" facade "gno.land/r/moul/x/upgrade/adminreg/facade/v0" implv0 "gno.land/r/moul/x/upgrade/adminreg/impl/v0" "gno.land/p/nt/uassert/v0" ) // TestProposeThenAccept checks the two-step shape: deploying nominates, and // only an owner transaction naming a path promotes it. // // Both impl realms are imported here, so both have already nominated themselves // by the time this runs, and neither is serving. func TestProposeThenAccept(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(facade.Ownable.Owner())) uassert.Equal(t, 2, len(facade.Candidates()), "both versions nominated themselves") uassert.Equal(t, "", facade.Live(), "nominating is not serving") uassert.AbortsContains(t, cur, "no implementation accepted", func() { facade.Greet("world") }) // Accepting names a path, not an object: this is a plain maketx call. facade.Accept(cross(cur), "gno.land/r/moul/x/upgrade/adminreg/impl/v0") uassert.Equal(t, "impl/v0", facade.Version()) uassert.Equal(t, "hello, world", facade.Greet("world")) // The upgrade, and the rollback, are the same single call. facade.Accept(cross(cur), "gno.land/r/moul/x/upgrade/adminreg/impl/v1") uassert.Equal(t, "HELLO, world!", facade.Greet("world")) facade.Accept(cross(cur), implv0.Path) uassert.Equal(t, "hello, world", facade.Greet("world")) facade.Accept(cross(cur), Path) // A path nobody nominated cannot be accepted, however owned you are. uassert.AbortsContains(t, cur, "no candidate at", func() { facade.Accept(cross(cur), "gno.land/r/moul/x/upgrade/adminreg/impl/v9") }) // A non-owner cannot accept even a legitimate candidate. testing.SetRealm(testing.NewUserRealm("g1w4ek2u33ta047h6lta047h6lta047h6ldvdwpn")) uassert.AbortsContains(t, cur, "caller is not owner", func() { facade.Accept(cross(cur), implv0.Path) }) uassert.Equal(t, "adminreg/facade/v0 [open]\n"+ "live: impl/v1 (gno.land/r/moul/x/upgrade/adminreg/impl/v1)\n"+ "HELLO, world!\n"+ "candidates: 2\n"+ "- gno.land/r/moul/x/upgrade/adminreg/impl/v0\n"+ "- gno.land/r/moul/x/upgrade/adminreg/impl/v1\n", facade.Render("")) } // TestLadder walks the three rungs and checks the ratchet refuses to loosen. // // Runs after TestProposeThenAccept on purpose: every rung is one-way, so the // tests that need to propose and accept freely must already have run. func TestLadder(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(facade.Ownable.Owner())) uassert.Equal(t, facade.StageOpen, facade.Stage()) // Rung 2: no new code, but rolling back among what exists still works. facade.Close(cross(cur)) uassert.Equal(t, facade.StageClosed, facade.Stage()) uassert.AbortsContains(t, cur, "no new candidate may be proposed", func() { Propose(cross(cur)) }) facade.Accept(cross(cur), implv0.Path) uassert.Equal(t, "hello, world", facade.Greet("world"), "rollback survives Close") facade.Accept(cross(cur), Path) // The ratchet only turns one way, even for the owner. uassert.AbortsContains(t, cur, "only tightens", func() { facade.Close(cross(cur)) }) // Rung 3: nothing is accepted again. facade.Freeze(cross(cur)) uassert.Equal(t, facade.StageFrozen, facade.Stage()) uassert.AbortsContains(t, cur, "is frozen", func() { facade.Accept(cross(cur), implv0.Path) }) uassert.AbortsContains(t, cur, "only tightens", func() { facade.Freeze(cross(cur)) }) // What was live stays live, and the render says which rung it is on. uassert.Equal(t, "HELLO, world!", facade.Greet("world")) uassert.Equal(t, "adminreg/facade/v0 [frozen]\n"+ "live: impl/v1 (gno.land/r/moul/x/upgrade/adminreg/impl/v1)\n"+ "HELLO, world!\n"+ "candidates: 2\n"+ "- gno.land/r/moul/x/upgrade/adminreg/impl/v0\n"+ "- gno.land/r/moul/x/upgrade/adminreg/impl/v1\n", facade.Render("")) }
- Attached funds
- 5000000ugnot
Arguments · 9
- #1lazy
- #2README.md
- #3# `gno.land/r/moul/x/upgrade/lazy/v1` Version 1 of the **lazy migration** pattern (pattern D). Declares a new record shape and converts each [`v0`](../gen0) record on first touch. Migrating writes, and writing means crossing, so `Get(cur, key)` is a transaction and is invisible to `vm/qeval` and to `Render`. `Peek(key)` is the free read-only half: same value, no migration. See [the pattern](../README.md) and [the exploration](../../README.md). <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:**  > 🧪 **Highly experimental — potentially vibe-coded.** Not audited; may break, change, or be removed at any time. Do not use with anything of value. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
- #4gnomod.toml
- #5module = "gno.land/r/moul/x/upgrade/lazy/v1" gno = "0.9" # public: the next layout change must be able to read these records to migrate them
- #6lazy.gno
- #7// untrusted-render: records are seeded in init or derived from lazy/v0's own // init; this realm exposes no setter, so no caller string ever reaches Render. // // Package lazy is version 1 of the "lazy migration" upgrade pattern // (pattern D of the exploration; see ../README.md). // // v1 changes the stored shape (Score widens, Active is new) and migrates one // record at a time, on first touch, paid for by whoever touched it. There is // no migration transaction and no pause. // // The catch on today's gno: migrating on read MUTATES, so the read path has to // be a crossing function. Get therefore costs a transaction and is invisible to // vm/qeval and to Render. Peek is the honest read-only half, and it reports the // pre-migration answer for records that have not been touched yet. package lazy import ( v0 "gno.land/r/moul/x/upgrade/lazy/v0" "gno.land/p/nt/avl/v0" "gno.land/p/nt/ufmt/v0" ) // Record is v1's shape: Score widened to int64 and scaled by 1000, plus a new // Active flag that migrated records get set to true. type Record struct { Name string Score int64 Active bool } func (r *Record) String() string { if r == nil { return "nil" } return ufmt.Sprintf("v1{%s %d %t}", r.Name, r.Score, r.Active) } var records = avl.NewTree() func init() { records.Set("cyd", &Record{Name: "cyd", Score: 3000, Active: true}) } // migrate converts a v0 record into a v1 one. Kept separate from Get so the // conversion is testable without a write. func migrate(old *v0.Record) *Record { return &Record{ Name: old.Name + "-migrated", Score: int64(old.Score) * 1000, Active: true, } } // Get returns a record, migrating it from v0 on first touch. // // Crossing, because the migration writes. That is the cost of the pattern: the // cheapest possible read is now a transaction. func Get(cur realm, key string) *Record { if v := records.Get(key); v != nil { return v.(*Record) } old := v0.Get(key) if old == nil { return nil } r := migrate(old) records.Set(key, r) return r } // Peek is the read-only half: it never migrates, so a record that has not been // touched yet reads as its v0 self converted on the fly and NOT stored. func Peek(key string) *Record { if v := records.Get(key); v != nil { return v.(*Record) } if old := v0.Get(key); old != nil { return migrate(old) } return nil } // Migrated is how many records have actually moved over. func Migrated() int { return records.Size() } // Pending is how many v0 records have never been touched. It is only a bound: // v0 keeps every record it ever had, migrated or not. func Pending() int { n := 0 for _, k := range []string{"ada", "bob"} { if records.Get(k) == nil && v0.Get(k) != nil { n++ } } return n } func Render(_ string) string { out := ufmt.Sprintf("lazy/v1: %d migrated, %d pending in v0\n", Migrated(), Pending()) records.Iterate("", "", func(k string, v any) bool { out += ufmt.Sprintf("- %s: %s\n", k, v.(*Record).String()) return false }) return out }
- #8lazy_test.gno
- #9package lazy import ( "testing" v0 "gno.land/r/moul/x/upgrade/lazy/v0" "gno.land/p/nt/uassert/v0" ) // TestLazyMigration walks a record from v0 into v1 on first touch and checks // what the two halves of the read path report on either side of it. func TestLazyMigration(cur realm, t *testing.T) { uassert.Equal(t, 1, Migrated(), "only v1's own seed has moved") uassert.Equal(t, 2, Pending(), "both v0 records are untouched") // Peek converts without storing: the answer is right, the state is not moved. uassert.Equal(t, "v1{ada-migrated 1000 true}", Peek("ada").String()) uassert.Equal(t, 1, Migrated(), "Peek must not migrate") // Get migrates. uassert.Equal(t, "v1{ada-migrated 1000 true}", Get(cross(cur), "ada").String()) uassert.Equal(t, 2, Migrated(), "ada has moved over") uassert.Equal(t, 1, Pending()) // Migrating COPIES: v0 still holds (and still pays for) the original. uassert.Equal(t, 2, v0.Size(), "v0 never shrinks") uassert.Equal(t, "v0{ada 1}", v0.Get("ada").String()) // A second Get is a plain hit, not a second migration. uassert.Equal(t, "v1{ada-migrated 1000 true}", Get(cross(cur), "ada").String()) uassert.Equal(t, 2, Migrated()) // A key in neither version is nil in both halves. uassert.True(t, Get(cross(cur), "zoe") == nil) uassert.True(t, Peek("zoe") == nil) // v1's own records are untouched by any of this. uassert.Equal(t, "v1{cyd 3000 true}", Get(cross(cur), "cyd").String()) uassert.Equal(t, "lazy/v1: 2 migrated, 1 pending in v0\n"+ "- ada: v1{ada-migrated 1000 true}\n"+ "- cyd: v1{cyd 3000 true}\n", Render("")) }
- Attached funds
- 5000000ugnot
Arguments · 9
- #1lock
- #2README.md
- #3# `gno.land/r/moul/x/upgrade/lock/v1` Version 1 of the **retire the predecessor** upgrade pattern (pattern B). Refuses every write until [`v0`](../gen0) has been retired onto it, then folds v0's frozen total into its own. At most one version is writable at any height, so there is a single authoritative answer. See [the pattern](../README.md) and [the exploration](../../README.md). <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:**  > 🧪 **Highly experimental — potentially vibe-coded.** Not audited; may break, change, or be removed at any time. Do not use with anything of value. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
- #4gnomod.toml
- #5module = "gno.land/r/moul/x/upgrade/lock/v1" gno = "0.9" # public: its successor must import it to read the total it will freeze
- #6lock.gno
- #7// Package lock is version 1 of the "retire the predecessor" upgrade pattern // (pattern B of the exploration; see ../README.md). // // v1 refuses every write until v0 has been retired. That is the whole point: // at most one version is writable at any height, so v1 can safely fold v0's // frozen total into its own and report a single authoritative number, the // thing pattern A cannot do. package lock import ( v0 "gno.land/r/moul/x/upgrade/lock/v0" "gno.land/p/nt/ufmt/v0" ) // Predecessor is the version this one supersedes. const Predecessor = "gno.land/r/moul/x/upgrade/lock/v0" var counter int // Inc adds n once the predecessor is frozen. func Inc(cur realm, n int) { assertPredecessorRetired() counter += n } // Get returns the predecessor's frozen total plus this version's own. func Get() int { return v0.Get() + counter } // Live reports whether the handover has happened. func Live() bool { return v0.Successor() == "gno.land/r/moul/x/upgrade/lock/v1" } func assertPredecessorRetired() { if !Live() { panic("lock/v1 is not live yet, " + Predecessor + " must be retired onto it first") } } func Render(_ string) string { if !Live() { return ufmt.Sprintf("lock/v1: waiting for %s to retire\n", Predecessor) } return ufmt.Sprintf("lock/v1: %d (own %d + retired v0 %d)\n", Get(), counter, v0.Get()) }
- #8lock_test.gno
- #9package lock import ( "testing" v0 "gno.land/r/moul/x/upgrade/lock/v0" "gno.land/p/nt/uassert/v0" ) // TestHandover walks the whole one-writer-at-a-time sequence in order: only v0 // writes, then the retirement flips it, then only v1 writes. func TestHandover(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(v0.Ownable.Owner())) // Before the handover: v0 writes, v1 refuses. v0.Inc(cross(cur), 10) uassert.False(t, Live(), "v1 must not be live before the handover") uassert.AbortsContains(t, cur, "not live yet", func() { Inc(cross(cur), 1) }) uassert.Equal(t, 10, Get(), "v1 already reports v0's running total") // The handover itself. v0.Retire(cross(cur), "gno.land/r/moul/x/upgrade/lock/v1") uassert.True(t, Live(), "v1 must be live once v0 names it") // After: v0 refuses, v1 writes, the total is single-valued. uassert.AbortsContains(t, cur, "is retired", func() { v0.Inc(cross(cur), 1) }) Inc(cross(cur), 100) uassert.Equal(t, 110, Get(), "v1 folds v0's frozen total into its own") uassert.Equal(t, 10, v0.Get(), "v0's total is frozen where it stopped") // Retirement is one-way. uassert.AbortsContains(t, cur, "already retired", func() { v0.Retire(cross(cur), "gno.land/r/moul/x/upgrade/lock/v2") }) uassert.Equal(t, "lock/v1: 110 (own 100 + retired v0 10)\n", Render("")) uassert.Equal(t, "lock/v0: 10 (retired, use gno.land/r/moul/x/upgrade/lock/v1)\n", v0.Render("")) }
- Attached funds
- 5000000ugnot
Arguments · 7
- #1impl
- #2README.md
- #3# `gno.land/r/moul/x/upgrade/selfreg/impl/v0` Version 0 of the **implementation realm** of pattern E. Registers itself with [the facade](../../facade) from `init`, so deploying it is the upgrade. See [the pattern](../../README.md) and [the exploration](../../../README.md). <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:**  > 🧪 **Highly experimental — potentially vibe-coded.** Not audited; may break, change, or be removed at any time. Do not use with anything of value. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
- #4gnomod.toml
- #5module = "gno.land/r/moul/x/upgrade/selfreg/impl/v0" gno = "0.9" # public: hands its own greeter to the facade; private aborts with "cannot persist object from the private realm"
- #6impl.gno
- #7// Package impl is version 0 of the implementation realm of the // "self-registering implementation" upgrade pattern (pattern E; see // ../../README.md). // // Deploying this realm is the whole upgrade: init registers it with the facade. package impl import ( facade "gno.land/r/moul/x/upgrade/selfreg/facade/v0" ) type greeter struct{} func (greeter) Greet(name string) string { return "hello, " + name } func (greeter) Version() string { return "impl/v0" } // Instance exposes the singleton so a test (or a rollback) can re-register it // without redeploying. func Instance() facade.Impl { return greeter{} } func init(cur realm) { facade.Register(cross(cur), Instance()) } // Register re-registers this implementation with the facade. init does it once // at deploy; exposing it as a transaction is what makes a rollback to an // already-deployed version a single call. func Register(cur realm) { facade.Register(cross(cur), Instance()) } // Seal asks the facade to end its upgradeability, forever. It only succeeds // while this implementation is the live one: the facade reads the caller off // the crossing frame, so this cannot be used to seal somebody else's tenure. func Seal(cur realm) { facade.Seal(cross(cur)) }
- Attached funds
- 5000000ugnot
Arguments · 9
- #1impl
- #2README.md
- #3# `gno.land/r/moul/x/upgrade/selfreg/impl/v1` Version 1 of the **implementation realm** of pattern E. Identical wiring to [`v0`](../gen0), different behaviour: deploying it takes the facade over on the spot, with no transaction from anybody. See [the pattern](../../README.md) and [the exploration](../../../README.md). <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:**  > 🧪 **Highly experimental — potentially vibe-coded.** Not audited; may break, change, or be removed at any time. Do not use with anything of value. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
- #4gnomod.toml
- #5module = "gno.land/r/moul/x/upgrade/selfreg/impl/v1" gno = "0.9" # public: hands its own greeter to the facade; private aborts with "cannot persist object from the private realm"
- #6impl.gno
- #7// Package impl is version 1 of the implementation realm of the // "self-registering implementation" upgrade pattern (pattern E; see // ../../README.md). // // Identical wiring to v0, different behaviour. Deploying it takes the facade // over on the spot, with no transaction from anybody. package impl import ( facade "gno.land/r/moul/x/upgrade/selfreg/facade/v0" ) type greeter struct{} func (greeter) Greet(name string) string { return "HELLO, " + name + "!" } func (greeter) Version() string { return "impl/v1" } // Instance exposes the singleton so a test (or a rollback) can re-register it // without redeploying. func Instance() facade.Impl { return greeter{} } func init(cur realm) { facade.Register(cross(cur), Instance()) } // Register re-registers this implementation with the facade. init does it once // at deploy; exposing it as a transaction is what makes a rollback to an // already-deployed version a single call. func Register(cur realm) { facade.Register(cross(cur), Instance()) } // Seal asks the facade to end its upgradeability, forever. It only succeeds // while this implementation is the live one: the facade reads the caller off // the crossing frame, so this cannot be used to seal somebody else's tenure. func Seal(cur realm) { facade.Seal(cross(cur)) }
- #8impl_test.gno
- #9package impl import ( "testing" facade "gno.land/r/moul/x/upgrade/selfreg/facade/v0" implv0 "gno.land/r/moul/x/upgrade/selfreg/impl/v0" "gno.land/p/nt/uassert/v0" ) // TestTakeoverOnDeploy checks that the last implementation to run its init owns // the facade, and that re-registering an older one rolls the behaviour back. // // Both impl realms are imported here, so both inits have already run by the // time this test starts; v1 is imported last and therefore holds the facade. func TestTakeoverOnDeploy(cur realm, t *testing.T) { uassert.Equal(t, "gno.land/r/moul/x/upgrade/selfreg/impl/v1", facade.Live()) uassert.Equal(t, "impl/v1", facade.Version()) uassert.Equal(t, "HELLO, world!", facade.Greet("world")) // Rolling back is the same one call: the facade only ever holds a pointer. implv0.Register(cross(cur)) uassert.Equal(t, "gno.land/r/moul/x/upgrade/selfreg/impl/v0", facade.Live()) uassert.Equal(t, "hello, world", facade.Greet("world")) // And forward again. Register(cross(cur)) uassert.Equal(t, "impl/v1", facade.Version()) uassert.Equal(t, "selfreg/facade/v0 [open]: impl/v1 (gno.land/r/moul/x/upgrade/selfreg/impl/v1)\nHELLO, world!\n", facade.Render("")) } // TestSeal ends the realm's upgradeability and checks that it is terminal. // // Runs after TestTakeoverOnDeploy on purpose: sealing is one-way, so every test // that needs to register must already have run. func TestSeal(cur realm, t *testing.T) { // The live implementation is the only actor that may seal, because with no // owner it is the only one the facade already trusts. uassert.Equal(t, "gno.land/r/moul/x/upgrade/selfreg/impl/v1", facade.Live()) uassert.Equal(t, facade.StageOpen, facade.Stage()) uassert.AbortsContains(t, cur, "only the live implementation may seal", func() { implv0.Seal(cross(cur)) }) Seal(cross(cur)) uassert.Equal(t, facade.StageSealed, facade.Stage()) // Nothing registers again, not even the implementation that sealed it. uassert.AbortsContains(t, cur, "is sealed", func() { Register(cross(cur)) }) uassert.AbortsContains(t, cur, "is sealed", func() { implv0.Register(cross(cur)) }) // What was live stays live, and says so. uassert.Equal(t, "HELLO, world!", facade.Greet("world")) uassert.Equal(t, "selfreg/facade/v0 [sealed]: impl/v1 (gno.land/r/moul/x/upgrade/selfreg/impl/v1)\nHELLO, world!\n", facade.Render("")) }
- Attached funds
- 5000000ugnot
Arguments · 9
- #1logic
- #2README.md
- #3# `gno.land/r/moul/x/upgrade/store/logic/v0` Version 0 of the **logic realm** of pattern C. Owns no state: every write lands in [`root/v0`](../../root). Step is 1. See [the pattern](../../README.md) and [the exploration](../../../README.md). <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:**  > 🧪 **Highly experimental — potentially vibe-coded.** Not audited; may break, change, or be removed at any time. Do not use with anything of value. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
- #4gnomod.toml
- #5module = "gno.land/r/moul/x/upgrade/store/logic/v0" gno = "0.9" # public: imported by .../store/logic/v1, whose test drives the handover between them
- #6logic.gno
- #7// Package logic is version 0 of the logic realm of the "state realm + swappable // logic" upgrade pattern (pattern C of the exploration; see ../../README.md). // // It owns no state at all. Everything it writes lands in the root data realm, // which is what makes replacing this realm cheap: there is nothing here to // migrate. package logic import ( root "gno.land/r/moul/x/upgrade/store/root/v0" "gno.land/p/nt/ufmt/v0" ) // Step is this version's business rule: one call, one unit. const Step = 1 // Inc applies this version's rule to the shared counter. func Inc(cur realm) int { return root.Inc(cross(cur), Step) } // Get reads the shared counter. Same number every version sees. func Get() int { return root.Get() } func Render(_ string) string { return ufmt.Sprintf("store/logic/v0: counter=%d step=%d\n", Get(), Step) }
- #8render_example_test.gno
- #9package logic // ExampleRender pins the render of the logic realm against an untouched store. func ExampleRender() { print(Render("")) // Output: // store/logic/v0: counter=0 step=1 }
- Attached funds
- 5000000ugnot
Arguments · 9
- #1logic
- #2README.md
- #3# `gno.land/r/moul/x/upgrade/store/logic/v1` Version 1 of the **logic realm** of pattern C. Same shape as [`v0`](../gen0), different rule (step 1000), same data. The upgrade changed the code without touching a byte of state. See [the pattern](../../README.md) and [the exploration](../../../README.md). <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:**  > 🧪 **Highly experimental — potentially vibe-coded.** Not audited; may break, change, or be removed at any time. Do not use with anything of value. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
- #4gnomod.toml
- #5module = "gno.land/r/moul/x/upgrade/store/logic/v1" gno = "0.9" # private on purpose: it holds no state and hands out no object, so the chain lets it # be redeployed in place. That is the seventh pattern, and it is measured in the README. private = true
- #6logic.gno
- #7// Package logic is version 1 of the logic realm of the "state realm + swappable // logic" upgrade pattern (pattern C of the exploration; see ../../README.md). // // Same shape as v0, different rule. The counter it reads and writes is the same // one v0 was using: the upgrade changed the code without touching the data. package logic import ( root "gno.land/r/moul/x/upgrade/store/root/v0" "gno.land/p/nt/ufmt/v0" ) // Step is this version's business rule: the change that justified the upgrade. const Step = 1000 // Inc applies this version's rule to the shared counter. func Inc(cur realm) int { return root.Inc(cross(cur), Step) } // Get reads the shared counter, including everything v0 wrote. func Get() int { return root.Get() } func Render(_ string) string { return ufmt.Sprintf("store/logic/v1: counter=%d step=%d\n", Get(), Step) }
- #8logic_test.gno
- #9package logic import ( "testing" logicv0 "gno.land/r/moul/x/upgrade/store/logic/v0" root "gno.land/r/moul/x/upgrade/store/root/v0" "gno.land/p/nt/uassert/v0" ) // TestSwapLogic shows the upgrade: the counter survives it untouched, and only // the realm root currently points at may write. func TestSwapLogic(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(root.Ownable.Owner())) // v0 is live out of the box. uassert.Equal(t, 1, logicv0.Inc(cross(cur))) uassert.Equal(t, 2, logicv0.Inc(cross(cur))) uassert.AbortsContains(t, cur, "is not the live logic realm", func() { Inc(cross(cur)) }) // The upgrade: one call, no migration. root.SetLive(cross(cur), "gno.land/r/moul/x/upgrade/store/logic/v1") // v1 writes on top of the data v0 left; v0 is locked out by the same gate. uassert.Equal(t, 1002, Inc(cross(cur)), "v1 adds its own step to v0's data") uassert.AbortsContains(t, cur, "is not the live logic realm", func() { logicv0.Inc(cross(cur)) }) // Reads stay open to every version, live or not. uassert.Equal(t, 1002, logicv0.Get(), "a retired version still reads the truth") uassert.Equal(t, "store/logic/v1: counter=1002 step=1000\n", Render("")) uassert.Equal(t, "store/logic/v0: counter=1002 step=1\n", logicv0.Render("")) }
- Attached funds
- 5000000ugnot
Arguments · 9
- #1wrap
- #2README.md
- #3# `gno.land/r/moul/x/upgrade/wrap/v1` Version 1 of the **wrapping versions** upgrade pattern (pattern A). Keeps its own counter and adds [`v0`](../gen0)'s on read. Both versions stay writable, so the two paths report different totals and neither is authoritative. `z_wrap_filetest.gno` walks that divergence step by step. See [the pattern](../README.md) and [the exploration](../../README.md). <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:**  > 🧪 **Highly experimental — potentially vibe-coded.** Not audited; may break, change, or be removed at any time. Do not use with anything of value. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
- #4gnomod.toml
- #5module = "gno.land/r/moul/x/upgrade/wrap/v1" gno = "0.9" # public: a further version must be able to wrap it the same way v1 wraps v0
- #6wrap.gno
- #7// Package wrap is version 1 of the "wrapping versions" upgrade pattern // (pattern A of the exploration; see ../README.md). // // v1 owns a counter of its own and ADDS v0's on read. Both versions stay live // and independently writable, so the two paths report different totals and // neither is authoritative. That is the pattern's defining trade-off: no // migration, no downtime, no coordination, and no single answer either. package wrap import ( v0 "gno.land/r/moul/x/upgrade/wrap/v0" "gno.land/p/nt/ufmt/v0" ) var counter int // Inc adds n to this version's own counter. It does NOT touch v0. func Inc(cur realm, n int) { counter += n } // Get returns v0's counter plus this version's own. Reading across the version // boundary is a plain non-crossing call: no realm is entered as a writer, so // this stays queryable from vm/qeval and from Render. func Get() int { return v0.Get() + counter } func Render(_ string) string { return ufmt.Sprintf("wrap/v1: %d (own %d + v0 %d)\n", Get(), counter, v0.Get()) }
- #8z_wrap_filetest.gno
- #9// PKGPATH: gno.land/r/moul/main package main import ( v0 "gno.land/r/moul/x/upgrade/wrap/v0" v1 "gno.land/r/moul/x/upgrade/wrap/v1" ) // Both versions stay writable, and writing one never moves the other. func main(cur realm) { print(v0.Render("")) print(v1.Render("")) v0.Inc(cross(cur), 10) print(v0.Render("")) print(v1.Render("")) v1.Inc(cross(cur), 100) print(v0.Render("")) print(v1.Render("")) } // Output: // wrap/v0: 0 // wrap/v1: 0 (own 0 + v0 0) // wrap/v0: 10 // wrap/v1: 10 (own 0 + v0 10) // wrap/v0: 10 // wrap/v1: 110 (own 100 + v0 10)
Result log
msg:0,success:true,log:,events:[] msg:1,success:true,log:,events:[] msg:2,success:true,log:,events:[] msg:3,success:true,log:,events:[] msg:4,success:true,log:,events:[] msg:5,success:true,log:,events:[] msg:6,success:true,log:,events:[] msg:7,success:true,log:,events:[] msg:8,success:true,log:,events:[] msg:9,success:true,log:,events:[] msg:10,success:true,log:,events:[] msg:11,success:true,log:,events:[] msg:12,success:true,log:,events:[]