feat: realm transaction sponsorship (PayGas + PayStorage)#5382
Conversation
🛠 PR Checks Summary🔴 Pending initial approval by a review team member, or review from tech-staff Manual Checks (for Reviewers):
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) ☑️ Contributor Actions:
☑️ Reviewer Actions:
📚 Resources:Debug
|
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
80e0ead to
e9fa164
Compare
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.
9a8cbe4 to
c82584a
Compare
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)
…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).
|
[bot] Review finding: [P1]
That means a 0-fee tx can set Suggested fix: make CheckExecute validate the deferred storage sponsorship invariant before returning OK, at least |
… 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.
Summary
Realm transaction sponsorship:
runtime.PayGasandruntime.PayStoragenative 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 atmaxFee(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 callsPayGasis rejected in both CheckTx and DeliverTx.runtime.PayStorage(maxDeposit)— realm pays storage deposits, capped atmaxDeposit. Independent ofPayGas(either, both, or neither).Fee.SponsorStorage— defers storage deposits to end-of-tx soPayStoragecan cover storage from all messages in a multi-message tx. Off by default (per-message behavior, backward compatible).MaxGasCreditPerTxblock param (free gas a 0-fee tx may use beforePayGas) and a per-validatorAllowZeroFeeTxsmempool opt-in. Settlement uses the auth module's dynamic gas price; the derived gas limit is capped at the credit window.Docs
docs/design/realm-gas-sponsorship-hld.mdgno.land/adr/pr5382_realm_transaction_sponsorship.mdTest 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-msgSponsorStorage).Rebased on
master; developed and reviewed with AI assistance (Claude Code).