Skip to content

feat: realm transaction sponsorship (PayGas + PayStorage) - #5382

Open
omarsy wants to merge 49 commits into
gnolang:masterfrom
omarsy:worktree-tm2-architecture
Open

omarsy wants to merge 49 commits into
gnolang:masterfrom
omarsy:worktree-tm2-architecture

Conversation

@omarsy

@omarsy omarsy commented Mar 28, 2026 •

Copy link
Copy Markdown
Member

Summary

Realm transaction sponsorship: runtime.PayGas and runtime.PayStorage let a realm pay a user's gas, and its own storage deposits, so a user with 0 gnot can transact. The realm decides during execution, after running its own logic (collect a token, check a whitelist, rate-limit).

func DoWork(cur realm) {
	// ... decide whether to sponsor ...
	runtime.PayGas(1_000_000, cur)    // pay this tx's gas, up to 1 gnot
	runtime.PayStorage(500_000, cur)  // pay my own storage deposits, up to 0.5 gnot
}

Ships disabled. Block.MaxGasCreditPerTx defaults to 0 and the per-validator allow_zero_fee_txs to false. With the window closed, 0-fee txs are rejected in every mode (a well-formed one with master's error at master's point) and fee-paying txs are unchanged, except that loading the larger chain/runtime costs about 3.5K more gas. Like the app's other block params, MaxGasCreditPerTx is read from the copy stored at genesis, so turning it on or off takes a chain upgrade.

State-breaking. Adding the natives changes the chain/runtime stdlib committed at genesis (the crossrealm38 apphash pin moves).

Do not enable on a public chain yet. A sponsored tx that fails at delivery charges nobody: a realm can make admission and delivery disagree, and a proposer can include 0-fee txs that were never admitted (see Open items). That needs a delivery-time penalty first.

How it works

  • PayGas(maxFee int64, rlm realm): rlm pays the tx's gas up to maxFee ugnot. The gas limit becomes what maxFee buys at the current gas price, capped by the credit window. Once per tx; covers every message.
  • PayStorage(maxDeposit int64, rlm realm): rlm pays deposits for its own storage, only in messages that call it (a MsgCall to it, or its init in MsgAddPackage), up to maxDeposit per tx. Storage in other realms stays on the user.
  • rlm is a capability, like banker.NewBanker(bt, rlm): it must be the current realm (rlm.IsCurrent()), and packages, MsgRun's ephemeral realm and sub-realm tokens are refused. It is the last parameter because a realm-typed first parameter makes a function crossing.
  • Both are no-ops outside a 0-fee sponsored tx, including queries and gno test. A 0-fee tx that never calls PayGas is rejected at CheckTx and at DeliverTx.
  • Settlement only on success: ceil(gasUsed × LastGasPrice), capped at maxFee, from the realm to the fee collector. On failure everything reverts and the realm pays nothing, so a realm that collects a token before PayGas cannot be drained by a deliberate failure.

Admission (tm2)

  • Credit window: new consensus param Block.MaxGasCreditPerTx (<= Block.MaxGas), the whole gas budget of a 0-fee tx.
  • Opt-in: [application] allow_zero_fee_txs, local mempool policy like MinGasPrices, checked in BaseApp.CheckTx.
  • RunTxModeCheckExecute: first-time CheckTx of a 0-fee tx runs its messages and dry-runs settlement in a discarded cache, so a tx without PayGas, with an insolvent sponsor or over its storage budget never enters the mempool. Only the ante's sequence increment persists. Rechecks stay ante-only. Simulate runs the same settlement dry run, so a wallet's estimate shows a sponsor that cannot pay.
  • Hooks: EndTxHook(ctx, result) error runs only on success, in Deliver and CheckExecute. New CommitTxHook commits gno.land's tx store, delivery only. Settlement events are appended after the hook; a failed tx reports no events.
  • Tx.ValidateBasic accepts an empty fee coin, because amino normally encodes a zero coin as "" and a sponsored tx carries one; the ante rejects a 0-fee tx unless the credit window is open. gnogenesis verify applies the same window check, since a genesis tx the ante rejects stops InitChain.

Storage events and gnokey

A StorageDepositEvent paid through PayStorage names the sponsor in payer, and a StorageUnlockEvent gives in sponsor_refund the part returned to it. Both are omitted otherwise, so unsponsored events encode exactly as before (JSON and amino). gnokey counts only the signer's share as STORAGE FEE / TOTAL TX COST, prints the sponsor's as SPONSORED STORAGE, and prints 0ugnot rather than a blank for a zero cost.

Changes since the previous revision

Second davd-gzl round (at ba63646):

  • The ante's out-of-gas log names the credit window for a 0-fee tx, not its declared gas wanted. Out-of-gas logs no longer tell a 0-fee tx to simulate with more gas, which cannot help it.
  • PayGas computes what maxFee buys in 128 bits instead of panicking on overflow, which only showed on chain since PayGas returns early under gno test.
  • Settlement also runs under Simulate (above), and a tx failed after its messages (PayGas not called, a settlement error) now keeps the reason in its log, since ABCIError keeps only the error type.
  • gnogenesis verify skips txs marked Failed, as the node does; its zero-fee check rejected them.
  • Docs: settlement's transfer is unmetered, and why; the hard-fork replay note covers both replay meters and the case where the re-priced debit fails.

Review follow-ups (davd-gzl, at 1f043dd):

  • gnogenesis verify rejects a zero-fee genesis tx while the genesis credit window is closed (a default node would panic in InitChain on it).
  • Storage events name the payer and gnokey no longer shows a sponsor's deposit as the signer's cost (above).
  • The out-of-gas log names the PayGas limit when it is below the window it reports as gas wanted.
  • CheckTx turns a decode panic into ErrTxDecode.
  • Tests: a 0-fee addpkg whose init sponsors itself (paygas_addpkg_init); CheckExecute drops message writes; the window, not gas-wanted, bounds a 0-fee tx; the realm pays nothing in a fee-paying tx; pinned costs in the cost comparison and multi-message fixtures; each use-case pattern runs at its own window instead of 30M. Each new assertion was checked to fail on the mutation it guards against.
  • ADR: a sponsored tx reserves the whole window in block packing, so a block holds at most Block.MaxGas / MaxGasCreditPerTx of them.

