Skip to content

feat(gnovm): chain/reflect, objects become addressable and the address is an account - #6466

Open
moul wants to merge 12 commits into
masterfrom
alt-complete
Open

moul wants to merge 12 commits into
masterfrom
alt-complete

Conversation

@moul

@moul moul commented Nov 7, 2026

Copy link
Copy Markdown
Member

This is #6214 finished rather than #6214 restated. It keeps that PR's API and
its five commits unchanged as its base, and answers the four questions it left
open: who may spend an object's money, what the address means once the
object is gone
, what admits a new kind of value to the addressable set,
and why #6026's grc20 fix is still not here.

Morgan asked for "either a simpler solution or a more complete idea of what it
means to give addresses to objects". This is the second one.

Closes nothing on its own; it is the base the #6026 fix is written against. See
gnovm/adr/chain_reflect_object_identity.md for the full record.

What #6214 says, and what this adds

"It cannot yet spend: a banker's sender is pinned to the realm's own package
address... Until then, treat an object address as an account that receives and
does not release."

— chain/reflect's package doc, on #6214

An address that can receive and cannot release is an address value goes into
and does not come out of. That is the sentence this branch deletes.

1. Spending — implemented

// chain/banker
const BankerTypeObjectSend BankerType = …

func NewObjectBanker(obj reflect.Object, rlm realm) Banker

The authority rule: an object address is spendable by the realm whose storage
clock minted the object's identity, and by nothing else.

It is not one policy among several. An ObjectID is {PkgID, NewTime} and
PkgID is PkgIDFromPkgPath(<realm path>), so the owner is inside the
identity rather than recorded beside it, and "is this mine" is
oid.PkgID == PkgIDFromPkgPath(rlm.PkgPath()): an equality between two values
the VM computed. Every alternative — an owner field, a transferable capability,
a registry of delegates — is realm state, which a realm can get wrong and which
an address created before it and outliving it would then contradict.

