Skip to content

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

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

feat: realm transaction sponsorship (PayGas + PayStorage)#5382
omarsy wants to merge 19 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 native functions that let a realm pay a user's gas fees and storage deposits from its own balance — enabling truly gasless transactions (0 gnot in the user's wallet). The realm decides during execution whether to sponsor, so it can run arbitrary logic first (collect payment in another token, check a whitelist, rate-limit, …).

How it works

  • runtime.PayGas(maxFee) — realm pays gas, capped at maxFee (ugnot). Takes effect only in a 0-fee, credit-window tx (a no-op in a normal fee-paying tx). A 0-fee tx that never calls PayGas is rejected in both CheckTx and DeliverTx.
  • runtime.PayStorage(maxDeposit) — realm pays storage deposits, capped at maxDeposit. Independent of PayGas (either, both, or neither).
  • Fee.SponsorStorage — defers storage deposits to end-of-tx so PayStorage can cover storage from all messages in a multi-message tx. Off by default (per-message behavior, backward compatible).
  • Call rules: realm-only, the function calling it must be defined in the paying realm, once per tx.
  • Consensus/config: new MaxGasCreditPerTx block param (free gas a 0-fee tx may use before PayGas) and a per-validator AllowZeroFeeTxs mempool opt-in. Settlement uses the auth module's dynamic gas price; the derived gas limit is capped at the credit window.

Docs

  • Design: docs/design/realm-gas-sponsorship-hld.md
  • ADR: gno.land/adr/pr5382_realm_transaction_sponsorship.md

Test plan

21 txtar integration tests: happy paths (basic, subrealm, method, multi-msg, MsgRun, normal fee), rejections (not called, disabled, insufficient balance, non-realm, double call, maxFee=0, budget exceeded), security (cross-realm callback, revert on failure, OOG), and storage sponsorship (single-msg, budget exceeded, multi-msg SponsorStorage).


Rebased on master; developed and reviewed with AI assistance (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)
@omarsy omarsy requested a review from thehowl July 4, 2026 15:18
@omarsy omarsy marked this pull request as ready for review July 4, 2026 15:18
@Gno2D2 Gno2D2 added the review/triage-pending PRs opened by external contributors that are waiting for the 1st review label Jul 4, 2026
omarsy added 6 commits July 4, 2026 22:51
…sion

Review fixes for the realm transaction sponsorship feature.

Settlement (gno.land, tm2):
- Charge a gas sponsor even when the tx fails at DeliverTx, matching normal
  fee txs (fee taken in the ante, kept on failure). EndTxHook now runs in a
  failure mode after msg writes are reverted, so an in-tx self-drain is undone
  before the charge and the sponsor's gas debit persists.
- Run settlement on a fresh infinite gas meter so it neither trips OOG on a
  tightened credit window nor inflates the tx's reported GasUsed; the gno-store
  commit stays on the real meter, so normal-tx gas accounting is unchanged.
- EndTxHook returns a typed error instead of panicking. A SponsorStorage tx
  that grows storage without a covering PayStorage now fails with a typed
  ErrUnauthorized rather than a recovered ErrInternal.
- Route sponsored gas fees to the governable p:fee_collector param (the same
  address the ante uses), not the hardcoded default preimage.

0-fee admission / consensus (tm2):
- Bound Block.MaxGasCreditPerTx by Block.MaxGas in ValidateConsensusParams so
  one sponsored tx cannot be sized larger than a whole block.
- Report GasWanted = credit window for 0-fee txs so mempool block packing is
  bounded by real worst-case gas instead of the client-supplied value.
- Skip full-VM re-execution of 0-fee txs on mempool recheck (CPU amplification).
- Tighten ValidateBasic to reject a malformed zero-amount fee while still
  accepting the canonical zero fee.