Earlier, reviewed again with independent adversarial passes; each finding below was reproduced before being fixed.

  • Fixed: PayStorage drain. It paid for growth in every realm the tx touched, and a deposit refunds whoever frees the bytes, so a co-message could route the sponsor's budget into its own realm and collect it later (+478,700ugnot to the signer across two 0-fee txs). Then, with own-storage only, another realm could keep an object the sponsor allocated (it lands in the sponsor's storage) and drop it for the refund (+597,800). Now: own storage only, in messages that call the sponsor. Regression txtars: paystorage_other_realm_not_sponsored, paystorage_entry_only.
  • Removed Fee.SponsorStorage. After master's fix(tm2)!: render the sign doc's fee in the shape Ledger accepts #6173 the signed fee is {amount, gas}, so the flag was not covered by signatures and could be flipped by anyone relaying the tx. Signing it would make Ledger refuse the tx. A commitment already covers later messages, so a sponsor's message goes first. This also removes the deferred storage path, two ante guards and the EnablePackage special case.
  • Capability API replaces the "function two frames up must be declared in the current realm" check, which hard-coded the VM's frame layout and let a confused deputy pay (a realm exporting a plain PayGas helper and a callback runner).
  • No balance pre-checks. Settlement is authoritative and the admission dry run rejects insolvent sponsors. The pre-checks read the whole balance with GetCoins (cost set by whoever sends the realm denoms), and settlement's ran on an infinite meter.
  • gno test no longer panics on a realm calling PayGas (zrealm_paygas.gno).
  • allow_zero_fee_txs is enforced once, in CheckTx; the opt-out path now has a test.
  • Feature-off parity: the closed-window zero-fee rejection sits where ValidateBasic used to reject, so a well-formed 0-fee tx records the same error as on master.
  • Native gas measured. payGas charges 1097 + 35448·len(pkgPath)/1024 gas and payStorage 1091 + 35576·len/1024 (the placeholders charged about half). Both match the pkgpath regexp twice before hashing. A sub-realm token (host#sub), the one reachable input the regexp rejects, is refused on its # first, since a failing match can backtrack longer than any match (at 256 bytes: 16-20 µs before the check, 0.3 µs after, against 9.8 µs for a match).
  • Merged master; dropped the HLD (docs/design/ is not a repo convention; the ADRs are the record).

Files

  • tm2: bft/types/params.go, bft/abci/types/{types.go,abci.proto,pb3_gen.go} (MaxGasCreditPerTx, field 6); sdk/baseapp.go, sdk/abci.go, sdk/context.go, sdk/types.go, sdk/options.go, sdk/config/config.go; sdk/auth/ante.go; std/tx.go; store/types/gas.go (GasMeter.SetLimit).
  • gnovm: stdlibs/chain/runtime/{paygas,paystorage}.{gno,go}, stdlibs/generated.go, stdlibs/native_gas.go (measured rows), stdlibs/internal/execctx/context.go, stdlibs/stdlibs.go, stdlibs/chain/emit_event.go (storage event payer fields).
  • gno.land: pkg/gnoland/app.go (settlement and commit hooks, opt-in wiring), pkg/sdk/vm/keeper.go (per-tx storage commitment, processStorageDeposit), pkg/keyscli/root.go (gnokey storage cost), node_inmemory.go, integration/node_testing.go (window and opt-in on for txtars).
  • contribs: gnogenesis/internal/verify/verify.go (zero-fee genesis tx check).
  • Tests: 28 txtars (paygas_*, paystorage_*; the txtar runner opens the credit window, capped by Block.MaxGas), tm2 baseapp/ante unit tests, Tx.ValidateBasic fee cases (tm2/pkg/std/tx_test.go), MaxGasCreditPerTx bounds (tm2/pkg/bft/types/params_test.go), gnovm/tests/files/zrealm_paygas.gno, and measurement tests sponsorship_{usecase,load,bench}_test.go (each use case is asserted at its window; a GRC20 approve + transferFrom paymaster tx uses 5.2-5.4M gas, under a 6M window).
  • Calibration: benches for both natives and for the sub-realm refusal (gnovm/cmd/calibrate/native_machine_bench_test.go), their gen_native_table.py rows, and the snapshot the rows were fitted from (gnovm/cmd/calibrate/sponsorship_bench_m1pro_arm64.txt).
  • ADRs: gno.land/adr/pr5382_realm_transaction_sponsorship.md, tm2/adr/pr5382_zero_fee_tx_admission_and_settlement.md; one exception noted in tm2/adr/pr6334_deferred_block_sync_writes.md (a sponsored 0-fee tx's first admission runs its messages in CheckTx).

Breaking for tm2 embedders: EndTxHook returns an error and runs only on success; store.GasMeter gained SetLimit.

Open items

  • Failing sponsored txs are free. Admission and delivery see different state, and a realm can condition PayGas on the difference (e.g. ChainHeight(), or a one-shot allowance several pending txs all pass); and a proposer can include 0-fee txs that were never admitted. Either way a tx burns up to the credit window at delivery, paid by nobody. Settlement cannot fix it (no realm called PayGas). The mitigation has to act at delivery (e.g. an on-chain penalty on the signer); required before enabling.
  • CheckExecute runs the VM under the mutex CheckTx shares with consensus, and a rejected tx can be resent with new bytes at no cost. Bounded by the credit window per attempt; opt-in only.
  • Sponsored gas feeds the dynamic gas price; failing sponsored txs move it without paying.
  • Settlement's transfer runs on its own meter, so it counts in neither the tx's gas nor the block's: metering it could fail a tx after its messages succeeded, and a failed sponsored tx pays nothing. A fee-paying tx is charged for its fee deduction, so a sponsored tx is that much cheaper in gas.
  • gasUsed now sets a bank debit, so any cross-node gas difference forks the app hash immediately.
  • Hardfork replay of a sponsored tx runs it as sponsored again (on the credit window in strict replay, or an infinite meter in source-gas replay mode, which gnogenesis fork generate writes by default), so the sponsor is debited for the new VM's gas at the target chain's gas price. If the sponsor cannot cover that debit the tx fails, and by default that stops InitChain. Unreachable today.
  • PayStorage charges everything the sponsor's messages add to its storage, whoever's code adds it (for example a hook keeping an object it allocated), and a deposit refunds whoever frees the bytes. So a sponsored function must not run code it does not control, nor let callers free what it sponsored (documented on PayStorage). Charging only the sponsor's own code would need the VM to track which realm's code produced each byte.
  • A sponsor's functions cannot be batched in one tx: a second PayGas/PayStorage panics.
  • The payGas/payStorage gas rows were measured on an Apple M1 Pro; like the rest of the table they need re-measuring on the reference hardware before deployment.

Developed with AI assistance (Claude Code), including independent adversarial review rounds; every finding was reproduced on running code before being acted on.

🤖 Generated with Claude Code

@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 📄 top-level-md labels Mar 28, 2026
@Gno2D2

Gno2D2 commented Mar 28, 2026 •

Copy link
Copy Markdown
Collaborator

🛠 PR Checks Summary

🔴 Pending initial approval by a review team member, or review from tech-staff

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):

🟢 Maintainers must be able to edit this pull request (more info)
🔴 Pending initial approval by a review team member, or review from tech-staff

☑️ 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
Automated Checks
Maintainers must be able to edit this pull request (more info)

If

🟢 Condition met
└── 🟢 And
    ├── 🟢 The base branch matches this pattern: ^master$
    └── 🟢 The pull request was created from a fork (head branch repo: omarsy/gno)

Then

🟢 Requirement satisfied
└── 🟢 Maintainer can modify this pull request

Pending initial approval by a review team member, or review from tech-staff

If

🟢 Condition met
└── 🟢 And
    ├── 🟢 The base branch matches this pattern: ^master$
    └── 🟢 Not (🔴 Pull request author is a member of the team: tech-staff)

Then

🔴 Requirement not satisfied
└── 🔴 If
    ├── 🔴 Condition
    │   └── 🔴 Or
    │       ├── 🔴 At least one of these user(s) reviewed the pull request: [aronpark1007 davd-gzl jefft0 notJoon omarsy MikaelVallenet] (with state "APPROVED")
    │       ├── 🔴 At least 1 user(s) of the team tech-staff reviewed pull request
    │       └── 🔴 This pull request is a draft
    └── 🔴 Else
        └── 🔴 And
            ├── 🟢 This label is applied to pull request: review/triage-pending
            └── 🔴 On no pull request

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

@codecov

codecov Bot commented Mar 28, 2026 •

Copy link
Copy Markdown

@omarsy
omarsy force-pushed the worktree-tm2-architecture branch 2 times, most recently from 80e0ead to e9fa164 Compare March 28, 2026 15:27
@omarsy omarsy changed the title docs: HLD for realm gas sponsorship (std.PayGas) feat: realm transaction sponsorship (PayGas + PayStorage) Mar 29, 2026
@github-actions github-actions Bot added the 📦 🌐 tendermint v2 Issues or PRs tm2 related label Mar 29, 2026
@alexiscolin alexiscolin added the a/ux User experience, product, marketing community, developer experience team label Apr 11, 2026
PR Review added 7 commits July 3, 2026 12:30
Rebased onto current master; squashes the original PR branch and resolves
conflicts against 266 commits of upstream drift (session accounts, gas
RefundGas/DebugTotals, single-cache runTx, gasConfig context field).
…as costs

- cross-realm calls: (cross) -> (cross(cur)) per current gno convention
- raise 0-fee gas-wanted for master's recalibrated (higher) gas costs
Adding the PayGas/PayStorage natives to the chain/runtime stdlib shifts the
genesis MemPackage Merkle root — the same benign genesis-encoding class of
change the test's own history documents. Realm behavior is unchanged.
- endTxHook: SponsorStorage tx that only frees storage (net-negative diffs,
  no PayStorage) is no longer wrongly failed; it now refunds to the tx caller.
  Only unpaid storage GROWTH without PayStorage fails the tx.
- baseapp runTx: exempt genesis (height 0) from the 0-fee PayGas enforcement,
  so 0-fee genesis txs aren't rejected on chains with MaxGasCreditPerTx>0.
- ProcessStorageDepositFromDiffs: add the rlm==nil guard that
  ProcessStorageDeposit already has, now that more diffs route through it.
accumulateStorageDiffs now merges ParamsRealmDiffs (chain/params byte deltas)
into the tx-level accumulator and flushes each realm's meta-key baseline,
mirroring the per-message ProcessStorageDeposit path. Without this a
SponsorStorage tx that wrote chain/params escaped the storage deposit and left
the persistent byte baseline stale. The tx is atomic, so the meta-key writes
revert with the deferred deposit if end-of-tx settlement fails.
@omarsy
omarsy force-pushed the worktree-tm2-architecture branch from 9a8cbe4 to c82584a Compare July 3, 2026 13:28
PR Review added 5 commits July 3, 2026 16:23
The PR added Fee.SponsorStorage (and BlockParams.MaxGasCreditPerTx,
PayGasInfo.Eligible) to the Go structs but never regenerated the proto3/amino
bindings. Amino BINARY encoding is proto-driven, so these fields were silently
dropped on the wire while JSON (used for sign-bytes) kept them — every real
SponsorStorage tx failed signature verification, and the flag never reached the
node at all. Regenerated via misc/genproto2.

Removing PayGasInfo from the wire also required dropping it from sdk.Result:
Result must stay wire-compatible with abci.ResponseDeliverTx (gnokey decodes a
simulate Result as ResponseDeliverTx), so a field-4 PayGasInfo broke decoding.
PayGasInfo is in-process only — endTxHook and runTx now read it from the
sdk.Context instead of result. Verified: Fee binary round-trip preserves
SponsorStorage, all 21 PayGas/PayStorage integration tests pass.
- gnovm/pkg/gnolang native_gas_test.go: add SetLimit to the recordingMeter mock
  (the GasMeter interface gained SetLimit in this branch), fixing the lint/test
  typecheck failure.
- docs HLD: replace a repo-relative markdown link with inline code and wrap
  '<'/'>' comparisons in code spans so the docs linter no longer parses them as
  broken links/autolinks.
Adding the SetLimit method shifted the gofmt column alignment of the adjacent
recordingMeter methods; 'make generate' (the fmt step) flagged it. No behavior
change.
ADR:
- rename to pr5382_realm_transaction_sponsorship.md (repo ADR convention)
- credit-window decision now notes DeliverTx (not just CheckTx) enforcement
- add decision: PayGas/PayStorage only take effect in sponsored 0-fee txs
  (no-op in normal fee txs), derived limit capped at the credit window

HLD:
- gas price is the auth module's dynamic LastGasPrice, not a new MinGasPrice
  consensus param (was wrong throughout; now matches ADR + code)
- gas settlement lives in gnoland/app.go endTxHook, not vm/keeper.go
- document the no-op-in-normal-fee-tx behavior and the DeliverTx enforcement
- derived gas limit capped at the credit window; ceiling division in settlement
- fix stale ante snippet, PayGasInfo struct (RealmPkgPath/Eligible, off Result),
  and the paygas.go file path
- add 'cur realm' receiver to entry/crossing functions
- std.GetOrigCaller() -> runtime.PreviousRealm().Address()
- std.CurrentRealm().Addr() -> cur.Address()
- std.GetTimestamp() -> time.Now().Unix()
- cross-realm calls use cross(cur)
Conflicts were additive on both sides; resolved by keeping both.

- ante/app: master's RequireSigForSimulate (signature verification for
  code-bearing messages under .app/simulate) and this branch's
  AllowZeroFeeTxs are independent AnteOptions, both kept. The signature
  check takes master's predicate, which subsumes the branch's plain
  !simulate; the note about 0-fee admission running in
  RunTxModeCheckExecute rather than Simulate still holds and is kept.
