Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Morse->Shannon Migration] state export/import - collect accounts #1039

Open
wants to merge 4 commits into
base: scaffold/migration-module
Choose a base branch
from
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
3,561 changes: 3,561 additions & 0 deletions api/poktroll/migration/legacy.pulsar.go

Large diffs are not rendered by default.

3,374 changes: 3,374 additions & 0 deletions api/poktroll/migration/types.pulsar.go

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions cmd/poktrolld/cmd/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"github.com/spf13/viper"

"github.com/pokt-network/poktroll/app"
"github.com/pokt-network/poktroll/cmd/poktrolld/cmd/migrate"
)

func initRootCmd(
Expand All @@ -52,6 +53,7 @@ func initRootCmd(
queryCommand(),
txCommand(),
keys.Commands(),
migrate.MigrateCmd(),
)
}

Expand Down
191 changes: 191 additions & 0 deletions cmd/poktrolld/cmd/migrate/migrate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
package migrate
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that this is all new code, let's use autocli instead.

Copy link
Contributor Author

@bryanchriswhite bryanchriswhite Jan 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AutoCLI does not apply here because there is not gRPC service, message, or query.

The purpose of this command is to facilitate the deterministic (i.e. reproducible) transformation from the Morse export data structure (MorseStateExport) into the Shannon import data structure (MorseAccountState). It does not interact with the network directly.


import (
"fmt"
"os"

cosmosmath "cosmossdk.io/math"
cmtjson "github.com/cometbft/cometbft/libs/json"
"github.com/spf13/cobra"

"github.com/pokt-network/poktroll/app/volatile"
migrationtypes "github.com/pokt-network/poktroll/x/migration/types"
)

var collectMorseAccountsCmd = &cobra.Command{
Use: "collect-morse-accounts [morse-state-path] [morse-accounts-path]",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See x/supplier/module/autocli.go and please add some examples in a similar style (and whitespace formatting) after you move to autocli

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💯 - autoCLI only applies to the claim step though, so you'll see skip: true until #1046.

See: #1032 (comment)

Args: cobra.ExactArgs(2),
Short: "Collect all account balances and corresponding stakes from the JSON file at [morse-state-path] and outputs them as JSON to [morse-accounts-path]",
Long: `Collects the account balances and corresponding stakes from the MorseStateExport JSON file at morse-state-path
and outputs them as a MorseAccountState JSON to morse-accounts-path for use with
Shannon's MsgUploadMorseState. The Morse state export is generated via the Morse CLI:
pocket util export-genesis-for-reset [height] [new-chain-id] > morse-state-export.json`,
RunE: runCollectMorseAccounts,
}

func MigrateCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "migrate",
Short: "Migration commands",
}
cmd.AddCommand(collectMorseAccountsCmd)

return cmd
}

// runCollectedMorseAccounts is run by the `poktrolld migrate collect-morse-accounts` command.
func runCollectMorseAccounts(cmd *cobra.Command, args []string) error {
inputPath := args[0]
outputPath := args[1]

return collectMorseAccounts(inputPath, outputPath)
}

// collectMorseAccounts transforms the JSON serialized MorseStateExport at
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any chance you can add an "integration" test (using that word lightly) of sorts so we even have examples (input & output) .json in one of the test fixtures for reference?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've been avoiding E2E tests for the time being. migrate_test.go exercises the export/import transform logic using dynamically generated fixture data. There is also a test for the fixture generator that ensures that it is solid.

If we wanted to integrate any more than this, we would require a running and synced Morse node (local or remote). I didn't see any value in pursuing this given the coverage that the current approach provides.

Additionally, there are integration-app level tests in #1047 which exercise the MorseAccountState import, and will be more in #1048 which exercise the claiming.

// inputStatePath into a JSON serialized MorseAccountState at outputStatePath.
func collectMorseAccounts(inputStatePath, outputStatePath string) error {
if err := validatePathIsFile(inputStatePath); err != nil {
return err
}

inputStateJSON, err := os.ReadFile(inputStatePath)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#PUC w/ a small one comment one-liner above each section.

if err != nil {
return err
}

inputState := new(migrationtypes.MorseStateExport)
if err = cmtjson.Unmarshal(inputStateJSON, inputState); err != nil {
return err
}

outputStateJSON, err := transformMorseState(inputState)
if err != nil {
return err
}

if err = os.WriteFile(outputStatePath, outputStateJSON, 0644); err != nil {
return err
}

return nil
}

