Skip to content

docs: Add design documentation for Multi Native Token (MNT) integration#11

Open
ping-ke wants to merge 10 commits into
masterfrom
mnt-design
Open

docs: Add design documentation for Multi Native Token (MNT) integration#11
ping-ke wants to merge 10 commits into
masterfrom
mnt-design

Conversation

@ping-ke

@ping-ke ping-ke commented Jun 8, 2026

Copy link
Copy Markdown

Summary

Add design documentation for Multi Native Token (MNT) integration in docs/mnt/:

  • goshard-mnt-design.md — Full design for goshard (the go-ethereum fork used by QuarkChain). Covers everything needed to implement MNT support with byte-for-byte state trie compatibility with goquarkchain/pyquarkchain.
  • goqkc-mnt-design.md — Reference documentation for goquarkchain's existing MNT design: Account struct, TokenBalances types, precompile/system contracts, transaction flow, and EVM dispatch mechanism.
  • mainnet-mnt-txs.md — Two real mainnet transactions with transfer_token_id = 46347397 (QKCUP, non-QKC), confirming the TransferTokenID field in EVM Context is a hard requirement for historical chain compatibility.

Key design decisions documented

StateAccount extension (goshard-mnt-design.md §2)

  • StateAccount gains a 5th field MntBalances *TokenBalances for non-QKC tokens
  • Balance *uint256.Int stays as-is for QKC (tokenID=35760) in EVM and state machine
  • At trie write time, EncodeAccountRLP merges both into goquarkchain's 6-element RLP format

EVM Context (§5.2)

  • GasTokenID and TransferTokenID added to vm.Context, mirroring goquarkchain
  • CanTransfer/Transfer signatures extended with uint64 tokenID parameter
  • Plain EVM value-transfer transactions can carry a non-QKC transfer_token_id without going through the transferMnt precompile — required for the two observed mainnet transactions

MNT Precompile/system contracts (§7.9–7.11)

  • 5 precompiles at 0x...514b4300010x...514b430005: currentMntID, transferMnt, deploySystemContract, mintMNT, balanceMNT
  • 2 system contracts at fixed addresses (0x514b43...0002/0003)
  • TokenIDQueried flag on Contract struct + checkTokenIDQueried in evm.Call — prevents contracts from silently accepting the wrong token

TokenBalances trie format (§4.3)

  • List format (≤16 tokens): 0x00 + RLP-sorted pairs — implemented
  • Trie format (>16 tokens): 0x01 + SecureTrie root — no mainnet accounts with >16 MNT tokens observed, no current requirement

Testing strategy (§12)

  • L1 unit tests: RLP byte-for-byte comparison against goquarkchain golden output
  • L2 snapshot replay: export goquarkchain RocksDB state, re-encode via goshard, verify root hash matches
  • L3 EVM integration: replay transactions on snapshot state, compare resulting root hashes

- goshard-mnt-design.md: full integration design for goshard including
  StateAccount extension, TokenBalances, EVM Context changes, 5 MNT
  precompiles, system contracts, journal support, and testing strategy
- goqkc-mnt-design.md: existing goquarkchain MNT design reference
  (Account struct, TokenBalances types, precompile interfaces, transaction
  flow examples, EVM dispatch mechanism)
- mainnet-mnt-txs.md: real mainnet transactions with non-QKC transfer_token_id
  (QKCUP, tokenID=46347397) confirming TransferTokenID requirement

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
ping-ke and others added 3 commits June 9, 2026 11:31
- §5.2/§7.11: GasTokenID/TransferTokenID moved to TxContext (not BlockContext);
  update all code snippets, function signatures, and summary table
- §5.4/§7.11: checkTokenIDQueried condition updated to != 0 && != types.DefaultTokenID
- §7.4: remove EncodeToByte32 (only needed for trie format, not implemented);
  renumber §7.5–§7.15 → §7.4–§7.13