- execctx: master's origin-send envelope fields and Mark* methods union
  with this branch's PayGasInfo/PayStorageInfo/GasPrice/
  StorageDepositDenom.
- vm keeper: msgCtx literals union both field sets in AddPackage, Call
  and Run. Master's unobserved-send guard runs before storage
  settlement, which keeps this branch's per-message vs deferred split.
  processStorageDeposit stays exported as this branch made it, since
  app.go's settlement hook calls it; keeper_inert.go's caller updated,
  and the blank line that detached its doc comment removed.
- apphash pin re-derived over both sides: master's stdlib changes plus
  the sponsorship stdlib surface.

Three txtar gas figures re-pinned for master's gas-schedule change
(paygas_cross_realm_sponsors 2144 -> 2261, and the two refund tests).
The invariants they assert are unchanged: the cross-realm test still
shows neither sponsor charged for the other's cost, and both refund
tests still show the 299500ugnot refund reaching the caller with the
sponsor strictly below its funding.
…Storage

EnablePackage settled storage per-message unconditionally, while
AddPackage, Call and Run all branch on ctx.SponsorStorage(). In a
sponsored multi-message tx containing a MsgEnablePackage, that message
charged the sponsoring realm up to its committed budget and recorded it
on PayStorageInfo.SpentDeposit; end-of-tx settlement then received the
full MaxDeposit again with its own running total reset, exposing the
realm to twice what it committed.

Neither merge parent could show this: master has no SponsorStorage, and
the branch had no keeper_inert.go. The merge renamed the call without
adding the branch the feature requires.

Three changes:

- keeper_inert.go guards on ctx.SponsorStorage() like the other three
  settlement sites. Root cause.
- app.go passes MaxDeposit - SpentDeposit to deferred settlement, so the
  per-transaction cap cannot re-arm even if some future path settles
  per-message again.
