Skip to content
319 changes: 319 additions & 0 deletions qkc/types/token.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,319 @@
// Copyright 2026-2027, QuarkChain.

// Token balance encoding follows pyquarkchain-compatible QKC wire bytes.

package types

import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"sort"
"strconv"
"strings"

"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/qkc/serialize"
"github.com/ethereum/go-ethereum/rlp"
"github.com/holiman/uint256"
)

type TokenBalancePair struct {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

we can add some comment as we remove "> 16 tokens",
// TokenBalances holds multi-token balances for an account.
// Serialization uses list format (0x00 prefix) for ≤ TokenTrieThreshold non-zero
// balances. Trie format (> 16 tokens) is not supported; SerializeToBytes returns
// an error if more than TokenTrieThreshold non-zero balances are present.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I made a change so that it'll fail loudly if > 16 tokens in 738ab78, @qzhodl may also take a look.

TokenID uint64
Balance *uint256.Int
}

type TokenBalances struct {
balances map[uint64]*uint256.Int
}

type TokenBalancesAlias TokenBalances

func (t *TokenBalances) MarshalJSON() ([]byte, error) {
keys := make([]uint64, 0, len(t.balances))
for key := range t.balances {
keys = append(keys, key)
}
sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] })
balances := make([]string, 0, len(keys))
for _, key := range keys {
val := t.balances[key]
balance := "0"
if val != nil {
balance = val.Dec()
}
balances = append(balances, strconv.FormatUint(key, 10)+":"+balance)
}
jsoncfg := struct {
TokenBalancesAlias
Balances string `json:"balances"`
}{TokenBalancesAlias: TokenBalancesAlias(*t), Balances: strings.Join(balances, ",")}
return json.Marshal(jsoncfg)
}

func (t *TokenBalances) UnmarshalJSON(input []byte) error {
var jsoncfg struct {
TokenBalancesAlias
Balances string `json:"balances"`
}
if err := json.Unmarshal(input, &jsoncfg); err != nil {
return err
}
*t = TokenBalances(jsoncfg.TokenBalancesAlias)
t.balances = make(map[uint64]*uint256.Int)
if jsoncfg.Balances == "" {
return nil
}
balList := strings.Split(jsoncfg.Balances, ",")
for _, val := range balList {
parts := strings.SplitN(val, ":", 2)
if len(parts) != 2 {
return fmt.Errorf("invalid token balance entry: %q", val)
}
key, err := strconv.ParseUint(parts[0], 10, 64)
if err != nil {
return fmt.Errorf("invalid token id %q: %w", parts[0], err)
}
balance, err := uint256.FromDecimal(parts[1])
if err != nil {
return fmt.Errorf("invalid token balance %q: %w", parts[1], err)
}
t.balances[key] = balance
}
return nil
}

func NewEmptyTokenBalances() *TokenBalances {
return &TokenBalances{
balances: make(map[uint64]*uint256.Int),
}
}

func NewTokenBalancesWithMap(data map[uint64]*uint256.Int) *TokenBalances {
t := &TokenBalances{
balances: make(map[uint64]*uint256.Int, len(data)),
}
for tokenID, balance := range data {
if balance == nil {
continue
}
t.balances[tokenID] = new(uint256.Int).Set(balance)
}
return t
}

func NewTokenBalances(data []byte) (*TokenBalances, error) {
tokenBalances := NewEmptyTokenBalances()
if len(data) == 0 {
return tokenBalances, nil
}

switch data[0] {
case byte(0):
balanceList := make([]*TokenBalancePair, 0)
if err := rlp.DecodeBytes(data[1:], &balanceList); err != nil {
return nil, err
}
for _, v := range balanceList {
if v == nil || v.Balance == nil {
continue
}
tokenBalances.balances[v.TokenID] = new(uint256.Int).Set(v.Balance)
}
case byte(1):
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 tokenBalances, nil
}

func (t *TokenBalances) Commit() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I think we need to remove this func or panic to avoid misuse.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in dec8117.

// TokenBalances is map-backed. Commit is kept as a no-op for callers that
// share code with older trie-backed implementations.
}

