From 879d5483850f40e3c1ec7414afa06b7d8defce10 Mon Sep 17 00:00:00 2001 From: Diego Essaya Date: Thu, 27 Jul 2023 17:22:06 -0300 Subject: [PATCH] feat: snapshot Solo environment --- packages/cryptolib/keypair.go | 2 + packages/cryptolib/private_key.go | 4 +- packages/cryptolib/public_key.go | 4 +- packages/solo/snapshot.go | 105 +++++++++++++++++++++++ packages/solo/solo.go | 127 ++++++++++++++++++---------- packages/solo/solotest/solo_test.go | 68 +++++++++++++++ packages/testutil/utxodb/utxodb.go | 62 ++++++++++++++ 7 files changed, 319 insertions(+), 53 deletions(-) create mode 100644 packages/solo/snapshot.go create mode 100644 packages/solo/solotest/solo_test.go diff --git a/packages/cryptolib/keypair.go b/packages/cryptolib/keypair.go index 4951fa0e89..a0fccd6a47 100644 --- a/packages/cryptolib/keypair.go +++ b/packages/cryptolib/keypair.go @@ -58,7 +58,9 @@ func (k *KeyPair) Address() *iotago.Ed25519Address { func (k *KeyPair) Read(r io.Reader) error { rr := rwutil.NewReader(r) + k.publicKey = new(PublicKey) rr.Read(k.publicKey) + k.privateKey = new(PrivateKey) rr.Read(k.privateKey) return rr.Err } diff --git a/packages/cryptolib/private_key.go b/packages/cryptolib/private_key.go index 7741624c78..b0caa10c95 100644 --- a/packages/cryptolib/private_key.go +++ b/packages/cryptolib/private_key.go @@ -83,9 +83,7 @@ func (pkT *PrivateKey) AddressKeys(addr iotago.Address) iotago.AddressKeys { func (pkT *PrivateKey) Read(r io.Reader) error { rr := rwutil.NewReader(r) - if len(pkT.key) != PrivateKeySize { - panic("unexpected private key size for read") - } + pkT.key = make([]byte, PrivateKeySize) rr.ReadN(pkT.key) return rr.Err } diff --git a/packages/cryptolib/public_key.go b/packages/cryptolib/public_key.go index 42ec65a9b7..3c612e2f77 100644 --- a/packages/cryptolib/public_key.go +++ b/packages/cryptolib/public_key.go @@ -93,9 +93,7 @@ func (pkT *PublicKey) String() string { func (pkT *PublicKey) Read(r io.Reader) error { rr := rwutil.NewReader(r) - if len(pkT.key) != PublicKeySize { - panic("unexpected public key size for read") - } + pkT.key = make([]byte, PublicKeySize) rr.ReadN(pkT.key) return rr.Err } diff --git a/packages/solo/snapshot.go b/packages/solo/snapshot.go new file mode 100644 index 0000000000..a7880be37f --- /dev/null +++ b/packages/solo/snapshot.go @@ -0,0 +1,105 @@ +package solo + +import ( + "encoding/json" + "os" + + "github.com/stretchr/testify/require" + + "github.com/iotaledger/hive.go/kvstore" + "github.com/iotaledger/wasp/packages/cryptolib" + "github.com/iotaledger/wasp/packages/isc" + "github.com/iotaledger/wasp/packages/testutil/utxodb" + "github.com/iotaledger/wasp/packages/util/rwutil" +) + +type soloSnapshot struct { + UtxoDB *utxodb.UtxoDBState + Chains []soloChainSnapshot +} + +type soloChainSnapshot struct { + Name string + StateControllerKeyPair []byte + ChainID []byte + OriginatorPrivateKey []byte + ValidatorFeeTarget []byte + DB [][]byte +} + +// SaveSnapshot generates a snapshot of the Solo environment +func (env *Solo) SaveSnapshot(fname string) { + env.glbMutex.Lock() + defer env.glbMutex.Unlock() + + snapshot := soloSnapshot{ + UtxoDB: env.utxoDB.State(), + } + + for _, ch := range env.chains { + chainSnapshot := soloChainSnapshot{ + Name: ch.Name, + StateControllerKeyPair: rwutil.WriteToBytes(ch.StateControllerKeyPair), + ChainID: ch.ChainID.Bytes(), + OriginatorPrivateKey: rwutil.WriteToBytes(ch.OriginatorPrivateKey), + ValidatorFeeTarget: ch.ValidatorFeeTarget.Bytes(), + } + + err := ch.db.Iterate(kvstore.EmptyPrefix, func(k, v []byte) bool { + chainSnapshot.DB = append(chainSnapshot.DB, k, v) + return true + }) + require.NoError(env.T, err) + + snapshot.Chains = append(snapshot.Chains, chainSnapshot) + } + + b, err := json.Marshal(snapshot) + require.NoError(env.T, err) + err = os.WriteFile(fname, b, 0o600) + require.NoError(env.T, err) +} + +// LoadSnapshot restores the Solo environment from the given snapshot +func (env *Solo) LoadSnapshot(fname string) { + env.glbMutex.Lock() + defer env.glbMutex.Unlock() + + b, err := os.ReadFile(fname) + require.NoError(env.T, err) + var snapshot soloSnapshot + err = json.Unmarshal(b, &snapshot) + require.NoError(env.T, err) + + env.utxoDB.SetState(snapshot.UtxoDB) + for _, chainSnapshot := range snapshot.Chains { + sckp, err := rwutil.ReadFromBytes(chainSnapshot.StateControllerKeyPair, new(cryptolib.KeyPair)) + require.NoError(env.T, err) + + chainID, err := isc.ChainIDFromBytes(chainSnapshot.ChainID) + require.NoError(env.T, err) + + okp, err := rwutil.ReadFromBytes(chainSnapshot.OriginatorPrivateKey, new(cryptolib.KeyPair)) + require.NoError(env.T, err) + + val, err := isc.AgentIDFromBytes(chainSnapshot.ValidatorFeeTarget) + require.NoError(env.T, err) + + db, err := env.chainStateDatabaseManager.ChainStateKVStore(chainID) + require.NoError(env.T, err) + for i := 0; i < len(chainSnapshot.DB); i += 2 { + err = db.Set(chainSnapshot.DB[i], chainSnapshot.DB[i+1]) + require.NoError(env.T, err) + } + + chainData := chainData{ + Name: chainSnapshot.Name, + StateControllerKeyPair: sckp, + ChainID: chainID, + OriginatorPrivateKey: okp, + ValidatorFeeTarget: val, + db: db, + } + env.addChain(chainData) + } +} diff --git a/packages/solo/solo.go b/packages/solo/solo.go index 623d5713f0..2c1dcf5a9f 100644 --- a/packages/solo/solo.go +++ b/packages/solo/solo.go @@ -15,6 +15,7 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/zap/zapcore" + "github.com/iotaledger/hive.go/kvstore" hivedb "github.com/iotaledger/hive.go/kvstore/database" "github.com/iotaledger/hive.go/logger" iotago "github.com/iotaledger/iota.go/v3" @@ -67,19 +68,14 @@ type Solo struct { ctx context.Context } -// Chain represents state of individual chain. -// There may be several parallel instances of the chain in the 'solo' test -type Chain struct { - // Env is a pointer to the global structure of the 'solo' test - Env *Solo - +// data to be persisted in the snapshot +type chainData struct { // Name is the name of the chain Name string // StateControllerKeyPair signature scheme of the chain address, the one used to control funds owned by the chain. // In Solo it is Ed25519 signature scheme (in full Wasp environment is is a BLS address) StateControllerKeyPair *cryptolib.KeyPair - StateControllerAddress iotago.Address // ChainID is the ID of the chain (in this version alias of the ChainAddress) ChainID isc.ChainID @@ -87,13 +83,25 @@ type Chain struct { // OriginatorPrivateKey the key pair used to create the chain (origin transaction). // It is a default key pair in many of Solo calls which require private key. OriginatorPrivateKey *cryptolib.KeyPair - OriginatorAddress iotago.Address - // OriginatorAgentID is the OriginatorAddress represented in the form of AgentID - OriginatorAgentID isc.AgentID // ValidatorFeeTarget is the agent ID to which all fees are accrued. By default, it is equal to OriginatorAgentID ValidatorFeeTarget isc.AgentID + db kvstore.KVStore +} + +// Chain represents state of individual chain. +// There may be several parallel instances of the chain in the 'solo' test +type Chain struct { + chainData + + StateControllerAddress iotago.Address + OriginatorAddress iotago.Address + OriginatorAgentID isc.AgentID + + // Env is a pointer to the global structure of the 'solo' test + Env *Solo + // Store is where the chain data (blocks, state) is stored store indexedstore.IndexedStore // Log is the named logger of the chain @@ -199,6 +207,17 @@ func (env *Solo) Publisher() *publisher.Publisher { return env.publisher } +func (env *Solo) GetChainByName(name string) *Chain { + env.glbMutex.Lock() + defer env.glbMutex.Unlock() + for _, ch := range env.chains { + if ch.Name == name { + return ch + } + } + panic("chain not found") +} + // WithNativeContract registers a native contract so that it may be deployed func (env *Solo) WithNativeContract(c *coreutil.ContractProcessor) *Solo { env.processorConfig.RegisterNativeContract(c) @@ -216,26 +235,12 @@ func (env *Solo) NewChain(depositFundsForOriginator ...bool) *Chain { return ret } -// NewChainExt returns also origin and init transactions. Used for core testing -// -// If 'chainOriginator' is nil, new one is generated and utxodb.FundsFromFaucetAmount (many) base tokens are loaded from the UTXODB faucet. -// ValidatorFeeTarget will be set to OriginatorAgentID, and can be changed after initialization. -// To deploy a chain instance the following steps are performed: -// - chain signature scheme (private key), chain address and chain ID are created -// - empty virtual state is initialized -// - origin transaction is created by the originator and added to the UTXODB -// - 'init' request transaction to the 'root' contract is created and added to UTXODB -// - backlog processing threads (goroutines) are started -// - VM processor cache is initialized -// - 'init' request is run by the VM. The 'root' contracts deploys the rest of the core contracts: -// -// Upon return, the chain is fully functional to process requests -func (env *Solo) NewChainExt( +func (env *Solo) deployChain( chainOriginator *cryptolib.KeyPair, initBaseTokens uint64, name string, originParams ...dict.Dict, -) (*Chain, *iotago.Transaction) { +) (chainData, *iotago.Transaction) { env.logger.Debugf("deploying new chain '%s'", name) if chainOriginator == nil { @@ -288,46 +293,74 @@ func (env *Solo) NewChainExt( env.logger.Infof(" chain '%s'. state controller address: %s", chainID.String(), stateControllerAddr.Bech32(parameters.L1().Protocol.Bech32HRP)) env.logger.Infof(" chain '%s'. originator address: %s", chainID.String(), originatorAddr.Bech32(parameters.L1().Protocol.Bech32HRP)) - chainlog := env.logger.Named(name) - - kvStore, err := env.chainStateDatabaseManager.ChainStateKVStore(chainID) + db, err := env.chainStateDatabaseManager.ChainStateKVStore(chainID) require.NoError(env.T, err) originAOMinSD := parameters.L1().Protocol.RentStructure.MinRent(originAO) - store := indexedstore.New(state.NewStore(kvStore)) + store := indexedstore.New(state.NewStore(db)) origin.InitChain(store, initParams, originAO.Amount-originAOMinSD) { block, err2 := store.LatestBlock() require.NoError(env.T, err2) - env.logger.Infof(" chain '%s'. origin trie root: %s", chainID.String(), block.TrieRoot()) + env.logger.Infof(" chain '%s'. origin trie root: %s", chainID, block.TrieRoot()) } - ret := &Chain{ - Env: env, + return chainData{ Name: name, ChainID: chainID, StateControllerKeyPair: stateControllerKey, - StateControllerAddress: stateControllerAddr, OriginatorPrivateKey: chainOriginator, - OriginatorAddress: originatorAddr, - OriginatorAgentID: originatorAgentID, ValidatorFeeTarget: originatorAgentID, - store: store, - proc: processors.MustNew(env.processorConfig), - log: chainlog, - metrics: metrics.NewChainMetricsProvider().GetChainMetrics(chainID), - } + db: db, + }, originTx +} - ret.mempool = newMempool(env.utxoDB.GlobalTime) +// NewChainExt returns also origin and init transactions. Used for core testing +// +// If 'chainOriginator' is nil, new one is generated and utxodb.FundsFromFaucetAmount (many) base tokens are loaded from the UTXODB faucet. +// ValidatorFeeTarget will be set to OriginatorAgentID, and can be changed after initialization. +// To deploy a chain instance the following steps are performed: +// - chain signature scheme (private key), chain address and chain ID are created +// - empty virtual state is initialized +// - origin transaction is created by the originator and added to the UTXODB +// - 'init' request transaction to the 'root' contract is created and added to UTXODB +// - backlog processing threads (goroutines) are started +// - VM processor cache is initialized +// - 'init' request is run by the VM. The 'root' contracts deploys the rest of the core contracts: +// +// Upon return, the chain is fully functional to process requests +func (env *Solo) NewChainExt( + chainOriginator *cryptolib.KeyPair, + initBaseTokens uint64, + name string, + originParams ...dict.Dict, +) (*Chain, *iotago.Transaction) { + chData, originTx := env.deployChain(chainOriginator, initBaseTokens, name, originParams...) env.glbMutex.Lock() - env.chains[chainID] = ret - env.glbMutex.Unlock() + defer env.glbMutex.Unlock() + ch := env.addChain(chData) - go ret.batchLoop() + ch.log.Infof("chain '%s' deployed. Chain ID: %s", ch.Name, ch.ChainID.String()) + return ch, originTx +} - ret.log.Infof("chain '%s' deployed. Chain ID: %s", ret.Name, ret.ChainID.String()) - return ret, originTx +func (env *Solo) addChain(chData chainData) *Chain { + ch := &Chain{ + chainData: chData, + StateControllerAddress: chData.StateControllerKeyPair.GetPublicKey().AsEd25519Address(), + OriginatorAddress: chData.OriginatorPrivateKey.GetPublicKey().AsEd25519Address(), + OriginatorAgentID: isc.NewAgentID(chData.OriginatorPrivateKey.GetPublicKey().AsEd25519Address()), + Env: env, + store: indexedstore.New(state.NewStore(chData.db)), + proc: processors.MustNew(env.processorConfig), + log: env.logger.Named(chData.Name), + metrics: metrics.NewChainMetricsProvider().GetChainMetrics(chData.ChainID), + mempool: newMempool(env.utxoDB.GlobalTime), + } + env.chains[chData.ChainID] = ch + go ch.batchLoop() + return ch } // AddToLedger adds (synchronously confirms) transaction to the UTXODB ledger. Return error if it is diff --git a/packages/solo/solotest/solo_test.go b/packages/solo/solotest/solo_test.go new file mode 100644 index 0000000000..1430fa22d3 --- /dev/null +++ b/packages/solo/solotest/solo_test.go @@ -0,0 +1,68 @@ +package solo_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/iotaledger/wasp/packages/isc" + "github.com/iotaledger/wasp/packages/solo" + "github.com/iotaledger/wasp/packages/vm/core/accounts" +) + +// This test is an example of how to generate a snapshot from a Solo chain. +// The snapshot is especially useful to test migrations. +func TestSaveSnapshot(t *testing.T) { + // skipped because the generated dump is fairly large + t.SkipNow() + + env := solo.New(t, &solo.InitOptions{AutoAdjustStorageDeposit: true, Debug: true, PrintStackTrace: true}) + ch := env.NewChain() + ch.MustDepositBaseTokensToL2(2*isc.Million, ch.OriginatorPrivateKey) + + // create foundry and native tokens on L2 + sn, nativeTokenID, err := ch.NewFoundryParams(1000).CreateFoundry() + require.NoError(t, err) + // mint some tokens for the user + err = ch.MintTokens(sn, 1000, ch.OriginatorPrivateKey) + require.NoError(t, err) + + _, err = ch.GetNativeTokenIDByFoundrySN(sn) + require.NoError(t, err) + ch.AssertL2NativeTokens(ch.OriginatorAgentID, nativeTokenID, 1000) + + // create NFT on L1 and deposit on L2 + nft, _, err := ch.Env.MintNFTL1(ch.OriginatorPrivateKey, ch.OriginatorAddress, []byte("foobar")) + require.NoError(t, err) + _, err = ch.PostRequestSync( + solo.NewCallParams(accounts.Contract.Name, accounts.FuncDeposit.Name). + WithNFT(nft). + AddBaseTokens(10*isc.Million). + WithMaxAffordableGasBudget(), + ch.OriginatorPrivateKey) + require.NoError(t, err) + + require.NotEmpty(t, ch.L2NFTs(ch.OriginatorAgentID)) + + ch.Env.SaveSnapshot("snapshot.db") +} + +// This test is an example of how to restore a Solo snapshot. +// The snapshot is especially useful to test migrations. +func TestLoadSnapshot(t *testing.T) { + // skipped because the generated dump is fairly large + t.SkipNow() + + env := solo.New(t, &solo.InitOptions{AutoAdjustStorageDeposit: true, Debug: true, PrintStackTrace: true}) + env.LoadSnapshot("snapshot.db") + + ch := env.GetChainByName("chain1") + + require.EqualValues(t, 5, ch.LatestBlockIndex()) + + nativeTokenID, err := ch.GetNativeTokenIDByFoundrySN(1) + require.NoError(t, err) + ch.AssertL2NativeTokens(ch.OriginatorAgentID, nativeTokenID, 1000) + + require.NotEmpty(t, ch.L2NFTs(ch.OriginatorAgentID)) +} diff --git a/packages/testutil/utxodb/utxodb.go b/packages/testutil/utxodb/utxodb.go index 58074dfbe1..5ba60e9544 100644 --- a/packages/testutil/utxodb/utxodb.go +++ b/packages/testutil/utxodb/utxodb.go @@ -1,6 +1,7 @@ package utxodb import ( + "encoding/hex" "errors" "fmt" "math/big" @@ -450,3 +451,64 @@ func (u *UtxoDB) checkLedgerBalance() { panic("utxodb: wrong ledger balance") } } + +type UtxoDBState struct { + Supply uint64 + Transactions map[string]*iotago.Transaction + UTXO []string + GlobalLogicalTime time.Time + TimeStep time.Duration +} + +func (u *UtxoDB) State() *UtxoDBState { + u.mutex.Lock() + defer u.mutex.Unlock() + + txs := make(map[string]*iotago.Transaction) + for txid, tx := range u.transactions { + txs[hex.EncodeToString(txid[:])] = tx + } + + utxo := make([]string, 0, len(u.utxo)) + for oid := range u.utxo { + utxo = append(utxo, hex.EncodeToString(oid[:])) + } + + return &UtxoDBState{ + Supply: u.supply, + Transactions: txs, + UTXO: utxo, + GlobalLogicalTime: u.globalLogicalTime, + TimeStep: u.timeStep, + } +} + +func (u *UtxoDB) SetState(state *UtxoDBState) { + u.mutex.Lock() + defer u.mutex.Unlock() + + u.supply = state.Supply + u.transactions = make(map[iotago.TransactionID]*iotago.Transaction) + u.utxo = make(map[iotago.OutputID]struct{}) + u.globalLogicalTime = state.GlobalLogicalTime + u.timeStep = state.TimeStep + + for s, tx := range state.Transactions { + b, err := hex.DecodeString(s) + if err != nil { + panic(err) + } + var txid iotago.TransactionID + copy(txid[:], b) + u.transactions[txid] = tx + } + for _, s := range state.UTXO { + b, err := hex.DecodeString(s) + if err != nil { + panic(err) + } + var oid iotago.OutputID + copy(oid[:], b) + u.utxo[oid] = struct{}{} + } +}