- ProcessStorageDepositFromDiffs no longer falls back to DefaultDeposit
  on a non-positive budget. That fallback made the failure mode of a
  defensive branch a LARGER charge than the invariant it defends allows,
  up to the chain default against a realm that committed less, and the
  change above made it reachable. Growth with no remaining budget is now
  an error; refunds still settle, since releasing storage needs none.

Adds TestVMKeeperEnableDefersStorageUnderSponsorStorage. It funds the
sponsoring realm deliberately, so removing the guard fails on the spend
assertion rather than incidentally on an unfunded payer: unguarded, the
realm is charged 215200ugnot during the message and SpentDeposit stops
being zero.
…t just the branch

Follow-up to a4dbfb3, from an independent review of it.

The regression test proved the guard exists but not that it keys on the
right thing: swapping ctx.SponsorStorage() for "psi != nil &&
psi.MaxDeposit > 0" still passed, because the test pre-set MaxDeposit.
That predicate is wrong in production for any tx whose MsgEnablePackage
precedes its PayStorage message: the enable would settle per-message
against the creator and drop its diffs from the accumulator.

Test changes:

- Two subtests, MaxDeposit > 0 and MaxDeposit == 0, modelling PayStorage
  running in an earlier or a later message. Both are SponsorStorage txs
  and both must defer, so the second kills that predicate.
- Tx caller is the approver, the production shape, since
  MsgEnablePackage.GetSigners returns the approver. It matters because
  deferred settlement refunds to ctx.TxCaller().
- Asserts the exact accumulated total (2152 bytes) rather than merely
  positive, which also catches a double-accumulate, and asserts the
  approver's balance is untouched.

Both mutants now fail: removing the guard fails both subtests, the wrong
predicate fails the second.

Also from that review:

- keeper_inert.go: the rationale block above the call described only the
  per-message arm. It now says what deferral changes: payer becomes the
  PayStorage realm, the cap becomes that realm's MaxDeposit rather than
  the submit-stamped ceiling, and freed storage refunds to the approver
  rather than the creator. Enable's diffs are normally positive so the
  refund case is contrived, but it is a real difference and was
  undocumented.
- keeper.go: the budget-exhausted error ranged a map, so the one branch
  that guard exists to make safe would report a different realm on every
  node. Now reports the first growing realm in sorted order.
…at it just locked

Regression from 23884d5, found independently by two review agents and
reproduced end to end.

Routing storage refunds to the tx caller is right when the freed bytes
were locked by an EARLIER transaction. On the per-message settlement path
it was also applied when THIS tx's sponsor had funded them one message
ago, so a 0-fee tx of

  msg[0] PayGas + PayStorage
  msg[1] grow   -> realm charged the deposit
  msg[2] free   -> signer paid the release

converted the sponsoring realm's balance into the signer's: 299500ugnot
per round trip in the repro, repeatable, capped per tx only by the
realm's own maxDeposit. No tx flag is needed, and the vulnerable sponsor
shape is the one this PR's own txtars use. The deferred path was never
exposed, since it nets each realm's diffs across the whole tx and so
never locks and releases the same bytes.

PayStorageInfo.SponsorFunded records per realm what this tx's sponsor
locked. The refund branch unwinds that first and pays the caller only the
excess, which is the part that genuinely predates this tx. Restricted
denoms are unaffected: the refund is withheld from everyone there.

The alternative, deferring storage for every 0-fee tx rather than only
under Fee.SponsorStorage, would also close it but costs a capability: a
0-fee tx that grows storage with PayGas alone would begin failing instead
of charging the signer.

Unsponsored settlement is unchanged. SponsorFunded is nil, so the lookup
yields zero and the original single-transfer branch runs.

Adds paystorage_sponsor_grow_free_no_drain.txtar, asserting the signer's
balance does not move at all. Mutation tested: disabling the sponsor-first
unwind fails it while both refund-to-caller tests still pass, so the two
behaviours are pinned independently.
Re-pin the crossrealm38 apphash: master's own stdlib changes and the
PayGas/PayStorage declarations both move the genesis root.
…ge path

After merging master the flag is no longer covered by signatures: gnolang#6173
renders the signed fee as {amount, gas}, so a relayer or proposer could
flip it on a signed tx. Rather than sign it (which Ledger's fee
allowlist would then refuse), remove it.

The per-message path already covers every message after the one that
calls PayStorage, because PayStorageInfo is shared across the tx, so a
sponsor only has to come first. This removes the wire field, the two
ante guards, the txCaller/sponsorStorage context fields, the
accumulator, ProcessStorageDepositFromDiffs, the end-of-tx storage
settlement and the EnablePackage special case.

Also drop docs/design/realm-gas-sponsorship-hld.md: the ADRs are the
record, and the HLD had drifted from the code. Point the use-case test
at p/nt/grc20/v0, where master moved grc20.
PayStorage redirected the payer for storage growth in every realm the
tx touched, while a released deposit refunds whoever frees the bytes.
Any other message in a sponsored tx could therefore grow a realm it
controls on the sponsor's budget and, in a second sponsored tx, free it
and collect the deposit: a zero-cost drain of up to maxDeposit per tx,
whatever the sponsor checks before paying. Reproduced: the signer gained
478700ugnot across two 0-fee txs while the sponsor lost the deposit.

Growth in the sponsor's own realm is the only growth it can control, so
it is the only growth it pays for; everything else stays on the caller.
With a single sponsored path, the per-realm SponsorFunded map collapses
into SpentDeposit, which is now the net deposit the sponsor has locked
in its own storage during the tx.

paystorage_other_realm_not_sponsored.txtar pins the drain;
paystorage_sponsor_grow_free_no_drain.txtar now grows and frees the
sponsor's own storage.
…only in its own messages

PayGas(maxFee, rlm) and PayStorage(maxDeposit, rlm) now take the paying
realm the way banker.NewBanker does: rlm must be current, and the native
refuses anything but a top-level /r/ realm (packages, MsgRun's ephemeral
realm, sub-realm tokens). rlm comes last because a realm-typed first
parameter makes a function crossing. This replaces the check that the
function two frames up was declared in the current realm, which
hard-coded the VM's frame layout and let a confused deputy through: a
realm exporting a plain PayGas helper and a callback runner could be
made to pay by passing the helper as the callback.

The natives only record the commitment. The balance pre-checks are gone:
settlement is authoritative, the bank checks a debit before writing, and
CheckTx dry-runs settlement, while the pre-checks read the realm's whole
balance with GetCoins (and settlement did so on an infinite meter). The
bank error now surfaces as is. Outside a sponsored tx both natives are
no-ops instead of panicking, so a sponsor realm can run under gno test.

PayStorage now applies only in a message whose entry is the sponsor (a
MsgCall to it, or its init in MsgAddPackage), for its own storage.
Storage is charged to the realm that allocated it and refunded to
whoever frees it, so with own-storage alone another realm could keep an
object the sponsor allocated, which lands in the sponsor's storage, and
collect the deposit by dropping it (+597800ugnot in the repro). The
commitment is per transaction and lives with the VM keeper's per-tx
state, so tm2 no longer carries PayStorageInfo.

