Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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
335 changes: 335 additions & 0 deletions qkc/types/token.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,335 @@
// 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"
)

// TokenTrieThreshold is the maximum number of non-zero token balances supported
// by this list-only implementation.
const TokenTrieThreshold = 16

const (
tokenBalanceListPrefix = byte(0)
tokenBalanceTriePrefix = byte(1)
)

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
}

// TokenBalances holds QKC account multi-token balances.
//
// Serialization uses list format (0x00 prefix) for up to TokenTrieThreshold
// non-zero balances. Trie format (>16 non-zero balances) is not supported;
// SerializeToBytes returns an error instead.
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 tokenBalanceListPrefix:
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 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 tokenBalances, nil
}

func (t *TokenBalances) Add(other map[uint64]*uint256.Int) error {

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 may need to change, as big.Int has minus value, while uint256.Int not. so this can only increase.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Add only supports non-negative deltas — the other values are *uint256.Int,
which can't represent a negative amount, and the overflow guard rejects wraps.
pyquarkchain's TokenBalanceMap.add allows negative v:

self.balance_map[k] = self.balance_map.get(k, 0) + v

which is used for cross-shard debits. This is fine for merge/credit-only use, but
the semantics are narrower than the original. No callers exist yet, so flagging
for when this gets wired up: if a debit path is ever needed, this signature
(unsigned map, no subtraction) won't cover it.

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 a2fc851.

for k, v := range other {
if v == nil {
continue
}
if data, ok := t.balances[k]; ok {
sum, overflow := new(uint256.Int).AddOverflow(data, v)
if overflow {
return fmt.Errorf("token balance overflow for token %d", k)
}
t.balances[k] = sum
} else {
t.balances[k] = new(uint256.Int).Set(v)
}
}
return nil
}

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) {
nonZeroEntries := t.nonZeroEntriesInBalancesCache()
if nonZeroEntries > TokenTrieThreshold {
return nil, fmt.Errorf("token balances exceed supported list size: %d > %d", nonZeroEntries, TokenTrieThreshold)
}
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,
})
}
sort.Slice(list, func(i, j int) bool { return list[i].TokenID < (list[j].TokenID) })
rlpData := new(bytes.Buffer)
rlpData.WriteByte(tokenBalanceListPrefix)
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 !k.IsUint64() {
return fmt.Errorf("token id overflows uint64: %v", k)
}
if v.Cmp(common.Big0) == 0 {
delete(t.balances, k.Uint64())
continue
}
balance, overflow := uint256.FromBig(v)
if overflow {
return fmt.Errorf("token balance overflows uint256: %v", v)
}
t.balances[k.Uint64()] = balance
}
return nil
}
Loading