// validatePathIsFile returns an error if the given path does not exist or is not a file.
func validatePathIsFile(path string) error {
info, err := os.Stat(path)
if err != nil {
return err
}

if info.IsDir() {
return fmt.Errorf("[morse-JSON-input-path] cannot be a directory")
}

return nil
}

// transformMorseState consolidates the Morse account balance, application stake,
// and supplier stake for each account as an entry in the resulting MorseAccountState.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about validator stake?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My understanding is that "supplier stake" is identical to "validator stake" in Morse terms. I intentionally chose Shannon terms for all the comments for consistency.

func transformMorseState(inputState *migrationtypes.MorseStateExport) ([]byte, error) {
morseWorkspace := &morseImportWorkspace{
addressToIdx: make(map[string]uint64),
accounts: make([]*migrationtypes.MorseAccount, 0),
}

// Iterate over accounts and copy the balances.
if err := collectInputAccountBalances(inputState, morseWorkspace); err != nil {
return nil, err
}

// Iterate over applications and add the stakes to the corresponding account balances.
if err := collectInputApplicationStakes(inputState, morseWorkspace); err != nil {
return nil, err
}

// Iterate over suppliers and add the stakes to the corresponding account balances.
err := collectInputSupplierStakes(inputState, morseWorkspace)
if err != nil {
return nil, err
}

morseAccountState := &migrationtypes.MorseAccountState{Accounts: morseWorkspace.accounts}
return cmtjson.Marshal(morseAccountState)
}

// collectInputAccountBalances iterates over the accounts in the inputState and
// adds the balances to the corresponding account balances in the morseWorkspace.
func collectInputAccountBalances(inputState *migrationtypes.MorseStateExport, morseWorkspace *morseImportWorkspace) error {
for _, exportAccount := range inputState.AppState.Auth.Accounts {
// DEV_NOTE: Ignore module accounts.
if exportAccount.Type != "posmint/Account" {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

@bryanchriswhite bryanchriswhite Jan 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this a module account?

The question isn't quite clear to me. The Morse data structure BaseAccount is used in Morse, but is seralized as a pb.Any type. This is the reason for the MorseAuthAccount type, which includes the type field (to avoid having to deal with this additional and unnecessary complexity). Also note that the module account data structure is different, hence the use of pb.Any.

With respect to the morse state export / account state import, my understanding is that we're only interested in externally owned accounts. Do you see a reason to migrate module accounts as well?

continue
}

addr := exportAccount.Value.Address.String()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s/addr/morseAddr

morseWorkspace.ensureAccount(addr, exportAccount)

coins := exportAccount.Value.Coins
if len(coins) == 0 {
return nil
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Log on this error. Should it ever actually happen?

}

// DEV_NOTE: SHOULD ONLY be one denom (upokt).
coin := coins[0]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you assert that it's exactly one?

if coin.Denom != volatile.DenomuPOKT {
return fmt.Errorf("unsupported denom %q", coin.Denom)
}

if err := morseWorkspace.addUpokt(addr, coin.Amount); err != nil {
return err
}
}
return nil
}

// collectInputApplicationStakes iterates over the applications in the inputState and
// adds the stake to the corresponding account balances in the morseWorkspace.
func collectInputApplicationStakes(inputState *migrationtypes.MorseStateExport, morseWorkspace *morseImportWorkspace) error {
for _, exportApplication := range inputState.AppState.Application.Applications {
addr := exportApplication.Address.String()

// DEV_NOTE: An account SHOULD exist for each actor.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Log if not

if !morseWorkspace.hasAccount(addr) {
// TODO_IN_THIS_COMMIT: consolidate error types...
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's do it

return fmt.Errorf("account %q not found", addr)
}

appStakeAmtUpokt, ok := cosmosmath.NewIntFromString(exportApplication.StakedTokens)
if !ok {
return fmt.Errorf("failed to parse application stake amount %q", exportApplication.StakedTokens)
}

if err := morseWorkspace.addUpokt(addr, appStakeAmtUpokt); err != nil {
return err
}
}
return nil
}