The banker sends and does nothing else. IssueCoin/RemoveCoin panic (a denom
is namespaced by a realm path; an object has none). There is no origin-send
variant (the envelope is credited to the realm's address, never an object's). A
sub-realm token is refused (its PkgID is not the host's).

Nothing on the Gno side is trusted. NewObjectBanker reads one thing out of the
reflect.Object it is handed — obj.ID(), a string — and the native re-parses
it into an ObjectID, re-renders and compares it (so a short hashlet that
hex.Decode would zero-extend is rejected), checks IsFinalized, compares the
PkgID against the caller's realm path, and re-derives the address. A forged
Object reaches nothing honest use does not already reach.

BankerTypeObjectSend is appended after BankerTypeRealmIssue, so no existing
constant moves, and the banker struct gains no field, so no persisted banker
is reinterpreted.

2. Lifecycle — answered, and pinned on a running chain

  • An address is never reassigned. Realm.Time is only ever incremented
    (targetRlm.Time++ in assignNewObjectID) and no deletion path decrements
    it. A tick, once spent, is spent forever.
  • Deleting the object does not delete the address. The bank holds the
    balance regardless, and the VM could not intervene if it wanted to: it cannot
    see balances. So NewObjectBanker takes an Object snapshot, not an object,
    and a realm can wind the balance down long after the object is gone.
  • The corollary is an obligation on the realm, stated in the docs: fund an
    object and keep no reflect.Object for it, and the balance is stranded
    forever. There is no recovery path, and there should not be one — a recovery
    path is a way for someone other than the minting realm to reach the address.
  • Unreachable is the same as deleted; the VM draws no distinction and
    neither does this.
  • In-place upgrade keeps everything. A redeploy takes over the realm at the
    path (GetPackageRealm → RunMemPackageOverRealm), so same path, same PkgID,
    same clock, and every address the old code minted stays funded and spendable.
  • A versioned upgrade is a migration. /r/foo/v2 is a different path, a
    different PkgID, a disjoint address space. v1 must drain, or stay callable.

gno.land/pkg/integration/testdata/object_address_lifecycle.txtar runs the
whole thing against a real node: 700000ugnot sent to an object's address, the
object dropped from realm state, the balance still there, a new object minted
onto a different tick (:18 → :22) with an empty balance, and the dead
object's 700000ugnot drained out through the stored snapshot.

3. The growth rule — decided, not implemented

#6214 says the addressable set "is meant to grow, and to only ever grow", which
says when a change is allowed, not which change. The ADR replaces that with a
four-part admission test, and scores every candidate kind against it in a table:

  1. One object, one answer — every route to the value resolves to one VM object.
  2. Reference, not copy — TypedValue.Copy shares rather than clones.
  3. Minted by a realm clock — the ObjectID is finalized and PkgID.IsRealmPkg().
  4. Monotone — no address already derivable moves, and no address it hands out
    is one another kind already does.

Container pointers fail 1, slices fail 1, structs and arrays by value fail 2,
stdlib and p/ objects fail 3. Two of those have a visible route in, and under
the rule they become ordinary admissions rather than judgement calls.

4. #6026 and grc20 — not in this PR, with the reason #6214 never gave

#6214 calls it "a small follow-up: drop NewToken's caller-supplied seqid.ID
and let Token.Address() be its identity". It is not small, and the blocker is
#6214's own stamping window.

NewToken returns a *Token allocated by the running call, so reflect.Of
returns ok == false until the realm frame returns. And every GRC20 in the
tree creates and uses its token in one transaction
— foo20, bar20,
wugnot, grc20factory, test20, the treasury filetests all do
NewToken(...) then Mint(...) then grc20reg.Register(...) inside init.
An address-backed ID() panics at deploy for all of them. That is not a
migration, it is a redeploy of every token on the chain plus an unexpressed new
authoring rule ("you may not mint in the transaction that creates the token").

The three obvious escapes are each worse than waiting: a lazily cached ID()
needs a write on a path reached from Render, and gives the token one id in its
creation transaction and another afterwards; a two-phase NewToken + Bind is
correct but is an API burden imposed by a PR about the VM; emitting no id at
creation gives up the guarantee that makes token creation observable.

What unblocks it is separating "has an identity" from "is persisted" in the
VM, so an object can be stamped at allocation. Today GetIsReal() is
ID.IsFinalized() and incRefCreatedDescendants guards on the same predicate,
so that means a third ObjectInfo state and an audit of every IsFinalized()
call site. Its own change, its own risk, and the prerequisite the #6026
follow-up should be written against.

zrealm_reflect_duplicate_symbol.gno, from #6214, still demonstrates the end
state on a local token type, so the target is not in doubt. GRC721 has the same
exposure and the same blocker.

What is implemented vs. what the ADR only decides

state
object addresses (chain/reflect, #6214's API, unchanged) implemented
spending from an object address (banker.NewObjectBanker) implemented, 2 filetests
the spending authority rule, enforced natively implemented
address survives deletion / unreachability implemented, verified over 7 real transactions
the clock never reissues a tick verified, invariant of existing code, no change needed
in-place upgrade keeps the clock verified, invariant of existing code, no change needed
versioned upgrade is a migration decided in the ADR, nothing to implement, it is an author obligation
the obligation to persist a funded object's Object decided in the ADR, documented in the package doc and the docs page
the four-part growth rule decided in the ADR, no code
#6026 grc20 fix not here, blocker named above and specified in ADR section 9

Diff

22 files, +2725 / -3 against master bc88c1ae. Of that, 17 files and 1862
insertions are #6214 merged in unchanged as the base; this branch adds the
banker capability, two filetests, one integration txtar, the rewritten ADR, the
docs page section and the genesis hash re-pin.

Consequences worth a reviewer's attention

  • chain/banker now imports chain/reflect. genstd recomputes initOrder
    from the import graph on its own; no manual ordering, no core change. Second
    stdlib-to-stdlib import after chain/runtime/unsafe → chain/runtime.
  • NewObjectBanker's realm argument is last, matching NewBanker(bt, rlm):
    a crossing function's first realm parameter must be named cur, and this
    is not a crossing function.
  • Genesis app hash moves. expectedCrossrealm38Hash is re-pinned: stdlib
    .gno sources are genesis state, and both reflect.gno (package comment) and
    banker.gno (new function) changed. banker.go, the gas row and the
    generated binding are not committed state. crossrealm38 imports neither
    package and its behaviour is unchanged.
  • One new gas row, chain/banker.objectBankerAddress at 1100, flat, drafted
    on an M1 against chain/reflect.objectInfo's 1200 on the same run. Both are
    pending the reference-machine re-fit, like the rest of the table.
  • The irrevocable-grant note applies. An object banker is a permanent grant
    over one address, exactly as a realm banker is over the realm address, with a
    strictly smaller blast radius — which also means a realm can now delegate
    spending over one fund instead of over everything it holds.

Gate

See .alt/GATE.md in the branch for the exact commands and output.

@github-actions github-actions Bot added 📖 documentation Improvements or additions to documentation 📦 🤖 gnovm Issues or PRs gnovm related 📦 ⛰️ gno.land Issues or PRs gno.land package related labels Nov 7, 2026
@Gno2D2

Gno2D2 commented Nov 7, 2026

Copy link
Copy Markdown
Collaborator

🛠 PR Checks Summary

All Automated Checks passed. ✅

Manual Checks (for Reviewers):
  • IGNORE the bot requirements for this PR (force green CI check)
Read More

🤖 This bot helps streamline PR reviews by verifying automated checks and providing guidance for contributors and reviewers.

✅ Automated Checks (for Contributors):

No automated checks match this pull request.

☑️ Contributor Actions:
  1. Fix any issues flagged by automated checks.
  2. Follow the Contributor Checklist to ensure your PR is ready for review.
    • Add new tests, or document why they are unnecessary.
    • Provide clear examples/screenshots, if necessary.
    • Update documentation, if required.
    • Ensure no breaking changes, or include BREAKING CHANGE notes.
    • Link related issues/PRs, where applicable.
☑️ Reviewer Actions:
  1. Complete manual checks for the PR, including the guidelines and additional checks if applicable.
📚 Resources:
Debug
Manual Checks
**IGNORE** the bot requirements for this PR (force green CI check)

If

🟢 Condition met
└── 🟢 On every pull request

Can be checked by

  • Any user with comment edit permission

…ce must declare one in go too

The transpiler emits a direct Go call for a native binding and passes the
argument with the type gno gave it, so the Go parameter has to be a type the
gno one is assignable to. objectInfo declared gno.TypedValue against a gno
`any`: it links, because a Go gno.TypedValue parameter is genstd's escape hatch
from reflect-based conversion and matches any gno type, and it is correct under
the interpreter, but it cannot be called from Go. `gno tool transpile -C
examples --gobuild .` fails the moment any transpiled package reaches the file.

The Go side now declares `any` as well, and genstd hands over the machine's
gno.TypedValue boxed in it, still unconverted; the native asserts it back. That
assertion fails only in the Go program the transpiler emits, which has no VM
behind it, and the no-identity answer is true of every value outside the VM.

The defect is inherited rather than new: reflect.go is byte-identical to #6214,
whose gno2go job is green only because nothing there imports chain/reflect in
gno, so the file is never transpiled. chain/banker imports it on this branch.

genstd also documents the gno.TypedValue-as-parameter hazard on isTypedValue,
and rejects an empty-interface result with a message naming the alternative
rather than generating code that cannot compile.

No public API changes: reflect.gno is untouched.

Signed-off-by: moul <94029+moul@users.noreply.github.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

📖 documentation Improvements or additions to documentation 📦 ⛰️ gno.land Issues or PRs gno.land package related 📦 🤖 gnovm Issues or PRs gnovm related

Projects

Status: 📥 Inbox

Development

Successfully merging this pull request may close these issues.

2 participants