Merge pull request #2994 from nspcc-dev/fix-init-cache
core: rework natives cache initialization
This commit is contained in:
commit
1bd22adcb3
14 changed files with 125 additions and 35 deletions
|
@ -1011,28 +1011,16 @@ func (bc *Blockchain) resetStateInternal(height uint32, stage stateChangeStage)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (bc *Blockchain) initializeNativeCache(blockHeight uint32, d *dao.Simple) error {
|
func (bc *Blockchain) initializeNativeCache(blockHeight uint32, d *dao.Simple) error {
|
||||||
err := bc.contracts.NEO.InitializeCache(blockHeight, d)
|
for _, c := range bc.contracts.Contracts {
|
||||||
|
for _, h := range c.Metadata().UpdateHistory {
|
||||||
|
if blockHeight >= h { // check that contract was deployed.
|
||||||
|
err := c.InitializeCache(blockHeight, d)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("can't init cache for NEO native contract: %w", err)
|
return fmt.Errorf("failed to initialize cache for %s: %w", c.Metadata().Name, err)
|
||||||
}
|
}
|
||||||
err = bc.contracts.Management.InitializeCache(d)
|
break
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("can't init cache for Management native contract: %w", err)
|
|
||||||
}
|
|
||||||
err = bc.contracts.Designate.InitializeCache(d)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("can't init cache for Designation native contract: %w", err)
|
|
||||||
}
|
|
||||||
bc.contracts.Oracle.InitializeCache(d)
|
|
||||||
if bc.P2PSigExtensionsEnabled() {
|
|
||||||
err = bc.contracts.Notary.InitializeCache(d)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("can't init cache for Notary native contract: %w", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
err = bc.contracts.Policy.InitializeCache(d)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("can't init cache for Policy native contract: %w", err)
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -2579,7 +2567,10 @@ func (bc *Blockchain) verifyTxAttributes(d *dao.Simple, tx *transaction.Transact
|
||||||
nvb := tx.Attributes[i].Value.(*transaction.NotValidBefore).Height
|
nvb := tx.Attributes[i].Value.(*transaction.NotValidBefore).Height
|
||||||
curHeight := bc.BlockHeight()
|
curHeight := bc.BlockHeight()
|
||||||
if isPartialTx {
|
if isPartialTx {
|
||||||
maxNVBDelta := bc.contracts.Notary.GetMaxNotValidBeforeDelta(bc.dao)
|
maxNVBDelta, err := bc.GetMaxNotValidBeforeDelta()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: failed to retrieve MaxNotValidBeforeDelta value from native Notary contract: %v", ErrInvalidAttribute, err)
|
||||||
|
}
|
||||||
if curHeight+maxNVBDelta < nvb {
|
if curHeight+maxNVBDelta < nvb {
|
||||||
return fmt.Errorf("%w: NotValidBefore (%d) bigger than MaxNVBDelta (%d) allows at height %d", ErrInvalidAttribute, nvb, maxNVBDelta, curHeight)
|
return fmt.Errorf("%w: NotValidBefore (%d) bigger than MaxNVBDelta (%d) allows at height %d", ErrInvalidAttribute, nvb, maxNVBDelta, curHeight)
|
||||||
}
|
}
|
||||||
|
@ -2984,11 +2975,14 @@ func (bc *Blockchain) GetMaxVerificationGAS() int64 {
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMaxNotValidBeforeDelta returns maximum NotValidBeforeDelta Notary limit.
|
// GetMaxNotValidBeforeDelta returns maximum NotValidBeforeDelta Notary limit.
|
||||||
func (bc *Blockchain) GetMaxNotValidBeforeDelta() uint32 {
|
func (bc *Blockchain) GetMaxNotValidBeforeDelta() (uint32, error) {
|
||||||
if !bc.config.P2PSigExtensions {
|
if !bc.config.P2PSigExtensions {
|
||||||
panic("disallowed call to Notary")
|
panic("disallowed call to Notary") // critical error, thus panic.
|
||||||
}
|
}
|
||||||
return bc.contracts.Notary.GetMaxNotValidBeforeDelta(bc.dao)
|
if bc.contracts.Notary.Metadata().UpdateHistory[0] > bc.BlockHeight() {
|
||||||
|
return 0, fmt.Errorf("native Notary is active starting from %d", bc.contracts.Notary.Metadata().UpdateHistory[0])
|
||||||
|
}
|
||||||
|
return bc.contracts.Notary.GetMaxNotValidBeforeDelta(bc.dao), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStoragePrice returns current storage price.
|
// GetStoragePrice returns current storage price.
|
||||||
|
|
|
@ -299,6 +299,69 @@ func TestBlockchain_StartFromExistingDB(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This test enables Notary native contract at non-zero height and checks that no
|
||||||
|
// Notary cache initialization is performed before that height on node restart.
|
||||||
|
func TestBlockchain_InitializeNativeCacheWrtNativeActivations(t *testing.T) {
|
||||||
|
const notaryEnabledHeight = 3
|
||||||
|
ps, path := newLevelDBForTestingWithPath(t, "")
|
||||||
|
customConfig := func(c *config.Blockchain) {
|
||||||
|
c.P2PSigExtensions = true
|
||||||
|
c.NativeUpdateHistories = make(map[string][]uint32)
|
||||||
|
for _, n := range []string{
|
||||||
|
nativenames.Neo,
|
||||||
|
nativenames.Gas,
|
||||||
|
nativenames.Designation,
|
||||||
|
nativenames.Management,
|
||||||
|
nativenames.CryptoLib,
|
||||||
|
nativenames.Ledger,
|
||||||
|
nativenames.Management,
|
||||||
|
nativenames.Oracle,
|
||||||
|
nativenames.Policy,
|
||||||
|
nativenames.StdLib,
|
||||||
|
nativenames.Notary,
|
||||||
|
} {
|
||||||
|
if n == nativenames.Notary {
|
||||||
|
c.NativeUpdateHistories[n] = []uint32{notaryEnabledHeight}
|
||||||
|
} else {
|
||||||
|
c.NativeUpdateHistories[n] = []uint32{0}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bc, validators, committee, err := chain.NewMultiWithCustomConfigAndStoreNoCheck(t, customConfig, ps)
|
||||||
|
require.NoError(t, err)
|
||||||
|
go bc.Run()
|
||||||
|
e := neotest.NewExecutor(t, bc, validators, committee)
|
||||||
|
e.AddNewBlock(t)
|
||||||
|
bc.Close() // Ensure persist is done and persistent store is properly closed.
|
||||||
|
|
||||||
|
ps, _ = newLevelDBForTestingWithPath(t, path)
|
||||||
|
|
||||||
|
// If NativeActivations are not taken into account during native cache initialization,
|
||||||
|
// bs.init() will panic on Notary cache initialization as it's not deployed yet.
|
||||||
|
require.NotPanics(t, func() {
|
||||||
|
bc, _, _, err = chain.NewMultiWithCustomConfigAndStoreNoCheck(t, customConfig, ps)
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
go bc.Run()
|
||||||
|
defer bc.Close()
|
||||||
|
e = neotest.NewExecutor(t, bc, validators, committee)
|
||||||
|
h := e.Chain.BlockHeight()
|
||||||
|
|
||||||
|
// Notary isn't initialized yet, so accessing Notary cache should return error.
|
||||||
|
_, err = e.Chain.GetMaxNotValidBeforeDelta()
|
||||||
|
require.Error(t, err)
|
||||||
|
|
||||||
|
// Ensure Notary will be properly initialized and accessing Notary cache works
|
||||||
|
// as expected.
|
||||||
|
for i := 0; i < notaryEnabledHeight; i++ {
|
||||||
|
require.NotPanics(t, func() {
|
||||||
|
e.AddNewBlock(t)
|
||||||
|
}, h+uint32(i)+1)
|
||||||
|
}
|
||||||
|
_, err = e.Chain.GetMaxNotValidBeforeDelta()
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
func TestBlockchain_AddHeaders(t *testing.T) {
|
func TestBlockchain_AddHeaders(t *testing.T) {
|
||||||
bc, acc := chain.NewSingleWithCustomConfig(t, func(c *config.Blockchain) {
|
bc, acc := chain.NewSingleWithCustomConfig(t, func(c *config.Blockchain) {
|
||||||
c.StateRootInHeader = true
|
c.StateRootInHeader = true
|
||||||
|
@ -1884,11 +1947,15 @@ func TestBlockchain_VerifyTx(t *testing.T) {
|
||||||
require.Error(t, bc.PoolTxWithData(tx, 5, mp, bc, verificationF))
|
require.Error(t, bc.PoolTxWithData(tx, 5, mp, bc, verificationF))
|
||||||
})
|
})
|
||||||
t.Run("bad NVB: too big", func(t *testing.T) {
|
t.Run("bad NVB: too big", func(t *testing.T) {
|
||||||
tx := getPartiallyFilledTx(bc.BlockHeight()+bc.GetMaxNotValidBeforeDelta()+1, bc.BlockHeight()+1)
|
maxNVB, err := bc.GetMaxNotValidBeforeDelta()
|
||||||
|
require.NoError(t, err)
|
||||||
|
tx := getPartiallyFilledTx(bc.BlockHeight()+maxNVB+1, bc.BlockHeight()+1)
|
||||||
require.True(t, errors.Is(bc.PoolTxWithData(tx, 5, mp, bc, verificationF), core.ErrInvalidAttribute))
|
require.True(t, errors.Is(bc.PoolTxWithData(tx, 5, mp, bc, verificationF), core.ErrInvalidAttribute))
|
||||||
})
|
})
|
||||||
t.Run("bad ValidUntilBlock: too small", func(t *testing.T) {
|
t.Run("bad ValidUntilBlock: too small", func(t *testing.T) {
|
||||||
tx := getPartiallyFilledTx(bc.BlockHeight(), bc.BlockHeight()+bc.GetMaxNotValidBeforeDelta()+1)
|
maxNVB, err := bc.GetMaxNotValidBeforeDelta()
|
||||||
|
require.NoError(t, err)
|
||||||
|
tx := getPartiallyFilledTx(bc.BlockHeight(), bc.BlockHeight()+maxNVB+1)
|
||||||
require.True(t, errors.Is(bc.PoolTxWithData(tx, 5, mp, bc, verificationF), core.ErrInvalidAttribute))
|
require.True(t, errors.Is(bc.PoolTxWithData(tx, 5, mp, bc, verificationF), core.ErrInvalidAttribute))
|
||||||
})
|
})
|
||||||
t.Run("good", func(t *testing.T) {
|
t.Run("good", func(t *testing.T) {
|
||||||
|
|
|
@ -153,6 +153,11 @@ type MethodAndPrice struct {
|
||||||
// Contract is an interface for all native contracts.
|
// Contract is an interface for all native contracts.
|
||||||
type Contract interface {
|
type Contract interface {
|
||||||
Initialize(*Context) error
|
Initialize(*Context) error
|
||||||
|
// InitializeCache aimed to initialize contract's cache when the contract has
|
||||||
|
// been deployed, but in-memory cached data were lost due to the node reset.
|
||||||
|
// It should be called each time after node restart iff the contract was
|
||||||
|
// deployed and no Initialize method was called.
|
||||||
|
InitializeCache(blockHeight uint32, d *dao.Simple) error
|
||||||
Metadata() *ContractMD
|
Metadata() *ContractMD
|
||||||
OnPersist(*Context) error
|
OnPersist(*Context) error
|
||||||
PostPersist(*Context) error
|
PostPersist(*Context) error
|
||||||
|
|
|
@ -10,6 +10,7 @@ import (
|
||||||
bls12381 "github.com/consensys/gnark-crypto/ecc/bls12-381"
|
bls12381 "github.com/consensys/gnark-crypto/ecc/bls12-381"
|
||||||
"github.com/consensys/gnark-crypto/ecc/bls12-381/fr"
|
"github.com/consensys/gnark-crypto/ecc/bls12-381/fr"
|
||||||
"github.com/decred/dcrd/dcrec/secp256k1/v4"
|
"github.com/decred/dcrd/dcrec/secp256k1/v4"
|
||||||
|
"github.com/nspcc-dev/neo-go/pkg/core/dao"
|
||||||
"github.com/nspcc-dev/neo-go/pkg/core/interop"
|
"github.com/nspcc-dev/neo-go/pkg/core/interop"
|
||||||
"github.com/nspcc-dev/neo-go/pkg/core/native/nativenames"
|
"github.com/nspcc-dev/neo-go/pkg/core/native/nativenames"
|
||||||
"github.com/nspcc-dev/neo-go/pkg/crypto/hash"
|
"github.com/nspcc-dev/neo-go/pkg/crypto/hash"
|
||||||
|
@ -467,6 +468,11 @@ func (c *Crypto) Initialize(ic *interop.Context) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InitializeCache implements the Contract interface.
|
||||||
|
func (c *Crypto) InitializeCache(blockHeight uint32, d *dao.Simple) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// OnPersist implements the Contract interface.
|
// OnPersist implements the Contract interface.
|
||||||
func (c *Crypto) OnPersist(ic *interop.Context) error {
|
func (c *Crypto) OnPersist(ic *interop.Context) error {
|
||||||
return nil
|
return nil
|
||||||
|
|
|
@ -132,7 +132,7 @@ func (s *Designate) Initialize(ic *interop.Context) error {
|
||||||
|
|
||||||
// InitializeCache fills native Designate cache from DAO. It is called at non-zero height, thus
|
// InitializeCache fills native Designate cache from DAO. It is called at non-zero height, thus
|
||||||
// we can fetch the roles data right from the storage.
|
// we can fetch the roles data right from the storage.
|
||||||
func (s *Designate) InitializeCache(d *dao.Simple) error {
|
func (s *Designate) InitializeCache(blockHeight uint32, d *dao.Simple) error {
|
||||||
cache := &DesignationCache{}
|
cache := &DesignationCache{}
|
||||||
roles := []noderoles.Role{noderoles.Oracle, noderoles.NeoFSAlphabet, noderoles.StateValidator}
|
roles := []noderoles.Role{noderoles.Oracle, noderoles.NeoFSAlphabet, noderoles.StateValidator}
|
||||||
if s.p2pSigExtensionsEnabled {
|
if s.p2pSigExtensionsEnabled {
|
||||||
|
|
|
@ -85,6 +85,11 @@ func (l *Ledger) Initialize(ic *interop.Context) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InitializeCache implements the Contract interface.
|
||||||
|
func (l *Ledger) InitializeCache(blockHeight uint32, d *dao.Simple) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// OnPersist implements the Contract interface.
|
// OnPersist implements the Contract interface.
|
||||||
func (l *Ledger) OnPersist(ic *interop.Context) error {
|
func (l *Ledger) OnPersist(ic *interop.Context) error {
|
||||||
// Actual block/tx processing is done in Blockchain.storeBlock().
|
// Actual block/tx processing is done in Blockchain.storeBlock().
|
||||||
|
|
|
@ -610,7 +610,7 @@ func (m *Management) OnPersist(ic *interop.Context) error {
|
||||||
// InitializeCache initializes contract cache with the proper values from storage.
|
// InitializeCache initializes contract cache with the proper values from storage.
|
||||||
// Cache initialization should be done apart from Initialize because Initialize is
|
// Cache initialization should be done apart from Initialize because Initialize is
|
||||||
// called only when deploying native contracts.
|
// called only when deploying native contracts.
|
||||||
func (m *Management) InitializeCache(d *dao.Simple) error {
|
func (m *Management) InitializeCache(blockHeight uint32, d *dao.Simple) error {
|
||||||
cache := &ManagementCache{
|
cache := &ManagementCache{
|
||||||
contracts: make(map[util.Uint160]*state.Contract),
|
contracts: make(map[util.Uint160]*state.Contract),
|
||||||
nep11: make(map[util.Uint160]struct{}),
|
nep11: make(map[util.Uint160]struct{}),
|
||||||
|
|
|
@ -82,7 +82,7 @@ func TestManagement_Initialize(t *testing.T) {
|
||||||
t.Run("good", func(t *testing.T) {
|
t.Run("good", func(t *testing.T) {
|
||||||
d := dao.NewSimple(storage.NewMemoryStore(), false, false)
|
d := dao.NewSimple(storage.NewMemoryStore(), false, false)
|
||||||
mgmt := newManagement()
|
mgmt := newManagement()
|
||||||
require.NoError(t, mgmt.InitializeCache(d))
|
require.NoError(t, mgmt.InitializeCache(0, d))
|
||||||
})
|
})
|
||||||
/* See #2801
|
/* See #2801
|
||||||
t.Run("invalid contract state", func(t *testing.T) {
|
t.Run("invalid contract state", func(t *testing.T) {
|
||||||
|
@ -101,7 +101,7 @@ func TestManagement_GetNEP17Contracts(t *testing.T) {
|
||||||
err := mgmt.Initialize(&interop.Context{DAO: d})
|
err := mgmt.Initialize(&interop.Context{DAO: d})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, mgmt.Policy.Initialize(&interop.Context{DAO: d}))
|
require.NoError(t, mgmt.Policy.Initialize(&interop.Context{DAO: d}))
|
||||||
err = mgmt.InitializeCache(d)
|
err = mgmt.InitializeCache(0, d)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
require.Empty(t, mgmt.GetNEP17Contracts(d))
|
require.Empty(t, mgmt.GetNEP17Contracts(d))
|
||||||
|
|
|
@ -99,6 +99,11 @@ func (g *GAS) Initialize(ic *interop.Context) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InitializeCache implements the Contract interface.
|
||||||
|
func (g *GAS) InitializeCache(blockHeight uint32, d *dao.Simple) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// OnPersist implements the Contract interface.
|
// OnPersist implements the Contract interface.
|
||||||
func (g *GAS) OnPersist(ic *interop.Context) error {
|
func (g *GAS) OnPersist(ic *interop.Context) error {
|
||||||
if len(ic.Block.Transactions) == 0 {
|
if len(ic.Block.Transactions) == 0 {
|
||||||
|
|
|
@ -305,7 +305,8 @@ func (n *NEO) Initialize(ic *interop.Context) error {
|
||||||
|
|
||||||
// InitializeCache initializes all NEO cache with the proper values from the storage.
|
// InitializeCache initializes all NEO cache with the proper values from the storage.
|
||||||
// Cache initialization should be done apart from Initialize because Initialize is
|
// Cache initialization should be done apart from Initialize because Initialize is
|
||||||
// called only when deploying native contracts.
|
// called only when deploying native contracts. InitializeCache implements the Contract
|
||||||
|
// interface.
|
||||||
func (n *NEO) InitializeCache(blockHeight uint32, d *dao.Simple) error {
|
func (n *NEO) InitializeCache(blockHeight uint32, d *dao.Simple) error {
|
||||||
cache := &NeoCache{
|
cache := &NeoCache{
|
||||||
gasPerVoteCache: make(map[string]big.Int),
|
gasPerVoteCache: make(map[string]big.Int),
|
||||||
|
|
|
@ -152,7 +152,7 @@ func (n *Notary) Initialize(ic *interop.Context) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *Notary) InitializeCache(d *dao.Simple) error {
|
func (n *Notary) InitializeCache(blockHeight uint32, d *dao.Simple) error {
|
||||||
cache := &NotaryCache{
|
cache := &NotaryCache{
|
||||||
maxNotValidBeforeDelta: uint32(getIntWithKey(n.ID, d, maxNotValidBeforeDeltaKey)),
|
maxNotValidBeforeDelta: uint32(getIntWithKey(n.ID, d, maxNotValidBeforeDeltaKey)),
|
||||||
notaryServiceFeePerKey: getIntWithKey(n.ID, d, notaryServiceFeeKey),
|
notaryServiceFeePerKey: getIntWithKey(n.ID, d, notaryServiceFeeKey),
|
||||||
|
|
|
@ -251,10 +251,11 @@ func (o *Oracle) Initialize(ic *interop.Context) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *Oracle) InitializeCache(d *dao.Simple) {
|
func (o *Oracle) InitializeCache(blockHeight uint32, d *dao.Simple) error {
|
||||||
cache := &OracleCache{}
|
cache := &OracleCache{}
|
||||||
cache.requestPrice = getIntWithKey(o.ID, d, prefixRequestPrice)
|
cache.requestPrice = getIntWithKey(o.ID, d, prefixRequestPrice)
|
||||||
d.SetCache(o.ID, cache)
|
d.SetCache(o.ID, cache)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getResponse(tx *transaction.Transaction) *transaction.OracleResponse {
|
func getResponse(tx *transaction.Transaction) *transaction.OracleResponse {
|
||||||
|
|
|
@ -153,7 +153,7 @@ func (p *Policy) Initialize(ic *interop.Context) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Policy) InitializeCache(d *dao.Simple) error {
|
func (p *Policy) InitializeCache(blockHeight uint32, d *dao.Simple) error {
|
||||||
cache := &PolicyCache{}
|
cache := &PolicyCache{}
|
||||||
err := p.fillCacheFromDAO(cache, d)
|
err := p.fillCacheFromDAO(cache, d)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/mr-tron/base58"
|
"github.com/mr-tron/base58"
|
||||||
|
"github.com/nspcc-dev/neo-go/pkg/core/dao"
|
||||||
"github.com/nspcc-dev/neo-go/pkg/core/interop"
|
"github.com/nspcc-dev/neo-go/pkg/core/interop"
|
||||||
"github.com/nspcc-dev/neo-go/pkg/core/native/nativenames"
|
"github.com/nspcc-dev/neo-go/pkg/core/native/nativenames"
|
||||||
base58neogo "github.com/nspcc-dev/neo-go/pkg/encoding/base58"
|
base58neogo "github.com/nspcc-dev/neo-go/pkg/encoding/base58"
|
||||||
|
@ -429,6 +430,11 @@ func (s *Std) Initialize(ic *interop.Context) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InitializeCache implements the Contract interface.
|
||||||
|
func (s *Std) InitializeCache(blockHeight uint32, d *dao.Simple) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// OnPersist implements the Contract interface.
|
// OnPersist implements the Contract interface.
|
||||||
func (s *Std) OnPersist(ic *interop.Context) error {
|
func (s *Std) OnPersist(ic *interop.Context) error {
|
||||||
return nil
|
return nil
|
||||||
|
|
Loading…
Reference in a new issue