Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions gno.land/pkg/integration/testdata/addpkg_import_testdep_gas.txtar
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Demonstrates the mempackage prod/test storage split's import-time gas win:
# a dependency's _test.gno bytes do NOT inflate the cost of importing it.
#
# depa and depb have byte-identical PRODUCTION code; depb additionally carries a
# large lib_test.gno. usea imports depa, useb imports depb, and usea/useb are
# byte-identical apart from the a<->b in the package name and import path. When
# addpkg type-checks an importer it decodes the imported dependency's stored
# blob; the split stores depb's test file in a pkg:<path>#allbutprod sibling that
# is never decoded here, so importing depb decodes the same bytes as importing
# depa => usea and useb cost the SAME gas.
#
# On the pre-split single-blob layout, depb's mempackage included its _test.gno,
# so type-checking useb decoded those extra bytes and useb cost MORE than usea.
#
# Measured: on this branch usea == useb == 3113401 (equal => the win). On master
# (pre-split) importing depb costs ~+40620 gas more than depa for its _test.gno
# bytes (useb > usea). The split removes that import penalty.
#
# The two GAS USED assertions below MUST stay equal to each other; that equality
# is the regression guard for the split (the absolute value is an ordinary pin).

gnoland start

gnokey maketx addpkg -pkgdir $WORK/depa -pkgpath gno.land/p/demo/depa -gas-fee 1000000ugnot -gas-wanted 5000000 -chainid=tendermint_test test1
gnokey maketx addpkg -pkgdir $WORK/depb -pkgpath gno.land/p/demo/depb -gas-fee 1000000ugnot -gas-wanted 5000000 -chainid=tendermint_test test1

gnokey maketx addpkg -pkgdir $WORK/usea -pkgpath gno.land/p/demo/usea -gas-fee 1000000ugnot -gas-wanted 5000000 -chainid=tendermint_test test1
stdout 'GAS USED:\s+3113401'
gnokey maketx addpkg -pkgdir $WORK/useb -pkgpath gno.land/p/demo/useb -gas-fee 1000000ugnot -gas-wanted 5000000 -chainid=tendermint_test test1
stdout 'GAS USED:\s+3113401'

-- depa/gnomod.toml --
module = "gno.land/p/demo/depa"
gno = "0.9"
-- depa/lib.gno --
package depa

func Hi() string { return "hi" }
-- depb/gnomod.toml --
module = "gno.land/p/demo/depb"
gno = "0.9"
-- depb/lib.gno --
package depb

func Hi() string { return "hi" }
-- depb/lib_test.gno --
package depb

// This _test.gno exists only to add bytes to depb's test files. Under the
// prod/test storage split these bytes live in the pkg:<path>#allbutprod
// sibling and are NOT decoded when another package imports depb, so they do
// not affect importer gas. Padding below makes the size difference vs the
// prod blob substantial and the master-vs-branch delta clearly measurable.
//
// pad: 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123
// pad: 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123
// pad: 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123
// pad: 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123
// pad: 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123
// pad: 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123
// pad: 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123
// pad: 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123
// pad: 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123
// pad: 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123
// pad: 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123
// pad: 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123
// pad: 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123
// pad: 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123
// pad: 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123
// pad: 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123
// pad: 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123
// pad: 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123
// pad: 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123
// pad: 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123
func testPad() string { return Hi() }
-- usea/gnomod.toml --
module = "gno.land/p/demo/usea"
gno = "0.9"
-- usea/lib.gno --
package usea

import "gno.land/p/demo/depa"

func Use() string { return depa.Hi() }
-- useb/gnomod.toml --
module = "gno.land/p/demo/useb"
gno = "0.9"
-- useb/lib.gno --
package useb

import "gno.land/p/demo/depb"

func Use() string { return depb.Hi() }
4 changes: 2 additions & 2 deletions gno.land/pkg/integration/testdata/gnokey_gasfee.txtar
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ stdout '"coins": "10000000000000ugnot"'
# gas-wanted/gas-fee on the simulate call are generous; simulate reports
# the actual gas the real tx would consume.
gnokey maketx addpkg -pkgdir $WORK/hello -pkgpath gno.land/r/hello -gas-wanted 3_500_000 -gas-fee 350001ugnot -chainid tendermint_test -simulate only test1
stdout 'GAS USED:\s+2756592'
stdout 'INFO: estimated gas usage: 2756592 \(suggested, with 5% margin: 2894422\), gas fee: 2894ugnot, current gas price: 1ugnot/1000gas'
stdout 'GAS USED:\s+2756609'
stdout 'INFO: estimated gas usage: 2756609 \(suggested, with 5% margin: 2894440\), gas fee: 2894ugnot, current gas price: 1ugnot/1000gas'