- §7.13/§13: Genesis MNT alloc marked out of scope
- Replace defaultMntTokenID with types.DefaultTokenID throughout

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
- Rename token_balances.go → token.go
- Add state_account_qkc.go (New) and gen_account_rlp.go (Deleted)
- Remove reader.go and transaction.go (not actually modified)
- Fix duplicate contracts_qkc.go entry
- Add core/evm.go; update line counts to match actual diff

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Comment thread docs/mnt/goshard-mnt-design.md Outdated
- Fix typo: rename Optial to Optional in goshard and goqkc design docs
- Clarify FullShardKey rationale: determined by creating tx, not derivable
- Remove historical comparison phrases (no longer, originally, etc.)
- Remove goshard simplification paragraph about trie format dependencies
- Fix test case matrix: remove FullShardKey column, correct cases 1 and 5
  - Case 1: QKC=0 + nil MntBalances → 0x80 (not QKC-only with balance)
  - Case 5: >16 tokens returns encode error (trie format not supported)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ping-ke
ping-ke requested a review from blockchaindevsh June 24, 2026 08:09
@blockchaindevsh

Copy link
Copy Markdown

mnt-design re-review — verdict: materially closer, still blocked

One real prior blocker fixed; the two root-divergence issues remain, plus a regression and a subtle new
encoding bug.