func (t *TokenBalances) Add(other map[uint64]*uint256.Int) {
for k, v := range other {
if v == nil {
continue
}
if data, ok := t.balances[k]; ok {
t.balances[k] = new(uint256.Int).Add(v, data)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P2] Do not silently wrap token balance additions

uint256.Int.Add computes modulo 2^256. Starting with balance 2^256 - 1 and adding 1 therefore changes this token to zero; both serialization paths then omit the token entirely. The Python reference uses arbitrary-precision addition, so the same operation produces 2^256 (and TokenBalanceMap can encode that 33-byte biguint). Even if this Go type intentionally restricts its internal representation to uint256, an overflow should be detected instead of silently changing a reward/fee total into zero. Please use AddOverflow and propagate an error (or otherwise enforce the invariant explicitly), and add a max-plus-one regression test.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 0054718.

} else {
t.balances[k] = new(uint256.Int).Set(v)
}
}
}

func (t *TokenBalances) SetValue(amount *uint256.Int, tokenID uint64) {
if amount == nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

SetValue stores zero-valued entries, which faithfully mirrors pyquarkchain's
TokenBalances.set_balance (it also stores zeros in _balances). However, in
pyquarkchain every output path drops zero entries:

  • serialize(): if v > 0 filter
  • is_blank(): counts only non-zero entries
  • to_dict(): if v == 0: ret.pop(k)
  • commit(): if bal: update else delete

So storing a zero is never externally observable. SerializeToBytes and
IsBlank here already filter correctly, but GetBalanceMap() and
MarshalJSON() do NOT — they leak zero entries that pyquarkchain's to_dict()
would strip.

Suggestion: drop zeros on write instead (if amount == nil || amount.IsZero() { delete(t.balances, tokenID); return }), matching qkc/common/token.go. This is
functionally equivalent to pyquarkchain (all outputs filter zeros anyway) and
removes the Get/Marshal divergence for free, rather than filtering in three
separate output methods.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I don’t think we should drop zeros on write here.

In pyquarkchain, set_balance() does store zero values in _balances, and the zero-only in-memory state serializes as 0x00 || rlp([]) (00c0) rather than empty bytes. This behavior is also covered by pyquarkchain’s test_encode_zero_balance.

So changing SetValue(0) to delete the entry would change SerializeToBytes() / EncodeRLP() output for zero-only state and could affect state/account hashing. I’ll keep the current behavior for account-state compatibility. JSON/GetBalanceMap can be revisited separately if we later split this from the account-state type.

amount = new(uint256.Int)
}
t.balances[tokenID] = new(uint256.Int).Set(amount)
}

func (t *TokenBalances) GetTokenBalance(tokenID uint64) *uint256.Int {
data, ok := t.balances[tokenID]
if ok {
return new(uint256.Int).Set(data)
}

return new(uint256.Int)
}

func (t *TokenBalances) GetBalanceMap() map[uint64]*uint256.Int {
data := t.Copy()
return data.balances
}

func (t *TokenBalances) Len() int {
return len(t.balances)
}

func (t *TokenBalances) nonZeroEntriesInBalancesCache() int {
sum := 0
for _, v := range t.balances {
if v != nil && !v.IsZero() {
sum++
}
}
return sum
}

func (t *TokenBalances) IsBlank() bool {
return t.nonZeroEntriesInBalancesCache() == 0
}

func (t *TokenBalances) Copy() *TokenBalances {
balancesCopy := make(map[uint64]*uint256.Int)
for k, v := range t.balances {
if v == nil {
continue
}
balancesCopy[k] = new(uint256.Int).Set(v)
}
return &TokenBalances{
balances: balancesCopy,
}
}

func (t *TokenBalances) SerializeToBytes() ([]byte, error) {
if t.Len() == 0 {
return nil, nil
}
list := make([]*TokenBalancePair, 0)
for k, v := range t.balances {
if v == nil || v.IsZero() {
continue
}
list = append(list, &TokenBalancePair{
TokenID: k,
Balance: v,
})
}
if len(list) == 0 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P2] Preserve the Python zero-only account-state encoding