// collectInputSupplierStakes iterates over the suppliers in the inputState and
// adds the stake to the corresponding account balances in the morseWorkspace.
func collectInputSupplierStakes(inputState *migrationtypes.MorseStateExport, morseWorkspace *morseImportWorkspace) error {
for _, exportSupplier := range inputState.AppState.Pos.Validators {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ShannonSuppliers == MorseNode (aka Morse Servicer)
ShannonValidator == MorseValidator (aka Morse Full Node)

Only top 1000 of staked validators are ACTUAL validators in Morse

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My understanding was that Morse supplier/node/servicer actors are all tendermint validators, and that tendermint uses sortition over the all validators to determine the active/voting set. I would have to look deeper into how Morse handles supplier staking to confirm/deny. According to tendermint v0.34 docs, the only ways for validators to be added are via genesis or an EndBlock message.

addr := exportSupplier.Address.String()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
addr := exportSupplier.Address.String()
supplierAddr := exportSupplier.Address.String()


// DEV_NOTE: An account SHOULD exist for each actor.
if !morseWorkspace.hasAccount(addr) {
// TODO_IN_THIS_COMMIT: consolidate error types...
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Let's do it
  2. Let's add some loggin

return fmt.Errorf("account %q not found", addr)
}

supplierStakeAmtUpokt, ok := cosmosmath.NewIntFromString(exportSupplier.StakedTokens)
if !ok {
return fmt.Errorf("failed to parse supplier stake amount %q", exportSupplier.StakedTokens)
}

if err := morseWorkspace.addUpokt(addr, supplierStakeAmtUpokt); err != nil {
return err
}
}
return nil
}
184 changes: 184 additions & 0 deletions cmd/poktrolld/cmd/migrate/migrate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
package migrate

import (
"encoding/binary"
"fmt"
"math"
"math/rand"
"os"
"path/filepath"
"strings"
"testing"

cometcrypto "github.com/cometbft/cometbft/crypto/ed25519"
cmtjson "github.com/cometbft/cometbft/libs/json"
cosmostypes "github.com/cosmos/cosmos-sdk/types"
"github.com/regen-network/gocuke"
"github.com/stretchr/testify/require"

"github.com/pokt-network/poktroll/app/volatile"
migrationtypes "github.com/pokt-network/poktroll/x/migration/types"
)

func TestCollectMorseAccounts(t *testing.T) {
tmpDir := t.TempDir()
outputPath := filepath.Join(tmpDir, "morse-state-output.json")
inputFile, err := os.CreateTemp(tmpDir, "morse-state-input.json")
require.NoError(t, err)

morseStateExportBz, morseAccountStateBz := newMorseStateExportAndAccountState(t, 10)
_, err = inputFile.Write(morseStateExportBz)
require.NoError(t, err)

err = inputFile.Close()
require.NoError(t, err)

// Call the function under test.
err = collectMorseAccounts(inputFile.Name(), outputPath)
require.NoError(t, err)

outputJSON, err := os.ReadFile(outputPath)
require.NoError(t, err)

expectedJSON := string(morseAccountStateBz)
require.NoError(t, err)

// Strip all whitespace from the expected JSON.
expectedJSON = strings.ReplaceAll(expectedJSON, "\n", "")
expectedJSON = strings.ReplaceAll(expectedJSON, " ", "")

require.NoError(t, err)
require.Equal(t, expectedJSON, string(outputJSON))
}

func TestNewTestMorseStateExport(t *testing.T) {
for i := 1; i < 10; i++ {
t.Run(fmt.Sprintf("num_accounts=%d", i), func(t *testing.T) {
morseStateExport := new(migrationtypes.MorseStateExport)
stateExportBz, _ := newMorseStateExportAndAccountState(t, i)
err := cmtjson.Unmarshal(stateExportBz, morseStateExport)
require.NoError(t, err)

exportAccounts := morseStateExport.AppState.Auth.Accounts
require.Equal(t, i, len(exportAccounts))

expectedShannonBalance := fmt.Sprintf("%d%d%d0%d%d%d", i, i, i, i, i, i)
morseAccountState := new(migrationtypes.MorseAccountState)
morseAccountStateBz, err := transformMorseState(morseStateExport)
require.NoError(t, err)

err = cmtjson.Unmarshal(morseAccountStateBz, morseAccountState)
require.NoError(t, err)

require.Equal(t, expectedShannonBalance, morseAccountState.Accounts[i-1].Coins[0].Amount.String())
})
}
}