✅ Fixed (verified vs pyquarkchain)

  • FullShardKey — RESOLVED. Now stored as StateAccount.FullShardKey uint32 ("set on first tx, preserved
    thereafter"), set per-tx via SetFullShardKey, preserved on re-create; getShardKeyForAddress removed. Matches
    pyquarkchain — _Account carries full_shard_key per account (evm/state.py:209,222).
  • No duplicate types — TokenBalances/Uint32 not re-created as geth-native files.

⛔ Blocker — empty() still NOT addressed

IsBlankMnt() is defined (doc line 592) but never wired into empty() (no empty()/EIP-158 reference anywhere in
the doc). pyquarkchain _Account.is_blank = nonce==0 && token_balances.is_blank() && code==empty — across all
tokens (evm/state.py:285-288). goshard keeping geth's Balance-only empty() will prune a
nonce0/QKC0/MNT≠0/no-code account that pyquarkchain keeps → divergent root. Fix: empty() must also check
IsBlankMnt().

⚠️ Regression — transferMnt now genuinely double-transfers

§7.11.5 (the "Full Implementation") does the move twice: lines 1094-1096 SubMntBalance(caller) +
AddMntBalance(to), then line 1104 evm.Call(…value…) (which transfers again via Transfer routing).
pyquarkchain proc_transfer_mnt (evm/specials.py:237-279) does no direct move — only validate → a read-only
balance check (get_balance(...) < value) → apply_msg(new_msg) (the single transfer happens inside the message
call). (The doc's own §5.2 snippet at 246-250 is correct — swap→evm.Call only — so the doc is internally
contradictory; §7.11.5 is the one that's wrong.) Fix: delete the direct Sub/Add in §7.11.5. (Also missing vs
pyquarkchain: GSTIPEND, GCALLVALUETRANSFER, GCALLNEWACCOUNT gas accounting — specials.py:257-271.)

🆕 New encoding bug — empty TokenBalances (subtle but real)

The new EncodeRLP switch maps MntBalances != nil && IsBlank() → 0x8200c0. But pyquarkchain
TokenBalances.serialize (evm/state.py:144-150) does: len==0 → b"" → 0x80; only len>0 with all-zero →
b"\x00"+rlp([]) → 0x8200c0. IsBlank() is true for both, so a non-nil empty map gets 0x8200c0 where
pyquarkchain emits 0x80 — and the new golden test (NewEmptyTokenBalances() → 0x8200c0) pins the wrong value.
Fix: discriminate on len==0 (→0x80) vs len>0 && nonzero==0 (→0x8200c0), not IsBlank(). (Conditional: only
bites if goshard ever produces a non-nil empty MntBalances; the map-of-zeros case is already correct.)

Still-open majors (unchanged; the doc defers them)

  • Gas-token conversion + RefundRate + burn, and refund/fee-always-in-default-token — not modeled.
  • Cross-shard token deposit — not designed.
  • mintMNT fidelity — missing chainID==0 + tokenID-range checks, new-account gas, and returns nil instead of
    the mintMNTSuccess word.
  • File Plan still adds GasTokenID/TransferTokenID to geth's mainnet core/types/transaction.go rather than
    sourcing from qkc/types.EvmTransaction via core.Message.

ping-ke and others added 3 commits June 29, 2026 17:14
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
- Rename token_balances.go → token.go throughout
- Remove trie-format fields (db, tokenTrie, Commit, EncodeRLP, DecodeRLP)
  from TokenBalances design; document balances-only-stores-nonzero invariant
- Replace "import cycle" rationale with "unseen on mainnet" for trie
  format limitation (§4.3 and §7.1)
- Fix §9.3 and §16 transferMnt flow: precompile does not Sub/Add directly;
  single transfer is performed inside evm.Call via Transfer routing

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Message struct (with GasTokenID/TransferTokenID) lives in
core/state_transition.go, not core/types/transaction.go.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
@ping-ke

ping-ke commented Jul 1, 2026

Copy link
Copy Markdown
Author

mnt-design re-review — verdict: materially closer, still blocked

One real prior blocker fixed; the two root-divergence issues remain, plus a regression and a subtle new encoding bug.

✅ Fixed (verified vs pyquarkchain)

  • FullShardKey — RESOLVED. Now stored as StateAccount.FullShardKey uint32 ("set on first tx, preserved
    thereafter"), set per-tx via SetFullShardKey, preserved on re-create; getShardKeyForAddress removed. Matches
    pyquarkchain — _Account carries full_shard_key per account (evm/state.py:209,222).
  • No duplicate types — TokenBalances/Uint32 not re-created as geth-native files.

⛔ Blocker — empty() still NOT addressed

IsBlankMnt() is defined (doc line 592) but never wired into empty() (no empty()/EIP-158 reference anywhere in the doc). pyquarkchain _Account.is_blank = nonce==0 && token_balances.is_blank() && code==empty — across all tokens (evm/state.py:285-288). goshard keeping geth's Balance-only empty() will prune a nonce0/QKC0/MNT≠0/no-code account that pyquarkchain keeps → divergent root. Fix: empty() must also check IsBlankMnt().

⚠️ Regression — transferMnt now genuinely double-transfers

§7.11.5 (the "Full Implementation") does the move twice: lines 1094-1096 SubMntBalance(caller) + AddMntBalance(to), then line 1104 evm.Call(…value…) (which transfers again via Transfer routing). pyquarkchain proc_transfer_mnt (evm/specials.py:237-279) does no direct move — only validate → a read-only balance check (get_balance(...) < value) → apply_msg(new_msg) (the single transfer happens inside the message call). (The doc's own §5.2 snippet at 246-250 is correct — swap→evm.Call only — so the doc is internally contradictory; §7.11.5 is the one that's wrong.) Fix: delete the direct Sub/Add in §7.11.5. (Also missing vs pyquarkchain: GSTIPEND, GCALLVALUETRANSFER, GCALLNEWACCOUNT gas accounting — specials.py:257-271.)

🆕 New encoding bug — empty TokenBalances (subtle but real)

The new EncodeRLP switch maps MntBalances != nil && IsBlank() → 0x8200c0. But pyquarkchain TokenBalances.serialize (evm/state.py:144-150) does: len==0 → b"" → 0x80; only len>0 with all-zero → b"\x00"+rlp([]) → 0x8200c0. IsBlank() is true for both, so a non-nil empty map gets 0x8200c0 where pyquarkchain emits 0x80 — and the new golden test (NewEmptyTokenBalances() → 0x8200c0) pins the wrong value. Fix: discriminate on len==0 (→0x80) vs len>0 && nonzero==0 (→0x8200c0), not IsBlank(). (Conditional: only bites if goshard ever produces a non-nil empty MntBalances; the map-of-zeros case is already correct.)

Still-open majors (unchanged; the doc defers them)

  • Gas-token conversion + RefundRate + burn, and refund/fee-always-in-default-token — not modeled.
  • Cross-shard token deposit — not designed.
  • mintMNT fidelity — missing chainID==0 + tokenID-range checks, new-account gas, and returns nil instead of
    the mintMNTSuccess word.
  • File Plan still adds GasTokenID/TransferTokenID to geth's mainnet core/types/transaction.go rather than
    sourcing from qkc/types.EvmTransaction via core.Message.

Resolved:

  • Blocker — empty() still NOT addressed
  • Regression — transferMnt now genuinely double-transfers

No Need to Resolved

  • New encoding bug — empty TokenBalances (subtle but real): Already use pyquarkchain trie dump data verify current feature/mnt-code branch code, and it can match the trie root after re-encode and build

Comment thread docs/mnt/goshard-mnt-design.md
Comment thread docs/mnt/goshard-mnt-design.md Outdated
Comment thread docs/mnt/goshard-mnt-design.md Outdated
```go
// core/vm/evm.go — inside Call(), after contract execution
if err == nil && len(contract.Code) != 0 && !contract.TokenIDQueried &&
evm.TxContext.TransferTokenID != 0 && evm.TxContext.TransferTokenID != types.DefaultTokenID &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

pyquarkchain supports token_id == 0, so technically evm.TxContext.TransferTokenID != 0 here can cause a fork, and so do other != 0 guards.

Comment thread docs/mnt/goshard-mnt-design.md Outdated
ping-ke and others added 2 commits July 8, 2026 20:21
…imAccount, remove bogus tokenID check

Comment 1 (line 901): activePrecompiledContracts was merging MNT precompiles
unconditionally — history replay of blocks before the MNT activation
timestamp would fork.  Fix: gate the merge behind rules.IsQKCMNT, backed
by a new QKCMNTTime *uint64 field in ChainConfig (same pattern as
ShanghaiTime/CancunTime).  Add implementation steps to Phase 3 list.

Comment 2 (line 1265): Snapshot tree was incorrectly listed under §10
"What Does NOT Change".  Fix: remove from that list; note that
SlimAccount must be extended with MntBalances and FullShardKey (both
rlp:"optional") and that flatReader.Account must copy those fields.
Add to Phase 2 implementation notes.

Comment 3 (line 834): transferMnt flow listed a 'tokenID ≠ QKC tokenID'
validation step that does not exist in goquarkchain transferMnt.Run.
Fix: remove step 4, re-number, update the 'Identical to goquarkchain'
prose to clarify the routing behaviour instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
}

evm.StateDB.AddMntBalance(recipient, amount, tokenID)
return nil, 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.

The existing goquarkchain mintMNT implementation returns the 32-byte success value, but here returns nil?

t.Run("qkc_tokenid_rejected", func(t *testing.T) {
input := encodeTransferMNTInput(bob, 35760, uint256.NewInt(1), nil)
_, err := precompile.Run(input, evm, newContract(alice))
require.ErrorIs(t, err, vm.ErrExecutionReverted)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Looks like transferMnt has no explicit tokenID == QKC rejection?

// QKC balance zero, MNT map explicitly empty → serialize as empty list → wire: 0x8200c0
// Preserves "account touched TokenBalances map then emptied it" history
// (9194 such accounts observed on mainnet).
tokenBal, _ = acct.MntBalances.SerializeToBytes()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

SerializeToBytes() is documented to fail for >16 token balances, but here drops that error.

{"mnt_only", uint256.NewInt(0), types.NewTokenBalancesWithMap(map[uint64]*uint256.Int{TokenIDEncode("QKCUP"): uint256.NewInt(500000)}), 0, "..."},
{"qkc_and_mnt", uint256.NewInt(1000000), types.NewTokenBalancesWithMap(map[uint64]*uint256.Int{TokenIDEncode("QKCUP"): uint256.NewInt(500000)}), 0, "..."},
{"nonzero_shardkey", uint256.NewInt(1000000), nil, 0x10000, "..."},
{"trie_format", uint256.NewInt(0), make17Tokens(), 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.

The test matrix says 17 distinct tokens should be an encode error, but the sample loop still calls require.NoError.

```go
func (st *stateTransition) buyGas() error {
gasTokenID := st.msg.GasTokenID
// deduct gas cost from gasTokenID balance

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Should it fail if gasTokenID != QKC?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants