Repository navigation
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)
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
left a comment
There was a problem hiding this comment.
AI review, claude-opus-5-5, standard review, skills · overview · claims · Status: REQUEST CHANGES
- Nit:
OutOfGasLogreceives 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.
| 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", |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
…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.
|
@davd-gzl review-body nit ( 🤖 Addressed by Claude Code |
| // client value (which can be 0) would let a proposer overfill the block. | ||
| reportedGasWanted := tx.Fee.GasWanted | ||
| if isZeroFeeTx { | ||
| reportedGasWanted = consParams.Block.MaxGasCreditPerTx |
There was a problem hiding this comment.
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.goTen 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)
- The mempool packs txs in arrival order and stops at the first one that does not fit.
- A fee payer must offer the block price for every unit reserved, while the sponsor pays
ceil(gasUsed × price). - The block runs 6.9% full, below the 70% target, and the gas price goes down while fee payers queue.
- These txs succeed and pay, so the delivery-time penalty in the open items does not reach them. Charging the sponsor for the reservation closes the gap, and until then it belongs with the preconditions for enabling.
| tx.Fee.GasWanted, consParams.Block.MaxGas, | ||
| ), | ||
| )) | ||
| isZeroFeeTx := tx.Fee.GasFee.IsZero() && consParams.Block.MaxGasCreditPerTx > 0 |
There was a problem hiding this comment.
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.goOne 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)
- An address gets an account only when it receives a coin, and a GRC20 holder has none, since the token's balance lives in the realm.
- The "first interaction" use case funds its signer with 1e12ugnot, and so do the load and bench tests and all 28 txtars.
- The ante cannot simply create the account: the account number is in the signed bytes, and account creation is the one per-signer cost a delivery-time penalty could lean on. State the precondition in the ADR and the
PayGasdoc either way.
davd-gzl
left a comment
There was a problem hiding this comment.
AI review, claude-opus-5-5, standard review, skills · overview · claims · Status: APPROVE
- Nit: the ante's out-of-gas recover passes
tx.Fee.GasWantedtoOutOfGasLogand tores.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() && |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
- 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.
|
@davd-gzl on the review summary:
🤖 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.
Summary
Realm transaction sponsorship:
runtime.PayGasandruntime.PayStoragelet 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).How it works
PayGas(maxFee int64, rlm realm):rlmpays the tx's gas up tomaxFeeugnot. The gas limit becomes whatmaxFeebuys at the current gas price, capped by the credit window. Once per tx; covers every message.PayStorage(maxDeposit int64, rlm realm):rlmpays deposits for its own storage, only in messages that call it (aMsgCallto it, or itsinitinMsgAddPackage), up tomaxDepositper tx. Storage in other realms stays on the user.rlmis a capability, likebanker.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.gno test. A 0-fee tx that never callsPayGasis rejected at CheckTx and at DeliverTx.ceil(gasUsed × LastGasPrice), capped atmaxFee, from the realm to the fee collector. On failure everything reverts and the realm pays nothing, so a realm that collects a token beforePayGascannot be drained by a deliberate failure.Admission (tm2)
Block.MaxGasCreditPerTx(<= Block.MaxGas), the whole gas budget of a 0-fee tx.[application] allow_zero_fee_txs, local mempool policy likeMinGasPrices, checked inBaseApp.CheckTx.RunTxModeCheckExecute: first-time CheckTx of a 0-fee tx runs its messages and dry-runs settlement in a discarded cache, so a tx withoutPayGas, 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.EndTxHook(ctx, result) errorruns only on success, in Deliver and CheckExecute. NewCommitTxHookcommits gno.land's tx store, delivery only. Settlement events are appended after the hook; a failed tx reports no events.Tx.ValidateBasicaccepts 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 verifyapplies the same window check, since a genesis tx the ante rejects stopsInitChain.Storage events and gnokey
A
StorageDepositEventpaid throughPayStoragenames the sponsor inpayer, and aStorageUnlockEventgives insponsor_refundthe 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 asSTORAGE FEE/TOTAL TX COST, prints the sponsor's asSPONSORED STORAGE, and prints0ugnotrather than a blank for a zero cost.Changes since the previous revision
Second davd-gzl round (at ba63646):
PayGascomputes whatmaxFeebuys in 128 bits instead of panicking on overflow, which only showed on chain sincePayGasreturns early undergno test.ABCIErrorkeeps only the error type.gnogenesis verifyskips txs markedFailed, as the node does; its zero-fee check rejected them.Review follow-ups (davd-gzl, at 1f043dd):
gnogenesis verifyrejects a zero-fee genesis tx while the genesis credit window is closed (a default node would panic inInitChainon it).PayGaslimit when it is below the window it reports as gas wanted.CheckTxturns a decode panic intoErrTxDecode.addpkgwhoseinitsponsors 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.Block.MaxGas / MaxGasCreditPerTxof them.Earlier, reviewed again with independent adversarial passes; each finding below was reproduced before being fixed.
paystorage_other_realm_not_sponsored,paystorage_entry_only.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 theEnablePackagespecial case.PayGashelper and a callback runner).GetCoins(cost set by whoever sends the realm denoms), and settlement's ran on an infinite meter.gno testno longer panics on a realm callingPayGas(zrealm_paygas.gno).allow_zero_fee_txsis enforced once, inCheckTx; the opt-out path now has a test.ValidateBasicused to reject, so a well-formed 0-fee tx records the same error as on master.payGascharges 1097 + 35448·len(pkgPath)/1024 gas andpayStorage1091 + 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).docs/design/is not a repo convention; the ADRs are the record).Files
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).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).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).gnogenesis/internal/verify/verify.go(zero-fee genesis tx check).paygas_*,paystorage_*; the txtar runner opens the credit window, capped byBlock.MaxGas), tm2 baseapp/ante unit tests,Tx.ValidateBasicfee cases (tm2/pkg/std/tx_test.go),MaxGasCreditPerTxbounds (tm2/pkg/bft/types/params_test.go),gnovm/tests/files/zrealm_paygas.gno, and measurement testssponsorship_{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).gnovm/cmd/calibrate/native_machine_bench_test.go), theirgen_native_table.pyrows, and the snapshot the rows were fitted from (gnovm/cmd/calibrate/sponsorship_bench_m1pro_arm64.txt).gno.land/adr/pr5382_realm_transaction_sponsorship.md,tm2/adr/pr5382_zero_fee_tx_admission_and_settlement.md; one exception noted intm2/adr/pr6334_deferred_block_sync_writes.md(a sponsored 0-fee tx's first admission runs its messages in CheckTx).Breaking for tm2 embedders:
EndTxHookreturns an error and runs only on success;store.GasMetergainedSetLimit.Open items
PayGason 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 calledPayGas). The mitigation has to act at delivery (e.g. an on-chain penalty on the signer); required before enabling.gasUsednow sets a bank debit, so any cross-node gas difference forks the app hash immediately.gnogenesis fork generatewrites 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 stopsInitChain. Unreachable today.PayStoragecharges 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 onPayStorage). Charging only the sponsor's own code would need the VM to track which realm's code produced each byte.PayGas/PayStoragepanics.payGas/payStoragegas 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