func BenchmarkTransformMorseState(b *testing.B) {
for i := 0; i < 5; i++ {
numAccounts := int(math.Pow10(i + 1))
morseStateExport := new(migrationtypes.MorseStateExport)
morseStateExportBz, _ := newMorseStateExportAndAccountState(b, numAccounts)
err := cmtjson.Unmarshal(morseStateExportBz, morseStateExport)
require.NoError(b, err)

b.Run(fmt.Sprintf("num_accounts=%d", numAccounts), func(b *testing.B) {

// Call the function under test.
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err = transformMorseState(morseStateExport)
require.NoError(b, err)
}
})
}
}

// TODO_CONSIDERATION: Test/benchmark execution speed can be optimized by refactoring this to a pre-generate fixture.
func newMorseStateExportAndAccountState(
t gocuke.TestingT,
numAccounts int,
) (morseStateExportBz []byte, morseAccountStateBz []byte) {
morseStateExport := &migrationtypes.MorseStateExport{
AppHash: "",
AppState: &migrationtypes.MorseAppState{
Application: &migrationtypes.MorseApplications{},
Auth: &migrationtypes.MorseAuth{},
Pos: &migrationtypes.MorsePos{},
},
}

morseAccountState := &migrationtypes.MorseAccountState{
Accounts: make([]*migrationtypes.MorseAccount, numAccounts),
}

for i := 1; i < numAccounts+1; i++ {
seedUint := rand.Uint64()
seedBz := make([]byte, 8)
binary.LittleEndian.PutUint64(seedBz, seedUint)
privKey := cometcrypto.GenPrivKeyFromSecret(seedBz)
pubKey := privKey.PubKey()
balanceAmount := int64(1e6*i + i) // i_000_00i
appStakeAmount := int64(1e5*i + (i * 10)) // i00_0i0
supplierStakeAmount := int64(1e4*i + (i * 100)) // i0_i00
sumAmount := balanceAmount + appStakeAmount + supplierStakeAmount // i_ii0_iii

// Add an account.
morseStateExport.AppState.Auth.Accounts = append(
morseStateExport.AppState.Auth.Accounts,
&migrationtypes.MorseAuthAccount{
Type: "posmint/Account",
Value: &migrationtypes.MorseAccount{
Address: pubKey.Address(),
Coins: cosmostypes.NewCoins(cosmostypes.NewInt64Coin(volatile.DenomuPOKT, balanceAmount)),
PubKey: &migrationtypes.MorsePublicKey{
Value: pubKey.Bytes(),
},
},
},
)

// Add an application.
morseStateExport.AppState.Application.Applications = append(
morseStateExport.AppState.Application.Applications,
&migrationtypes.MorseApplication{
Address: pubKey.Address(),
PublicKey: pubKey.Bytes(),
Jailed: false,
Status: 2,
StakedTokens: fmt.Sprintf("%d", appStakeAmount),
},
)

// Add a supplier.
morseStateExport.AppState.Pos.Validators = append(
morseStateExport.AppState.Pos.Validators,
&migrationtypes.MorseValidator{
Address: pubKey.Address(),
PublicKey: pubKey.Bytes(),
Jailed: false,
Status: 2,
StakedTokens: fmt.Sprintf("%d", supplierStakeAmount),
},
)

// Add the account to the morseAccountState.
morseAccountState.Accounts[i-1] = &migrationtypes.MorseAccount{
Address: pubKey.Address(),
Coins: cosmostypes.NewCoins(cosmostypes.NewInt64Coin(volatile.DenomuPOKT, sumAmount)),
PubKey: &migrationtypes.MorsePublicKey{
Value: pubKey.Bytes(),
},
}
}

var err error
morseStateExportBz, err = cmtjson.Marshal(morseStateExport)
require.NoError(t, err)

morseAccountStateBz, err = cmtjson.Marshal(morseAccountState)
require.NoError(t, err)

return morseStateExportBz, morseAccountStateBz
}
Loading
Loading