New txtars: paygas_realm_capability, paystorage_entry_only. The
filetest zrealm_paygas pins the no-op outside a transaction.
Outside DeliverTx, SetGasMeter only hands out an infinite meter at height
0, and the ante already rejects 0-fee txs there before reaching it. The
override replaced an identical meter.
Capability API, PayStorage limited to the sponsor's own storage in its
own messages and why, no pre-checks, no SponsorStorage flag, and the
consensus note that gasUsed now sets a balance debit. The HLD they
pointed to is gone.
AllowZeroFeeTxs was configured twice, as an AnteOptions field and a
BaseApp flag, for the same local policy. BaseApp.CheckTx already decides
the admission mode, so it now refuses a 0-fee tx there when the validator
has not opted in, before running anything, and the ante option is gone.
The credit-window condition CheckTx and runTx both spelled out is now
isSponsoredTx.

Also drop PayGasInfo.RealmPkgPath (written, never read), comments left
over from SponsorStorage, and the single-use genesis exemption on the
closed-window zero-fee rule, so a 0-fee genesis tx is rejected as on
master. TestCheckTxZeroFeeRejectedWhenNotOptedIn covers the opt-out path,
which had no test.
…document PayStorage's limits

The closed-window zero-fee rejection ran before the GasWanted check, so a
0-fee tx with GasWanted above Block.MaxGas recorded InsufficientFee where
master records InvalidGasWanted, and the error is part of the block
results. It now sits right after ValidateBasic, where ValidateBasic used
to reject these, so with the feature off every tx gets master's checks in
master's order.

Docs, from a second review round:
- PayStorage charges everything its messages add to the sponsor's
  storage, whoever's code adds it (for example an object a hook keeps),
  so a sponsored function must not run code it does not control. Refunds
  in its storage are netted against what it locked earlier in the tx,
  whoever paid.
- Invalid arguments to PayGas/PayStorage always panic; only a valid call
  is a no-op outside a sponsored tx.
- tm2 ADR: a proposer can include a 0-fee tx that never calls PayGas; it
  fails but burns up to the credit window for free, so the mitigation for
  failing sponsored txs has to act at delivery. Note the ~3.5K gas a tx
  pays to load the larger chain/runtime.
- Stale SponsorStorage and "CheckTx never runs handlers" comments.
From a third review round, which found no code defect:
- With the window closed, only a well-formed 0-fee tx gets master's error
  at master's point; a malformed one fails ValidateBasic's later checks.
- MaxGasCreditPerTx is read from the copy stored at InitChain, so it is
  fixed at genesis; it is not a governance kill switch.
- A proposer can include a 0-fee tx that skips PayGas (it fails, unpaid).
- Refunds in the sponsor's storage are netted against what it locked in
  the tx, whoever paid; genesis delivery is exempt from sponsorship;
  storage over budget fails in the handler, not the EndTxHook.
- Stale headers in seven sponsorship txtars.
- Integration harness: open the credit window in the txtar runner, capped
  by Block.MaxGas, instead of in the shared genesis defaults. A caller that
  lowered MaxGas below the default window got an invalid genesis; this
  broke contribs/gpao's TestRunAdoptsTheCeilingOnceTheChainAnswers.
- Lint: run the use-case subtests in parallel (tparallel), and apply the
  go fix modernizations CI asks for.
- Tests asked for in review: Tx.ValidateBasic's fee cases (the empty coin
  a sponsored tx carries is accepted, malformed fees are not), and
  ValidateConsensusParams' MaxGasCreditPerTx bounds.
- processStorageDeposit's doc: a refund goes to the storage fee collector
  while ugnot is a restricted denom.
The two rows were placeholders copied from chain.packageAddress. Both natives
match the pkgpath regexp twice (IsRealmPath, then IsGnoRunPath inside
DerivePkgCryptoAddr) before hashing, so they cost 2.2x packageAddress per
byte, and the placeholder charged about half of the measured cost at the
256-byte pkgpath limit.

- calibrate: benches for both natives on their committing branch, over valid
  realm paths of 12 to 256 bytes in the regexp's worst-case shape
  (gno.land/r/a/a/...); NATIVE_SPECS rows for gen_native_table.py; and the
  snapshot the rows were fitted from, the fastest of 40 interleaved samples
  per size on a loaded Apple M1 Pro.
- native_gas.go: payGas 1097 + 35448/1024 per byte, payStorage 1091 +
  35576/1024, both previously 552 + 15201/1024. The fitter reproduces both rows
  from the snapshot.
- txtars: five sponsor balances re-derived. Every sponsored tx in them now
  uses exactly the predicted extra gas: +2035 for a 24-byte sponsor path,
  +2117 for a 26-byte one.
- ADR: the native gas entries are measured, no longer pending.
The payGas/payStorage gas rows are fitted on paths the pkgpath regexp
matches. A sub-realm token ("host#sub") passes rlm.IsCurrent(), reaches
the natives and fails the regexp, and a failing match backtracks: at the
256-byte limit it cost about 1.5x the matching path the row charges for.

Both natives now refuse a '#' path before the regexp, so every regexp
run they can reach is a match. Same bench, before and after the check,
256 bytes: the refusal went from 15.8-20.4us to 0.26-0.37us, against
9.7-10.0us for the matching path.

BenchmarkNative_Runtime_PayGasSubRealm_256 pins that (not fitted; the
row is charged before the native branches), and the bench comment and
the ADR no longer call the matched shape the overall worst case.

@davd-gzl davd-gzl left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review, claude-opus-5-5, standard review, skills · overview · claims · Status: REQUEST CHANGES

  • Nit: OutOfGasLog receives the credit window as gas wanted, so a sponsored tx stopped by the lower PayGas limit reports a gas used below its stated gas wanted.