## No fee was charged, and the sequence number did not change.
gnokey query auth/accounts/$test1_user_addr
Expand Down
6 changes: 3 additions & 3 deletions gno.land/pkg/integration/testdata/restart_gas.txtar
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@
gnoland start

gnokey maketx addpkg -pkgdir $WORK/bar -pkgpath gno.land/r/$test1_user_addr/bar -gas-fee 1000000ugnot -gas-wanted 100000000 -chainid=tendermint_test test1
stdout 'GAS USED: +2780212'
stdout 'GAS USED: +2780229'

gnokey maketx addpkg -pkgdir $WORK/foo -pkgpath gno.land/r/$test1_user_addr/foo -gas-fee 1000000ugnot -gas-wanted 100000000 -chainid=tendermint_test test1
stdout 'GAS USED: +2782531'
stdout 'GAS USED: +2782548'

gnoland restart

gnokey maketx addpkg -pkgdir $WORK/baz -pkgpath gno.land/r/$test1_user_addr/baz -gas-fee 1000000ugnot -gas-wanted 100000000 -chainid=tendermint_test test1
stdout 'GAS USED: +2782558'
stdout 'GAS USED: +2782575'

-- bar/gnomod.toml --
module = "bar"
Expand Down
7 changes: 6 additions & 1 deletion gno.land/pkg/sdk/vm/apphash_crossrealm38_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,12 @@ import (
// their *_test.gno source bytes), which shifts the iavlStore Merkle root. This
// is the only consensus-relevant change in that PR; verified by bisection that
// no other change in the PR moves this hash. The shift is therefore expected.
const expectedCrossrealm38Hash = "28f55f0ad9842bc3c4d8984f1a63a709203a1e99fae7e816786825e26629f618"
//
// Hash bumped 2026-06-14: mempackage prod/test storage split — MP*All packages now
// store production files under pkg:<path> (typed MP*Prod) and test/filetest files
// under a pkg:<path>#allbutprod sibling, changing stored package bytes and the
// iavlStore Merkle root. Behavior is unchanged; only the storage encoding shifted.
const expectedCrossrealm38Hash = "adef42a3fcc41839fb59faf81e0190fd1f51fe7afba82f717d4def275c84c977"

func TestAppHashCrossrealm38(t *testing.T) {
env := setupTestEnv()
Expand Down
24 changes: 22 additions & 2 deletions gno.land/pkg/sdk/vm/keeper.go
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,14 @@ func (vm *VMKeeper) AddPackage(ctx sdk.Context, msg MsgAddPackage) (err error) {
if err := gno.ValidateMemPackageAny(msg.Package); err != nil {
return ErrInvalidPkgPath(err.Error())
}
// Reject packages with no production .gno files (e.g. only _test.gno
// files). The storage split writes no prod blob for them (store.go
// splitProdAllButProd), so a restarted node would rebuild no PackageNode
// while a non-restarted node still holds the deploy-time node in RAM —
// making call gas depend on restart history.
if gno.MPFProd.FilterMemPackage(memPkg).IsEmpty() {
return ErrInvalidPackage("package has no production .gno files")
}

if !strings.HasPrefix(pkgPath, chainDomain+"/") {
return ErrInvalidPkgPath("invalid domain: " + pkgPath)
Expand All @@ -624,6 +632,15 @@ func (vm *VMKeeper) AddPackage(ctx sdk.Context, msg MsgAddPackage) (err error) {
if pv != nil && !pv.Private {
return ErrPkgAlreadyExists("package already exists: " + pkgPath)
}
if pv != nil {
// A private package is being redeployed (non-private re-adds were
// rejected above). Clear its prior mempackage blobs first: AddMemPackage
// stores an MP*All package as a prod blob plus a #allbutprod sibling, and
// its conditional writes don't fully replace across both keys, so a stale
// sibling (or stale prod blob, if redeployed prod-less) could otherwise
// survive the re-add and be served by qfile/GetMemPackage.
gnostore.DeleteMemPackage(pkgPath)
}

if !gno.IsRealmPath(pkgPath) && !gno.IsPPackagePath(pkgPath) {
return ErrInvalidPkgPath("package path must be valid realm or p package path")
Expand Down Expand Up @@ -1394,7 +1411,8 @@ func (vm *VMKeeper) QueryFile(ctx sdk.Context, filepath string) (res string, err
}
return memFile.Body, nil
} else {
memPkg := store.GetMemPackage(dirpath)
// GetMemPackageAll so the file listing includes test/filetest files.
memPkg := store.GetMemPackageAll(dirpath)
if memPkg == nil {
return "", errors.Wrapf(&InvalidPackageError{}, "package %q is not available", dirpath)
}
Expand All @@ -1412,7 +1430,9 @@ func (vm *VMKeeper) QueryDoc(ctx sdk.Context, pkgPath string) (*doc.JSONDocument
ctx = ctx.WithGasMeter(store.NewGasMeter(maxGasQuery))
store := vm.newGnoTransactionStore(ctx) // throwaway (never committed)

memPkg := store.GetMemPackage(pkgPath)
// GetMemPackageAll for parity with QueryFile, so doc generation sees test
// files (e.g. for any future test-derived examples).
memPkg := store.GetMemPackageAll(pkgPath)
if memPkg == nil {
err := ErrInvalidPkgPath(fmt.Sprintf(
"package not found: %s", pkgPath))
Expand Down
91 changes: 91 additions & 0 deletions gno.land/pkg/sdk/vm/keeper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
bft "github.com/gnolang/gno/tm2/pkg/bft/types"
"github.com/gnolang/gno/tm2/pkg/crypto"
"github.com/gnolang/gno/tm2/pkg/db/memdb"
tmerrors "github.com/gnolang/gno/tm2/pkg/errors"
"github.com/gnolang/gno/tm2/pkg/log"
"github.com/gnolang/gno/tm2/pkg/sdk"
tu "github.com/gnolang/gno/tm2/pkg/sdk/testutils"
Expand Down Expand Up @@ -126,6 +127,96 @@ func Echo(cur realm) string {
assert.Nil(t, memFile)
}

// A package with no production .gno files (only _test.gno / _filetest.gno)
// must be rejected: the storage split writes no prod blob for it, so a
// restarted node would rebuild no PackageNode while a non-restarted node
// still holds the deploy-time node in RAM (divergent call gas).
func TestVMKeeperAddPackage_NoProdFiles(t *testing.T) {
env := setupTestEnv()
ctx := env.vmk.MakeGnoTransactionStore(env.ctx)

addr := crypto.AddressFromPreimage([]byte("addr1"))
acc := env.acck.NewAccountWithAddress(ctx, addr)
env.acck.SetAccount(ctx, acc)
env.bankk.SetCoins(ctx, addr, initialBalance)

const pkgPath = "gno.land/r/testonly"
files := []*std.MemFile{
{Name: "gnomod.toml", Body: gnolang.GenGnoModLatest(pkgPath)},
{
Name: "testonly_test.gno",
Body: `package testonly
import "testing"
func TestNothing(t *testing.T) {}`,
},
}

err := env.vmk.AddPackage(ctx, NewMsgAddPackage(addr, pkgPath, files))

require.Error(t, err)
assert.Equal(t, InvalidPackageError{}, tmerrors.Cause(err))
assert.Nil(t, env.vmk.getGnoTransactionStore(ctx).GetPackage(pkgPath, false))
assert.Nil(t, env.vmk.getGnoTransactionStore(ctx).GetMemPackageAll(pkgPath))
}

// Structural companion to addpkg_import_testdep_gas.txtar: importing a
// dependency that carries a _test.gno file must cost exactly the same gas as
// importing one without, because the storage split keeps a dep's test bytes
// in the #allbutprod sibling, which import-time type-checking never decodes.
// The txtar pins two equal literals (which a bulk gas re-pin could silently
// de-equalize); this asserts the equality itself.
func TestVMKeeperAddPackage_ImportTestDepGasEqual(t *testing.T) {
env := setupTestEnv()

addr := crypto.AddressFromPreimage([]byte("addr1"))
{
ctx := env.vmk.MakeGnoTransactionStore(env.ctx)
acc := env.acck.NewAccountWithAddress(ctx, addr)
env.acck.SetAccount(ctx, acc)
env.bankk.SetCoins(ctx, addr, initialBalance)
env.vmk.CommitGnoTransactionStore(ctx)
}

// deploy runs msg in its own tx store with a fresh gas meter and
// returns the gas the deploy consumed.
deploy := func(pkgPath, libName, libBody string, extra ...*std.MemFile) int64 {
t.Helper()
files := append([]*std.MemFile{
{Name: "gnomod.toml", Body: gnolang.GenGnoModLatest(pkgPath)},
{Name: libName, Body: libBody},
}, extra...)
gm := types.NewInfiniteGasMeter()
ctx := env.vmk.MakeGnoTransactionStore(env.ctx.WithGasMeter(gm))
require.NoError(t, env.vmk.AddPackage(ctx, NewMsgAddPackage(addr, pkgPath, files)))
env.vmk.CommitGnoTransactionStore(ctx)
return gm.GasConsumed()
}

// depa and depb have byte-identical production code (a<->b only); depb
// additionally carries a _test.gno.
deploy("gno.land/p/demo/depa", "lib.gno",
"package depa\n\nfunc Hi() string { return \"hi\" }\n")
deploy("gno.land/p/demo/depb", "lib.gno",
"package depb\n\nfunc Hi() string { return \"hi\" }\n",
&std.MemFile{Name: "lib_test.gno", Body: `package depb

// Test-only bytes: under the prod/test storage split these live in the
// pkg:<path>#allbutprod sibling and must not affect importer gas.
// pad: 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789
// pad: 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789
func testPad() string { return Hi() }
`})

// usea and useb are byte-identical apart from a<->b.
useaGas := deploy("gno.land/p/demo/usea", "lib.gno",
"package usea\n\nimport \"gno.land/p/demo/depa\"\n\nfunc Use() string { return depa.Hi() }\n")
usebGas := deploy("gno.land/p/demo/useb", "lib.gno",
"package useb\n\nimport \"gno.land/p/demo/depb\"\n\nfunc Use() string { return depb.Hi() }\n")

assert.Equal(t, useaGas, usebGas,
"importing a dep with test files must cost the same as importing one without")
}

func TestVMKeeperAddPackage_DraftPackage(t *testing.T) {
env := setupTestEnv()
ctx := env.vmk.MakeGnoTransactionStore(env.ctx)
Expand Down
6 changes: 3 additions & 3 deletions gno.land/pkg/sdk/vm/params.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ const (
// Depth floors calibrated for B+32 at 100M items with 10K cache, batched 1000 muts.
minGetReadDepth100Default = int64(300) // 3.0 GET read ops
minSetReadDepth100Default = int64(200) // 2.0 SET read ops
minWriteDepth100Default = int64(440) // 4.4 write ops (batched)
minWriteDepth100Default = 440 // 4.4 write ops (batched)
// Iterator step flat cost; mirrors store.DefaultGasConfig().IterNextCostFlat.
iterNextCostFlatDefault = int64(1_000)
iterNextCostFlatDefault = 1_000
)

var ASCIIDomain = regexp.MustCompile(`^(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,}$`)
Expand Down Expand Up @@ -152,7 +152,7 @@ func (p Params) Validate() error {
// realistic per-step cost while far below the block gas limit
// (~3B) so that a single adversarial proposal can't make one
// iterator step drain an entire block's gas budget.
const maxIterNextCostFlat = int64(100_000)
const maxIterNextCostFlat = 100_000
if p.IterNextCostFlat <= 0 {
return fmt.Errorf("IterNextCostFlat must be positive, got %d", p.IterNextCostFlat)
}
Expand Down
9 changes: 7 additions & 2 deletions gnovm/pkg/gnolang/debugger.go
Original file line number Diff line number Diff line change
Expand Up @@ -614,12 +614,17 @@ func debugLineInfo(m *Machine) {

func isMemPackage(st Store, pkgPath string) bool {
ds, ok := st.(*defaultStore)
return ok && ds.iavlStore.Has(ds.gctx, []byte(backendPackagePathKey(pkgPath)))
return ok && (ds.iavlStore.Has(ds.gctx, []byte(backendPackagePathKey(pkgPath))) ||
ds.iavlStore.Has(ds.gctx, []byte(backendPackageAllButProdKey(pkgPath))))
}

func fileContent(st Store, pkgPath, name string) (string, error) {
if isMemPackage(st, pkgPath) {
return st.GetMemFile(pkgPath, name).Body, nil
// The package is in the store, but the requested file may not be in it;
// guard the nil before dereferencing, then fall through to disk.
if mf := st.GetMemFile(pkgPath, name); mf != nil {
return mf.Body, nil
}
}
buf, err := os.ReadFile(name)
return string(buf), err
Expand Down
11 changes: 11 additions & 0 deletions gnovm/pkg/gnolang/machine.go
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,17 @@ func assertBorrowedRealm(pkgPath string, r *Realm) {
func (m *Machine) PreprocessAllFilesAndSaveBlockNodes() {
ch := m.Store.IterMemPackage()
for mpkg := range ch {
if mpkg == nil {
// An indexed package with no production files (e.g. an
// xxx_test-only package) has no prod blob, so GetMemPackage
// returns nil. There are no production block nodes to build;
// its test files live under the #allbutprod sibling. On-chain
// this is unreachable — the vm keeper rejects prod-less packages
// at AddPackage (block-node state would otherwise depend on
// restart history) — so this skip is defensive, for non-chain
// stores.
continue
}
mpkg = MPFProd.FilterMemPackage(mpkg)
fset := m.ParseMemPackage(mpkg)
pn := NewPackageNode(Name(mpkg.Name), mpkg.Path, fset)
Expand Down
Loading