Since account-state encoding is intentionally retained in this type, a non-empty balance cache containing only zero values must remain byte-compatible with evm.state.TokenBalances. Python checks whether _balances is empty before filtering zero values, so an in-memory map such as {1: 0}—which is reachable through set_balance(..., 0)—serializes as 00c0 (0x00 || RLP([])). This post-filter empty check instead returns no bytes for the same map; the outer RLP is consequently 80 rather than 8200c0, which can change account RLP and the state root. Please keep the true empty-map encoding as empty, but encode a non-empty zero-only map as 00c0, and add a Python golden for both the inner bytes and the outer RLP.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 63c240d.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Just check goquarkchain, and it do not have this issue.

return nil, nil
}
sort.Slice(list, func(i, j int) bool { return list[i].TokenID < (list[j].TokenID) })
rlpData := new(bytes.Buffer)
rlpData.WriteByte(byte(0))
err := rlp.Encode(rlpData, list)
if err != nil {
return nil, err
}
return rlpData.Bytes(), nil
}

func (t *TokenBalances) EncodeRLP(w io.Writer) error {
dataSer, err := t.SerializeToBytes()
if err != nil {
return err
}
dataRlp, err := rlp.EncodeToBytes(dataSer)
if err != nil {
return err
}
_, err = w.Write(dataRlp)
return err
}

func (t *TokenBalances) DecodeRLP(s *rlp.Stream) error {
dataRawBytes, err := s.Raw()
if err != nil {
return err
}
dataRlp := new([]byte)
err = rlp.DecodeBytes(dataRawBytes, dataRlp)
if err != nil {
return err
}
b, err := NewTokenBalances(*dataRlp)
if err != nil {
return err
}
(*t).balances = (*b).balances
return err
}

func (t *TokenBalances) Serialize(w *[]byte) error {
// Follow pyquarkchain (TokenBalanceMap's PrependedSizeMapSerializer skip_func):
// omit zero-balance entries from both the count and the body. This keeps
// Serialize/Deserialize symmetric and preserves pyquarkchain-compatible bytes.
keys := make([]uint64, 0, t.Len())
for k, v := range t.balances {
if v == nil || v.IsZero() {
continue
}
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] })
if err := serialize.Serialize(w, uint32(len(keys))); err != nil {
return err
}
for _, key := range keys {
v := t.balances[key]
if err := serialize.Serialize(w, new(big.Int).SetUint64(key)); err != nil {
return err
}
if err := serialize.Serialize(w, v.ToBig()); err != nil {
return err
}
}
return nil
}

func (t *TokenBalances) Deserialize(bb *serialize.ByteBuffer) error {
t.balances = make(map[uint64]*uint256.Int)
num, err := bb.GetUInt32()
if err != nil {
return err
}
for index := uint32(0); index < num; index++ {
k := new(big.Int)
if err := serialize.Deserialize(bb, k); err != nil {
return err
}
v := new(big.Int)

if err := serialize.Deserialize(bb, v); err != nil {
return err
}
if v.Cmp(common.Big0) == 0 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P2] Preserve Python last-write-wins semantics for duplicate token IDs

PrependedSizeMapSerializer.deserialize first assigns every decoded pair into a dictionary and only then applies the zero-value filter. For example, 0000000201010101010100 encodes two entries, (tokenID=1, balance=1) followed by (tokenID=1, balance=0). Python decodes this as an empty map and reserializes it as 00000000. This implementation skips the final zero before it can overwrite or delete the earlier value, so Go retains {1: 1} and reserializes it as 0000000101010101. The same QKC wire bytes therefore produce different logical coinbase maps and can affect header hashing or validation. Please apply last-write-wins before filtering zeros, or at minimum delete a previously decoded entry when the final value for that key is zero, and add this Python-generated decode vector as a regression test.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in f569468.

continue
}
if !k.IsUint64() {
return fmt.Errorf("token id overflows uint64: %v", k)
}
balance, overflow := uint256.FromBig(v)
if overflow {
return fmt.Errorf("token balance overflows uint256: %v", v)
}
t.balances[k.Uint64()] = balance
}
return nil
}
Loading