Comment thread tm2/pkg/std/tx.go Resolved
Comment thread gno.land/pkg/sdk/vm/keeper.go Resolved
Comment thread gno.land/pkg/sdk/vm/keeper.go Resolved
r"BenchmarkNative_Runtime_GetRealm_(\d+)-\d+\s+\d+\s+([\d.]+)\s+ns/op"),
# Bench name encodes len(pkgPath); the paths are valid realm paths in the
# regexp's costliest shape, from the shortest (12) to the 256-byte limit.
("chain/runtime", "payGas", 0, "LenString",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the payGas and payStorage rows floor the base to the 12-byte sample and then charge those 12 bytes again through the slope, so the shortest path is overcharged.

numbers
python3 gen_native_table.py sponsorship_bench_m1pro_arm64.txt --no-plot --md-out /dev/null --go-out /dev/null
Base 1097 (ns at N=12); a 12-byte path costs 1097 + 35448*12/1024 = 1512

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left as is: fit_linear floors the base at the smallest sample for every LenString row in the table (master's fitter), so they all err high by slope × min size; here that is a constant ~415 gas. Changing it for these two rows would make them the only ones fitted differently, and changing the fitter moves every row. It fits with the reference-hardware re-measure.

🤖 Addressed by Claude Code

Comment thread tm2/pkg/sdk/auth/ante.go Resolved
Comment thread tm2/pkg/sdk/baseapp.go Resolved
…yer, tests)

- gnogenesis verify rejects a zero-fee genesis tx while the genesis credit
  window is closed: ValidateBasic accepts it, the node's ante does not, and
  a rejected genesis tx stops InitChain.
- Storage events say who paid: StorageDepositEvent.Payer is the realm that
  paid through PayStorage, StorageUnlockEvent.SponsorRefund the part
  returned to it. Both are omitted otherwise, so unsponsored events encode
  as before (encoding/json, amino JSON and binary). gnokey counts only the
  signer's share as STORAGE FEE and TOTAL TX COST, prints the sponsor's as
  SPONSORED STORAGE, and prints 0ugnot rather than a blank for a zero cost.
- The out-of-gas log names the PayGas limit when it is below the window
  reported as gas wanted.
- CheckTx turns a decode panic into ErrTxDecode.
- Tests: a 0-fee addpkg sponsored by its own init; CheckExecute drops
  message writes; the window, not gas-wanted, bounds a 0-fee tx; the realm
  pays nothing in a fee-paying tx; pinned costs in the cost comparison,
  multi-message and grow/free fixtures; each use-case pattern runs at its
  own window instead of 30M.
- ADRs: a sponsored tx reserves the whole window in block packing; the
  event fields; the genesis verify check; measured GRC20 cost.
@omarsy

omarsy commented Oct 5, 2026

Copy link
Copy Markdown
Member Author

@davd-gzl review-body nit (OutOfGasLog with the window as gas wanted): fixed in ba63646. The log now names the meter's limit when PayGas lowered it below the window; TestOutOfGasLogReportsPayGasLimit pins it. The other points are answered in their threads.

🤖 Addressed by Claude Code

Comment thread tm2/pkg/sdk/auth/ante.go Outdated
// client value (which can be 0) would let a proposer overfill the block.
reportedGasWanted := tx.Fee.GasWanted
if isZeroFeeTx {
reportedGasWanted = consParams.Block.MaxGasCreditPerTx

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following davd-gzl's thread on this line: a sponsored tx reserves the whole credit window in block packing and its sponsor pays only for gas used, so ten sponsored calls fill a 100M block for 6,940ugnot against 100,000ugnot in fees.

repro

The test sets MaxGas to the test4/test5 value of 100M and the window to the recommended 10M, and reuses the mempool wiring of sponsorship_load_test.go.

# from a local clone of gnolang/gno:
gh pr checkout 5382 -R gnolang/gno
cat > gno.land/pkg/gnoland/zz_block_reservation_test.go <<'EOF'
package gnoland

// Sponsored txs reserve the whole credit window when the mempool packs a block,
// but their sponsor pays only for the gas they use. With the window the PR
// recommends (10M) and the 100M block of the test4/test5 genesis, ten cheap
// sponsored txs fill the reap budget, and a fee-paying tx submitted after them
// waits for the next block. Reserving that block costs the sponsor what ten
// PayGas calls use, not what the block holds.
//
// Uses setupLoadEnv's mempool wiring from the PR's sponsorship_load_test.go.
// Current expectation (head ba63646a): the reaped block holds the 10 sponsored
// txs and not the fee-paying one, and the sponsor pays for under 10% of the
// block. Once the sponsor pays for what it reserved, the attack costs what
// fees cost; then flip the two require lines marked "fixed" if packing also
// changes.

import (
	"testing"
	"time"

	"github.com/stretchr/testify/require"

	"github.com/gnolang/gno/gno.land/pkg/sdk/vm"
	"github.com/gnolang/gno/gnovm/pkg/gnolang"
	"github.com/gnolang/gno/tm2/pkg/amino"
	abci "github.com/gnolang/gno/tm2/pkg/bft/abci/types"
	bft "github.com/gnolang/gno/tm2/pkg/bft/types"
	"github.com/gnolang/gno/tm2/pkg/crypto"
	"github.com/gnolang/gno/tm2/pkg/crypto/ed25519"
	"github.com/gnolang/gno/tm2/pkg/db/memdb"
	"github.com/gnolang/gno/tm2/pkg/sdk"
	"github.com/gnolang/gno/tm2/pkg/std"
)

const reservationSponsor = `package cheap

import "chain/runtime"

func Sponsor(cur realm) string {
	runtime.PayGas(1000000, cur)
	return "ok"
}
`

func TestSponsoredTxsReserveWindowPayGasUsed(t *testing.T) {
	const (
		maxGas  = 100_000_000 // test4/test5 genesis Block.MaxGas
		window  = 10_000_000  // the PR's recommended MaxGasCreditPerTx
		nSpons  = 10
		sponsor = "gno.land/r/attacker/cheap"
	)

	opts := TestAppOptions(memdb.NewMemDB())
	opts.AllowZeroFeeTxs = true
	app, err := NewAppWithOptions(opts)
	require.NoError(t, err)
	bapp := app.(*sdk.BaseApp)

	deployer := crypto.AddressFromPreimage([]byte("resv_deployer"))
	sponsorAddr := gnolang.DerivePkgCryptoAddr(sponsor)
	st := DefaultGenState()
	st.Balances = []Balance{
		{Address: deployer, Amount: []std.Coin{{Amount: 1e15, Denom: "ugnot"}}},
		{Address: sponsorAddr, Amount: []std.Coin{{Amount: 1e12, Denom: "ugnot"}}},
	}
	keys := make([]ed25519.PrivKeyEd25519, nSpons+1) // last one is the honest fee payer
	for i := range keys {
		keys[i] = ed25519.GenPrivKey()
		st.Balances = append(st.Balances, Balance{Address: keys[i].PubKey().Address(), Amount: []std.Coin{{Amount: 1e12, Denom: "ugnot"}}})
	}
	st.Txs = []TxWithMetadata{{Tx: mustDeployTx(deployer, sponsor, "cheap.gno", reservationSponsor)}}

	bp := defaultBlockParams()
	bp.MaxGas = maxGas
	bp.MaxGasCreditPerTx = window
	const chainID = "resv"
	r := bapp.InitChain(abci.RequestInitChain{Time: time.Now(), ChainID: chainID, ConsensusParams: &abci.ConsensusParams{Block: bp}, AppState: st})
	require.True(t, r.IsOK(), "InitChain: %v", r)
	bapp.BeginBlock(abci.RequestBeginBlock{Header: &bft.Header{ChainID: chainID, Height: 1}})
	bapp.EndBlock(abci.RequestEndBlock{})
	bapp.Commit()

	sign := func(i int, fee std.Fee) []byte {
		q := bapp.Query(abci.RequestQuery{Path: "auth/accounts/" + keys[i].PubKey().Address().String()})
		require.True(t, q.IsOK())
		var acc GnoAccount
		require.NoError(t, amino.UnmarshalJSON(q.Data, &acc))
		tx := std.Tx{Msgs: []std.Msg{vm.NewMsgCall(keys[i].PubKey().Address(), nil, sponsor, "Sponsor", nil)}, Fee: fee}
		sb, err := tx.GetSignBytes(chainID, acc.GetAccountNumber(), 0)
		require.NoError(t, err)
		sig, err := keys[i].Sign(sb)
		require.NoError(t, err)
		tx.Signatures = []std.Signature{{PubKey: keys[i].PubKey(), Signature: sig}}
		return amino.MustMarshal(tx)
	}

	env := &loadEnv{app: bapp, chainID: chainID}
	mp, done := env.mempool(t)
	defer done()

	for i := 0; i < nSpons; i++ {
		var res abci.ResponseCheckTx
		require.NoError(t, mp.CheckTx(sign(i, std.Fee{GasWanted: window}), func(r abci.Response) { res = r.(abci.ResponseCheckTx) }))
		require.True(t, res.IsOK(), "sponsored %d: %v %s", i, res.Error, res.Log)
	}
	honest := sign(nSpons, std.Fee{GasWanted: 2_000_000, GasFee: std.Coin{Denom: "ugnot", Amount: 1_000_000}})
	var hres abci.ResponseCheckTx
	require.NoError(t, mp.CheckTx(honest, func(r abci.Response) { hres = r.(abci.ResponseCheckTx) }))
	require.True(t, hres.IsOK(), "honest: %v %s", hres.Error, hres.Log)

	block := mp.ReapMaxBytesMaxGas(-1, maxGas)
	honestReaped := false
	for _, tx := range block {
		if string(tx) == string(honest) {
			honestReaped = true
		}
	}

	balance := func() int64 {
		q := bapp.Query(abci.RequestQuery{Path: "bank/balances/" + sponsorAddr.String()})
		require.True(t, q.IsOK())
		var coins std.Coins
		require.NoError(t, amino.UnmarshalJSON(q.Data, &coins))
		return coins.AmountOf("ugnot")
	}
	before := balance()
	bapp.BeginBlock(abci.RequestBeginBlock{Header: &bft.Header{ChainID: chainID, Height: 2}})
	var used int64
	for _, tx := range block {
		d := bapp.DeliverTx(abci.RequestDeliverTx{Tx: tx})
		require.True(t, d.IsOK(), "deliver: %v %s", d.Error, d.Log)
		used += d.GasUsed
	}
	bapp.EndBlock(abci.RequestEndBlock{})
	bapp.Commit()
	paid := before - balance()

	t.Logf("pending: %d sponsored (GasWanted reported %d each) + 1 fee-paying", nSpons, window)
	t.Logf("reaped under MaxGas=%d: %d txs, fee-paying tx included: %v", maxGas, len(block), honestReaped)
	t.Logf("gas the block actually used: %d (%.1f%% of MaxGas); sponsor paid %dugnot", used, 100*float64(used)/maxGas, paid)

	require.Len(t, block, nSpons)           // fixed: require.Len(t, block, nSpons+1)
	require.False(t, honestReaped)          // fixed: require.True(t, honestReaped)
	require.Less(t, used*10, int64(maxGas)) // the reserved block runs under 10% full
}
EOF
(cd gno.land && go test ./pkg/gnoland/ -run TestSponsoredTxsReserveWindowPayGasUsed -v -count=1)
rm gno.land/pkg/gnoland/zz_block_reservation_test.go

Ten sponsored txs fill the block's packing budget, and the fee-paying tx sent after them waits for the next block:

pending: 10 sponsored (GasWanted reported 10000000 each) + 1 fee-paying
reaped under MaxGas=100000000: 10 txs, fee-paying tx included: false
gas the block actually used: 6932210 (6.9% of MaxGas); sponsor paid 6940ugnot
--- PASS: TestSponsoredTxsReserveWindowPayGasUsed (0.65s)

Comment thread tm2/pkg/sdk/auth/ante.go
tx.Fee.GasWanted, consParams.Block.MaxGas,
),
))
isZeroFeeTx := tx.Fee.GasFee.IsZero() && consParams.Block.MaxGasCreditPerTx > 0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No sponsor can pay for a key that never received a coin: the ante refuses a signer without an account before any realm runs, and that key is the onboarding case the ADR opens with.

