diff --git a/changelog.md b/changelog.md index 53972979b2..81c129e8e3 100644 --- a/changelog.md +++ b/changelog.md @@ -20,6 +20,7 @@ * [4561](https://github.com/zeta-chain/node/pull/4561) - add signet to BTC client `resolveParams` to fix e2e `unknown chain params` error * [4569](https://github.com/zeta-chain/node/pull/4569) - tighten MtA proof bounds in tss-lib to fix Alpha-Rays / TSSHOCK vulnerability * [4597](https://github.com/zeta-chain/node/pull/4597) - respect `DisableTssBlockScan` in Bitcoin observer to align inbound observation behavior with EVM observer +* [4647](https://github.com/zeta-chain/node/pull/4647) - `zetatool snapshot` refuses an export with incomplete staking or distribution records ### Tests diff --git a/cmd/zetatool/cli/snapshot.go b/cmd/zetatool/cli/snapshot.go index 133ec005d3..7cfceadbcd 100644 --- a/cmd/zetatool/cli/snapshot.go +++ b/cmd/zetatool/cli/snapshot.go @@ -41,12 +41,26 @@ func NewSnapshotCMD() *cobra.Command { Short: "Build a per-address native-ZETA balance snapshot for the Solana migration", Long: `Build the ZETA->Solana migration snapshot (Stage 2) from a zetacored export. -Reads the bank and staking genesis from an export produced with +Reads the bank, staking and distribution genesis from an export produced with --for-zero-height (so withdrawn staking rewards and validator commission are already folded into liquid bank balances) and attributes native ZETA to each claimable address. Module accounts and any pinned contracts (e.g. WZETA) are routed to a single remainder that goes to the migration multisig. +The command refuses an export it cannot fully account for: + - staking records that do not add up: a validator's delegation shares must + sum to its delegator_shares, with no duplicate, unknown-validator or + negative records, and its tokens must be credited to its delegators + - staking pools that do not match exactly: the bonded pool must equal bonded + validator tokens, the not-bonded pool must equal unbonding/unbonded + validator tokens plus every unbonding entry + - an export not taken at zero height: any outstanding rewards, current + rewards or accumulated commission, or a distribution module balance other + than the community pool + - a pin on a staking pool, or a remainder its categories do not explain + - bank balances that do not sum to the recorded supply +These are hard errors; --supply-check=false does not disable them. + Outputs into --output: snapshot_db.csv Postgres COPY-ready (raw azeta + 9-decimal SPL amount) schema.sql matching Postgres table DDL @@ -62,7 +76,8 @@ Outputs into --output: cmd.Flags().StringSlice(flagSnapshotWZeta, nil, "alias for --pin: non-claimable contract addresses (e.g. WZETA)") cmd.Flags(). String(flagSnapshotPinFile, "", "file of non-claimable addresses, one per line (# comments; first field per line is the address)") - cmd.Flags().Bool(flagSnapshotSupplyCheck, true, "fail on any output check (supply mismatch, unmatched pin)") + cmd.Flags(). + Bool(flagSnapshotSupplyCheck, true, "fail on any output check (attributed total, CSV re-sum, unmatched pin)") if err := cmd.MarkFlagRequired(flagSnapshotInput); err != nil { panic(err) @@ -297,7 +312,7 @@ func printSummary(cmd *cobra.Command, gen *snapshot.Genesis, res *snapshot.Resul fmt.Fprintf(out, " validators: %d\n", len(gen.Staking.Validators)) fmt.Fprintf(out, " claimable accts: %d\n", len(res.Accounts)) if res.NonStandard > 0 { - fmt.Fprintf(out, " non-std swept: %d holder(s), %s azeta -> remainder\n", + fmt.Fprintf(out, " non-std swept: %d holder(s), %s liquid azeta -> remainder\n", res.NonStandard, res.NonStandardAzeta) } fmt.Fprintln(out, " buckets (azeta):") @@ -305,6 +320,13 @@ func printSummary(cmd *cobra.Command, gen *snapshot.Genesis, res *snapshot.Resul fmt.Fprintf(out, " staked: %s\n", res.TotalStaked) fmt.Fprintf(out, " unbonding: %s\n", res.TotalUnbonding) fmt.Fprintf(out, " remainder: %s\n", res.Remainder) + b := res.Breakdown + fmt.Fprintln(out, " remainder by category (azeta):") + fmt.Fprintf(out, " module accts: %s\n", b.ModuleAccounts) + fmt.Fprintf(out, " zero address: %s\n", b.ZeroAddress) + fmt.Fprintf(out, " non-20-byte: %s\n", b.NonStandard) + fmt.Fprintf(out, " pins: %s\n", b.Pinned) + fmt.Fprintf(out, " share dust: %s\n", b.ShareDust) if len(res.Pinned) > 0 { subtotal := sdkmath.ZeroInt() fmt.Fprintln(out, " pinned (non-claimable):") @@ -316,4 +338,5 @@ func printSummary(cmd *cobra.Command, gen *snapshot.Genesis, res *snapshot.Resul } fmt.Fprintf(out, " total supply: %s azeta\n", res.Supply) fmt.Fprintf(out, " SPL cap (9dec): %s\n", res.SPLCap()) + fmt.Fprintf(out, " conversion dust (18->9): %s azeta\n", res.ConversionDust()) } diff --git a/cmd/zetatool/cli/snapshot_test.go b/cmd/zetatool/cli/snapshot_test.go index aa42982792..c9dcc355d8 100644 --- a/cmd/zetatool/cli/snapshot_test.go +++ b/cmd/zetatool/cli/snapshot_test.go @@ -1,6 +1,8 @@ package cli import ( + "bytes" + "encoding/json" "os" "path/filepath" "testing" @@ -8,6 +10,27 @@ import ( "github.com/stretchr/testify/require" ) +const auditPin = "0x3333333333333333333333333333333333333333" + +// auditExport is the path of a ported audit export in the snapshot testdata. +func auditExport(name string) string { + return filepath.Join("..", "snapshot", "testdata", name+".json") +} + +// runSnapshotCmd runs `zetatool snapshot` on input with extra flags and +// returns its stdout. +func runSnapshotCmd(t *testing.T, input string, extra ...string) (string, error) { + t.Helper() + cmd := NewSnapshotCMD() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + args := []string{"--input", input, "--output", t.TempDir(), "--chain-id", "7000", "--pin", auditPin} + cmd.SetArgs(append(args, extra...)) + err := cmd.Execute() + return out.String(), err +} + func writePinFile(t *testing.T, content string) string { t.Helper() path := filepath.Join(t.TempDir(), "pins.txt") @@ -77,3 +100,47 @@ func TestReadPinFileMainnetList(t *testing.T) { require.Len(t, pins, 16) require.Equal(t, "0x5f0b1a82749cb4e2278ec87f8bf6b618dc71a8bf", pins[0], "WZETA is the largest holder") } + +func TestSnapshotCompleteExport(t *testing.T) { + // ACT + out, err := runSnapshotCmd(t, auditExport("complete")) + + // ASSERT + require.NoError(t, err) + require.Contains(t, out, "remainder by category (azeta):") + require.Contains(t, out, "pins: 5000000000000000000") + require.Contains(t, out, "conversion dust (18->9): 0 azeta") +} + +// TestSnapshotRefusalNotSkippable runs an export that lost its unbonding record +// and checks it is refused even with --supply-check=false. +func TestSnapshotRefusalNotSkippable(t *testing.T) { + for _, supplyCheck := range []string{"true", "false"} { + t.Run("supply-check="+supplyCheck, func(t *testing.T) { + // ACT + _, err := runSnapshotCmd(t, auditExport("omitted_unbonding"), "--supply-check="+supplyCheck) + + // ASSERT + require.ErrorContains(t, err, "not-bonded pool balance 100000000000000000000 != ") + }) + } +} + +func TestSnapshotMissingDistribution(t *testing.T) { + // ARRANGE + raw, err := os.ReadFile(auditExport("complete")) + require.NoError(t, err) + var doc map[string]map[string]json.RawMessage + require.NoError(t, json.Unmarshal(raw, &doc)) + delete(doc["app_state"], "distribution") + stripped, err := json.Marshal(doc) + require.NoError(t, err) + input := filepath.Join(t.TempDir(), "export.json") + require.NoError(t, os.WriteFile(input, stripped, 0o600)) + + // ACT + _, err = runSnapshotCmd(t, input) + + // ASSERT + require.ErrorContains(t, err, `app_state is missing the "distribution" module`) +} diff --git a/cmd/zetatool/snapshot/compute.go b/cmd/zetatool/snapshot/compute.go index 8be3f595a0..2eb35182ad 100644 --- a/cmd/zetatool/snapshot/compute.go +++ b/cmd/zetatool/snapshot/compute.go @@ -19,19 +19,43 @@ func SumBankDenom(gen *Genesis, denom string) sdkmath.Int { return total } +// moduleCanonical returns a module account's canonical address. +func moduleCanonical(name string) (string, error) { + canon, err := canonicalFromBytes(authtypes.NewModuleAddress(name).Bytes()) + if err != nil { + return "", fmt.Errorf("canonicalize module account %q: %w", name, err) + } + return canon, nil +} + +// stakingPools maps the canonical address of each staking pool to its module +// name. The pools hold all delegated and unbonding azeta. +func stakingPools() (map[string]string, error) { + pools := make(map[string]string, 2) + for _, name := range []string{stakingtypes.BondedPoolName, stakingtypes.NotBondedPoolName} { + canon, err := moduleCanonical(name) + if err != nil { + return nil, err + } + pools[canon] = name + } + return pools, nil +} + // nonClaimableSet builds the set of canonical addresses that must not be // attributed: the module accounts plus the caller's pins (e.g. WZETA). The pins // are also returned on their own, zero-seeded, so Compute can report how much // azeta each one kept out of attribution (two pin inputs can canonicalize to the -// same account; the map dedupes them). -func nonClaimableSet(pins []string) (map[string]bool, map[string]sdkmath.Int, error) { +// same account; the map dedupes them). A pin on a staking pool is refused: the +// pool's balance is already credited to its delegators. +func nonClaimableSet(pins []string, pools map[string]string) (map[string]bool, map[string]sdkmath.Int, error) { set := make(map[string]bool, len(moduleAccountNames)+len(pins)+1) pinned := make(map[string]sdkmath.Int, len(pins)) set[zeroAddress] = true for _, name := range moduleAccountNames { - canon, err := canonicalFromBytes(authtypes.NewModuleAddress(name).Bytes()) + canon, err := moduleCanonical(name) if err != nil { - return nil, nil, fmt.Errorf("canonicalize module account %q: %w", name, err) + return nil, nil, err } set[canon] = true } @@ -40,6 +64,12 @@ func nonClaimableSet(pins []string) (map[string]bool, map[string]sdkmath.Int, er if err != nil { return nil, nil, fmt.Errorf("canonicalize pin %q: %w", pin, err) } + if pool, isPool := pools[canon]; isPool { + return nil, nil, fmt.Errorf( + "pin %s is the %s staking pool; its balance is already accounted for by validator tokens", + pin, pool, + ) + } set[canon] = true if _, ok := pinned[canon]; !ok { pinned[canon] = sdkmath.ZeroInt() @@ -60,17 +90,48 @@ func nonClaimableSet(pins []string) (map[string]bool, map[string]sdkmath.Int, er // remainder = supply - sum(attributed). It must be >= 0: staked/unbonding azeta // physically sits in the (non-claimable) staking pool module accounts, so those // pool balances cancel against the amounts credited to delegators here. +// +// Before attributing anything, Compute refuses an export with unpaid rewards or +// incomplete staking records (see validateDistribution and validateStaking), and +// afterwards it refuses a remainder its categories do not fully explain. func Compute(gen *Genesis, cfg Config) (*Result, error) { - nonClaimable, pinned, err := nonClaimableSet(cfg.Pins) + if err := validateDistribution(gen, cfg.Denom); err != nil { + return nil, err + } + shareDust, err := validateStaking(gen, cfg.Denom) + if err != nil { + return nil, err + } + pools, err := stakingPools() if err != nil { return nil, err } + nonClaimable, pinned, err := nonClaimableSet(cfg.Pins, pools) + if err != nil { + return nil, err + } + + res := &Result{ + NonStandardAzeta: sdkmath.ZeroInt(), + Breakdown: RemainderBreakdown{ + ModuleAccounts: sdkmath.ZeroInt(), + ZeroAddress: sdkmath.ZeroInt(), + NonStandard: sdkmath.ZeroInt(), + ShareDust: shareDust, + }, + } - // addPinned accumulates what a pin kept out of attribution. The other - // non-claimable addresses (module accounts, the zero address) are not tracked. - addPinned := func(canon string, amt sdkmath.Int) { - if cur, isPin := pinned[canon]; isPin { + // addNonClaimable routes azeta kept out of attribution to its remainder + // category: a pin first, then the zero address, else a module account. + addNonClaimable := func(canon string, amt sdkmath.Int) { + cur, isPin := pinned[canon] + switch { + case isPin: pinned[canon] = cur.Add(amt) + case canon == zeroAddress: + res.Breakdown.ZeroAddress = res.Breakdown.ZeroAddress.Add(amt) + default: + res.Breakdown.ModuleAccounts = res.Breakdown.ModuleAccounts.Add(amt) } } @@ -105,8 +166,6 @@ func Compute(gen *Genesis, cfg Config) (*Result, error) { return acc } - res := &Result{NonStandardAzeta: sdkmath.ZeroInt()} - // liquid for i := range gen.Bank.Balances { bal := gen.Bank.Balances[i] @@ -122,10 +181,14 @@ func Compute(gen *Genesis, cfg Config) (*Result, error) { res.NonStandard++ res.NonStandardAzeta = res.NonStandardAzeta.Add(amt) } + res.Breakdown.NonStandard = res.Breakdown.NonStandard.Add(amt) continue } if nonClaimable[canon] { - addPinned(canon, amt) + // a staking pool's balance is credited to its delegators below + if _, isPool := pools[canon]; !isPool { + addNonClaimable(canon, amt) + } continue } if amt.IsZero() { @@ -142,27 +205,26 @@ func Compute(gen *Genesis, cfg Config) (*Result, error) { if err != nil { return nil, fmt.Errorf("classify delegator %q: %w", del.DelegatorAddress, err) } - // a pinned delegator skips attribution too, but only after the exchange - // rate math below, so its stake is reported against the pin - _, isPin := pinned[canon] - if !ok || (nonClaimable[canon] && !isPin) { - continue - } valCanon, err := Canonical(del.ValidatorAddress) if err != nil { return nil, fmt.Errorf("canonicalize delegation validator %q: %w", del.ValidatorAddress, err) } - val, ok := validators[valCanon] - if !ok { + val, found := validators[valCanon] + if !found { return nil, fmt.Errorf("delegation references unknown validator %q", del.ValidatorAddress) } - if val.DelegatorShares.IsZero() { - return nil, fmt.Errorf("validator %q has zero delegator shares", del.ValidatorAddress) + // floor(shares * tokens / delegator_shares); validateStaking guarantees + // a validator with zero delegator_shares has only zero-share delegations + staked := sdkmath.ZeroInt() + if !val.DelegatorShares.IsZero() { + staked = val.TokensFromShares(del.Shares).TruncateInt() } - // floor(shares * tokens / delegator_shares) - staked := val.TokensFromShares(del.Shares).TruncateInt() - if isPin { - addPinned(canon, staked) + if !ok { + res.Breakdown.NonStandard = res.Breakdown.NonStandard.Add(staked) + continue + } + if nonClaimable[canon] { + addNonClaimable(canon, staked) continue } acc := get(canon) @@ -176,15 +238,16 @@ func Compute(gen *Genesis, cfg Config) (*Result, error) { if err != nil { return nil, fmt.Errorf("classify unbonding delegator %q: %w", ubd.DelegatorAddress, err) } - if !ok { - continue - } sum := sdkmath.ZeroInt() for _, entry := range ubd.Entries { sum = sum.Add(entry.Balance) } + if !ok { + res.Breakdown.NonStandard = res.Breakdown.NonStandard.Add(sum) + continue + } if nonClaimable[canon] { - addPinned(canon, sum) + addNonClaimable(canon, sum) continue } acc := get(canon) @@ -195,7 +258,8 @@ func Compute(gen *Genesis, cfg Config) (*Result, error) { } // finalize sorts the accounts and pins, computes bucket totals and the -// remainder, and verifies the remainder is non-negative. +// remainder, and verifies the remainder is non-negative and exactly explained by +// its categories. func finalize( res *Result, accounts map[string]*Account, @@ -220,8 +284,10 @@ func finalize( }) res.Pinned = make([]PinnedAddr, 0, len(pinned)) + res.Breakdown.Pinned = sdkmath.ZeroInt() for canon, amt := range pinned { res.Pinned = append(res.Pinned, PinnedAddr{Canonical: canon, Azeta: amt}) + res.Breakdown.Pinned = res.Breakdown.Pinned.Add(amt) } sort.Slice(res.Pinned, func(i, j int) bool { if !res.Pinned[i].Azeta.Equal(res.Pinned[j].Azeta) { @@ -237,5 +303,16 @@ func finalize( res.Supply, res.AttributedTotal(), ) } + if bankSum := SumBankDenom(gen, denom); !bankSum.Equal(res.Supply) { + return nil, fmt.Errorf("bank balances sum %s != supply %s", bankSum, res.Supply) + } + // with the supply matching the bank and the staking records validated, the + // categories explain the remainder by construction; this is a backstop + if b := res.Breakdown; !res.Remainder.Equal(b.Total()) { + return nil, fmt.Errorf( + "remainder %s != categorised %s (modules %s, zero address %s, non-20-byte %s, pins %s, share dust %s)", + res.Remainder, b.Total(), b.ModuleAccounts, b.ZeroAddress, b.NonStandard, b.Pinned, b.ShareDust, + ) + } return res, nil } diff --git a/cmd/zetatool/snapshot/compute_test.go b/cmd/zetatool/snapshot/compute_test.go index f127dc509c..c408c3f21a 100644 --- a/cmd/zetatool/snapshot/compute_test.go +++ b/cmd/zetatool/snapshot/compute_test.go @@ -43,8 +43,9 @@ func canonHex(b []byte) string { return "0x" + hex.EncodeToString(b) } // and one external delegation, whose commission liquid + self-stake fold // into the operator account // - an external unbonding delegation -// - a module account (bonded pool) that must NOT be attributed +// - a module account (not-bonded pool) that must NOT be attributed // - a pinned WZETA contract that must NOT be attributed +// - a zero-address delegation that completes the validator's shares func TestCompute(t *testing.T) { // ARRANGE valBytes := addr20(0x02) @@ -52,7 +53,8 @@ func TestCompute(t *testing.T) { eoaBytes := addr20(0x01) unbBytes := addr20(0x04) wzetaBytes := addr20(0x05) - poolBytes := authtypes.NewModuleAddress(stakingtypes.BondedPoolName).Bytes() + zeroBytes := make([]byte, addressLen) + poolBytes := authtypes.NewModuleAddress(stakingtypes.NotBondedPoolName).Bytes() // slashed validator: 90 tokens backing 100 shares -> exchange rate 0.9 validator := stakingtypes.Validator{ @@ -67,23 +69,26 @@ func TestCompute(t *testing.T) { gen := &Genesis{ Bank: banktypes.GenesisState{ - Supply: coin(2100), + Supply: coin(2175), Balances: []banktypes.Balance{ {Address: accBech32(t, eoaBytes), Coins: coin(1000)}, {Address: accBech32(t, valBytes), Coins: coin(500)}, // withdrawn commission {Address: accBech32(t, delBytes), Coins: coin(200)}, {Address: accBech32(t, unbBytes), Coins: coin(10)}, - {Address: accBech32(t, poolBytes), Coins: coin(90)}, // module: not attributed + {Address: accBech32(t, poolBytes), Coins: coin(165)}, // module: not attributed {Address: accBech32(t, wzetaBytes), Coins: coin(300)}, // pinned: not attributed }, }, Staking: stakingtypes.GenesisState{ + Params: stakingtypes.Params{BondDenom: BaseDenom}, Validators: []stakingtypes.Validator{validator}, Delegations: []stakingtypes.Delegation{ // self-delegation: floor(40 * 90/100) = 36 {DelegatorAddress: accBech32(t, valBytes), ValidatorAddress: valBech32(t, valBytes), Shares: sdkmath.LegacyNewDec(40)}, // external: floor(33 * 90/100) = floor(29.7) = 29 {DelegatorAddress: accBech32(t, delBytes), ValidatorAddress: valBech32(t, valBytes), Shares: sdkmath.LegacyNewDec(33)}, + // zero address: floor(27 * 90/100) = floor(24.3) = 24, not attributed + {DelegatorAddress: accBech32(t, zeroBytes), ValidatorAddress: valBech32(t, valBytes), Shares: sdkmath.LegacyNewDec(27)}, }, UnbondingDelegations: []stakingtypes.UnbondingDelegation{ { @@ -135,15 +140,23 @@ func TestCompute(t *testing.T) { // bucket totals require.Equal(t, "1710", res.TotalLiquid.String()) // 1000+500+200+10 - require.Equal(t, "65", res.TotalStaked.String()) // 36+29 + require.Equal(t, "65", res.TotalStaked.String()) // 36+29 require.Equal(t, "75", res.TotalUnbonding.String()) // invariants - require.Equal(t, "2100", res.Supply.String()) + require.Equal(t, "2175", res.Supply.String()) require.Equal(t, "1850", res.AttributedTotal().String()) - require.Equal(t, "250", res.Remainder.String()) + require.Equal(t, "325", res.Remainder.String()) require.False(t, res.Remainder.IsNegative()) require.Equal(t, res.Supply.String(), res.AttributedTotal().Add(res.Remainder).String()) + + // remainder by category: 90 tokens - (36+29+24) staked leaves 1 of share dust + require.Equal(t, "0", res.Breakdown.ModuleAccounts.String(), "the staking pool is in no category") + require.Equal(t, "24", res.Breakdown.ZeroAddress.String()) + require.Equal(t, "0", res.Breakdown.NonStandard.String()) + require.Equal(t, "300", res.Breakdown.Pinned.String()) + require.Equal(t, "1", res.Breakdown.ShareDust.String()) + require.Equal(t, res.Remainder.String(), res.Breakdown.Total().String()) } func TestComputeZeroAddressNotAttributed(t *testing.T) { @@ -160,6 +173,7 @@ func TestComputeZeroAddressNotAttributed(t *testing.T) { {Address: accBech32(t, zeroBytes), Coins: coin(400)}, // zero address: not claimable }, }, + Staking: stakingtypes.GenesisState{Params: stakingtypes.Params{BondDenom: BaseDenom}}, } // ACT @@ -174,6 +188,11 @@ func TestComputeZeroAddressNotAttributed(t *testing.T) { } require.Equal(t, "600", res.AttributedTotal().String()) require.Equal(t, "400", res.Remainder.String(), "zero-address balance folds into remainder") + require.Equal(t, "400", res.Breakdown.ZeroAddress.String()) + require.Equal(t, "0", res.Breakdown.ModuleAccounts.String()) + require.Equal(t, "0", res.Breakdown.NonStandard.String()) + require.Equal(t, "0", res.Breakdown.Pinned.String()) + require.Equal(t, "0", res.Breakdown.ShareDust.String()) } // TestComputePinnedAccounting checks that a pin holding liquid, staked and @@ -186,27 +205,30 @@ func TestComputePinnedAccounting(t *testing.T) { eoaBytes := addr20(0x01) pinBytes := addr20(0x05) - // slashed validator: 90 tokens backing 100 shares -> exchange rate 0.9 + // slashed validator: 18 tokens backing 20 shares -> exchange rate 0.9 validator := stakingtypes.Validator{ OperatorAddress: valBech32(t, valBytes), - Tokens: sdkmath.NewInt(90), - DelegatorShares: sdkmath.LegacyNewDec(100), + Tokens: sdkmath.NewInt(18), + DelegatorShares: sdkmath.LegacyNewDec(20), Status: stakingtypes.Bonded, } coin := func(n int64) sdk.Coins { return sdk.NewCoins(sdk.NewInt64Coin(BaseDenom, n)) } gen := &Genesis{ Bank: banktypes.GenesisState{ - Supply: coin(1000), + Supply: coin(928), Balances: []banktypes.Balance{ {Address: accBech32(t, eoaBytes), Coins: coin(600)}, {Address: accBech32(t, pinBytes), Coins: coin(300)}, + {Address: moduleBech32(t, stakingtypes.BondedPoolName), Coins: coin(18)}, + {Address: moduleBech32(t, stakingtypes.NotBondedPoolName), Coins: coin(10)}, }, }, Staking: stakingtypes.GenesisState{ + Params: stakingtypes.Params{BondDenom: BaseDenom}, Validators: []stakingtypes.Validator{validator}, Delegations: []stakingtypes.Delegation{ - // pinned delegator: floor(20 * 90/100) = 18 + // pinned delegator: floor(20 * 18/20) = 18 { DelegatorAddress: accBech32(t, pinBytes), ValidatorAddress: valBech32(t, valBytes), @@ -241,8 +263,13 @@ func TestComputePinnedAccounting(t *testing.T) { require.Equal(t, "328", res.Pinned[0].Azeta.String(), "liquid 300 + staked 18 + unbonding 10") require.Equal(t, "600", res.AttributedTotal().String()) - require.Equal(t, "400", res.Remainder.String()) + require.Equal(t, "328", res.Remainder.String()) require.Equal(t, res.Supply.String(), res.AttributedTotal().Add(res.Remainder).String()) + require.Equal(t, "328", res.Breakdown.Pinned.String()) + require.Equal(t, "0", res.Breakdown.ModuleAccounts.String()) + require.Equal(t, "0", res.Breakdown.ZeroAddress.String()) + require.Equal(t, "0", res.Breakdown.NonStandard.String()) + require.Equal(t, "0", res.Breakdown.ShareDust.String()) } // TestComputePinNoHolder covers the typo case: a pin that matches nothing in the @@ -255,9 +282,13 @@ func TestComputePinNoHolder(t *testing.T) { gen := &Genesis{ Bank: banktypes.GenesisState{ - Supply: coin(1000), - Balances: []banktypes.Balance{{Address: accBech32(t, eoaBytes), Coins: coin(600)}}, + Supply: coin(1000), + Balances: []banktypes.Balance{ + {Address: accBech32(t, eoaBytes), Coins: coin(600)}, + {Address: moduleBech32(t, authtypes.FeeCollectorName), Coins: coin(400)}, + }, }, + Staking: stakingtypes.GenesisState{Params: stakingtypes.Params{BondDenom: BaseDenom}}, } // ACT @@ -285,13 +316,14 @@ func TestComputePinnedOrder(t *testing.T) { gen := &Genesis{ Bank: banktypes.GenesisState{ - Supply: coin(1000), + Supply: coin(110), Balances: []banktypes.Balance{ {Address: accBech32(t, tieHighBytes), Coins: coin(5)}, {Address: accBech32(t, bigBytes), Coins: coin(100)}, {Address: accBech32(t, tieLowBytes), Coins: coin(5)}, }, }, + Staking: stakingtypes.GenesisState{Params: stakingtypes.Params{BondDenom: BaseDenom}}, } cfg := Config{Denom: BaseDenom, Pins: []string{ canonHex(tieHighBytes), canonHex(bigBytes), canonHex(tieLowBytes), @@ -312,22 +344,300 @@ func TestComputePinnedOrder(t *testing.T) { func TestComputeUnknownValidator(t *testing.T) { // ARRANGE: a delegation pointing at a validator not present in the set + delegator, validator := accBech32(t, addr20(0x03)), valBech32(t, addr20(0x09)) gen := &Genesis{ Bank: banktypes.GenesisState{Supply: sdk.NewCoins(sdk.NewInt64Coin(BaseDenom, 100))}, Staking: stakingtypes.GenesisState{ + Params: stakingtypes.Params{BondDenom: BaseDenom}, + Delegations: []stakingtypes.Delegation{ + {DelegatorAddress: delegator, ValidatorAddress: validator, Shares: sdkmath.LegacyNewDec(10)}, + }, + }, + } + + // ACT + _, err := Compute(gen, Config{Denom: BaseDenom}) + + // ASSERT + require.ErrorContains(t, err, "delegation from "+delegator+" references unknown validator "+validator) +} + +// TestComputeRemainderCategories gives every non-claimable kind of holder liquid, +// stake and unbonding at once, and checks each lands in its own category and +// the categories add up to the remainder. +func TestComputeRemainderCategories(t *testing.T) { + // ARRANGE + valBytes := addr20(0x02) + eoaBytes := addr20(0x01) + pinBytes := addr20(0x05) + zeroBytes := make([]byte, addressLen) + nonStdBytes := make([]byte, 32) + for i := range nonStdBytes { + nonStdBytes[i] = 0x0e + } + coin := func(n int64) sdk.Coins { return sdk.NewCoins(sdk.NewInt64Coin(BaseDenom, n)) } + + pin := accBech32(t, pinBytes) + module := moduleBech32(t, authtypes.FeeCollectorName) + zero := accBech32(t, zeroBytes) + nonStd := accBech32(t, nonStdBytes) + eoa := accBech32(t, eoaBytes) + val := valBech32(t, valBytes) + + delegate := func(addr string, shares int64) stakingtypes.Delegation { + return stakingtypes.Delegation{DelegatorAddress: addr, ValidatorAddress: val, Shares: sdkmath.LegacyNewDec(shares)} + } + unbond := func(addr string, amt int64) stakingtypes.UnbondingDelegation { + return stakingtypes.UnbondingDelegation{ + DelegatorAddress: addr, + ValidatorAddress: val, + Entries: []stakingtypes.UnbondingDelegationEntry{{Balance: sdkmath.NewInt(amt)}}, + } + } + + // slashed validator: 90 tokens backing 100 shares -> exchange rate 0.9 + gen := &Genesis{ + Bank: banktypes.GenesisState{ + Supply: coin(1615), + Balances: []banktypes.Balance{ + {Address: pin, Coins: coin(100)}, + {Address: module, Coins: coin(200)}, + {Address: zero, Coins: coin(300)}, + {Address: nonStd, Coins: coin(400)}, + {Address: eoa, Coins: coin(500)}, + {Address: moduleBech32(t, stakingtypes.BondedPoolName), Coins: coin(90)}, + {Address: moduleBech32(t, stakingtypes.NotBondedPoolName), Coins: coin(25)}, + }, + }, + Staking: stakingtypes.GenesisState{ + Params: stakingtypes.Params{BondDenom: BaseDenom}, + Validators: []stakingtypes.Validator{{ + OperatorAddress: val, + Tokens: sdkmath.NewInt(90), + DelegatorShares: sdkmath.LegacyNewDec(100), + Status: stakingtypes.Bonded, + }}, + // stakes 22, 22, 22, 13, 9 = 88 of 90 tokens -> share dust 2 + Delegations: []stakingtypes.Delegation{ + delegate(pin, 25), delegate(module, 25), delegate(zero, 25), delegate(nonStd, 15), delegate(eoa, 10), + }, + UnbondingDelegations: []stakingtypes.UnbondingDelegation{ + unbond(pin, 3), unbond(module, 4), unbond(zero, 5), unbond(nonStd, 6), unbond(eoa, 7), + }, + }, + } + + // ACT + res, err := Compute(gen, Config{Denom: BaseDenom, Pins: []string{pin}}) + + // ASSERT + require.NoError(t, err) + require.Len(t, res.Accounts, 1) + require.Equal(t, "516", res.AttributedTotal().String()) + require.Equal(t, "1099", res.Remainder.String()) + + require.Equal(t, "125", res.Breakdown.Pinned.String()) + require.Equal(t, "226", res.Breakdown.ModuleAccounts.String()) + require.Equal(t, "327", res.Breakdown.ZeroAddress.String()) + require.Equal(t, "419", res.Breakdown.NonStandard.String()) + require.Equal(t, "2", res.Breakdown.ShareDust.String()) + require.Equal(t, res.Remainder.String(), res.Breakdown.Total().String()) + + require.Equal(t, 1, res.NonStandard, "non-standard count stays liquid-only") + require.Equal(t, "400", res.NonStandardAzeta.String(), "non-standard azeta stays liquid-only") +} + +func TestComputeRefusesStakingPoolPin(t *testing.T) { + // ARRANGE + pool := moduleBech32(t, stakingtypes.BondedPoolName) + gen := &Genesis{Staking: stakingtypes.GenesisState{Params: stakingtypes.Params{BondDenom: BaseDenom}}} + + // ACT + _, err := Compute(gen, Config{Denom: BaseDenom, Pins: []string{pool}}) + + // ASSERT + require.ErrorContains(t, err, "pin "+pool+" is the bonded_tokens_pool staking pool") +} + +// TestComputeRefusesSupplyMismatch records a supply 1 above the bank balances. +func TestComputeRefusesSupplyMismatch(t *testing.T) { + // ARRANGE + coin := func(n int64) sdk.Coins { return sdk.NewCoins(sdk.NewInt64Coin(BaseDenom, n)) } + gen := &Genesis{ + Bank: banktypes.GenesisState{ + Supply: coin(1001), + Balances: []banktypes.Balance{ + {Address: accBech32(t, addr20(0x01)), Coins: coin(600)}, + {Address: accBech32(t, make([]byte, addressLen)), Coins: coin(400)}, + }, + }, + Staking: stakingtypes.GenesisState{Params: stakingtypes.Params{BondDenom: BaseDenom}}, + } + + // ACT + _, err := Compute(gen, Config{Denom: BaseDenom}) + + // ASSERT + require.ErrorContains(t, err, "bank balances sum 1000 != supply 1001") +} + +// TestFinalizeRefusesUnexplainedRemainder hands finalize a breakdown that misses +// part of the remainder. Compute cannot produce one once the supply matches the +// bank and the staking records are validated, so the identity check is a +// backstop and is reached here directly. +func TestFinalizeRefusesUnexplainedRemainder(t *testing.T) { + // ARRANGE + coin := func(n int64) sdk.Coins { return sdk.NewCoins(sdk.NewInt64Coin(BaseDenom, n)) } + gen := &Genesis{ + Bank: banktypes.GenesisState{ + Supply: coin(400), + Balances: []banktypes.Balance{{Address: accBech32(t, make([]byte, addressLen)), Coins: coin(400)}}, + }, + } + res := &Result{Breakdown: RemainderBreakdown{ + ModuleAccounts: sdkmath.ZeroInt(), + ZeroAddress: sdkmath.NewInt(399), + NonStandard: sdkmath.ZeroInt(), + ShareDust: sdkmath.ZeroInt(), + }} + + // ACT + _, err := finalize(res, map[string]*Account{}, map[string]sdkmath.Int{}, gen, BaseDenom) + + // ASSERT + require.ErrorContains(t, err, "remainder 400 != categorised 399 (modules 0, zero address 399, non-20-byte 0") +} + +// TestComputePinOverridesCategory pins the zero address and a module account, +// each holding liquid, stake and unbonding, and checks a pin takes precedence +// over both categories. +func TestComputePinOverridesCategory(t *testing.T) { + // ARRANGE + coin := func(n int64) sdk.Coins { return sdk.NewCoins(sdk.NewInt64Coin(BaseDenom, n)) } + zero := accBech32(t, make([]byte, addressLen)) + module := moduleBech32(t, authtypes.FeeCollectorName) + val := valBech32(t, addr20(0x02)) + + gen := &Genesis{ + Bank: banktypes.GenesisState{ + Supply: coin(368), + Balances: []banktypes.Balance{ + {Address: zero, Coins: coin(100)}, + {Address: module, Coins: coin(200)}, + {Address: moduleBech32(t, stakingtypes.BondedPoolName), Coins: coin(50)}, + {Address: moduleBech32(t, stakingtypes.NotBondedPoolName), Coins: coin(18)}, + }, + }, + Staking: stakingtypes.GenesisState{ + Params: stakingtypes.Params{BondDenom: BaseDenom}, + Validators: []stakingtypes.Validator{{ + OperatorAddress: val, + Tokens: sdkmath.NewInt(50), + DelegatorShares: sdkmath.LegacyNewDec(50), + Status: stakingtypes.Bonded, + }}, Delegations: []stakingtypes.Delegation{ + {DelegatorAddress: zero, ValidatorAddress: val, Shares: sdkmath.LegacyNewDec(20)}, + {DelegatorAddress: module, ValidatorAddress: val, Shares: sdkmath.LegacyNewDec(30)}, + }, + UnbondingDelegations: []stakingtypes.UnbondingDelegation{ { - DelegatorAddress: accBech32(t, addr20(0x03)), - ValidatorAddress: valBech32(t, addr20(0x09)), - Shares: sdkmath.LegacyNewDec(10), + DelegatorAddress: zero, + ValidatorAddress: val, + Entries: []stakingtypes.UnbondingDelegationEntry{{Balance: sdkmath.NewInt(7)}}, + }, + { + DelegatorAddress: module, + ValidatorAddress: val, + Entries: []stakingtypes.UnbondingDelegationEntry{{Balance: sdkmath.NewInt(11)}}, }, }, }, } // ACT - _, err := Compute(gen, Config{Denom: BaseDenom}) + res, err := Compute(gen, Config{Denom: BaseDenom, Pins: []string{zero, module}}) + + // ASSERT + require.NoError(t, err) + require.Empty(t, res.Accounts) + require.Equal(t, []PinnedAddr{ + {Canonical: canonHex(authtypes.NewModuleAddress(authtypes.FeeCollectorName).Bytes()), Azeta: sdkmath.NewInt(241)}, + {Canonical: zeroAddress, Azeta: sdkmath.NewInt(127)}, + }, res.Pinned, "module 200+30+11, zero address 100+20+7") + require.Equal(t, "368", res.Breakdown.Pinned.String()) + require.Equal(t, "0", res.Breakdown.ModuleAccounts.String()) + require.Equal(t, "0", res.Breakdown.ZeroAddress.String()) + require.Equal(t, "368", res.Remainder.String()) + require.Equal(t, res.Remainder.String(), res.Breakdown.Total().String()) +} + +func TestComputeConversionDust(t *testing.T) { + // ARRANGE + coin := func(n int64) sdk.Coins { return sdk.NewCoins(sdk.NewInt64Coin(BaseDenom, n)) } + gen := &Genesis{ + Bank: banktypes.GenesisState{ + Supply: coin(1_500_000_700), + Balances: []banktypes.Balance{ + {Address: accBech32(t, addr20(0x01)), Coins: coin(1_500_000_000)}, + {Address: accBech32(t, make([]byte, addressLen)), Coins: coin(700)}, + }, + }, + Staking: stakingtypes.GenesisState{Params: stakingtypes.Params{BondDenom: BaseDenom}}, + } + + // ACT + res, err := Compute(gen, Config{Denom: BaseDenom}) // ASSERT - require.ErrorContains(t, err, "unknown validator") + require.NoError(t, err) + require.Equal(t, "1", res.SPLCap().String()) + require.Equal(t, "500000700", res.ConversionDust().String()) +} + +// TestComputeAuditExports runs the staking exports from the audit. Complete +// exports attribute the holder's stake or unbonding; dropping the delegation +// or the unbonding record is refused instead of moving it into the remainder. +func TestComputeAuditExports(t *testing.T) { + const ( + holder = "0x1111111111111111111111111111111111111111" + pin = "0x3333333333333333333333333333333333333333" + ) + tests := []struct { + name string + wantErr string + }{ + {name: "complete"}, + {name: "complete_unbonding"}, + { + name: "omitted_delegations", + wantErr: "validator 0x2222222222222222222222222222222222222222 delegation shares sum 0.000000000000000000 " + + "!= delegator_shares 100000000000000000000", + }, + { + name: "omitted_unbonding", + wantErr: "not-bonded pool balance 100000000000000000000 != unbonding/unbonded validator tokens 0 + unbonding entries 0", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // ARRANGE + gen := loadExport(t, tt.name) + + // ACT + res, err := Compute(gen, Config{Denom: BaseDenom, Pins: []string{pin}}) + + // ASSERT + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + return + } + require.NoError(t, err) + require.Len(t, res.Accounts, 1) + require.Equal(t, holder, res.Accounts[0].Canonical) + require.Equal(t, "110000000000000000000", res.Accounts[0].Total().String()) + require.Equal(t, "5000000000000000000", res.Remainder.String()) + require.Equal(t, "5000000000000000000", res.Breakdown.Pinned.String()) + }) + } } diff --git a/cmd/zetatool/snapshot/parse.go b/cmd/zetatool/snapshot/parse.go index f8716e2a1f..cd43a20537 100644 --- a/cmd/zetatool/snapshot/parse.go +++ b/cmd/zetatool/snapshot/parse.go @@ -6,20 +6,22 @@ import ( "github.com/cosmos/cosmos-sdk/codec" banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" + distrtypes "github.com/cosmos/cosmos-sdk/x/distribution/types" stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" ) -// Genesis holds the module genesis states needed for the snapshot. Only bank -// and staking are consumed: distribution is exported (rewards are already -// folded into bank via --for-zero-height) but not read here. +// Genesis holds the module genesis states needed for the snapshot. Bank and +// staking drive attribution; distribution is only checked, to prove the export +// was taken with --for-zero-height and left no unpaid rewards behind. type Genesis struct { - Bank banktypes.GenesisState - Staking stakingtypes.GenesisState + Bank banktypes.GenesisState + Staking stakingtypes.GenesisState + Distribution distrtypes.GenesisState } -// ParseAppState pulls the bank and staking genesis states out of a decoded -// `app_state` map. It is deterministic and does no file or RPC I/O; the caller -// reads the export file and hands over the raw module JSON. +// ParseAppState pulls the bank, staking and distribution genesis states out of a +// decoded `app_state` map. It is deterministic and does no file or RPC I/O; the +// caller reads the export file and hands over the raw module JSON. func ParseAppState(cdc codec.JSONCodec, appState map[string]json.RawMessage) (*Genesis, error) { bankRaw, ok := appState[banktypes.ModuleName] if !ok { @@ -29,6 +31,10 @@ func ParseAppState(cdc codec.JSONCodec, appState map[string]json.RawMessage) (*G if !ok { return nil, fmt.Errorf("app_state is missing the %q module", stakingtypes.ModuleName) } + distrRaw, ok := appState[distrtypes.ModuleName] + if !ok { + return nil, fmt.Errorf("app_state is missing the %q module", distrtypes.ModuleName) + } var gen Genesis if err := cdc.UnmarshalJSON(bankRaw, &gen.Bank); err != nil { @@ -37,5 +43,8 @@ func ParseAppState(cdc codec.JSONCodec, appState map[string]json.RawMessage) (*G if err := cdc.UnmarshalJSON(stakingRaw, &gen.Staking); err != nil { return nil, fmt.Errorf("unmarshal staking genesis: %w", err) } + if err := cdc.UnmarshalJSON(distrRaw, &gen.Distribution); err != nil { + return nil, fmt.Errorf("unmarshal distribution genesis: %w", err) + } return &gen, nil } diff --git a/cmd/zetatool/snapshot/parse_test.go b/cmd/zetatool/snapshot/parse_test.go index 0c22c5b1ce..61f2941d8e 100644 --- a/cmd/zetatool/snapshot/parse_test.go +++ b/cmd/zetatool/snapshot/parse_test.go @@ -5,8 +5,10 @@ import ( "testing" sdkmath "cosmossdk.io/math" + "github.com/cosmos/cosmos-sdk/codec" sdk "github.com/cosmos/cosmos-sdk/types" banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" + distrtypes "github.com/cosmos/cosmos-sdk/x/distribution/types" stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" "github.com/stretchr/testify/require" @@ -35,15 +37,16 @@ func TestParseAppState(t *testing.T) { }}, } - bankRaw, err := cdc.MarshalJSON(&bank) - require.NoError(t, err) - stakingRaw, err := cdc.MarshalJSON(&staking) - require.NoError(t, err) - appState := map[string]json.RawMessage{ - banktypes.ModuleName: bankRaw, - stakingtypes.ModuleName: stakingRaw, + distr := distrtypes.GenesisState{ + Params: distrtypes.DefaultParams(), + FeePool: distrtypes.FeePool{CommunityPool: sdk.NewDecCoins(sdk.NewInt64DecCoin(BaseDenom, 7))}, + OutstandingRewards: []distrtypes.ValidatorOutstandingRewardsRecord{ + {ValidatorAddress: valBech32(t, addr20(0x02))}, + }, } + appState := marshalAppState(t, cdc, &bank, &staking, &distr) + // ACT gen, err := ParseAppState(cdc, appState) @@ -51,6 +54,8 @@ func TestParseAppState(t *testing.T) { require.NoError(t, err) require.Len(t, gen.Bank.Balances, 2) require.Len(t, gen.Staking.Validators, 1) + require.Len(t, gen.Distribution.OutstandingRewards, 1) + require.Equal(t, "7", gen.Distribution.FeePool.CommunityPool.AmountOf(BaseDenom).TruncateInt().String()) require.Equal(t, "1500", gen.Bank.Supply.AmountOf(BaseDenom).String()) require.Equal(t, "1500", SumBankDenom(gen, BaseDenom).String()) } @@ -61,3 +66,41 @@ func TestParseAppStateMissingModule(t *testing.T) { _, err := ParseAppState(cdc, map[string]json.RawMessage{}) require.ErrorContains(t, err, "bank") } + +func TestParseAppStateMissingDistribution(t *testing.T) { + // ARRANGE + cdc := app.MakeEncodingConfig(7001).Codec + appState := marshalAppState(t, cdc, &banktypes.GenesisState{}, &stakingtypes.GenesisState{}, nil) + + // ACT + _, err := ParseAppState(cdc, appState) + + // ASSERT + require.ErrorContains(t, err, `missing the "distribution" module`) +} + +// marshalAppState encodes the given module genesis states into an app_state +// map; a nil distr leaves the distribution module out. +func marshalAppState( + t *testing.T, + cdc codec.JSONCodec, + bank *banktypes.GenesisState, + staking *stakingtypes.GenesisState, + distr *distrtypes.GenesisState, +) map[string]json.RawMessage { + t.Helper() + bankRaw, err := cdc.MarshalJSON(bank) + require.NoError(t, err) + stakingRaw, err := cdc.MarshalJSON(staking) + require.NoError(t, err) + appState := map[string]json.RawMessage{ + banktypes.ModuleName: bankRaw, + stakingtypes.ModuleName: stakingRaw, + } + if distr != nil { + distrRaw, err := cdc.MarshalJSON(distr) + require.NoError(t, err) + appState[distrtypes.ModuleName] = distrRaw + } + return appState +} diff --git a/cmd/zetatool/snapshot/snapshot.go b/cmd/zetatool/snapshot/snapshot.go index abc9427c22..d6dfb407cb 100644 --- a/cmd/zetatool/snapshot/snapshot.go +++ b/cmd/zetatool/snapshot/snapshot.go @@ -5,7 +5,9 @@ // Everything here is free of file, RPC and cobra dependencies so it can be // unit-tested with hand-built fixtures. Reward math is intentionally absent: // the Stage 1 export runs with --for-zero-height, so withdrawn staking rewards -// and validator commission are already folded into liquid bank balances. +// and validator commission are already folded into liquid bank balances. That is +// checked, not assumed: Compute refuses an export with unpaid rewards, and one +// whose staking records do not fully explain the staking pools. package snapshot import ( @@ -119,6 +121,34 @@ type Result struct { // Their azeta is not attributed and is swept into the remainder. NonStandard int NonStandardAzeta sdkmath.Int + + // Breakdown splits the remainder by where its azeta came from. Compute + // refuses a result whose categories do not sum exactly to Remainder. + Breakdown RemainderBreakdown +} + +// RemainderBreakdown is the remainder by category. An address counts in one +// category only: a pin, else a module account, else the zero address; +// non-20-byte holders are disjoint from all three. The staking pools' own +// balances are in no category: validator tokens and unbonding entries explain +// them fully, and those are credited to their delegators. +type RemainderBreakdown struct { + // ModuleAccounts is module liquid outside the two staking pools, plus + // module accounts' own stake and unbonding. + ModuleAccounts sdkmath.Int + // ZeroAddress is the null address's liquid + stake + unbonding. + ZeroAddress sdkmath.Int + // NonStandard is liquid + stake + unbonding of non-20-byte holders. + NonStandard sdkmath.Int + // Pinned equals the sum of Result.Pinned. + Pinned sdkmath.Int + // ShareDust is validator tokens lost to flooring each delegation's stake. + ShareDust sdkmath.Int +} + +// Total sums every category. +func (b RemainderBreakdown) Total() sdkmath.Int { + return b.ModuleAccounts.Add(b.ZeroAddress).Add(b.NonStandard).Add(b.Pinned).Add(b.ShareDust) } // AttributedTotal is the sum over attributed accounts (liquid+staked+unbonding). @@ -135,3 +165,10 @@ func (r *Result) SPLCap() sdkmath.Int { } return total } + +// ConversionDust is the azeta lost by flooring each row from 18 to 9 decimals: +// supply minus the SPL cap scaled back to azeta. It is reported only; it is not +// part of the azeta remainder. +func (r *Result) ConversionDust() sdkmath.Int { + return r.Supply.Sub(r.SPLCap().Mul(oneGZeta)) +} diff --git a/cmd/zetatool/snapshot/testdata/complete.json b/cmd/zetatool/snapshot/testdata/complete.json new file mode 100644 index 0000000000..ac4d8dc1e0 --- /dev/null +++ b/cmd/zetatool/snapshot/testdata/complete.json @@ -0,0 +1,66 @@ +{ + "app_state": { + "bank": { + "balances": [ + { + "address": "0x4fea76427b8345861e80a3540a8a9d936fd39391", + "coins": [ + { + "denom": "azeta", + "amount": "100000000000000000000" + } + ] + }, + { + "address": "0x1111111111111111111111111111111111111111", + "coins": [ + { + "denom": "azeta", + "amount": "10000000000000000000" + } + ] + }, + { + "address": "0x3333333333333333333333333333333333333333", + "coins": [ + { + "denom": "azeta", + "amount": "5000000000000000000" + } + ] + } + ], + "supply": [ + { + "denom": "azeta", + "amount": "115000000000000000000" + } + ] + }, + "staking": { + "validators": [ + { + "operator_address": "0x2222222222222222222222222222222222222222", + "tokens": "100000000000000000000", + "delegator_shares": "100000000000000000000.000000000000000000", + "status": "BOND_STATUS_BONDED" + } + ], + "delegations": [ + { + "delegator_address": "0x1111111111111111111111111111111111111111", + "validator_address": "0x2222222222222222222222222222222222222222", + "shares": "100000000000000000000.000000000000000000" + } + ], + "params": { + "bond_denom": "azeta" + } + }, + "distribution": { + "fee_pool": { + "community_pool": [] + } + } + } +} diff --git a/cmd/zetatool/snapshot/testdata/complete_unbonding.json b/cmd/zetatool/snapshot/testdata/complete_unbonding.json new file mode 100644 index 0000000000..2f4a416fdf --- /dev/null +++ b/cmd/zetatool/snapshot/testdata/complete_unbonding.json @@ -0,0 +1,63 @@ +{ + "app_state": { + "bank": { + "balances": [ + { + "address": "0x5911b844d7bc224654fe0dcd16babd2d253f2fdf", + "coins": [ + { + "denom": "azeta", + "amount": "100000000000000000000" + } + ] + }, + { + "address": "0x1111111111111111111111111111111111111111", + "coins": [ + { + "denom": "azeta", + "amount": "10000000000000000000" + } + ] + }, + { + "address": "0x3333333333333333333333333333333333333333", + "coins": [ + { + "denom": "azeta", + "amount": "5000000000000000000" + } + ] + } + ], + "supply": [ + { + "denom": "azeta", + "amount": "115000000000000000000" + } + ] + }, + "staking": { + "unbonding_delegations": [ + { + "delegator_address": "0x1111111111111111111111111111111111111111", + "validator_address": "0x2222222222222222222222222222222222222222", + "entries": [ + { + "balance": "100000000000000000000", + "initial_balance": "100000000000000000000" + } + ] + } + ], + "params": { + "bond_denom": "azeta" + } + }, + "distribution": { + "fee_pool": { + "community_pool": [] + } + } + } +} diff --git a/cmd/zetatool/snapshot/testdata/omitted_delegations.json b/cmd/zetatool/snapshot/testdata/omitted_delegations.json new file mode 100644 index 0000000000..44d08ce40b --- /dev/null +++ b/cmd/zetatool/snapshot/testdata/omitted_delegations.json @@ -0,0 +1,60 @@ +{ + "app_state": { + "bank": { + "balances": [ + { + "address": "0x4fea76427b8345861e80a3540a8a9d936fd39391", + "coins": [ + { + "denom": "azeta", + "amount": "100000000000000000000" + } + ] + }, + { + "address": "0x1111111111111111111111111111111111111111", + "coins": [ + { + "denom": "azeta", + "amount": "10000000000000000000" + } + ] + }, + { + "address": "0x3333333333333333333333333333333333333333", + "coins": [ + { + "denom": "azeta", + "amount": "5000000000000000000" + } + ] + } + ], + "supply": [ + { + "denom": "azeta", + "amount": "115000000000000000000" + } + ] + }, + "staking": { + "validators": [ + { + "operator_address": "0x2222222222222222222222222222222222222222", + "tokens": "100000000000000000000", + "delegator_shares": "100000000000000000000.000000000000000000", + "status": "BOND_STATUS_BONDED" + } + ], + "delegations": [], + "params": { + "bond_denom": "azeta" + } + }, + "distribution": { + "fee_pool": { + "community_pool": [] + } + } + } +} diff --git a/cmd/zetatool/snapshot/testdata/omitted_unbonding.json b/cmd/zetatool/snapshot/testdata/omitted_unbonding.json new file mode 100644 index 0000000000..aa72648858 --- /dev/null +++ b/cmd/zetatool/snapshot/testdata/omitted_unbonding.json @@ -0,0 +1,52 @@ +{ + "app_state": { + "bank": { + "balances": [ + { + "address": "0x5911b844d7bc224654fe0dcd16babd2d253f2fdf", + "coins": [ + { + "denom": "azeta", + "amount": "100000000000000000000" + } + ] + }, + { + "address": "0x1111111111111111111111111111111111111111", + "coins": [ + { + "denom": "azeta", + "amount": "10000000000000000000" + } + ] + }, + { + "address": "0x3333333333333333333333333333333333333333", + "coins": [ + { + "denom": "azeta", + "amount": "5000000000000000000" + } + ] + } + ], + "supply": [ + { + "denom": "azeta", + "amount": "115000000000000000000" + } + ] + }, + "staking": { + "unbonding_delegations": [], + "params": { + "bond_denom": "azeta" + } + }, + "distribution": { + "fee_pool": { + "community_pool": [] + } + } + } +} diff --git a/cmd/zetatool/snapshot/testdata/settled_rewards.json b/cmd/zetatool/snapshot/testdata/settled_rewards.json new file mode 100644 index 0000000000..7834a37f7d --- /dev/null +++ b/cmd/zetatool/snapshot/testdata/settled_rewards.json @@ -0,0 +1,95 @@ +{ + "app_state": { + "bank": { + "balances": [ + { + "address": "0x4fea76427b8345861e80a3540a8a9d936fd39391", + "coins": [ + { + "denom": "azeta", + "amount": "100000000000000000000" + } + ] + }, + { + "address": "0x1111111111111111111111111111111111111111", + "coins": [ + { + "denom": "azeta", + "amount": "20000000000000000000" + } + ] + }, + { + "address": "0x3333333333333333333333333333333333333333", + "coins": [ + { + "denom": "azeta", + "amount": "5000000000000000000" + } + ] + }, + { + "address": "0x93354845030274cd4bf1686abd60ab28ec52e1a7", + "coins": [ + { + "denom": "azeta", + "amount": "0" + } + ] + } + ], + "supply": [ + { + "denom": "azeta", + "amount": "125000000000000000000" + } + ] + }, + "staking": { + "validators": [ + { + "operator_address": "0x2222222222222222222222222222222222222222", + "tokens": "100000000000000000000", + "delegator_shares": "100000000000000000000.000000000000000000", + "status": "BOND_STATUS_BONDED" + } + ], + "delegations": [ + { + "delegator_address": "0x1111111111111111111111111111111111111111", + "validator_address": "0x2222222222222222222222222222222222222222", + "shares": "100000000000000000000.000000000000000000" + } + ], + "params": { + "bond_denom": "azeta" + } + }, + "distribution": { + "outstanding_rewards": [ + { + "validator_address": "0x2222222222222222222222222222222222222222", + "outstanding_rewards": [] + } + ], + "validator_current_rewards": [ + { + "validator_address": "0x2222222222222222222222222222222222222222", + "rewards": { + "rewards": [], + "period": "2" + } + } + ], + "validator_accumulated_commissions": [ + { + "validator_address": "0x2222222222222222222222222222222222222222", + "accumulated": { + "commission": [] + } + } + ] + } + } +} diff --git a/cmd/zetatool/snapshot/testdata/unwithdrawn_rewards.json b/cmd/zetatool/snapshot/testdata/unwithdrawn_rewards.json new file mode 100644 index 0000000000..a2029998b5 --- /dev/null +++ b/cmd/zetatool/snapshot/testdata/unwithdrawn_rewards.json @@ -0,0 +1,105 @@ +{ + "app_state": { + "bank": { + "balances": [ + { + "address": "0x4fea76427b8345861e80a3540a8a9d936fd39391", + "coins": [ + { + "denom": "azeta", + "amount": "100000000000000000000" + } + ] + }, + { + "address": "0x1111111111111111111111111111111111111111", + "coins": [ + { + "denom": "azeta", + "amount": "10000000000000000000" + } + ] + }, + { + "address": "0x3333333333333333333333333333333333333333", + "coins": [ + { + "denom": "azeta", + "amount": "5000000000000000000" + } + ] + }, + { + "address": "0x93354845030274cd4bf1686abd60ab28ec52e1a7", + "coins": [ + { + "denom": "azeta", + "amount": "10000000000000000000" + } + ] + } + ], + "supply": [ + { + "denom": "azeta", + "amount": "125000000000000000000" + } + ] + }, + "staking": { + "validators": [ + { + "operator_address": "0x2222222222222222222222222222222222222222", + "tokens": "100000000000000000000", + "delegator_shares": "100000000000000000000.000000000000000000", + "status": "BOND_STATUS_BONDED" + } + ], + "delegations": [ + { + "delegator_address": "0x1111111111111111111111111111111111111111", + "validator_address": "0x2222222222222222222222222222222222222222", + "shares": "100000000000000000000.000000000000000000" + } + ], + "params": { + "bond_denom": "azeta" + } + }, + "distribution": { + "outstanding_rewards": [ + { + "validator_address": "0x2222222222222222222222222222222222222222", + "outstanding_rewards": [ + { + "denom": "azeta", + "amount": "10000000000000000000.000000000000000000" + } + ] + } + ], + "validator_current_rewards": [ + { + "validator_address": "0x2222222222222222222222222222222222222222", + "rewards": { + "rewards": [ + { + "denom": "azeta", + "amount": "10000000000000000000.000000000000000000" + } + ], + "period": "2" + } + } + ], + "validator_accumulated_commissions": [ + { + "validator_address": "0x2222222222222222222222222222222222222222", + "accumulated": { + "commission": [] + } + } + ] + } + } +} diff --git a/cmd/zetatool/snapshot/validate.go b/cmd/zetatool/snapshot/validate.go new file mode 100644 index 0000000000..63bb232056 --- /dev/null +++ b/cmd/zetatool/snapshot/validate.go @@ -0,0 +1,264 @@ +package snapshot + +import ( + "fmt" + + sdkmath "cosmossdk.io/math" + distrtypes "github.com/cosmos/cosmos-sdk/x/distribution/types" + stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" +) + +// validateDistribution refuses an export that still holds unpaid staking +// rewards. A --for-zero-height export pays out every reward and commission and +// resets the per-validator records to zero, so any non-zero record means the +// rewards were never credited to a holder. The distribution module's balance +// must then be exactly the community pool (the SDK's own import rule), which +// catches an export whose reward records were dropped while the azeta stayed. +func validateDistribution(gen *Genesis, denom string) error { + distr := gen.Distribution + for _, r := range distr.OutstandingRewards { + if !r.OutstandingRewards.IsZero() { + return fmt.Errorf( + "validator %s has non-zero outstanding_rewards %s; the export is not --for-zero-height", + r.ValidatorAddress, r.OutstandingRewards, + ) + } + } + for _, r := range distr.ValidatorCurrentRewards { + if !r.Rewards.Rewards.IsZero() { + return fmt.Errorf( + "validator %s has non-zero current_rewards %s; the export is not --for-zero-height", + r.ValidatorAddress, r.Rewards.Rewards, + ) + } + } + for _, r := range distr.ValidatorAccumulatedCommissions { + if !r.Accumulated.Commission.IsZero() { + return fmt.Errorf( + "validator %s has non-zero accumulated_commission %s; the export is not --for-zero-height", + r.ValidatorAddress, r.Accumulated.Commission, + ) + } + } + + // outstanding rewards are all zero by now, so the SDK's + // TruncateDecimal(outstanding + community pool) is just the community pool + holdings, _ := distr.FeePool.CommunityPool.TruncateDecimal() + balance, err := moduleBalance(gen, distrtypes.ModuleName, denom) + if err != nil { + return err + } + if want := holdings.AmountOf(denom); !balance.Equal(want) { + return fmt.Errorf("distribution module balance %s != outstanding rewards + community pool %s", balance, want) + } + return nil +} + +// stakingTally accumulates one validator's delegations during validateStaking. +type stakingTally struct { + val stakingtypes.Validator + shares sdkmath.LegacyDec + delegations int + staked sdkmath.Int +} + +// delegationKey identifies a (delegator, validator) staking record. The +// delegator is its raw address bytes so non-20-byte holders are keyed too. +type delegationKey struct { + delegator string + validator string +} + +// validateStaking proves the staking records are complete: every delegation +// and unbonding entry the chain holds is present, so none of their azeta can +// silently fall into the remainder. It checks the full population, including +// module accounts, pins and non-20-byte holders. It returns the total +// share-rounding dust: validator tokens no delegator is credited with because +// each delegation's stake is floored. +func validateStaking(gen *Genesis, denom string) (sdkmath.Int, error) { + st := gen.Staking + if st.Params.BondDenom != denom { + return sdkmath.Int{}, fmt.Errorf("staking bond_denom %q != snapshot denom %q", st.Params.BondDenom, denom) + } + + tallies := make([]*stakingTally, 0, len(st.Validators)) + byOperator := make(map[string]*stakingTally, len(st.Validators)) + bondedTokens, notBondedTokens := sdkmath.ZeroInt(), sdkmath.ZeroInt() + for i := range st.Validators { + v := st.Validators[i] + canon, err := Canonical(v.OperatorAddress) + if err != nil { + return sdkmath.Int{}, fmt.Errorf("canonicalize validator %q: %w", v.OperatorAddress, err) + } + if _, dup := byOperator[canon]; dup { + return sdkmath.Int{}, fmt.Errorf("duplicate validator %s", v.OperatorAddress) + } + if v.Tokens.IsNegative() { + return sdkmath.Int{}, fmt.Errorf("validator %s has negative tokens %s", v.OperatorAddress, v.Tokens) + } + if v.DelegatorShares.IsNegative() { + return sdkmath.Int{}, fmt.Errorf( + "validator %s has negative delegator_shares %s", + v.OperatorAddress, v.DelegatorShares, + ) + } + switch v.Status { + case stakingtypes.Bonded: + bondedTokens = bondedTokens.Add(v.Tokens) + case stakingtypes.Unbonding, stakingtypes.Unbonded: + notBondedTokens = notBondedTokens.Add(v.Tokens) + default: + return sdkmath.Int{}, fmt.Errorf("validator %s has invalid status %s", v.OperatorAddress, v.Status) + } + tally := &stakingTally{val: v, shares: sdkmath.LegacyZeroDec(), staked: sdkmath.ZeroInt()} + tallies = append(tallies, tally) + byOperator[canon] = tally + } + + owners := make([]*stakingTally, len(st.Delegations)) + seen := make(map[delegationKey]bool, len(st.Delegations)) + for i := range st.Delegations { + del := st.Delegations[i] + key, err := stakingRecordKey(del.DelegatorAddress, del.ValidatorAddress) + if err != nil { + return sdkmath.Int{}, err + } + if del.Shares.IsNegative() { + return sdkmath.Int{}, fmt.Errorf( + "delegation %s -> %s has negative shares %s", + del.DelegatorAddress, del.ValidatorAddress, del.Shares, + ) + } + tally, ok := byOperator[key.validator] + if !ok { + return sdkmath.Int{}, fmt.Errorf( + "delegation from %s references unknown validator %s", + del.DelegatorAddress, del.ValidatorAddress, + ) + } + if seen[key] { + return sdkmath.Int{}, fmt.Errorf( + "duplicate delegation %s -> %s", + del.DelegatorAddress, del.ValidatorAddress, + ) + } + seen[key] = true + tally.shares = tally.shares.Add(del.Shares) + tally.delegations++ + owners[i] = tally + } + + // every share sum must match before any stake is computed: TokensFromShares + // divides by delegator_shares, which a mismatched validator may have at zero + for _, tally := range tallies { + if !tally.shares.Equal(tally.val.DelegatorShares) { + return sdkmath.Int{}, fmt.Errorf( + "validator %s delegation shares sum %s != delegator_shares %s", + tally.val.OperatorAddress, tally.shares, tally.val.DelegatorShares, + ) + } + } + for i := range st.Delegations { + tally := owners[i] + if tally.val.DelegatorShares.IsZero() { + continue + } + staked := tally.val.TokensFromShares(st.Delegations[i].Shares).TruncateInt() + tally.staked = tally.staked.Add(staked) + } + shareDust := sdkmath.ZeroInt() + for _, tally := range tallies { + dust := tally.val.Tokens.Sub(tally.staked) + if dust.GT(sdkmath.NewInt(int64(tally.delegations))) { + return sdkmath.Int{}, fmt.Errorf( + "validator %s share-rounding dust %s exceeds its %d delegations", + tally.val.OperatorAddress, dust, tally.delegations, + ) + } + shareDust = shareDust.Add(dust) + } + + // unbonding records may point at a validator that has since been removed + unbonding := sdkmath.ZeroInt() + seen = make(map[delegationKey]bool, len(st.UnbondingDelegations)) + for i := range st.UnbondingDelegations { + ubd := st.UnbondingDelegations[i] + key, err := stakingRecordKey(ubd.DelegatorAddress, ubd.ValidatorAddress) + if err != nil { + return sdkmath.Int{}, err + } + if seen[key] { + return sdkmath.Int{}, fmt.Errorf( + "duplicate unbonding delegation %s -> %s", + ubd.DelegatorAddress, ubd.ValidatorAddress, + ) + } + seen[key] = true + for _, entry := range ubd.Entries { + if entry.Balance.IsNegative() { + return sdkmath.Int{}, fmt.Errorf( + "unbonding delegation %s -> %s has negative entry balance %s", + ubd.DelegatorAddress, ubd.ValidatorAddress, entry.Balance, + ) + } + unbonding = unbonding.Add(entry.Balance) + } + } + + bondedPool, err := moduleBalance(gen, stakingtypes.BondedPoolName, denom) + if err != nil { + return sdkmath.Int{}, err + } + if !bondedPool.Equal(bondedTokens) { + return sdkmath.Int{}, fmt.Errorf( + "bonded pool balance %s != bonded validator tokens %s", + bondedPool, bondedTokens, + ) + } + notBondedPool, err := moduleBalance(gen, stakingtypes.NotBondedPoolName, denom) + if err != nil { + return sdkmath.Int{}, err + } + if !notBondedPool.Equal(notBondedTokens.Add(unbonding)) { + return sdkmath.Int{}, fmt.Errorf( + "not-bonded pool balance %s != unbonding/unbonded validator tokens %s + unbonding entries %s", + notBondedPool, notBondedTokens, unbonding, + ) + } + return shareDust, nil +} + +// stakingRecordKey decodes a delegation or unbonding record's addresses into +// its (delegator, validator) key. +func stakingRecordKey(delegator, validator string) (delegationKey, error) { + raw, err := decodeAddr(delegator) + if err != nil { + return delegationKey{}, fmt.Errorf("decode delegator %q: %w", delegator, err) + } + valCanon, err := Canonical(validator) + if err != nil { + return delegationKey{}, fmt.Errorf("canonicalize validator %q: %w", validator, err) + } + return delegationKey{delegator: string(raw), validator: valCanon}, nil +} + +// moduleBalance sums the denom balance of a module account across every bank +// row that resolves to it; an absent row is a zero balance. +func moduleBalance(gen *Genesis, moduleName, denom string) (sdkmath.Int, error) { + want, err := moduleCanonical(moduleName) + if err != nil { + return sdkmath.Int{}, err + } + total := sdkmath.ZeroInt() + for i := range gen.Bank.Balances { + bal := gen.Bank.Balances[i] + canon, ok, err := Classify(bal.Address) + if err != nil { + return sdkmath.Int{}, fmt.Errorf("classify balance holder %q: %w", bal.Address, err) + } + if ok && canon == want { + total = total.Add(bal.Coins.AmountOf(denom)) + } + } + return total, nil +} diff --git a/cmd/zetatool/snapshot/validate_test.go b/cmd/zetatool/snapshot/validate_test.go new file mode 100644 index 0000000000..e149f8bb7b --- /dev/null +++ b/cmd/zetatool/snapshot/validate_test.go @@ -0,0 +1,424 @@ +package snapshot + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + sdkmath "cosmossdk.io/math" + sdk "github.com/cosmos/cosmos-sdk/types" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" + distrtypes "github.com/cosmos/cosmos-sdk/x/distribution/types" + stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" + "github.com/stretchr/testify/require" + + "github.com/zeta-chain/node/app" +) + +func azetaCoins(n int64) sdk.Coins { return sdk.NewCoins(sdk.NewInt64Coin(BaseDenom, n)) } + +func moduleBech32(t *testing.T, name string) string { + return accBech32(t, authtypes.NewModuleAddress(name).Bytes()) +} + +// loadExport parses a ported audit export from testdata. +func loadExport(t *testing.T, name string) *Genesis { + t.Helper() + raw, err := os.ReadFile(filepath.Join("testdata", name+".json")) + require.NoError(t, err) + var doc struct { + AppState map[string]json.RawMessage `json:"app_state"` + } + require.NoError(t, json.Unmarshal(raw, &doc)) + gen, err := ParseAppState(app.MakeEncodingConfig(7000).Codec, doc.AppState) + require.NoError(t, err) + return gen +} + +// stakingFixture is an internally consistent export. Validator 0x02 is bonded +// and slashed (90 tokens backing 100 shares) with two fractional delegations +// whose floored stakes leave 1 azeta of share dust; validator 0x06 is unbonded +// with one delegation; 0x07 is unbonding 15 from 0x02. Both pools match. +func stakingFixture(t *testing.T) *Genesis { + valA, valB := valBech32(t, addr20(0x02)), valBech32(t, addr20(0x06)) + return &Genesis{ + Bank: banktypes.GenesisState{ + Supply: azetaCoins(155), + Balances: []banktypes.Balance{ + {Address: moduleBech32(t, stakingtypes.BondedPoolName), Coins: azetaCoins(90)}, + {Address: moduleBech32(t, stakingtypes.NotBondedPoolName), Coins: azetaCoins(65)}, + }, + }, + Staking: stakingtypes.GenesisState{ + Params: stakingtypes.Params{BondDenom: BaseDenom}, + Validators: []stakingtypes.Validator{ + { + OperatorAddress: valA, + Tokens: sdkmath.NewInt(90), + DelegatorShares: sdkmath.LegacyNewDec(100), + Status: stakingtypes.Bonded, + }, + { + OperatorAddress: valB, + Tokens: sdkmath.NewInt(50), + DelegatorShares: sdkmath.LegacyNewDec(50), + Status: stakingtypes.Unbonded, + }, + }, + Delegations: []stakingtypes.Delegation{ + { + DelegatorAddress: accBech32(t, addr20(0x03)), + ValidatorAddress: valA, + Shares: sdkmath.LegacyMustNewDecFromStr("33.5"), + }, + { + DelegatorAddress: accBech32(t, addr20(0x04)), + ValidatorAddress: valA, + Shares: sdkmath.LegacyMustNewDecFromStr("66.5"), + }, + { + DelegatorAddress: accBech32(t, addr20(0x03)), + ValidatorAddress: valB, + Shares: sdkmath.LegacyNewDec(50), + }, + }, + UnbondingDelegations: []stakingtypes.UnbondingDelegation{ + { + DelegatorAddress: accBech32(t, addr20(0x07)), + ValidatorAddress: valA, + Entries: []stakingtypes.UnbondingDelegationEntry{ + {Balance: sdkmath.NewInt(10)}, + {Balance: sdkmath.NewInt(5)}, + }, + }, + }, + }, + } +} + +func TestValidateStakingAccepts(t *testing.T) { + valGone := valBech32(t, addr20(0x09)) + tests := []struct { + name string + mutate func(g *Genesis) + }{ + {name: "complete export", mutate: func(*Genesis) {}}, + {name: "jailed validator", mutate: func(g *Genesis) { g.Staking.Validators[0].Jailed = true }}, + {name: "unbonding validator", mutate: func(g *Genesis) { g.Staking.Validators[1].Status = stakingtypes.Unbonding }}, + { + name: "zero-token zero-share validator without delegations", + mutate: func(g *Genesis) { + g.Staking.Validators = append(g.Staking.Validators, stakingtypes.Validator{ + OperatorAddress: valBech32(t, addr20(0x08)), + Tokens: sdkmath.ZeroInt(), + DelegatorShares: sdkmath.LegacyZeroDec(), + Status: stakingtypes.Unbonded, + }) + }, + }, + { + name: "unbonding from a validator absent from the export", + mutate: func(g *Genesis) { + g.Staking.UnbondingDelegations = append(g.Staking.UnbondingDelegations, stakingtypes.UnbondingDelegation{ + DelegatorAddress: accBech32(t, addr20(0x07)), + ValidatorAddress: valGone, + Entries: []stakingtypes.UnbondingDelegationEntry{{Balance: sdkmath.NewInt(4)}}, + }) + g.Bank.Balances[1].Coins = azetaCoins(69) + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // ARRANGE + gen := stakingFixture(t) + tt.mutate(gen) + + // ACT + dust, err := validateStaking(gen, BaseDenom) + + // ASSERT + require.NoError(t, err) + require.Equal(t, "1", dust.String()) + }) + } +} + +func TestValidateStakingRefuses(t *testing.T) { + valA, valB := valBech32(t, addr20(0x02)), valBech32(t, addr20(0x06)) + del3, del4, unb7 := accBech32(t, addr20(0x03)), accBech32(t, addr20(0x04)), accBech32(t, addr20(0x07)) + valC := valBech32(t, addr20(0x08)) + valUnknown := valBech32(t, addr20(0x09)) + + tests := []struct { + name string + mutate func(g *Genesis) + wantErr string + }{ + { + name: "omitted delegation", + mutate: func(g *Genesis) { g.Staking.Delegations = g.Staking.Delegations[1:] }, + wantErr: "validator " + valA + " delegation shares sum 66.500000000000000000 != delegator_shares 100", + }, + { + name: "omitted unbonding entry", + mutate: func(g *Genesis) { + g.Staking.UnbondingDelegations[0].Entries = g.Staking.UnbondingDelegations[0].Entries[:1] + }, + wantErr: "not-bonded pool balance 65 != unbonding/unbonded validator tokens 50 + unbonding entries 10", + }, + { + name: "bonded pool one over", + mutate: func(g *Genesis) { g.Bank.Balances[0].Coins = azetaCoins(91) }, + wantErr: "bonded pool balance 91 != bonded validator tokens 90", + }, + { + name: "bonded pool one under", + mutate: func(g *Genesis) { g.Bank.Balances[0].Coins = azetaCoins(89) }, + wantErr: "bonded pool balance 89 != bonded validator tokens 90", + }, + { + name: "not-bonded pool one over", + mutate: func(g *Genesis) { g.Bank.Balances[1].Coins = azetaCoins(66) }, + wantErr: "not-bonded pool balance 66 != unbonding/unbonded validator tokens 50 + unbonding entries 15", + }, + { + name: "not-bonded pool one under", + mutate: func(g *Genesis) { g.Bank.Balances[1].Coins = azetaCoins(64) }, + wantErr: "not-bonded pool balance 64 != unbonding/unbonded validator tokens 50 + unbonding entries 15", + }, + { + name: "duplicate validator", + mutate: func(g *Genesis) { + g.Staking.Validators = append(g.Staking.Validators, g.Staking.Validators[1]) + }, + wantErr: "duplicate validator " + valB, + }, + { + name: "duplicate delegation", + mutate: func(g *Genesis) { + g.Staking.Delegations = append(g.Staking.Delegations, g.Staking.Delegations[0]) + }, + wantErr: "duplicate delegation " + del3 + " -> " + valA, + }, + { + name: "duplicate unbonding delegation", + mutate: func(g *Genesis) { + g.Staking.UnbondingDelegations = append(g.Staking.UnbondingDelegations, g.Staking.UnbondingDelegations[0]) + }, + wantErr: "duplicate unbonding delegation " + unb7 + " -> " + valA, + }, + { + name: "unknown validator", + mutate: func(g *Genesis) { g.Staking.Delegations[1].ValidatorAddress = valUnknown }, + wantErr: "delegation from " + del4 + " references unknown validator " + valUnknown, + }, + { + name: "invalid status", + mutate: func(g *Genesis) { g.Staking.Validators[1].Status = stakingtypes.Unspecified }, + wantErr: "validator " + valB + " has invalid status BOND_STATUS_UNSPECIFIED", + }, + { + name: "negative delegation shares", + mutate: func(g *Genesis) { g.Staking.Delegations[1].Shares = sdkmath.LegacyNewDec(-1) }, + wantErr: "delegation " + del4 + " -> " + valA + " has negative shares", + }, + { + name: "negative unbonding entry", + mutate: func(g *Genesis) { + g.Staking.UnbondingDelegations[0].Entries[1].Balance = sdkmath.NewInt(-5) + }, + wantErr: "unbonding delegation " + unb7 + " -> " + valA + " has negative entry balance -5", + }, + { + name: "negative validator tokens", + mutate: func(g *Genesis) { g.Staking.Validators[1].Tokens = sdkmath.NewInt(-1) }, + wantErr: "validator " + valB + " has negative tokens -1", + }, + { + name: "negative validator delegator_shares", + mutate: func(g *Genesis) { + g.Staking.Validators[1].DelegatorShares = sdkmath.LegacyNewDec(-50) + }, + wantErr: "validator " + valB + " has negative delegator_shares", + }, + { + name: "wrong bond denom", + mutate: func(g *Genesis) { g.Staking.Params.BondDenom = "stake" }, + wantErr: `staking bond_denom "stake" != snapshot denom "azeta"`, + }, + { + name: "tokens without shares or delegations", + mutate: func(g *Genesis) { + g.Staking.Validators = append(g.Staking.Validators, stakingtypes.Validator{ + OperatorAddress: valC, + Tokens: sdkmath.NewInt(5), + DelegatorShares: sdkmath.LegacyZeroDec(), + Status: stakingtypes.Unbonded, + }) + g.Bank.Balances[1].Coins = azetaCoins(70) + }, + wantErr: "validator " + valC + " share-rounding dust 5 exceeds its 0 delegations", + }, + { + name: "zero-share validator with a delegation", + mutate: func(g *Genesis) { + g.Staking.Validators = append(g.Staking.Validators, stakingtypes.Validator{ + OperatorAddress: valC, + Tokens: sdkmath.ZeroInt(), + DelegatorShares: sdkmath.LegacyZeroDec(), + Status: stakingtypes.Unbonded, + }) + g.Staking.Delegations = append(g.Staking.Delegations, stakingtypes.Delegation{ + DelegatorAddress: del3, + ValidatorAddress: valC, + Shares: sdkmath.LegacyOneDec(), + }) + }, + wantErr: "validator " + valC + " delegation shares sum 1.000000000000000000 != delegator_shares 0", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // ARRANGE + gen := stakingFixture(t) + tt.mutate(gen) + + // ACT + _, err := validateStaking(gen, BaseDenom) + + // ASSERT + require.ErrorContains(t, err, tt.wantErr) + }) + } +} + +// distributionFixture is a valid --for-zero-height distribution state: every +// per-validator record is zero and the module holds the truncated community +// pool (10.5 -> 10). +func distributionFixture(t *testing.T) *Genesis { + valA := valBech32(t, addr20(0x02)) + return &Genesis{ + Bank: banktypes.GenesisState{ + Balances: []banktypes.Balance{ + {Address: moduleBech32(t, distrtypes.ModuleName), Coins: azetaCoins(10)}, + }, + }, + Distribution: distrtypes.GenesisState{ + FeePool: distrtypes.FeePool{CommunityPool: sdk.NewDecCoins( + sdk.NewDecCoinFromDec(BaseDenom, sdkmath.LegacyMustNewDecFromStr("10.5")), + )}, + OutstandingRewards: []distrtypes.ValidatorOutstandingRewardsRecord{{ValidatorAddress: valA}}, + ValidatorCurrentRewards: []distrtypes.ValidatorCurrentRewardsRecord{ + {ValidatorAddress: valA, Rewards: distrtypes.ValidatorCurrentRewards{Period: 2}}, + }, + ValidatorAccumulatedCommissions: []distrtypes.ValidatorAccumulatedCommissionRecord{ + {ValidatorAddress: valA}, + }, + }, + } +} + +func TestValidateDistribution(t *testing.T) { + valA := valBech32(t, addr20(0x02)) + reward := sdk.NewDecCoins(sdk.NewInt64DecCoin(BaseDenom, 3)) + + tests := []struct { + name string + mutate func(g *Genesis) + wantErr string + }{ + {name: "zero-height export", mutate: func(*Genesis) {}}, + {name: "empty distribution and no module balance", mutate: func(g *Genesis) { *g = Genesis{} }}, + { + name: "outstanding rewards", + mutate: func(g *Genesis) { g.Distribution.OutstandingRewards[0].OutstandingRewards = reward }, + wantErr: "validator " + valA + " has non-zero outstanding_rewards", + }, + { + name: "current rewards", + mutate: func(g *Genesis) { g.Distribution.ValidatorCurrentRewards[0].Rewards.Rewards = reward }, + wantErr: "validator " + valA + " has non-zero current_rewards", + }, + { + name: "accumulated commission", + mutate: func(g *Genesis) { + g.Distribution.ValidatorAccumulatedCommissions[0].Accumulated.Commission = reward + }, + wantErr: "validator " + valA + " has non-zero accumulated_commission", + }, + { + name: "module balance one over", + mutate: func(g *Genesis) { g.Bank.Balances[0].Coins = azetaCoins(11) }, + wantErr: "distribution module balance 11 != outstanding rewards + community pool 10", + }, + { + name: "module balance one under", + mutate: func(g *Genesis) { g.Bank.Balances[0].Coins = azetaCoins(9) }, + wantErr: "distribution module balance 9 != outstanding rewards + community pool 10", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // ARRANGE + gen := distributionFixture(t) + tt.mutate(gen) + + // ACT + err := validateDistribution(gen, BaseDenom) + + // ASSERT + if tt.wantErr == "" { + require.NoError(t, err) + return + } + require.ErrorContains(t, err, tt.wantErr) + }) + } +} + +// TestValidateDistributionAuditExports runs the reward exports from the audit: +// an export taken without --for-zero-height, the same export with its reward +// records stripped while the module still holds the unpaid azeta, and a settled +// export. +func TestValidateDistributionAuditExports(t *testing.T) { + t.Run("unwithdrawn rewards", func(t *testing.T) { + // ARRANGE + gen := loadExport(t, "unwithdrawn_rewards") + + // ACT + err := validateDistribution(gen, BaseDenom) + + // ASSERT + require.ErrorContains(t, err, + "validator 0x2222222222222222222222222222222222222222 has non-zero outstanding_rewards") + }) + + t.Run("reward records stripped", func(t *testing.T) { + // ARRANGE + gen := loadExport(t, "unwithdrawn_rewards") + gen.Distribution.OutstandingRewards = nil + gen.Distribution.ValidatorCurrentRewards = nil + gen.Distribution.ValidatorAccumulatedCommissions = nil + + // ACT + err := validateDistribution(gen, BaseDenom) + + // ASSERT + require.ErrorContains(t, err, + "distribution module balance 10000000000000000000 != outstanding rewards + community pool 0") + }) + + t.Run("settled rewards", func(t *testing.T) { + // ARRANGE + gen := loadExport(t, "settled_rewards") + + // ACT + err := validateDistribution(gen, BaseDenom) + + // ASSERT + require.NoError(t, err) + }) +} diff --git a/contrib/snapshot/README.md b/contrib/snapshot/README.md index a286a0d55b..530e0317fd 100644 --- a/contrib/snapshot/README.md +++ b/contrib/snapshot/README.md @@ -79,8 +79,9 @@ python3 contrib/snapshot/export_snapshot.py --network mainnet --for-zero-height Useful flags: -- `--for-zero-height` fold rewards + commission into bank (recommended for the migration). -- `--modules` default `bank,staking,distribution`. +- `--for-zero-height` fold rewards + commission into bank. Required: Stage 2 + refuses an export with unpaid rewards. +- `--modules` default `bank,staking,distribution`. Stage 2 needs all three. - `--height` default `-1` (latest). - `--skip-download` reuse the cached snapshot in `~/zetacored_snapshot_`. - `--dry-run` print every command, execute nothing. @@ -107,7 +108,8 @@ Flags: - `--pin-file` same thing from a file, one address per line (`#` comments; only the first field per line is read, so a TSV works). Mainnet list is committed: `contrib/snapshot/pins_mainnet.txt`. -- `--supply-check` fail on any output check (default true). +- `--supply-check` fail on any output check (default true). It does not cover + the hard refusals in "Acceptance guarantees" below. Mainnet with the committed pin list: @@ -126,15 +128,56 @@ Outputs in `--output`: - `schema.sql` matching Postgres DDL. - `snapshot_human.csv` readable ZETA-unit copy for eyeballing. -The command prints bucket totals and runs four checks (sum export == supply; sum -attributed + remainder == total; re-sum CSV == total; every pin matched a holder), -non-zero exit on any failure. +The command prints bucket totals and runs three output checks (sum attributed + +remainder == total; re-sum CSV == total; every pin matched a holder), non-zero +exit on any failure. A bank sum that does not equal the supply is refused +earlier, before any output is written (see "Acceptance guarantees"). Pin accounting: the summary prints one line per pin with the azeta it kept out of attribution, plus a subtotal. A pin that matched no holder in the export reports zero and fails the run, which is what catches a typo'd address or a pin file -pointed at the wrong network. `--supply-check=false` downgrades every check, -including this one, to a stderr warning. +pointed at the wrong network. `--supply-check=false` downgrades these three +output checks, including this one, to a stderr warning. + +The summary also splits the remainder by category (module accounts, zero +address, non-20-byte holders, pins, share-rounding dust) and prints the azeta +lost to the 18->9 decimal floor. + +## Acceptance guarantees + +Before writing anything, Stage 2 refuses an export it cannot fully account for. +These are hard errors. `--supply-check=false` does not turn them off. + +- The export has no `distribution` module. +- The staking `bond_denom` is not `azeta`. +- A validator is listed twice, has an invalid status, or has negative tokens or + delegator shares. +- A delegation has negative shares, points at a validator not in the export, or + repeats a (delegator, validator) pair. +- A validator's delegation shares do not sum exactly to its `delegator_shares`. + This is what catches a dropped delegation. +- A validator's tokens exceed what its delegators are credited by more than one + azeta per delegation (rounding). +- An unbonding record repeats a (delegator, validator) pair, or has a negative + entry. +- The bonded pool balance is not exactly the bonded validators' tokens. +- The not-bonded pool balance is not exactly the unbonding and unbonded + validators' tokens plus every unbonding entry. This is what catches a dropped + unbonding record. +- Any validator has outstanding rewards, current rewards or accumulated + commission. That means the export was not taken with `--for-zero-height`. +- The distribution module balance is not exactly the community pool. +- A pin is one of the staking pools. +- The bank balances do not sum exactly to the recorded supply. +- The remainder is not exactly the sum of its categories. + +What these checks do NOT prove, and the operator still has to show for the +final export: + +- The emissions collection address and the contract inventory are complete. + Both go to the foundation treasury, so they need checking once by hand. +- The remainder reaches the treasury, once. +- The export came from the agreed chain and height. ## Look at the output diff --git a/contrib/snapshot/export_snapshot.py b/contrib/snapshot/export_snapshot.py index c79b0fe62e..2d5504164d 100644 --- a/contrib/snapshot/export_snapshot.py +++ b/contrib/snapshot/export_snapshot.py @@ -9,25 +9,28 @@ reads committed state directly from the data dir, so we only init a home, drop in the network config, load a snapshot's `data/` into it, and run the export. +For the migration snapshot, `--for-zero-height` and the bank, staking and distribution +modules are required: `zetatool snapshot` refuses an export without them. + Run from the root zeta-node directory: - python3 contrib/snapshot/export_snapshot.py --network testnet - python3 contrib/snapshot/export_snapshot.py --network mainnet + python3 contrib/snapshot/export_snapshot.py --network testnet --for-zero-height + python3 contrib/snapshot/export_snapshot.py --network mainnet --for-zero-height Or: - make snapshot-export NETWORK=mainnet - make snapshot-export NETWORK=testnet SNAPSHOT_EXPORT_ARGS="--height 1000000 --modules bank,staking" + make snapshot-export NETWORK=mainnet SNAPSHOT_EXPORT_ARGS="--for-zero-height" + make snapshot-export NETWORK=testnet SNAPSHOT_EXPORT_ARGS="--for-zero-height --height 1000000 --modules bank,staking,distribution" Examples: # Testnet, latest height, default modules (bank,staking,distribution) - python3 contrib/snapshot/export_snapshot.py --network testnet + python3 contrib/snapshot/export_snapshot.py --network testnet --for-zero-height # Mainnet, stamping a version label on the locally-built binary - python3 contrib/snapshot/export_snapshot.py --network mainnet --node-version v36.0.1 + python3 contrib/snapshot/export_snapshot.py --network mainnet --for-zero-height --node-version v36.0.1 # Reuse an already-downloaded snapshot cache - python3 contrib/snapshot/export_snapshot.py --network mainnet --skip-download + python3 contrib/snapshot/export_snapshot.py --network mainnet --for-zero-height --skip-download # Dry run: print every resolved command, execute nothing - python3 contrib/snapshot/export_snapshot.py --network mainnet --dry-run + python3 contrib/snapshot/export_snapshot.py --network mainnet --for-zero-height --dry-run Caveats: 1. Binary-version compatibility: the `zetacored` binary must be able to open the