Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 0 additions & 21 deletions core/types/gen_account_rlp.go

This file was deleted.

55 changes: 31 additions & 24 deletions core/types/state_account.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,47 +20,52 @@ 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

// StateAccount is the Ethereum consensus representation of accounts.
// These objects are stored in the main account trie.
// StateAccount is the QuarkChain consensus representation of accounts.
// It uses the codec in state_account_qkc.go because the standard four-field
// Ethereum codec cannot encode its token and shard fields.
type StateAccount struct {
Nonce uint64
Balance *uint256.Int
Root common.Hash // merkle root of the storage trie
CodeHash []byte
Nonce uint64
MntBalances *qkccommon.TokenBalances // QKC and MNT balances.
Root common.Hash // merkle root of the storage trie
CodeHash []byte
FullShardKey uint32
}

// NewEmptyStateAccount constructs an empty state account.
func NewEmptyStateAccount() *StateAccount {
return &StateAccount{
Balance: new(uint256.Int),
Root: EmptyRootHash,
CodeHash: EmptyCodeHash.Bytes(),
MntBalances: qkccommon.NewEmptyTokenBalances(),
Root: EmptyRootHash,
CodeHash: EmptyCodeHash.Bytes(),
}
}

// Copy returns a deep-copied state account object.
func (acct *StateAccount) Copy() *StateAccount {
var balance *uint256.Int
if acct.Balance != nil {
balance = new(uint256.Int).Set(acct.Balance)
var mntBalances *qkccommon.TokenBalances
if acct.MntBalances != nil {
mntBalances = acct.MntBalances.Copy()
}
return &StateAccount{
Nonce: acct.Nonce,
Balance: balance,
Root: acct.Root,
CodeHash: common.CopyBytes(acct.CodeHash),
Nonce: acct.Nonce,
MntBalances: mntBalances,
Root: acct.Root,
CodeHash: common.CopyBytes(acct.CodeHash),
FullShardKey: acct.FullShardKey,
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This comment is the only thing standing between the node and a silently wrong state root - nothing in the tree actually disables snap sync or the flat reader, and geth enables the snapshot by default. Once core/state is wired up in PR 2, core/state/database_mpt.go:159 calls EncodeMPTState() -> SlimAccountRLP on every commit, so the snapshot layer stores accounts with MNT balances and FullShardKey dropped, and core/state/reader.go:110 rebuilds StateAccount from them. That fails silently rather than loudly.

The conversion is also lossy in a second way the comment does not mention: FullAccount builds its balances with NewQKCTokenBalances(slim.Balance), which inserts an explicit zero entry when the balance is zero. Re-encoding then yields token_balances = 0x00c0 instead of the empty string, so even a pure-QKC account with a zero balance round-trips to a different leaf hash - snapshot-driven trie regeneration (core/state/snapshot/conversion.go) would produce a different state root.

SlimAccountRLP itself is on the unconditional commit path, so it cannot panic. Please put a real guard on the consuming side instead - refuse to construct the snapshot / flat reader, or return an error from the snapshot read path in core/state/reader.go - so that "must not be enabled" is enforced rather than documented.

// 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.
// SlimAccount is retained unchanged for the inherited snapshot and pathdb code.
// Goshard currently supports neither snap sync nor the snapshot database flat
// reader, so this representation intentionally carries only the QKC balance.
// Converting between SlimAccount and StateAccount therefore loses MNT balances
// and FullShardKey. This is a known issue which is ignored until those modes are
// supported; they must not be enabled before the conversion becomes lossless.
type SlimAccount struct {
Nonce uint64
Balance *uint256.Int
Expand All @@ -72,7 +77,7 @@ type SlimAccount struct {
func SlimAccountRLP(account StateAccount) []byte {
slim := SlimAccount{
Nonce: account.Nonce,
Balance: account.Balance,
Balance: account.Balance(),
}
if account.Root != EmptyRootHash {
slim.Root = account.Root[:]
Expand All @@ -94,8 +99,10 @@ func FullAccount(data []byte) (*StateAccount, error) {
if err := rlp.DecodeBytes(data, &slim); err != nil {
return nil, err
}
var account StateAccount
account.Nonce, account.Balance = slim.Nonce, slim.Balance
account := StateAccount{
Nonce: slim.Nonce,
MntBalances: NewQKCTokenBalances(slim.Balance),
}

// Interpret the storage root and code hash in slim format.
if len(slim.Root) == 0 {
Expand Down
97 changes: 97 additions & 0 deletions core/types/state_account_qkc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// 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 matches pyquarkchain's six-field _Account encoding.
type qkcAccountRLP struct {
Nonce uint64
TokenBal []byte
Root common.Hash
CodeHash []byte
FullShardKey qkccommon.Uint32
Optional []byte
}

// GetMntBalance returns the balance of tokenID in the account's unified balance map.
func (acct *StateAccount) GetMntBalance(tokenID uint64) *uint256.Int {
if acct.MntBalances == nil {
return new(uint256.Int)
}
return acct.MntBalances.GetTokenBalance(tokenID)
}

// Balance returns the account's QKC balance.
func (acct *StateAccount) Balance() *uint256.Int {
return acct.GetMntBalance(qkccommon.DefaultTokenID)
}

// SetBalance sets the account's QKC balance.
func (acct *StateAccount) SetBalance(balance *uint256.Int) {
if acct.MntBalances == nil {
acct.MntBalances = qkccommon.NewEmptyTokenBalances()
}
acct.MntBalances.SetValue(balance, qkccommon.DefaultTokenID)
}

// NewQKCTokenBalances creates an account balance map with a QKC balance.
func NewQKCTokenBalances(balance *uint256.Int) *qkccommon.TokenBalances {
balances := qkccommon.NewEmptyTokenBalances()
if balance != nil {
balances.SetValue(balance, qkccommon.DefaultTokenID)
}
return balances
}

// EncodeRLP implements pyquarkchain's account encoding.
func (acct *StateAccount) EncodeRLP(w io.Writer) error {
balances := acct.MntBalances
if balances == nil {
balances = qkccommon.NewEmptyTokenBalances()
}
tokenBal, err := balances.SerializeToBytes()
if err != nil {
return err
}
return rlp.Encode(w, &qkcAccountRLP{
Nonce: acct.Nonce,
TokenBal: tokenBal,
Root: acct.Root,
CodeHash: acct.CodeHash,
FullShardKey: qkccommon.Uint32(acct.FullShardKey),
})
}

// DecodeRLP implements pyquarkchain's account decoding.
func (acct *StateAccount) DecodeRLP(s *rlp.Stream) error {
raw, err := s.Raw()
if err != nil {
return err
}
var wire qkcAccountRLP
if err := rlp.DecodeBytes(raw, &wire); err != nil {
return err
}
if len(wire.Optional) != 0 {
return errors.New("unsupported non-empty QuarkChain account optional field")
}
acct.Nonce = wire.Nonce
acct.Root = wire.Root
acct.CodeHash = wire.CodeHash
acct.FullShardKey = uint32(wire.FullShardKey)
balances, err := qkccommon.NewTokenBalances(wire.TokenBal)
if err != nil {
return err
}
acct.MntBalances = balances
return nil
}
180 changes: 180 additions & 0 deletions core/types/state_account_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
// Copyright 2026-2027, QuarkChain.

package types

import (
"encoding/hex"
"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"
)

var (
pyqkcAccountQKC = decodeStateAccountHex("f853018900c7c6828bb08203e8a056e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421a0c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470840000000180")
pyqkcAccountMNT = decodeStateAccountHex("f858058e00ccc4648201f4c6828bb08207d0a056e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421a0c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470840000000180")
pyqkcAccountZero = decodeStateAccountHex("f84a8080a056e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421a0c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470840000000180")
)

func decodeStateAccountHex(input string) []byte {
data, err := hex.DecodeString(input)
if err != nil {
panic(err)
}
return data
}

func TestStateAccountPyquarkchainEncoding(t *testing.T) {
tests := []struct {
name string
acct StateAccount
want []byte
}{
{
name: "QKC balance",
acct: StateAccount{
Nonce: 1,
MntBalances: qkccommon.NewTokenBalancesWithMap(map[uint64]*uint256.Int{
qkccommon.DefaultTokenID: uint256.NewInt(1000),
}),
Root: EmptyRootHash, CodeHash: EmptyCodeHash[:], FullShardKey: 1,
},
want: pyqkcAccountQKC,
},
{
name: "QKC and MNT balances",
acct: StateAccount{
Nonce: 5,
MntBalances: qkccommon.NewTokenBalancesWithMap(map[uint64]*uint256.Int{
100: uint256.NewInt(500),
qkccommon.DefaultTokenID: uint256.NewInt(2000),
}),
Root: EmptyRootHash,
CodeHash: EmptyCodeHash[:],
FullShardKey: 1,
},
want: pyqkcAccountMNT,
},
{
name: "zero account",
acct: StateAccount{MntBalances: qkccommon.NewEmptyTokenBalances(), Root: EmptyRootHash, CodeHash: EmptyCodeHash[:], FullShardKey: 1},
want: pyqkcAccountZero,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := rlp.EncodeToBytes(&test.acct)
require.NoError(t, err)
assert.Equal(t, test.want, got)
})
}
}

func TestStateAccountPyquarkchainDecoding(t *testing.T) {
tests := []struct {
name string
input []byte
nonce uint64
balances map[uint64]*uint256.Int
}{
{name: "QKC balance", input: pyqkcAccountQKC, nonce: 1, balances: map[uint64]*uint256.Int{qkccommon.DefaultTokenID: uint256.NewInt(1000)}},
{name: "QKC and MNT balances", input: pyqkcAccountMNT, nonce: 5, balances: map[uint64]*uint256.Int{100: uint256.NewInt(500), qkccommon.DefaultTokenID: uint256.NewInt(2000)}},
{name: "zero account", input: pyqkcAccountZero, balances: map[uint64]*uint256.Int{}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var acct StateAccount
require.NoError(t, rlp.DecodeBytes(test.input, &acct))
assert.Equal(t, test.nonce, acct.Nonce)
assert.Equal(t, EmptyRootHash, acct.Root)
assert.Equal(t, EmptyCodeHash[:], acct.CodeHash)
assert.Equal(t, uint32(1), acct.FullShardKey)
if assert.NotNil(t, acct.MntBalances) {
assert.Equal(t, test.balances, acct.MntBalances.GetBalanceMap())
}
})
}
}

func TestStateAccountExplicitZeroBalanceEncoding(t *testing.T) {
acct := NewEmptyStateAccount()
encoded, err := rlp.EncodeToBytes(acct)
require.NoError(t, err)
var wire qkcAccountRLP
require.NoError(t, rlp.DecodeBytes(encoded, &wire))
assert.Empty(t, wire.TokenBal)

previous := acct.MntBalances.GetTokenBalance(qkccommon.DefaultTokenID)
acct.MntBalances.SetValue(uint256.NewInt(1), qkccommon.DefaultTokenID)
acct.MntBalances.SetValue(previous, qkccommon.DefaultTokenID)
encoded, err = rlp.EncodeToBytes(acct)
require.NoError(t, err)
require.NoError(t, rlp.DecodeBytes(encoded, &wire))
assert.Equal(t, []byte{0x00, 0xc0}, wire.TokenBal)
}

func TestStateAccountCopyMntBalances(t *testing.T) {
original := StateAccount{
MntBalances: qkccommon.NewTokenBalancesWithMap(map[uint64]*uint256.Int{
100: uint256.NewInt(2),
qkccommon.DefaultTokenID: uint256.NewInt(1),
}),
FullShardKey: 3,
}
copied := original.Copy()
copied.MntBalances.SetValue(uint256.NewInt(4), 100)
copied.MntBalances.SetValue(uint256.NewInt(5), qkccommon.DefaultTokenID)

assert.Equal(t, uint256.NewInt(2), original.MntBalances.GetTokenBalance(100))
assert.Equal(t, uint256.NewInt(4), copied.MntBalances.GetTokenBalance(100))
assert.Equal(t, uint256.NewInt(1), original.MntBalances.GetTokenBalance(qkccommon.DefaultTokenID))
assert.Equal(t, uint256.NewInt(5), copied.MntBalances.GetTokenBalance(qkccommon.DefaultTokenID))
assert.Equal(t, original.FullShardKey, copied.FullShardKey)
}

func TestStateAccountBalance(t *testing.T) {
acct := StateAccount{MntBalances: NewQKCTokenBalances(uint256.NewInt(42))}
if got := acct.Balance(); got.Cmp(uint256.NewInt(42)) != 0 {
t.Fatalf("QKC balance mismatch: have %v, want 42", got)
}
acct.SetBalance(uint256.NewInt(43))
if got := acct.Balance(); got.Cmp(uint256.NewInt(43)) != 0 {
t.Fatalf("updated QKC balance mismatch: have %v, want 43", got)
}
acct.SetBalance(nil)
if got := acct.Balance(); !got.IsZero() {
t.Fatalf("nil QKC balance should set zero, have %v", got)
}
newAcct := &StateAccount{}
newAcct.SetBalance(uint256.NewInt(7))
if got := newAcct.Balance(); got.Cmp(uint256.NewInt(7)) != 0 {
t.Fatalf("QKC balance on nil map mismatch: have %v, want 7", got)
}
if got := (&StateAccount{}).Balance(); !got.IsZero() {
t.Fatalf("nil balance map should return zero, have %v", got)
}
}

func TestStateAccountRejectsUnsupportedQKCEncoding(t *testing.T) {
tests := []struct {
name string
wire qkcAccountRLP
want string
}{
{name: "token trie", wire: qkcAccountRLP{TokenBal: append([]byte{1}, make([]byte, common.HashLength)...), Root: EmptyRootHash}, want: "trie"},
{name: "unknown token encoding", wire: qkcAccountRLP{TokenBal: []byte{2}, Root: EmptyRootHash}, want: "enum byte"},
{name: "optional field", wire: qkcAccountRLP{Root: EmptyRootHash, Optional: []byte{1}}, want: "optional field"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
encoded, err := rlp.EncodeToBytes(&test.wire)
require.NoError(t, err)
var acct StateAccount
assert.ErrorContains(t, rlp.DecodeBytes(encoded, &acct), test.want)
})
}
}