gnovm / storage:
- PayGas folds a PayStorage commitment into its balance check only when the
  same realm made it (mirror of PayStorage's existing guard).
- Deferred storage refund uses the proportional deposit formula, matching the
  per-message path, so a governance StoragePrice change cannot panic settlement
  or orphan deposits.

Tests: adds a baseapp unit test covering the EndTxHook committed flag and the
charge-on-failure path (only reachable via force-inclusion / check-deliver
divergence, so not exercised by the txtar suite).
0-fee (PayGas-sponsored) txs were admitted to the mempool via
RunTxModeSimulate, whose context wrapping discards the ante handler's
account sequence increment. That capped each sender to one in-flight
sponsored tx per block and let same-sequence copies all pass CheckTx and
then deterministically fail in-block.

Add RunTxModeCheckExecute: it executes the tx's messages during CheckTx
(to validate that a realm called PayGas) but persists the ante writes —
notably the sequence increment — to checkState when the tx is admitted,
while discarding the message writes. A rejected tx flushes nothing, so
its sequence is not consumed and it can be resubmitted.

Because the new mode is not Simulate, signatures are verified normally,
so the VerifySignaturesContextKey override that was needed for the
Simulate-based admission is removed.

Adds a baseapp regression test (TestCheckTxSponsoredSequencePersists)
that admits successive sponsored txs from one account and asserts the
ante writes persist across CheckTx calls within a block.
…sInfo codec

Consolidate the 0-fee feature gate so a production node can actually enable it:
- Move AllowZeroFeeTxs from the (dead, never-read) tm2 MempoolConfig to the
  gno.land AppConfig [application] section, next to MinGasPrices — the analogous
  per-validator mempool policy that is also enforced in the ante.
- Wire it through gnoland.NewApp so `gnoland start` reads it end-to-end into both
  BaseApp.allowZeroFeeTxs and AnteOptions.AllowZeroFeeTxs (previously only the
  in-memory/test node constructors set it, so production could not opt in).
- Default remains false; the feature also requires Block.MaxGasCreditPerTx > 0
  (set via genesis).

Remove the unused amino registration of sdk.PayGasInfo: the type is in-process
only (a pointer field on sdk.Context, never serialized), so it needs no codec.
Drops it from the amino package registration, sdk.proto, and the generated
pb3_gen.go marshal code — restoring those files to their pre-feature state. The
PayGasInfo Go struct is unchanged.

Also updates the HLD design doc to reflect the [application] config placement and
the RunTxModeCheckExecute admission path.
Restore the design's original settlement semantics: when a 0-fee sponsored tx
fails at DeliverTx, all state reverts and the realm does NOT pay — end-of-tx
settlement runs only when the tx succeeds. This reverts the charge-on-failure
behavior introduced earlier in this PR.

Charging a sponsor on failure looked symmetric with normal fee txs (which keep
their fee on failure) and closed a narrow "free block gas on a force-included
failing tx" vector. But it reintroduces the griefing attack the atomic
all-or-nothing rule was built to prevent: settling gnot on a path where the
realm's message-side collection reverts (e.g. the USDC it was paid) lets an
attacker engineer a failure to drain the realm's gnot for nothing. The residual
free-execution-on-failure is bounded by the credit window and only reachable by
a block proposer (who can waste their own block anyway) or a rare check/deliver
divergence, so the realm should pay only when the whole tx — collection
included — commits.

Removes the settleFailedTx machinery and the failure-mode EndTxHook path. Keeps
the two independent improvements from that change: EndTxHook returns a typed
error instead of panicking, and success-path settlement runs on an infinite gas
meter (so it cannot trip OOG or inflate reported GasUsed; normal-tx gas
accounting is unchanged).

Updates the HLD §5.6 with the rationale and §5.7 for the CheckExecute admission
path, and adds baseapp tests asserting settlement runs only on success and that
a panic after PayGas reverts all state without charging anyone.
…t, guards, docs

Review fixes from a deep loop-review of the feature.

- PayStorage now no-ops outside 0-fee sponsored txs (add PayStorageInfo.Eligible,
  set it in runTx, early-return in the native), mirroring PayGas. Previously a
  realm exposing an unconditional PayStorage silently paid the storage deposit
  for any caller's writes on ordinary fee-paying txs — a self-drain. (HIGH)
- PayGas rejects a pre-existing PayStorage from a DIFFERENT realm, mirroring
  PayStorage's guard, so the "same realm for both" rule is order-independent.
- Sponsored per-message storage deposit is capped by the realm's PayStorage
  budget, not the caller's DefaultDeposit, so a large sponsored write is no
  longer wrongly rejected (mirrors the deferred ProcessStorageDepositFromDiffs).
- Reject Fee.SponsorStorage on a normal fee-paying tx at the ante, so the mistake
  surfaces at submission (CheckTx) instead of failing opaquely at inclusion.
- Fix the PayGas stdlib doc (the gas limit derives from the dynamic gas price,
  not MinGasPrice) and note the "only in sponsored txs" rule; bump the genesis
  apphash for the stdlib .gno source change.

Tests (each verified to fail without its fix): paystorage_normal_fee_unaffected
(realm balance unchanged on a fee-paying tx that grows storage),
paygas_cross_realm_sponsors (two-realm gas+storage sponsorship rejected), and an
ante unit test for the SponsorStorage-on-fee-tx rejection.
A deferred SponsorStorage tx accumulates all messages' storage diffs and settles
once at end-of-tx, losing per-message caller identity: the refund-only branch can
credit freed storage to only a single tx caller (signerAddrs[0]). On a
multi-signer tx that would misroute a co-signer's storage refund to the first
signer. Reject SponsorStorage on multi-signer txs at the ante so the single tx
caller is unambiguous. (Found via a targeted review of the untested multi-signer
path; MsgRun and MsgAddPackage 0-fee sponsorship were also investigated and found
sound — clean failures and correct payer attribution.)

Also adds the Tendermint2 ADR for the feature (tm2/adr/), covering the
MaxGasCreditPerTx consensus param, the two-gate opt-in, RunTxModeCheckExecute
admission, the success-only settlement hook, GasMeter.SetLimit, the
std.Fee.SponsorStorage wire field, and the reverted charge-on-failure
alternative — the tm2-scoped decisions AGENTS.md requires an ADR for.

Adds an ante unit test for the multi-signer rejection (verified to fail without
the guard).
@moul

moul commented Jul 5, 2026

Copy link
Copy Markdown
Member

[bot] Review finding:

[P1] SponsorStorage=true txs can pass CheckTx without PayStorage, then fail for free in DeliverTx.

SponsorStorage defers storage diffs instead of settling per message in gno.land/pkg/sdk/vm/keeper.go, and the "storage grew but no realm called PayStorage" rejection only happens in the app end hook in gno.land/pkg/gnoland/app.go. But RunTxModeCheckExecute returns before any end hook runs in tm2/pkg/sdk/baseapp.go.

That means a 0-fee tx can set SponsorStorage=true, grow storage, call PayGas, never call PayStorage, and still be admitted to the mempool. In DeliverTx it fails at settlement; the earlier PayGas transfer is inside the tx cache and gets reverted, so nobody pays. This gives bounded but repeatable free CheckTx execution plus failed block inclusion up to MaxGasCreditPerTx.

Suggested fix: make CheckExecute validate the deferred storage sponsorship invariant before returning OK, at least grewStorage => PayStorageInfo.MaxDeposit > 0, and ideally the same budget/balance checks used by settlement against the cached execution result.

… realms

PayGas and PayStorage previously had to be called by the same realm — each
native panicked if the other's commitment came from a different realm. That
guard was not security-necessary: each native already validates its own caller
(creator == payer), and settlement charges each commitment from its own realm's
balance, capped by that realm's own maxFee/maxDeposit. Requiring one realm only
blocked the natural account-abstraction "paymaster" composition (a shared gas
sponsor + an app realm paying its own storage).

Remove both same-realm guards. The balance pre-check now folds the other
commitment ONLY when the SAME realm made it (RealmPkgPath == currentPkgPath);
when a different realm sponsors the other resource, each realm's pre-check
covers only its own commitment — matching the per-realm settlement.

- paygas.go / paystorage.go: drop the mirror guards; make the affordability
  fold same-realm-conditional (overflow guard preserved).
- paygas.gno / paystorage.gno: document the independence. This edits stdlib
  .gno source committed into the genesis MemPackage, so the
  apphash_crossrealm38 expected hash is bumped (encoding-only; behavior
  unchanged).
- paygas_cross_realm_sponsors.txtar: rewritten to assert two-realm
  sponsorship SUCCEEDS, with exact per-realm balances proving neither realm
  is cross-charged; gaspayer is funded below maxFee+maxDeposit so the test
  also catches a fold regression.
- HLD + ADR: document why allowing two realms is safe.
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 📦 🌐 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.

5 participants