diff --git a/core/types/gen_account_rlp.go b/core/types/gen_account_rlp.go deleted file mode 100644 index 8b424493afb..00000000000 --- a/core/types/gen_account_rlp.go +++ /dev/null @@ -1,21 +0,0 @@ -// Code generated by rlpgen. DO NOT EDIT. - -package types - -import "github.com/ethereum/go-ethereum/rlp" -import "io" - -func (obj *StateAccount) EncodeRLP(_w io.Writer) error { - w := rlp.NewEncoderBuffer(_w) - _tmp0 := w.List() - w.WriteUint64(obj.Nonce) - if obj.Balance == nil { - w.Write(rlp.EmptyString) - } else { - w.WriteUint256(obj.Balance) - } - w.WriteBytes(obj.Root[:]) - w.WriteBytes(obj.CodeHash) - w.ListEnd(_tmp0) - return w.Flush() -} diff --git a/core/types/state_account.go b/core/types/state_account.go index 52ef843b352..9817686bd35 100644 --- a/core/types/state_account.go +++ b/core/types/state_account.go @@ -20,19 +20,29 @@ import ( "bytes" "github.com/ethereum/go-ethereum/common" + qkccommon "github.com/ethereum/go-ethereum/qkc/common" "github.com/ethereum/go-ethereum/rlp" "github.com/holiman/uint256" ) -//go:generate go run ../../rlp/rlpgen -type StateAccount -out gen_account_rlp.go +// NOTE: StateAccount uses a hand-written QuarkChain codec (EncodeRLP/DecodeRLP in +// state_account_qkc.go) for the 6-element MNT account format. The rlpgen go:generate +// directive was intentionally removed: regenerating gen_account_rlp.go would create a +// conflicting standard 4-field codec and silently drop MntBalances / FullShardKey. // StateAccount is the Ethereum consensus representation of accounts. // These objects are stored in the main account trie. type StateAccount struct { - Nonce uint64 - Balance *uint256.Int - Root common.Hash // merkle root of the storage trie - CodeHash []byte + Nonce uint64 + Balance *uint256.Int + Root common.Hash // merkle root of the storage trie + CodeHash []byte + MntBalances *qkccommon.TokenBalances // Non-QKC balances. + FullShardKey uint32 // QuarkChain shard key; set on first tx, preserved thereafter + // balanceUpdated keeps a changed zero QKC balance encoded as 00c0. It remains + // set after a revert because pyquarkchain restores the previous value by + // writing it back, preserving the zero-valued token entry. + balanceUpdated bool } // NewEmptyStateAccount constructs an empty state account. @@ -50,29 +60,53 @@ func (acct *StateAccount) Copy() *StateAccount { if acct.Balance != nil { balance = new(uint256.Int).Set(acct.Balance) } + var mnt *qkccommon.TokenBalances + if acct.MntBalances != nil { + mnt = acct.MntBalances.Copy() + } return &StateAccount{ - Nonce: acct.Nonce, - Balance: balance, - Root: acct.Root, - CodeHash: common.CopyBytes(acct.CodeHash), + Nonce: acct.Nonce, + Balance: balance, + Root: acct.Root, + CodeHash: common.CopyBytes(acct.CodeHash), + MntBalances: mnt, + FullShardKey: acct.FullShardKey, + balanceUpdated: acct.balanceUpdated, } } -// SlimAccount is a modified version of an Account, where the root is replaced -// with a byte slice. This format can be used to represent full-consensus format -// or slim format which replaces the empty root and code hash as nil byte slice. +// IsBalanceUpdated reports whether the QKC balance has been explicitly updated. +func (acct *StateAccount) IsBalanceUpdated() bool { + return acct.balanceUpdated +} + +// MarkBalanceUpdated records that the QKC balance entry has been written. +func (acct *StateAccount) MarkBalanceUpdated() { + acct.balanceUpdated = true +} + +// SlimAccount is the compact RLP account format used by state snapshots, +// pathdb readers, and account iterators. To support snapshots in goshard, the +// standard format must be extended with FullShardKey and MntBal so snapshots +// preserve QuarkChain-specific account state. The added fields are optional +// trailing RLP fields, keeping old snapshots readable and leaving room for +// future extensions without changing the existing account format. type SlimAccount struct { Nonce uint64 Balance *uint256.Int Root []byte // Nil if root equals to types.EmptyRootHash CodeHash []byte // Nil if hash equals to types.EmptyCodeHash + // QKC-specific fields; both optional so old snapshots remain readable. + FullShardKey uint32 `rlp:"optional"` // QuarkChain shard key + MntBal []byte `rlp:"optional"` // Non-QKC TokenBalances.SerializeToBytes output } // SlimAccountRLP encodes the state account in 'slim RLP' format. func SlimAccountRLP(account StateAccount) []byte { slim := SlimAccount{ - Nonce: account.Nonce, - Balance: account.Balance, + Nonce: account.Nonce, + Balance: account.Balance, + FullShardKey: account.FullShardKey, } if account.Root != EmptyRootHash { slim.Root = account.Root[:] @@ -80,6 +114,16 @@ func SlimAccountRLP(account StateAccount) []byte { if !bytes.Equal(account.CodeHash, EmptyCodeHash[:]) { slim.CodeHash = account.CodeHash } + if account.MntBalances != nil { + mntBal, err := account.MntBalances.SerializeToBytes() + if err != nil { + panic(err) + } + slim.MntBal = mntBal + } + if len(slim.MntBal) == 0 && account.IsBalanceUpdated() { + slim.MntBal = []byte{0x00, 0xc0} + } data, err := rlp.EncodeToBytes(slim) if err != nil { panic(err) @@ -87,17 +131,41 @@ func SlimAccountRLP(account StateAccount) []byte { return data } -// FullAccount decodes the data on the 'slim RLP' format and returns -// the consensus format account. +// FullAccount decodes snapshot data from slim RLP into a StateAccount. +// +// This conversion intentionally follows the semantics of StateAccount's +// EncodeRLP and DecodeRLP methods instead of preserving the original account +// bytes. An explicitly updated zero balance can initially encode as 00c0, but +// decoding loses the zero entry because the serialized token list is empty. +// Re-encoding the decoded account therefore produces an empty TokenBal. Doing +// the same normalization here ensures that snapshot and trie reads return the +// same StateAccount. +// +// Callers reconstructing trie leaves must use FullAccountRLP, which preserves +// the explicit zero-balance update marker. This distinction does not imply general +// byte-preserving snap sync support. func FullAccount(data []byte) (*StateAccount, error) { + return fullAccount(data, false) +} + +func fullAccount(data []byte, restoreBalanceUpdated bool) (*StateAccount, error) { var slim SlimAccount if err := rlp.DecodeBytes(data, &slim); err != nil { return nil, err } var account StateAccount - account.Nonce, account.Balance = slim.Nonce, slim.Balance - - // Interpret the storage root and code hash in slim format. + account.Nonce, account.Balance, account.FullShardKey = slim.Nonce, slim.Balance, slim.FullShardKey + if len(slim.MntBal) > 0 { + tb, err := qkccommon.NewTokenBalances(slim.MntBal) + if err != nil { + return nil, err + } + if tb.Len() != 0 { + account.MntBalances = tb + } else if restoreBalanceUpdated { + account.MarkBalanceUpdated() + } + } if len(slim.Root) == 0 { account.Root = EmptyRootHash } else { @@ -111,9 +179,10 @@ func FullAccount(data []byte) (*StateAccount, error) { return &account, nil } -// FullAccountRLP converts data on the 'slim RLP' format into the full RLP-format. +// FullAccountRLP converts slim RLP into full RLP while preserving an explicit +// zero-balance update marker for snapshot proof verification and trie regeneration. func FullAccountRLP(data []byte) ([]byte, error) { - account, err := FullAccount(data) + account, err := fullAccount(data, true) if err != nil { return nil, err } diff --git a/core/types/state_account_qkc.go b/core/types/state_account_qkc.go new file mode 100644 index 00000000000..244edede883 --- /dev/null +++ b/core/types/state_account_qkc.go @@ -0,0 +1,99 @@ +// Copyright 2026-2027, QuarkChain. + +package types + +import ( + "errors" + "io" + + "github.com/ethereum/go-ethereum/common" + qkccommon "github.com/ethereum/go-ethereum/qkc/common" + "github.com/ethereum/go-ethereum/rlp" + "github.com/holiman/uint256" +) + +// qkcAccountRLP is the wire struct for QuarkChain's 6-element account format: +// [Nonce, TokenBal(bytes), Root, CodeHash, FullShardKey(4B fixed), Optional]. +// TokenBal is stored as raw serialized bytes (matching pyquarkchain's `binary` type), +// so nil encodes as 0x80 (empty string), not 0xC0 (empty list). +type qkcAccountRLP struct { + Nonce uint64 + TokenBal []byte // SerializeToBytes output; nil = no balances + Root common.Hash + CodeHash []byte + FullShardKey qkccommon.Uint32 + Optional []byte +} + +// tokenBalancesForEncoding combines the split QKC and MNT balances into the +// unified token table used by the wire format. +func (acct *StateAccount) tokenBalancesForEncoding() *qkccommon.TokenBalances { + merged := qkccommon.NewEmptyTokenBalances() + qkcIsZero := acct.Balance == nil || acct.Balance.IsZero() + if !qkcIsZero || acct.IsBalanceUpdated() { + merged.SetValue(acct.Balance, qkccommon.DefaultTokenID) + } + if acct.MntBalances != nil && acct.MntBalances.Len() > 0 { + for id, bal := range acct.MntBalances.GetBalanceMap() { + merged.SetValue(bal, id) + } + } + return merged +} + +// EncodeRLP implements rlp.Encoder for StateAccount using QuarkChain's +// 6-element format. Root is always written as 32 bytes (no nil optimization). +func (acct *StateAccount) EncodeRLP(w io.Writer) error { + tokenBal, err := acct.tokenBalancesForEncoding().SerializeToBytes() + if err != nil { + return err + } + qkc := &qkcAccountRLP{ + Nonce: acct.Nonce, + Root: acct.Root, + CodeHash: acct.CodeHash, + TokenBal: tokenBal, + FullShardKey: qkccommon.Uint32(acct.FullShardKey), + Optional: nil, + } + return rlp.Encode(w, qkc) +} + +// DecodeRLP implements rlp.Decoder for StateAccount using QuarkChain's +// 6-element format. +func (acct *StateAccount) DecodeRLP(s *rlp.Stream) error { + raw, err := s.Raw() + if err != nil { + return err + } + var qkc qkcAccountRLP + if err := rlp.DecodeBytes(raw, &qkc); err != nil { + return err + } + acct.Nonce = qkc.Nonce + acct.CodeHash = qkc.CodeHash + acct.Root = qkc.Root + acct.FullShardKey = uint32(qkc.FullShardKey) + if len(qkc.Optional) != 0 { + return errors.New("unsupported non-empty QuarkChain account optional field") + } + acct.Balance = new(uint256.Int) + acct.MntBalances = nil + acct.balanceUpdated = false + if len(qkc.TokenBal) > 0 { + tb, err := qkccommon.NewTokenBalances(qkc.TokenBal) + if err != nil { + return err + } + balMap := tb.GetBalanceMap() + qkcBal, hasQKC := balMap[qkccommon.DefaultTokenID] + if hasQKC { + acct.Balance.Set(qkcBal) + delete(balMap, qkccommon.DefaultTokenID) + } + if len(balMap) != 0 { + acct.MntBalances = qkccommon.NewTokenBalancesWithMap(balMap) + } + } + return nil +} diff --git a/core/types/state_account_qkc_test.go b/core/types/state_account_qkc_test.go new file mode 100644 index 00000000000..e58729e0447 --- /dev/null +++ b/core/types/state_account_qkc_test.go @@ -0,0 +1,574 @@ +// Copyright 2026-2027, QuarkChain. + +package types + +import ( + "bytes" + "encoding/hex" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + qkccommon "github.com/ethereum/go-ethereum/qkc/common" + "github.com/ethereum/go-ethereum/rlp" + "github.com/holiman/uint256" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// pyquarkchain test vectors generated by: +// +// from quarkchain.evm.state import _Account, TokenBalancePair +// import rlp +// from rlp.sedes import BigEndianInt, binary, CountableList, big_endian_int +// +// See pyquarkchain/quarkchain/evm/state.py _Account for the canonical format. +// +// All vectors use: +// - storage = EmptyRootHash (keccak256 of empty) +// - code_hash = EmptyCodeHash (keccak256 of empty string) +// - full_shard_key = 1 (BigEndianInt(4) → always 4 bytes on the wire) +// - optional = b"" +var ( + pyqkcVecNonce1QKC1000 = mustHex("f853018900c7c6828bb08203e8a056e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421a0c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470840000000180") + pyqkcVecNonce5QKC2000MNT500 = mustHex("f858058e00ccc4648201f4c6828bb08207d0a056e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421a0c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470840000000180") + pyqkcVecZeroAccount = mustHex("f84a8080a056e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421a0c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470840000000180") +) + +func mustHex(s string) []byte { + b, err := hex.DecodeString(s) + if err != nil { + panic(err) + } + return b +} + +// TestStateAccountEncodeDecodeRoundtrip verifies that EncodeRLP→DecodeRLP is +// a lossless roundtrip for all fields including FullShardKey. +func TestStateAccountEncodeDecodeRoundtrip(t *testing.T) { + cases := []struct { + name string + acct StateAccount + }{ + { + name: "empty", + acct: *NewEmptyStateAccount(), + }, + { + name: "nonce1 QKC=1000 shard=1", + acct: StateAccount{ + Nonce: 1, + Balance: uint256.NewInt(1000), + Root: EmptyRootHash, + CodeHash: EmptyCodeHash[:], + FullShardKey: 1, + }, + }, + { + name: "nonce5 QKC=2000 MNT[100]=500 shard=0x2f3e", + acct: StateAccount{ + Nonce: 5, + Balance: uint256.NewInt(2000), + Root: EmptyRootHash, + CodeHash: EmptyCodeHash[:], + MntBalances: qkccommon.NewTokenBalancesWithMap(map[uint64]*uint256.Int{ + 100: uint256.NewInt(500), + }), + FullShardKey: 0x2f3e, + }, + }, + { + name: "non-empty root and codehash shard=0x72ea", + acct: StateAccount{ + Nonce: 3, + Balance: uint256.NewInt(1e18), + Root: common.HexToHash("0xdeadbeef"), + CodeHash: common.Hex2Bytes("abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"), + FullShardKey: 0x72ea, + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + encoded, err := rlp.EncodeToBytes(&tc.acct) + require.NoError(t, err) + + var decoded StateAccount + require.NoError(t, rlp.DecodeBytes(encoded, &decoded)) + + assert.Equal(t, tc.acct.Nonce, decoded.Nonce) + assert.Equal(t, tc.acct.Root, decoded.Root) + assert.Equal(t, tc.acct.CodeHash, decoded.CodeHash) + assert.Equal(t, tc.acct.FullShardKey, decoded.FullShardKey) + require.NotNil(t, decoded.Balance) + assert.Equal(t, tc.acct.Balance, decoded.Balance) + if tc.acct.MntBalances == nil || tc.acct.MntBalances.IsBlank() { + assert.True(t, decoded.MntBalances == nil || decoded.MntBalances.IsBlank()) + } else { + require.NotNil(t, decoded.MntBalances) + assert.Equal(t, tc.acct.MntBalances.GetBalanceMap(), decoded.MntBalances.GetBalanceMap()) + } + }) + } +} + +func TestStateAccountEmptyBalancesPythonGolden(t *testing.T) { + empty := StateAccount{Balance: nil, Root: EmptyRootHash, CodeHash: EmptyCodeHash.Bytes()} + encoded, err := rlp.EncodeToBytes(&empty) + require.NoError(t, err) + var emptyWire qkcAccountRLP + require.NoError(t, rlp.DecodeBytes(encoded, &emptyWire)) + assert.Empty(t, emptyWire.TokenBal) + + zeroOnly := StateAccount{ + Balance: new(uint256.Int), + MntBalances: qkccommon.NewTokenBalancesWithMap(map[uint64]*uint256.Int{100: new(uint256.Int)}), + Root: EmptyRootHash, + CodeHash: EmptyCodeHash.Bytes(), + FullShardKey: 1, + } + encoded, err = rlp.EncodeToBytes(&zeroOnly) + require.NoError(t, err) + var zeroWire qkcAccountRLP + require.NoError(t, rlp.DecodeBytes(encoded, &zeroWire)) + assert.Equal(t, []byte{0, 0xc0}, zeroWire.TokenBal) + + var decoded StateAccount + require.NoError(t, rlp.DecodeBytes(encoded, &decoded)) + assert.Nil(t, decoded.MntBalances) + assert.False(t, decoded.IsBalanceUpdated()) + reencoded, err := rlp.EncodeToBytes(&decoded) + require.NoError(t, err) + var reencodedWire qkcAccountRLP + require.NoError(t, rlp.DecodeBytes(reencoded, &reencodedWire)) + assert.Empty(t, reencodedWire.TokenBal) +} + +func TestStateAccountDecodeRejectsUnsupportedTokenBalanceEncoding(t *testing.T) { + tests := []struct { + name string + tokenBal []byte + want string + }{ + {name: "trie", tokenBal: append([]byte{1}, make([]byte, common.HashLength)...), want: "trie"}, + {name: "unknown prefix", tokenBal: []byte{2}, want: "unknown enum byte"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + wire := qkcAccountRLP{TokenBal: test.tokenBal, Root: EmptyRootHash, CodeHash: EmptyCodeHash.Bytes()} + encoded, err := rlp.EncodeToBytes(&wire) + require.NoError(t, err) + var account StateAccount + err = rlp.DecodeBytes(encoded, &account) + assert.ErrorContains(t, err, test.want) + }) + } +} + +// TestSlimAccountRoundtripPreservesMNT guards the snapshot round-trip: an account +// that flows StateAccount -> SlimAccountRLP -> FullAccount (the path taken when a +// state read is served from the snapshot flat layer) must retain its MntBalances +// and FullShardKey. Before the SlimAccount fields were added, both were silently +// dropped, so a snapshot-served QKC account read MntBalances=nil / FullShardKey=0 +// and re-committed a corrupted account, forking the trie root. +func TestSlimAccountRoundtripPreservesMNT(t *testing.T) { + cases := []struct { + name string + acct StateAccount + wantElements int + }{ + { + name: "no MNT, no shard", + acct: StateAccount{ + Nonce: 1, + Balance: uint256.NewInt(100), + Root: EmptyRootHash, + CodeHash: EmptyCodeHash[:], + }, + wantElements: 4, + }, + { + name: "no MNT, shard set", + acct: StateAccount{ + Nonce: 7, + Balance: uint256.NewInt(1234), + Root: EmptyRootHash, + CodeHash: EmptyCodeHash[:], + FullShardKey: 0x1a2b, + }, + wantElements: 5, + }, + { + name: "MNT, no shard", + acct: StateAccount{ + Nonce: 8, + Balance: uint256.NewInt(4000), + Root: EmptyRootHash, + CodeHash: EmptyCodeHash[:], + MntBalances: qkccommon.NewTokenBalancesWithMap(map[uint64]*uint256.Int{ + 100: uint256.NewInt(500), + }), + }, + wantElements: 6, + }, + { + name: "MNT + shard set", + acct: StateAccount{ + Nonce: 9, + Balance: uint256.NewInt(5000), + Root: EmptyRootHash, + CodeHash: EmptyCodeHash[:], + MntBalances: qkccommon.NewTokenBalancesWithMap(map[uint64]*uint256.Int{ + 100: uint256.NewInt(500), + 200: uint256.NewInt(900), + }), + FullShardKey: 0x2f3e, + }, + wantElements: 6, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + slim := SlimAccountRLP(tc.acct) + var elements []rlp.RawValue + require.NoError(t, rlp.DecodeBytes(slim, &elements)) + assert.Len(t, elements, tc.wantElements) + + decoded, err := FullAccount(slim) + require.NoError(t, err) + + assert.Equal(t, tc.acct.Nonce, decoded.Nonce) + assert.Equal(t, tc.acct.Balance, decoded.Balance) + assert.Equal(t, tc.acct.FullShardKey, decoded.FullShardKey) + if tc.acct.MntBalances == nil || tc.acct.MntBalances.IsBlank() { + assert.True(t, decoded.MntBalances == nil || decoded.MntBalances.IsBlank()) + } else { + require.NotNil(t, decoded.MntBalances) + assert.Equal(t, tc.acct.MntBalances.GetBalanceMap(), decoded.MntBalances.GetBalanceMap()) + } + }) + } +} + +// TestStateAccountPyquarkchainDecodeCompatibility verifies that goshard can +// decode blobs produced by pyquarkchain. These are the authoritative wire +// vectors for the QKC 6-element RLP format. All vectors use full_shard_key=1. +func TestStateAccountPyquarkchainDecodeCompatibility(t *testing.T) { + cases := []struct { + name string + blob []byte + wantNonce uint64 + wantQKC *uint256.Int + wantMNT map[uint64]*uint256.Int // nil = no MNT tokens expected + wantFullShardKey uint32 + }{ + { + name: "nonce=1 QKC=1000", + blob: pyqkcVecNonce1QKC1000, + wantNonce: 1, + wantQKC: uint256.NewInt(1000), + wantFullShardKey: 1, + }, + { + name: "nonce=5 QKC=2000 MNT[100]=500", + blob: pyqkcVecNonce5QKC2000MNT500, + wantNonce: 5, + wantQKC: uint256.NewInt(2000), + wantMNT: map[uint64]*uint256.Int{100: uint256.NewInt(500)}, + wantFullShardKey: 1, + }, + { + name: "zero account", + blob: pyqkcVecZeroAccount, + wantNonce: 0, + wantQKC: uint256.NewInt(0), + wantFullShardKey: 1, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var acct StateAccount + require.NoError(t, rlp.DecodeBytes(tc.blob, &acct)) + + assert.Equal(t, tc.wantNonce, acct.Nonce) + assert.Equal(t, EmptyRootHash, acct.Root) + assert.Equal(t, EmptyCodeHash[:], acct.CodeHash) + assert.Equal(t, tc.wantFullShardKey, acct.FullShardKey) + require.NotNil(t, acct.Balance) + assert.Equal(t, tc.wantQKC, acct.Balance) + + if tc.wantMNT == nil { + assert.True(t, acct.MntBalances == nil || acct.MntBalances.IsBlank()) + } else { + require.NotNil(t, acct.MntBalances) + assert.Equal(t, tc.wantMNT, acct.MntBalances.GetBalanceMap()) + } + }) + } +} + +// TestStateAccountPyquarkchainEncodeCompatibility verifies that goshard +// produces byte-for-byte identical output to pyquarkchain for the same account. +// This is the strongest compatibility check: same input → same wire bytes. +func TestStateAccountPyquarkchainEncodeCompatibility(t *testing.T) { + cases := []struct { + name string + acct StateAccount + want []byte + }{ + { + name: "nonce=1 QKC=1000", + acct: StateAccount{ + Nonce: 1, + Balance: uint256.NewInt(1000), + Root: EmptyRootHash, + CodeHash: EmptyCodeHash[:], + FullShardKey: 1, + }, + want: pyqkcVecNonce1QKC1000, + }, + { + name: "nonce=5 QKC=2000 MNT[100]=500", + acct: StateAccount{ + Nonce: 5, + Balance: uint256.NewInt(2000), + Root: EmptyRootHash, + CodeHash: EmptyCodeHash[:], + MntBalances: qkccommon.NewTokenBalancesWithMap(map[uint64]*uint256.Int{ + 100: uint256.NewInt(500), + }), + FullShardKey: 1, + }, + want: pyqkcVecNonce5QKC2000MNT500, + }, + { + name: "zero account", + acct: StateAccount{ + Nonce: 0, + Balance: uint256.NewInt(0), + Root: EmptyRootHash, + CodeHash: EmptyCodeHash[:], + FullShardKey: 1, + }, + want: pyqkcVecZeroAccount, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := rlp.EncodeToBytes(&tc.acct) + require.NoError(t, err) + assert.Equal(t, hex.EncodeToString(tc.want), hex.EncodeToString(got), + "goshard encoding must match pyquarkchain byte-for-byte") + }) + } +} + +// TestSlimRLPRoundTripEquivalence verifies that slim account round trips preserve +// QuarkChain-specific MntBalances and FullShardKey fields. The round-trip +// slim-RLP → decode → QKC-encode produces the same bytes as direct +// QKC-encode. This is the invariant that allows execute.go to use slim-RLP as the +// accountOrigin format for history reversal without affecting trie root correctness. +func TestSlimRLPRoundTripEquivalence(t *testing.T) { + cases := []struct { + name string + acc StateAccount + }{ + {"zero balance, empty root", StateAccount{Nonce: 0, Balance: new(uint256.Int), Root: EmptyRootHash, CodeHash: EmptyCodeHash.Bytes()}}, + {"nonzero balance, empty root", StateAccount{Nonce: 5, Balance: uint256.NewInt(1_000_000), Root: EmptyRootHash, CodeHash: EmptyCodeHash.Bytes()}}, + {"with storage root", StateAccount{Nonce: 1, Balance: uint256.NewInt(42), Root: common.HexToHash("0xabcdef"), CodeHash: EmptyCodeHash.Bytes()}}, + {"with code hash", StateAccount{Nonce: 99, Balance: uint256.NewInt(999), Root: EmptyRootHash, CodeHash: common.FromHex("0xdeadbeef" + strings.Repeat("00", 28))}}, + // The following cases guard the slim format carrying QKC-specific fields: + // before SlimAccount was extended, FullShardKey and MntBalances were dropped + // on the slim round-trip, forking the trie root when a snapshot-served account + // was re-committed. + {"fullShardKey set", StateAccount{Nonce: 2, Balance: uint256.NewInt(7), Root: EmptyRootHash, CodeHash: EmptyCodeHash.Bytes(), FullShardKey: 0x1a2b3c4d}}, + {"nonzero QKC updated", StateAccount{Balance: uint256.NewInt(1000), Root: EmptyRootHash, CodeHash: EmptyCodeHash.Bytes(), balanceUpdated: true}}, + {"MNT only, zero QKC", StateAccount{Nonce: 3, Balance: new(uint256.Int), Root: EmptyRootHash, CodeHash: EmptyCodeHash.Bytes(), MntBalances: qkccommon.NewTokenBalancesWithMap(map[uint64]*uint256.Int{100: uint256.NewInt(500)})}}, + {"MNT + QKC + shard", StateAccount{Nonce: 8, Balance: uint256.NewInt(2000), Root: EmptyRootHash, CodeHash: EmptyCodeHash.Bytes(), MntBalances: qkccommon.NewTokenBalancesWithMap(map[uint64]*uint256.Int{100: uint256.NewInt(500), 200: uint256.NewInt(900)}), FullShardKey: 0x2f3e}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // Path A: direct QKC encode + directQKC, err := rlp.EncodeToBytes(&tc.acc) + if err != nil { + t.Fatalf("direct QKC encode: %v", err) + } + // Path B: slim-RLP → FullAccount decode → QKC encode (execute.go path) + slim := SlimAccountRLP(tc.acc) + decoded, err := FullAccount(slim) + if err != nil { + t.Fatalf("FullAccount decode: %v", err) + } + viaSlim, err := rlp.EncodeToBytes(decoded) + if err != nil { + t.Fatalf("QKC encode after slim round-trip: %v", err) + } + if !bytes.Equal(directQKC, viaSlim) { + t.Errorf("mismatch:\n direct QKC: %x\n via slim: %x", directQKC, viaSlim) + } + }) + } +} + +// TestStateAccountEncodeBalanceMntCombinations walks every combination of +// Balance (nil / zero / non-zero) against MntBalances (nil / empty / zero-valued +// entry / non-zero entry) and pins the resulting wire TokenBal. +// +// The nil-Balance rows guard that nil QKC is treated as zero. An empty MNT map +// remains absent, while an explicit zero-valued token entry serializes as 00c0. +// Balance must always decode back non-nil. +func TestStateAccountEncodeBalanceMntCombinations(t *testing.T) { + withMap := func(m map[uint64]*uint256.Int) *qkccommon.TokenBalances { + return qkccommon.NewTokenBalancesWithMap(m) + } + cases := []struct { + name string + bal *uint256.Int + mnt *qkccommon.TokenBalances + updated bool + // wantTokenBal is the expected hex of the wire TokenBal field. QKC + // (tokenID 35760 = 0x8bb0) sorts after tokenID 100 in the pair list. + wantTokenBal string + }{ + {"nil balance, nil mnt", nil, nil, false, ""}, + {"nil balance, updated", nil, nil, true, "00c0"}, + {"nil balance, empty mnt", nil, qkccommon.NewEmptyTokenBalances(), false, ""}, + {"nil balance, zero-valued mnt", nil, withMap(map[uint64]*uint256.Int{100: new(uint256.Int)}), false, "00c0"}, + {"nil balance, non-zero mnt", nil, withMap(map[uint64]*uint256.Int{100: uint256.NewInt(5)}), false, "00c3c26405"}, + {"zero balance, nil mnt", new(uint256.Int), nil, false, ""}, + {"zero balance, updated", new(uint256.Int), nil, true, "00c0"}, + {"zero balance, empty mnt", new(uint256.Int), qkccommon.NewEmptyTokenBalances(), false, ""}, + {"zero balance, zero-valued mnt", new(uint256.Int), withMap(map[uint64]*uint256.Int{100: new(uint256.Int)}), false, "00c0"}, + {"zero balance, non-zero mnt", new(uint256.Int), withMap(map[uint64]*uint256.Int{100: uint256.NewInt(5)}), false, "00c3c26405"}, + {"non-zero balance, nil mnt", uint256.NewInt(9), nil, false, "00c5c4828bb009"}, + {"non-zero balance, empty mnt", uint256.NewInt(9), qkccommon.NewEmptyTokenBalances(), false, "00c5c4828bb009"}, + {"non-zero balance, zero-valued mnt", uint256.NewInt(9), withMap(map[uint64]*uint256.Int{100: new(uint256.Int)}), false, "00c5c4828bb009"}, + {"non-zero balance, non-zero mnt", uint256.NewInt(9), withMap(map[uint64]*uint256.Int{100: uint256.NewInt(5)}), false, "00c8c26405c4828bb009"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + account := StateAccount{ + Nonce: 1, + Balance: tc.bal, + Root: EmptyRootHash, + CodeHash: EmptyCodeHash.Bytes(), + MntBalances: tc.mnt, + FullShardKey: 7, + balanceUpdated: tc.updated, + } + encoded, err := rlp.EncodeToBytes(&account) + require.NoError(t, err) + + var wire qkcAccountRLP + require.NoError(t, rlp.DecodeBytes(encoded, &wire)) + assert.Equal(t, tc.wantTokenBal, common.Bytes2Hex(wire.TokenBal)) + + var decoded StateAccount + require.NoError(t, rlp.DecodeBytes(encoded, &decoded)) + assert.Equal(t, uint32(7), decoded.FullShardKey) + require.NotNil(t, decoded.Balance, "nil Balance must decode back as zero") + if tc.bal == nil { + assert.True(t, decoded.Balance.IsZero()) + } else { + assert.Equal(t, tc.bal, decoded.Balance) + } + }) + } +} + +func TestStateAccountZeroBalanceUpdateEncoding(t *testing.T) { + var account StateAccount + require.NoError(t, rlp.DecodeBytes(pyqkcVecNonce1QKC1000, &account)) + assert.Nil(t, account.MntBalances) + assert.False(t, account.IsBalanceUpdated()) + + slim := SlimAccountRLP(account) + decodedSlim, err := FullAccount(slim) + require.NoError(t, err) + assert.Nil(t, decodedSlim.MntBalances) + assert.False(t, decodedSlim.IsBalanceUpdated()) + decodedSlim.MarkBalanceUpdated() + decodedSlim.Balance.Clear() + zeroSlim := SlimAccountRLP(*decodedSlim) + var zeroSlimAccount SlimAccount + require.NoError(t, rlp.DecodeBytes(zeroSlim, &zeroSlimAccount)) + assert.Equal(t, []byte{0x00, 0xc0}, zeroSlimAccount.MntBal) + decodedSlim, err = FullAccount(zeroSlim) + require.NoError(t, err) + assert.Nil(t, decodedSlim.MntBalances) + assert.False(t, decodedSlim.IsBalanceUpdated()) + drained, err := rlp.EncodeToBytes(decodedSlim) + require.NoError(t, err) + var drainedWire qkcAccountRLP + require.NoError(t, rlp.DecodeBytes(drained, &drainedWire)) + assert.Empty(t, drainedWire.TokenBal) + + decoded := StateAccount{MntBalances: qkccommon.NewTokenBalancesWithMap(map[uint64]*uint256.Int{123: uint256.NewInt(456)})} + require.NoError(t, rlp.DecodeBytes(drained, &decoded)) + assert.Nil(t, decoded.MntBalances) + assert.False(t, decoded.IsBalanceUpdated()) + reencoded, err := rlp.EncodeToBytes(&decoded) + require.NoError(t, err) + var reencodedWire qkcAccountRLP + require.NoError(t, rlp.DecodeBytes(reencoded, &reencodedWire)) + assert.Empty(t, reencodedWire.TokenBal) +} + +func TestBalanceUpdatedIsPresenceMarker(t *testing.T) { + account := NewEmptyStateAccount() + account.MarkBalanceUpdated() + account.MarkBalanceUpdated() + + assert.True(t, account.IsBalanceUpdated()) + assert.True(t, account.Copy().IsBalanceUpdated()) +} + +// TestZeroBalanceUpdateRoundTrip verifies that both consensus and snapshot +// reads normalize an explicitly present zero balance to the same account state. +func TestZeroBalanceUpdateRoundTrip(t *testing.T) { + account := NewEmptyStateAccount() + account.MarkBalanceUpdated() + + consensusEncoded, err := rlp.EncodeToBytes(&account) + require.NoError(t, err) + var consensusWire qkcAccountRLP + require.NoError(t, rlp.DecodeBytes(consensusEncoded, &consensusWire)) + require.Equal(t, []byte{0x00, 0xc0}, consensusWire.TokenBal) + consensusDecoded := new(StateAccount) + require.NoError(t, rlp.DecodeBytes(consensusEncoded, consensusDecoded)) + require.False(t, consensusDecoded.IsBalanceUpdated()) + consensusReencoded, err := rlp.EncodeToBytes(consensusDecoded) + require.NoError(t, err) + assert.NotEqual(t, consensusEncoded, consensusReencoded) + require.NoError(t, rlp.DecodeBytes(consensusReencoded, &consensusWire)) + assert.Empty(t, consensusWire.TokenBal) + + slimEncoded := SlimAccountRLP(*account) + var slimWire SlimAccount + require.NoError(t, rlp.DecodeBytes(slimEncoded, &slimWire)) + require.Equal(t, []byte{0x00, 0xc0}, slimWire.MntBal) + fullEncoded, err := FullAccountRLP(slimEncoded) + require.NoError(t, err) + var fullWire qkcAccountRLP + require.NoError(t, rlp.DecodeBytes(fullEncoded, &fullWire)) + assert.Equal(t, []byte{0x00, 0xc0}, fullWire.TokenBal) + assert.Equal(t, consensusEncoded, fullEncoded) + + slimDecoded, err := FullAccount(slimEncoded) + require.NoError(t, err) + require.False(t, slimDecoded.IsBalanceUpdated()) + + slimReencoded := SlimAccountRLP(*slimDecoded) + require.NoError(t, rlp.DecodeBytes(slimReencoded, &slimWire)) + assert.Empty(t, slimWire.MntBal) + + consensusAfterSync, err := rlp.EncodeToBytes(slimDecoded) + require.NoError(t, err) + require.NoError(t, rlp.DecodeBytes(consensusAfterSync, &consensusWire)) + assert.Empty(t, consensusWire.TokenBal) + assert.Equal(t, consensusReencoded, consensusAfterSync) +} diff --git a/qkc/common/token.go b/qkc/common/token.go index 467a490cbaa..080a9bb620a 100644 --- a/qkc/common/token.go +++ b/qkc/common/token.go @@ -184,7 +184,7 @@ func NewTokenBalances(data []byte) (*TokenBalances, error) { case tokenBalanceTriePrefix: return nil, errors.New("token balance trie encoding is unsupported") default: - return nil, fmt.Errorf("Unknown enum byte in token_balances:%v", data[0]) + return nil, fmt.Errorf("unknown enum byte in token_balances: %v", data[0]) } return tokenBalances, nil