repro

The test sends the same 0-fee call to a realm that calls PayGas, once from a key with no account and once from a key holding 1ugnot.

# from a local clone of gnolang/gno:
gh pr checkout 5382 -R gnolang/gno
cat > gno.land/pkg/gnoland/zz_fresh_account_test.go <<'EOF'
package gnoland

// A signer with no on-chain account cannot send a sponsored tx: the ante
// looks the signer up before any message runs, so a brand-new key is refused
// even though a realm would pay. One ugnot from anyone creates the account
// and unlocks the same tx.
//
// Current expectation (head ba63646a): "no account" is refused with
// UnknownAddressError, "account holding 1ugnot" is admitted.
// If sponsorship is meant to onboard a key that never received a coin, flip
// wantOK for "no account" to true.

import (
	"testing"
	"time"

	"github.com/stretchr/testify/require"

	"github.com/gnolang/gno/gno.land/pkg/sdk/vm"
	"github.com/gnolang/gno/gnovm/pkg/gnolang"
	"github.com/gnolang/gno/tm2/pkg/amino"
	abci "github.com/gnolang/gno/tm2/pkg/bft/abci/types"
	bft "github.com/gnolang/gno/tm2/pkg/bft/types"
	"github.com/gnolang/gno/tm2/pkg/crypto"
	"github.com/gnolang/gno/tm2/pkg/crypto/ed25519"
	"github.com/gnolang/gno/tm2/pkg/db/memdb"
	"github.com/gnolang/gno/tm2/pkg/errors"
	"github.com/gnolang/gno/tm2/pkg/sdk"
	"github.com/gnolang/gno/tm2/pkg/std"
)

const freshAccountSponsor = `package paymaster

import "chain/runtime"

func Sponsor(cur realm) string {
	runtime.PayGas(5000000, cur)
	return "sponsored"
}
`

func TestSponsoredTxNeedsExistingAccount(t *testing.T) {
	cases := []struct {
		name    string
		balance []std.Coin // nil: the key never appears on chain
		wantOK  bool
	}{
		{"no account", nil, false}, // fixed expectation: true
		{"account holding 1ugnot", []std.Coin{{Denom: "ugnot", Amount: 1}}, true},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			opts := TestAppOptions(memdb.NewMemDB())
			opts.AllowZeroFeeTxs = true
			app, err := NewAppWithOptions(opts)
			require.NoError(t, err)
			bapp := app.(*sdk.BaseApp)

			deployer := crypto.AddressFromPreimage([]byte("fresh_deployer"))
			priv := ed25519.GenPrivKey()
			addr := priv.PubKey().Address()

			st := DefaultGenState()
			st.Balances = []Balance{
				{Address: deployer, Amount: []std.Coin{{Amount: 1e15, Denom: "ugnot"}}},
				{Address: gnolang.DerivePkgCryptoAddr("gno.land/r/fresh/paymaster"), Amount: []std.Coin{{Amount: 1e14, Denom: "ugnot"}}},
			}
			if tc.balance != nil {
				st.Balances = append(st.Balances, Balance{Address: addr, Amount: tc.balance})
			}
			st.Txs = append(st.Txs, TxWithMetadata{
				Tx: mustDeployTx(deployer, "gno.land/r/fresh/paymaster", "paymaster.gno", freshAccountSponsor),
			})

			bp := defaultBlockParams()
			bp.MaxGasCreditPerTx = 2_000_000
			r := bapp.InitChain(abci.RequestInitChain{
				Time: time.Now(), ChainID: "fresh",
				ConsensusParams: &abci.ConsensusParams{Block: bp}, AppState: st,
			})
			require.True(t, r.IsOK(), "InitChain: %v", r)
			bapp.BeginBlock(abci.RequestBeginBlock{Header: &bft.Header{ChainID: "fresh", Height: 1}})
			bapp.EndBlock(abci.RequestEndBlock{})
			bapp.Commit()

			var accNum uint64
			if q := bapp.Query(abci.RequestQuery{Path: "auth/accounts/" + addr.String()}); q.IsOK() && len(q.Data) > 0 && string(q.Data) != "null" {
				var acc GnoAccount
				require.NoError(t, amino.UnmarshalJSON(q.Data, &acc))
				accNum = acc.GetAccountNumber()
			}

			tx := std.Tx{
				Msgs: []std.Msg{vm.NewMsgCall(addr, nil, "gno.land/r/fresh/paymaster", "Sponsor", nil)},
				Fee:  std.Fee{GasWanted: 2_000_000, GasFee: std.Coin{}},
			}
			sb, err := tx.GetSignBytes("fresh", accNum, 0)
			require.NoError(t, err)
			sig, err := priv.Sign(sb)
			require.NoError(t, err)
			tx.Signatures = []std.Signature{{PubKey: priv.PubKey(), Signature: sig}}

			res := bapp.CheckTx(abci.RequestCheckTx{Tx: amino.MustMarshal(tx)})
			t.Logf("%-24s CheckTx error=%T log=%.120q", tc.name, res.Error, res.Log)
			if tc.wantOK {
				require.True(t, res.IsOK(), "%v %s", res.Error, res.Log)
				return
			}
			require.IsType(t, std.UnknownAddressError{}, errors.Cause(res.Error))
		})
	}
}
EOF
(cd gno.land && go test ./pkg/gnoland/ -run TestSponsoredTxNeedsExistingAccount -v -count=1)
rm gno.land/pkg/gnoland/zz_fresh_account_test.go

