qkc/types: add token balances#33
Conversation
|
|
||
| type TokenBalances struct { | ||
| db *triedb.Database | ||
| tokenTrie *trie.SecureTrie |
There was a problem hiding this comment.
As we discussed in a previous meeting, since there is only one MNT token on the QuarkChain mainnet (<16) and users cannot create new ones, we can remove the tokenTrie and use only balances.
You can also refer to Ping's design doc and PR for the MNT implementation.
| func (t *TokenBalances) MarshalJSON() ([]byte, error) { | ||
| balances := "" | ||
| for key, val := range t.balances { | ||
| bal := fmt.Sprintf("%d:%d", key, val.Uint64()) |
There was a problem hiding this comment.
[P2] Preserve the full uint256 value in JSON
Balance supports 256-bit values, but Uint64() keeps only the low 64 bits. For example, a balance of 2^80 is emitted as 0, so a JSON round trip silently changes the balance. The inverse path has the same range problem because it scans into Go int and calls SetUint64. Please format the full decimal value (for example, with val.Dec() or ToBig().String()), parse it into uint256.Int, parse token IDs with strconv.ParseUint, and add a round-trip case above math.MaxUint64.
| mapping[3] = testU256(99) | ||
|
|
||
| b := NewTokenBalancesWithMap(mapping) | ||
| encoded, err := rlp.EncodeToBytes(b) |
There was a problem hiding this comment.
[P2] Add an independent Python golden for the QKC serializer
These tests cover (*TokenBalances).SerializeToBytes (the 0x00 || RLP(...) state encoding) and the RLP codec, but they do not exercise the distinct QKC serialization path serialize.SerializeToBytes(tb), which dispatches to TokenBalances.Serialize, or the corresponding Deserialize path. Even a Go-only encode/decode round trip would be insufficient, because the Go encoder and decoder could share the same incorrect assumption while still disagreeing with pyquarkchain. Please compare both encoding and decoding against independently generated pyquarkchain bytes. For example, TokenBalanceMap({3232: 109, 0: 0, 3567: 999999}).serialize().hex() produces 00000002020ca0016d020def030f423f in the current Python reference.
Answer from codex: I found it. The last prompt where you asked me to review this set of code was: |
| if err := serialize.Deserialize(bb, v); err != nil { | ||
| return err | ||
| } | ||
| if v.Cmp(common.Big0) == 0 { |
There was a problem hiding this comment.
[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.
| Balance: v, | ||
| }) | ||
| } | ||
| if len(list) == 0 { |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
Just check goquarkchain, and it do not have this issue.
| continue | ||
| } | ||
| if data, ok := t.balances[k]; ok { | ||
| t.balances[k] = new(uint256.Int).Add(v, data) |
There was a problem hiding this comment.
[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.
| "github.com/holiman/uint256" | ||
| ) | ||
|
|
||
| type TokenBalancePair struct { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
You can referance this code https://github.com/QuarkChain/goshard/blob/3860cb77f14676120d66637ab5fe692fc1cfe0bb/qkc/common/token.go
| return tokenBalances, nil | ||
| } | ||
|
|
||
| func (t *TokenBalances) Commit() { |
There was a problem hiding this comment.
I think we need to remove this func or panic to avoid misuse.
| } | ||
|
|
||
| func (t *TokenBalances) Add(other map[uint64]*uint256.Int) { | ||
| func (t *TokenBalances) Add(other map[uint64]*uint256.Int) error { |
There was a problem hiding this comment.
This may need to change, as big.Int has minus value, while uint256.Int not. so this can only increase.
There was a problem hiding this comment.
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.
| } | ||
|
|
||
| func (t *TokenBalances) Add(other map[uint64]*uint256.Int) { | ||
| func (t *TokenBalances) Add(other map[uint64]*uint256.Int) error { |
There was a problem hiding this comment.
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.
| } | ||
|
|
||
| func (t *TokenBalances) SetValue(amount *uint256.Int, tokenID uint64) { | ||
| if amount == nil { |
There was a problem hiding this comment.
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 > 0filter - 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.
There was a problem hiding this comment.
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.
2nd PR for QuarkChain/pm#155.