diff --git a/cmd/atomoned/cmd/inplace_testnet.go b/cmd/atomoned/cmd/inplace_testnet.go new file mode 100644 index 000000000..71069f111 --- /dev/null +++ b/cmd/atomoned/cmd/inplace_testnet.go @@ -0,0 +1,243 @@ +package cmd + +import ( + "errors" + "io" + "strings" + + "github.com/spf13/cast" + "github.com/spf13/cobra" + + "github.com/cometbft/cometbft/crypto" + "github.com/cometbft/cometbft/libs/bytes" + cmtproto "github.com/cometbft/cometbft/proto/tendermint/types" + + dbm "github.com/cosmos/cosmos-db" + + "cosmossdk.io/log" + "cosmossdk.io/math" + storetypes "cosmossdk.io/store/types" + + "github.com/cosmos/cosmos-sdk/client/flags" + codectypes "github.com/cosmos/cosmos-sdk/codec/types" + "github.com/cosmos/cosmos-sdk/crypto/keys/ed25519" + "github.com/cosmos/cosmos-sdk/server" + servertypes "github.com/cosmos/cosmos-sdk/server/types" + sdk "github.com/cosmos/cosmos-sdk/types" + distrtypes "github.com/cosmos/cosmos-sdk/x/distribution/types" + minttypes "github.com/cosmos/cosmos-sdk/x/mint/types" + slashingtypes "github.com/cosmos/cosmos-sdk/x/slashing/types" + stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" + + atomone "github.com/atomone-hub/atomone/app" + "github.com/atomone-hub/atomone/app/params" +) + +const valVotingPower int64 = 900000000000000 + +var flagAccountsToFund = "accounts-to-fund" + +type valArgs struct { + newValAddr bytes.HexBytes + newOperatorAddress string + newValPubKey crypto.PubKey + accountsToFund []string + upgradeToTrigger string + homeDir string +} + +func NewInPlaceTestnetCmd() *cobra.Command { + cmd := server.InPlaceTestnetCreator(newTestnetApp) + cmd.Example = `atomoned in-place-testnet testing-1 atonevaloper1w7f3xx7e75p4l7qdym5msqem9rd4dyc4jfa7ag --home $HOME/.atomone/validator1 --validator-privkey=6dq+/KHNvyiw2TToCgOpUpQKIzrLs69Rb8Az39xvmxPHNoPxY1Cil8FY+4DhT9YwD6s0tFABMlLcpaylzKKBOg== --accounts-to-fund="atone1f7twgcq4ypzg7y24wuywy06xmdet8pc4m7dv9c,atone1qvuhm5m644660nd8377d6l7yz9e9hhm9hv8p87"` + + cmd.Flags().String(flagAccountsToFund, "", "Comma-separated list of account addresses that will be funded for testing purposes") + return cmd +} + +// newTestnetApp starts by running the normal newApp method. From there, the app interface returned is modified in order +// for a testnet to be created from the provided app. +func newTestnetApp(logger log.Logger, db dbm.DB, traceStore io.Writer, appOpts servertypes.AppOptions) servertypes.Application { + // Create an app and type cast to an App + newApp := newApp(logger, db, traceStore, appOpts) + testApp, ok := newApp.(*atomone.AtomOneApp) + if !ok { + panic("app created from newApp is not of type App") + } + + // Get command args + args, err := getCommandArgs(appOpts) + if err != nil { + panic(err) + } + + return initAppForTestnet(testApp, args) +} + +func initAppForTestnet(app *atomone.AtomOneApp, args valArgs) *atomone.AtomOneApp { + // Required Changes: + // + ctx := app.NewUncachedContext(true, cmtproto.Header{}) + + pubkey := &ed25519.PubKey{Key: args.newValPubKey.Bytes()} + pubkeyAny, err := codectypes.NewAnyWithValue(pubkey) + handleErr(err) + + // STAKING + // + + // Create Validator struct for our new validator. + newVal := stakingtypes.Validator{ + OperatorAddress: args.newOperatorAddress, + ConsensusPubkey: pubkeyAny, + Jailed: false, + Status: stakingtypes.Bonded, + Tokens: math.NewInt(valVotingPower), + DelegatorShares: math.LegacyMustNewDecFromStr("10000000"), + Description: stakingtypes.Description{ + Moniker: "Testnet Validator", + }, + Commission: stakingtypes.Commission{ + CommissionRates: stakingtypes.CommissionRates{ + Rate: math.LegacyMustNewDecFromStr("0.05"), + MaxRate: math.LegacyMustNewDecFromStr("0.1"), + MaxChangeRate: math.LegacyMustNewDecFromStr("0.05"), + }, + }, + MinSelfDelegation: math.OneInt(), + } + + validator, err := app.StakingKeeper.ValidatorAddressCodec().StringToBytes(newVal.GetOperator()) + handleErr(err) + + // Remove all validators from power store + stakingKey := app.GetKey(stakingtypes.ModuleName) + stakingStore := ctx.KVStore(stakingKey) + iterator, err := app.StakingKeeper.ValidatorsPowerStoreIterator(ctx) + handleErr(err) + + for ; iterator.Valid(); iterator.Next() { + stakingStore.Delete(iterator.Key()) + } + iterator.Close() + + // Remove all validators from last validators store + iterator, err = app.StakingKeeper.LastValidatorsIterator(ctx) + handleErr(err) + + for ; iterator.Valid(); iterator.Next() { + stakingStore.Delete(iterator.Key()) + } + iterator.Close() + + // Remove all validators from validators store + iterator = stakingStore.Iterator(stakingtypes.ValidatorsKey, storetypes.PrefixEndBytes(stakingtypes.ValidatorsKey)) + for ; iterator.Valid(); iterator.Next() { + stakingStore.Delete(iterator.Key()) + } + iterator.Close() + + // Remove all validators from unbonding queue + iterator = stakingStore.Iterator(stakingtypes.ValidatorQueueKey, storetypes.PrefixEndBytes(stakingtypes.ValidatorQueueKey)) + for ; iterator.Valid(); iterator.Next() { + stakingStore.Delete(iterator.Key()) + } + iterator.Close() + + // Add our validator to power and last validators store + handleErr(app.StakingKeeper.SetValidator(ctx, newVal)) + handleErr(app.StakingKeeper.SetValidatorByConsAddr(ctx, newVal)) + handleErr(app.StakingKeeper.SetValidatorByPowerIndex(ctx, newVal)) + handleErr(app.StakingKeeper.SetLastValidatorPower(ctx, validator, 0)) + handleErr(app.StakingKeeper.Hooks().AfterValidatorCreated(ctx, validator)) + + // DISTRIBUTION + // + + // Initialize records for this validator across all distribution stores + handleErr(app.DistrKeeper.SetValidatorHistoricalRewards(ctx, validator, 0, distrtypes.NewValidatorHistoricalRewards(sdk.DecCoins{}, 1))) + handleErr(app.DistrKeeper.SetValidatorCurrentRewards(ctx, validator, distrtypes.NewValidatorCurrentRewards(sdk.DecCoins{}, 1))) + handleErr(app.DistrKeeper.SetValidatorAccumulatedCommission(ctx, validator, distrtypes.InitialValidatorAccumulatedCommission())) + handleErr(app.DistrKeeper.SetValidatorOutstandingRewards(ctx, validator, distrtypes.ValidatorOutstandingRewards{Rewards: sdk.DecCoins{}})) + + // SLASHING + // + + // Set validator signing info for our new validator. + newConsAddr := sdk.ConsAddress(args.newValAddr.Bytes()) + newValidatorSigningInfo := slashingtypes.ValidatorSigningInfo{ + Address: newConsAddr.String(), + StartHeight: app.LastBlockHeight() - 1, + Tombstoned: false, + } + _ = app.SlashingKeeper.SetValidatorSigningInfo(ctx, newConsAddr, newValidatorSigningInfo) + + // BANK + // + bondDenom, err := app.StakingKeeper.BondDenom(ctx) + handleErr(err) + if bondDenom == "" { + bondDenom = params.BondDenom + } + + defaultCoins := sdk.NewCoins(sdk.NewInt64Coin(bondDenom, 1000000000)) + + // Fund local accounts + for _, accountStr := range args.accountsToFund { + handleErr(app.BankKeeper.MintCoins(ctx, minttypes.ModuleName, defaultCoins)) + + account, err := app.AccountKeeper.AddressCodec().StringToBytes(accountStr) + handleErr(err) + + handleErr(app.BankKeeper.SendCoinsFromModuleToAccount(ctx, minttypes.ModuleName, account, defaultCoins)) + } + + return app +} + +// parse the input flags and returns valArgs +func getCommandArgs(appOpts servertypes.AppOptions) (valArgs, error) { + args := valArgs{} + + newValAddr, ok := appOpts.Get(server.KeyNewValAddr).(bytes.HexBytes) + if !ok { + return args, errors.New("newValAddr is not of type bytes.HexBytes") + } + args.newValAddr = newValAddr + newValPubKey, ok := appOpts.Get(server.KeyUserPubKey).(crypto.PubKey) + if !ok { + return args, errors.New("newValPubKey is not of type crypto.PubKey") + } + args.newValPubKey = newValPubKey + newOperatorAddress, ok := appOpts.Get(server.KeyNewOpAddr).(string) + if !ok { + return args, errors.New("newOperatorAddress is not of type string") + } + args.newOperatorAddress = newOperatorAddress + upgradeToTrigger, ok := appOpts.Get(server.KeyTriggerTestnetUpgrade).(string) + if !ok { + return args, errors.New("upgradeToTrigger is not of type string") + } + args.upgradeToTrigger = upgradeToTrigger + + // parsing and set accounts to fund + accountsString := cast.ToString(appOpts.Get(flagAccountsToFund)) + if len(accountsString) > 0 { + args.accountsToFund = strings.Split(accountsString, ",") + } + + // home dir + homeDir := cast.ToString(appOpts.Get(flags.FlagHome)) + if homeDir == "" { + return args, errors.New("invalid home dir") + } + args.homeDir = homeDir + + return args, nil +} + +// handleErr prints the error and exits the program if the error is not nil +func handleErr(err error) { + if err != nil { + panic(err) + } +} diff --git a/cmd/atomoned/cmd/root.go b/cmd/atomoned/cmd/root.go index e80fe4c42..35eccda4e 100644 --- a/cmd/atomoned/cmd/root.go +++ b/cmd/atomoned/cmd/root.go @@ -146,6 +146,7 @@ func initRootCmd( ) { rootCmd.AddCommand( genutilcli.InitCmd(basicManager, atomone.DefaultNodeHome), + NewInPlaceTestnetCmd(), tmcli.NewCompletionCmd(rootCmd, true), NewTestnetCmd(basicManager, banktypes.GenesisBalancesIterator{}), addDebugCommands(debug.Cmd()), diff --git a/docs/README.md b/docs/README.md index 78eb9b886..dda063b16 100644 --- a/docs/README.md +++ b/docs/README.md @@ -69,3 +69,88 @@ localnet. the chain data). Block production should restart. 10. Check that the upgrade procedure has been executed properly. 11. Restart the node to ensure it continues producing blocks after the upgrade. + +## In-place Testnet from Mainnet State + +The `in-place-testnet` command takes an existing chain's data directory +(typically a mainnet snapshot) and converts it into a single-validator +testnet running locally. This is useful to: + +- Dry-run an upgrade handler against real mainnet state. +- Reproduce a mainnet bug locally with full balances and history. +- Test governance flows that require concentrated voting power (your sole + validator can pass any active proposal in seconds). + +### Setup + +1. Initialize a fresh node home: + ```sh + atomoned init localnet --home ~/.atomone/validator1 --chain-id atomone-1 + ``` + +2. Replace the generated genesis with the `atomone-1` mainnet genesis: + ```sh + wget -O ~/.atomone/validator1/config/genesis.json \ + https://snapshots.polkachu.com/genesis/atomone/genesis.json + ``` + +3. Set `minimum-gas-prices` in `~/.atomone/validator1/config/app.toml` (e.g. + `0uatone`) and wire `seeds` / `persistent_peers` in `config.toml` from the + [`atomone-hub/networks`](https://github.com/atomone-hub/networks/tree/main/atomone-1) + repo. + +4. Download a recent mainnet snapshot (e.g. from + [polkachu](https://polkachu.com/tendermint_snapshots/atomone) or itrocket) + and extract it. `lz4` is required for decompression. + ```sh + rm -rf ~/.atomone/validator1/data && mkdir ~/.atomone/validator1/data + echo '{ "height": "0", "round": 0, "step": 0 }' \ + > ~/.atomone/validator1/data/priv_validator_state.json + lz4 -d .tar.lz4 -c | tar -x -C ~/.atomone/validator1 + ``` + +5. Start the node so it loads the snapshot, then stop it cleanly with + `Ctrl-C` once `atomoned status` shows the snapshot height. The node does + not need to be caught up to tip; `in-place-testnet` only needs the latest + committed block in the blockstore. + ```sh + atomoned start --home ~/.atomone/validator1 --x-crisis-skip-assert-invariants + ``` + + The binary version must be compatible with the snapshot height (e.g. + `v3.3.0` for a current mainnet snapshot). To dry-run a future upgrade, + load the snapshot with the pre-upgrade binary first, then run + `in-place-testnet` with the post-upgrade binary and + `--trigger-testnet-upgrade=`. + +### Convert to Testnet + +1. Add a local key whose valoper address will become the new sole validator: + ```sh + atomoned keys add testnet-val --home ~/.atomone/validator1 --keyring-backend test + VAL_OPER=$(atomoned keys show testnet-val --bech val -a \ + --home ~/.atomone/validator1 --keyring-backend test) + ACCOUNT=$(atomoned keys show testnet-val -a \ + --home ~/.atomone/validator1 --keyring-backend test) + ``` + +2. Run `in-place-testnet`: + ```sh + atomoned in-place-testnet testing-1 "$VAL_OPER" \ + --home ~/.atomone/validator1 \ + --accounts-to-fund="$ACCOUNT" + ``` + Confirm with `y` when prompted. The node will restart with chain-id + `testing-1`, your local key as the only validator, and the funded + accounts holding 1000 ATONE each. + +3. Verify: + ```sh + atomoned status --home ~/.atomone/validator1 \ + | jq '.NodeInfo.network, .ValidatorInfo' + atomoned q staking validators --home ~/.atomone/validator1 -o json \ + | jq '.validators | length' + atomoned q bank balances "$ACCOUNT" --home ~/.atomone/validator1 + ``` + Expect `network: "testing-1"`, exactly one validator, and + `1000000000 uatone` on the funded account.