One ugnot from anyone is the difference between refused and admitted:

no account               CheckTx error=std.UnknownAddressError
account holding 1ugnot   CheckTx error=<nil> log="msg:0,success:true,log:,events:[]"
--- PASS: TestSponsoredTxNeedsExistingAccount (0.67s)

@davd-gzl davd-gzl left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review, claude-opus-5-5, standard review, skills · overview · claims · Status: APPROVE

  • Nit: the ante's out-of-gas recover passes tx.Fee.GasWanted to OutOfGasLog and to res.GasWanted. A 0-fee tx that runs out of gas in the ante reports its declared gas wanted, often 0, rather than the credit window it ran on.
  • Related suggestion: a replayed 0-fee tx keeping its source height is marked sponsored and gets an unmetered gas meter in source replay mode. Settlement then debits the sponsoring realm from the new VM's gas at the genesis price, so its balance after a hard fork differs from the source chain's. No chain with sponsorship enabled exists yet.

// carries one, but the node's ante still rejects a zero-fee tx
// unless the credit window is open, and a rejected genesis tx
// stops InitChain.
if bp := genesis.ConsensusParams.Block; tx.Tx.Fee.GasFee.IsZero() &&

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: the check fires only when MaxGasCreditPerTx is 0, so with the window open a 0-fee genesis tx at a replayed height passes verify. That tx then fails as sponsored without PayGas and halts InitChain.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left as is: verify can't tell without running a replayed 0-fee tx whether it calls PayGas, and even a replay that did can fail if the sponsor can't cover the re-priced debit (now in the tm2 ADR's hard-fork replay bullet). What b79a3d4 changes here: verify skips txs marked Failed, as the node does; its zero-fee check rejected those.

🤖 Addressed by Claude Code

Comment thread gno.land/pkg/gnoland/app.go Resolved
Comment thread gnovm/stdlibs/chain/runtime/paygas.go Resolved
Comment thread tm2/pkg/sdk/baseapp.go Resolved
- The ante's out-of-gas log names the credit window for a 0-fee tx, not
  its declared gas wanted; out-of-gas logs (ante and runTx) no longer
  tell a 0-fee tx to simulate with more gas, which cannot help it.
- PayGas computes the gas maxFee buys in 128 bits and saturates instead
  of panicking on overflow, which only showed on chain: PayGas returns
  before this arithmetic under gno test.
- Settlement also runs under Simulate, as a dry run like admission, so a
  wallet's estimate shows a sponsor that cannot pay. A tx failed after
  its messages (PayGas not called, a settlement error) keeps the reason
  in its log; ABCIError keeps only the error type.
- gnogenesis verify skips txs marked Failed, as the node does.
- The txtar runner caps the window with min(): MaxGas there is never -1.
- Docs: the settlement transfer is unmetered, and why (app.go, tm2 ADR);
  EndTxHook runs in Deliver, CheckExecute and Simulate; the hard-fork
  replay note covers both replay meters and a failing re-priced debit.
- Tests: TestSettlementDryRuns (admission and Simulate);
  paygas_insufficient_balance reaches admission again (-simulate skip)
  and adds a -simulate only dry run; TestPayGasLargeMaxFee; ante and
  runTx hint pins.
@omarsy

omarsy commented Oct 15, 2026

Copy link
Copy Markdown
Member Author

@davd-gzl on the review summary:

  • Ante out-of-gas: fixed in b79a3d4. The log now names the credit window for a 0-fee tx (TestAnteHandlerZeroFeeOutOfGasReportsWindow). baseapp drops an aborted ante's GasWanted for every tx, so the log is what reaches clients. Out-of-gas logs, in the ante and in runTx, also stop telling a 0-fee tx to "simulate with consensus maximum", which cannot help it.
  • Hard-fork replay: this was already an open item. The tm2 ADR now spells out both replay meters (the credit window in strict replay; an infinite meter in source-gas replay mode, which gnogenesis fork generate writes by default), and that a re-priced debit the sponsor can't cover fails the tx, which stops InitChain by default.

🤖 Addressed by Claude Code

…-tm2-architecture

# Conflicts:
#	tm2/pkg/sdk/auth/ante.go
gnolang#6334's ADR said CheckTx runs no messages, which is no longer true for a
sponsored 0-fee tx's first admission (RunTxModeCheckExecute). Note the
exception there, and in the sponsorship ADR that CheckExecute runs the
same VM paths while block sync holds commits: nothing on them iterates
the database, and an iterator refused during CheckTx fails only that tx.
…-tm2-architecture

# Conflicts:
#	tm2/pkg/sdk/baseapp.go
gnolang#6406 made runTx put the reason for a decode failure in the log, through
txDecodeFailure, since ABCIError keeps only the error's type. CheckTx in this
PR decodes before runTx, to pick the run mode, and still set the error alone,
so an undecodable tx lost its reason at admission only. Use the same helper,
and pin it in the test that already covers DeliverTx.
gnolang#6399 leaves a master signature's zero SessionAddr off the wire, so every
master-signed tx is 43 bytes smaller and uses 430 less gas, as its own pins
show. Each sponsored tx here costs its sponsor 0 or 1 ugnot less after rounding
up. Re-derive the five sponsorship fixtures that pin exact gas or balances.

Also correct paygas_cost_comparison's comment: the gap between calls 2 and 3 is
the wrapper's cost minus the ante's fee deduction a 0-fee tx skips, not the
wrapper's cost alone.

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

a/ux User experience, product, marketing community, developer experience team 📖 documentation Improvements or additions to documentation 🤝 contribs 📦 🌐 tendermint v2 Issues or PRs tm2 related 📦 ⛰️ gno.land Issues or PRs gno.land package related 📦 🤖 gnovm Issues or PRs gnovm related review/triage-pending PRs opened by external contributors that are waiting for the 1st review 📄 top-level-md

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

6 participants