chore: initial commit for v0.0.1

DChain single-node blockchain + React Native messenger client.

Core:
- PBFT consensus with multi-sig validator admission + equivocation slashing
- BadgerDB + schema migration scaffold (CurrentSchemaVersion=0)
- libp2p gossipsub (tx/v1, blocks/v1, relay/v1, version/v1)
- Native Go contracts (username_registry) alongside WASM (wazero)
- WebSocket gateway with topic-based fanout + Ed25519-nonce auth
- Relay mailbox with NaCl envelope encryption (X25519 + Ed25519)
- Prometheus /metrics, per-IP rate limit, body-size cap

Deployment:
- Single-node compose (deploy/single/) with Caddy TLS + optional Prometheus
- 3-node dev compose (docker-compose.yml) with mocked internet topology
- 3-validator prod compose (deploy/prod/) for federation
- Auto-update from Gitea via /api/update-check + systemd timer
- Build-time version injection (ldflags → node --version)
- UI / Swagger toggle flags (DCHAIN_DISABLE_UI, DCHAIN_DISABLE_SWAGGER)

Client (client-app/):
- Expo / React Native / NativeWind
- E2E NaCl encryption, typing indicator, contact requests
- Auto-discovery of canonical contracts, chain_id aware, WS reconnect on node switch

Documentation:
- README.md, CHANGELOG.md, CONTEXT.md
- deploy/single/README.md with 6 operator scenarios
- deploy/UPDATE_STRATEGY.md with 4-layer forward-compat design
- docs/contracts/*.md per contract
This commit is contained in:
vsecoder
2026-04-17 14:16:44 +03:00
commit 7e7393e4f8
196 changed files with 55947 additions and 0 deletions

139
blockchain/block.go Normal file
View File

@@ -0,0 +1,139 @@
package blockchain
import (
"bytes"
"crypto/ed25519"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"encoding/json"
"errors"
"time"
)
// Block is the fundamental unit of the chain.
type Block struct {
Index uint64 `json:"index"`
Timestamp time.Time `json:"timestamp"`
Transactions []*Transaction `json:"transactions"`
PrevHash []byte `json:"prev_hash"`
Hash []byte `json:"hash"` // SHA-256 over canonical fields
ValidatorSig []byte `json:"validator_sig"` // Ed25519 sig over Hash
Validator string `json:"validator"` // hex pub key of signing validator
// TotalFees collected in this block (credited to Validator)
TotalFees uint64 `json:"total_fees"`
}
// canonicalBytes returns a deterministic byte slice for hashing.
// Order: index | timestamp | prev_hash | tx_hashes | total_fees | validator
func (b *Block) canonicalBytes() []byte {
var buf bytes.Buffer
// 8-byte big-endian index
idxBuf := make([]byte, 8)
binary.BigEndian.PutUint64(idxBuf, b.Index)
buf.Write(idxBuf)
// 8-byte unix nano timestamp
tsBuf := make([]byte, 8)
binary.BigEndian.PutUint64(tsBuf, uint64(b.Timestamp.UnixNano()))
buf.Write(tsBuf)
buf.Write(b.PrevHash)
// Hash each transaction and include its hash
for _, tx := range b.Transactions {
h := txHash(tx)
buf.Write(h)
}
// 8-byte fees
feesBuf := make([]byte, 8)
binary.BigEndian.PutUint64(feesBuf, b.TotalFees)
buf.Write(feesBuf)
buf.WriteString(b.Validator)
return buf.Bytes()
}
// txHash returns SHA-256 of the canonical transaction bytes.
func txHash(tx *Transaction) []byte {
data, _ := json.Marshal(tx)
h := sha256.Sum256(data)
return h[:]
}
// ComputeHash fills b.Hash from the canonical bytes.
func (b *Block) ComputeHash() {
sum := sha256.Sum256(b.canonicalBytes())
b.Hash = sum[:]
}
// Sign signs b.Hash with the given Ed25519 private key and stores the signature.
func (b *Block) Sign(privKey ed25519.PrivateKey) {
b.ValidatorSig = ed25519.Sign(privKey, b.Hash)
}
// Validate checks the block's structural integrity:
// 1. Hash matches canonical bytes
// 2. ValidatorSig is a valid Ed25519 signature over Hash
// 3. PrevHash is provided (except genesis)
func (b *Block) Validate(prevHash []byte) error {
// Recompute and compare hash
sum := sha256.Sum256(b.canonicalBytes())
if !bytes.Equal(sum[:], b.Hash) {
return errors.New("block hash mismatch")
}
// Verify validator signature
pubKeyBytes, err := hex.DecodeString(b.Validator)
if err != nil {
return errors.New("invalid validator pub key hex")
}
if !ed25519.Verify(ed25519.PublicKey(pubKeyBytes), b.Hash, b.ValidatorSig) {
return errors.New("invalid validator signature")
}
// Check chain linkage (skip for genesis)
if b.Index > 0 {
if !bytes.Equal(b.PrevHash, prevHash) {
return errors.New("prev_hash mismatch")
}
}
// Validate each transaction's fee minimum
var totalFees uint64
for _, tx := range b.Transactions {
if tx.Fee < MinFee {
return errors.New("transaction fee below minimum")
}
totalFees += tx.Fee
}
if totalFees != b.TotalFees {
return errors.New("total_fees mismatch")
}
return nil
}
// GenesisBlock creates the first block with no transactions.
// It is signed by the bootstrap validator.
func GenesisBlock(validatorPubHex string, privKey ed25519.PrivateKey) *Block {
b := &Block{
Index: 0,
Timestamp: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
Transactions: []*Transaction{},
PrevHash: bytes.Repeat([]byte{0}, 32),
Validator: validatorPubHex,
TotalFees: 0,
}
b.ComputeHash()
b.Sign(privKey)
return b
}
// HashHex returns the block hash as a hex string.
func (b *Block) HashHex() string {
return hex.EncodeToString(b.Hash)
}

2589
blockchain/chain.go Normal file

File diff suppressed because it is too large Load Diff

796
blockchain/chain_test.go Normal file
View File

@@ -0,0 +1,796 @@
package blockchain_test
import (
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"testing"
"time"
"go-blockchain/blockchain"
"go-blockchain/identity"
)
// ─── helpers ────────────────────────────────────────────────────────────────
// newChain opens a fresh BadgerDB-backed chain in a temp directory and
// registers a cleanup that closes the DB then removes the directory.
// We avoid t.TempDir() because on Windows, BadgerDB's mmap'd value-log files
// may still be held open for a brief moment after Close() returns, causing
// the automatic TempDir cleanup to fail with "directory not empty".
// Using os.MkdirTemp + a retry loop works around this race.
func newChain(t *testing.T) *blockchain.Chain {
t.Helper()
dir, err := os.MkdirTemp("", "dchain-test-*")
if err != nil {
t.Fatalf("MkdirTemp: %v", err)
}
c, err := blockchain.NewChain(dir)
if err != nil {
_ = os.RemoveAll(dir)
t.Fatalf("NewChain: %v", err)
}
t.Cleanup(func() {
_ = c.Close()
// Retry removal to handle Windows mmap handle release delay.
for i := 0; i < 20; i++ {
if err := os.RemoveAll(dir); err == nil {
return
}
time.Sleep(10 * time.Millisecond)
}
})
return c
}
// newIdentity generates a fresh Ed25519 + X25519 keypair for test use.
func newIdentity(t *testing.T) *identity.Identity {
t.Helper()
id, err := identity.Generate()
if err != nil {
t.Fatalf("identity.Generate: %v", err)
}
return id
}
// addGenesis creates and commits the genesis block signed by validator.
func addGenesis(t *testing.T, c *blockchain.Chain, validator *identity.Identity) *blockchain.Block {
t.Helper()
b := blockchain.GenesisBlock(validator.PubKeyHex(), validator.PrivKey)
if err := c.AddBlock(b); err != nil {
t.Fatalf("AddBlock(genesis): %v", err)
}
return b
}
// txID produces a short deterministic transaction ID.
func txID(from string, typ blockchain.EventType) string {
h := sha256.Sum256([]byte(fmt.Sprintf("%s:%s:%d", from, typ, time.Now().UnixNano())))
return hex.EncodeToString(h[:16])
}
// makeTx builds a minimal transaction with all required fields set.
// Signature is intentionally left nil — chain.applyTx does not re-verify
// Ed25519 tx signatures (that is the consensus engine's job).
func makeTx(typ blockchain.EventType, from, to string, amount, fee uint64, payload []byte) *blockchain.Transaction {
return &blockchain.Transaction{
ID: txID(from, typ),
Type: typ,
From: from,
To: to,
Amount: amount,
Fee: fee,
Payload: payload,
Timestamp: time.Now().UTC(),
}
}
// mustJSON marshals v and panics on error (test helper only).
func mustJSON(v any) []byte {
b, err := json.Marshal(v)
if err != nil {
panic(err)
}
return b
}
// buildBlock wraps txs in a block that follows prev, computes hash, and signs
// it with validatorPriv. TotalFees is computed from the tx slice.
func buildBlock(t *testing.T, prev *blockchain.Block, validator *identity.Identity, txs []*blockchain.Transaction) *blockchain.Block {
t.Helper()
var totalFees uint64
for _, tx := range txs {
totalFees += tx.Fee
}
b := &blockchain.Block{
Index: prev.Index + 1,
Timestamp: time.Now().UTC(),
Transactions: txs,
PrevHash: prev.Hash,
Validator: validator.PubKeyHex(),
TotalFees: totalFees,
}
b.ComputeHash()
b.Sign(validator.PrivKey)
return b
}
// mustAddBlock calls c.AddBlock and fails the test on error.
func mustAddBlock(t *testing.T, c *blockchain.Chain, b *blockchain.Block) {
t.Helper()
if err := c.AddBlock(b); err != nil {
t.Fatalf("AddBlock (index %d): %v", b.Index, err)
}
}
// mustBalance reads the balance and fails on error.
func mustBalance(t *testing.T, c *blockchain.Chain, pubHex string) uint64 {
t.Helper()
bal, err := c.Balance(pubHex)
if err != nil {
t.Fatalf("Balance(%s): %v", pubHex[:8], err)
}
return bal
}
// ─── tests ───────────────────────────────────────────────────────────────────
// 1. Genesis block credits GenesisAllocation to the validator.
func TestGenesisCreatesBalance(t *testing.T) {
c := newChain(t)
val := newIdentity(t)
addGenesis(t, c, val)
bal := mustBalance(t, c, val.PubKeyHex())
if bal != blockchain.GenesisAllocation {
t.Errorf("expected GenesisAllocation=%d, got %d", blockchain.GenesisAllocation, bal)
}
}
// 2. Transfer moves tokens between two identities and leaves correct balances.
func TestTransfer(t *testing.T) {
c := newChain(t)
val := newIdentity(t)
alice := newIdentity(t)
genesis := addGenesis(t, c, val)
// Fund alice via a transfer from validator.
const sendAmount = 100 * blockchain.Token
const fee = blockchain.MinFee
tx := makeTx(
blockchain.EventTransfer,
val.PubKeyHex(),
alice.PubKeyHex(),
sendAmount, fee,
mustJSON(blockchain.TransferPayload{}),
)
b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{tx})
mustAddBlock(t, c, b1)
valBal := mustBalance(t, c, val.PubKeyHex())
aliceBal := mustBalance(t, c, alice.PubKeyHex())
// Validator: genesis - sendAmount - fee + fee (validator earns TotalFees back)
expectedVal := blockchain.GenesisAllocation - sendAmount - fee + fee
if valBal != expectedVal {
t.Errorf("validator balance: got %d, want %d", valBal, expectedVal)
}
if aliceBal != sendAmount {
t.Errorf("alice balance: got %d, want %d", aliceBal, sendAmount)
}
}
// 3. Transfer that exceeds sender's balance must fail AddBlock.
func TestTransferInsufficientFunds(t *testing.T) {
c := newChain(t)
val := newIdentity(t)
alice := newIdentity(t)
genesis := addGenesis(t, c, val)
// alice has 0 balance — try to spend 1 token
tx := makeTx(
blockchain.EventTransfer,
alice.PubKeyHex(),
val.PubKeyHex(),
1*blockchain.Token, blockchain.MinFee,
mustJSON(blockchain.TransferPayload{}),
)
b := buildBlock(t, genesis, val, []*blockchain.Transaction{tx})
// AddBlock must succeed — the bad tx is skipped rather than rejecting the block.
if err := c.AddBlock(b); err != nil {
t.Fatalf("AddBlock returned unexpected error: %v", err)
}
// Alice's balance must still be 0 — the skipped tx had no effect.
bal, err := c.Balance(alice.PubKeyHex())
if err != nil {
t.Fatalf("Balance: %v", err)
}
if bal != 0 {
t.Errorf("expected alice balance 0, got %d", bal)
}
}
// 4. EventRegisterKey stores X25519 key in IdentityInfo.
func TestRegisterKeyStoresIdentity(t *testing.T) {
c := newChain(t)
val := newIdentity(t)
alice := newIdentity(t)
genesis := addGenesis(t, c, val)
payload := blockchain.RegisterKeyPayload{
PubKey: alice.PubKeyHex(),
Nickname: "alice",
PowNonce: 0,
PowTarget: "0",
X25519PubKey: alice.X25519PubHex(),
}
tx := makeTx(
blockchain.EventRegisterKey,
alice.PubKeyHex(),
"",
0, blockchain.RegistrationFee,
mustJSON(payload),
)
// Fund alice with enough to cover RegistrationFee before she registers.
fundTx := makeTx(
blockchain.EventTransfer,
val.PubKeyHex(),
alice.PubKeyHex(),
blockchain.RegistrationFee, blockchain.MinFee,
mustJSON(blockchain.TransferPayload{}),
)
b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{fundTx})
mustAddBlock(t, c, b1)
b2 := buildBlock(t, b1, val, []*blockchain.Transaction{tx})
mustAddBlock(t, c, b2)
info, err := c.IdentityInfo(alice.PubKeyHex())
if err != nil {
t.Fatalf("IdentityInfo: %v", err)
}
if !info.Registered {
t.Error("expected Registered=true after REGISTER_KEY tx")
}
if info.Nickname != "alice" {
t.Errorf("nickname: got %q, want %q", info.Nickname, "alice")
}
if info.X25519Pub != alice.X25519PubHex() {
t.Errorf("X25519Pub: got %q, want %q", info.X25519Pub, alice.X25519PubHex())
}
}
// 5. ContactRequest flow: pending → accepted → blocked.
func TestContactRequestFlow(t *testing.T) {
c := newChain(t)
val := newIdentity(t)
alice := newIdentity(t) // requester
bob := newIdentity(t) // target
genesis := addGenesis(t, c, val)
// Fund alice and bob for fees.
const contactAmt = blockchain.MinContactFee
fundAlice := makeTx(blockchain.EventTransfer, val.PubKeyHex(), alice.PubKeyHex(),
contactAmt+2*blockchain.MinFee, blockchain.MinFee, mustJSON(blockchain.TransferPayload{}))
fundBob := makeTx(blockchain.EventTransfer, val.PubKeyHex(), bob.PubKeyHex(),
2*blockchain.MinFee, blockchain.MinFee, mustJSON(blockchain.TransferPayload{}))
b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{fundAlice, fundBob})
mustAddBlock(t, c, b1)
// Alice sends contact request to Bob.
reqTx := makeTx(
blockchain.EventContactRequest,
alice.PubKeyHex(),
bob.PubKeyHex(),
contactAmt, blockchain.MinFee,
mustJSON(blockchain.ContactRequestPayload{Intro: "Hey Bob!"}),
)
b2 := buildBlock(t, b1, val, []*blockchain.Transaction{reqTx})
mustAddBlock(t, c, b2)
contacts, err := c.ContactRequests(bob.PubKeyHex())
if err != nil {
t.Fatalf("ContactRequests: %v", err)
}
if len(contacts) != 1 {
t.Fatalf("expected 1 contact record, got %d", len(contacts))
}
if contacts[0].Status != blockchain.ContactPending {
t.Errorf("status: got %q, want %q", contacts[0].Status, blockchain.ContactPending)
}
// Bob accepts.
acceptTx := makeTx(
blockchain.EventAcceptContact,
bob.PubKeyHex(),
alice.PubKeyHex(),
0, blockchain.MinFee,
mustJSON(blockchain.AcceptContactPayload{}),
)
b3 := buildBlock(t, b2, val, []*blockchain.Transaction{acceptTx})
mustAddBlock(t, c, b3)
contacts, err = c.ContactRequests(bob.PubKeyHex())
if err != nil {
t.Fatalf("ContactRequests after accept: %v", err)
}
if len(contacts) != 1 || contacts[0].Status != blockchain.ContactAccepted {
t.Errorf("expected accepted, got %v", contacts)
}
// Bob then blocks Alice (status transitions from accepted → blocked).
blockTx := makeTx(
blockchain.EventBlockContact,
bob.PubKeyHex(),
alice.PubKeyHex(),
0, blockchain.MinFee,
mustJSON(blockchain.BlockContactPayload{}),
)
b4 := buildBlock(t, b3, val, []*blockchain.Transaction{blockTx})
mustAddBlock(t, c, b4)
contacts, err = c.ContactRequests(bob.PubKeyHex())
if err != nil {
t.Fatalf("ContactRequests after block: %v", err)
}
if len(contacts) != 1 || contacts[0].Status != blockchain.ContactBlocked {
t.Errorf("expected blocked, got %v", contacts)
}
}
// 6. ContactRequest with amount below MinContactFee must fail.
func TestContactRequestInsufficientFee(t *testing.T) {
c := newChain(t)
val := newIdentity(t)
alice := newIdentity(t)
bob := newIdentity(t)
genesis := addGenesis(t, c, val)
// Fund alice.
fundAlice := makeTx(blockchain.EventTransfer, val.PubKeyHex(), alice.PubKeyHex(),
blockchain.MinContactFee+blockchain.MinFee, blockchain.MinFee,
mustJSON(blockchain.TransferPayload{}))
b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{fundAlice})
mustAddBlock(t, c, b1)
// Amount is one µT below MinContactFee.
reqTx := makeTx(
blockchain.EventContactRequest,
alice.PubKeyHex(),
bob.PubKeyHex(),
blockchain.MinContactFee-1, blockchain.MinFee,
mustJSON(blockchain.ContactRequestPayload{}),
)
b := buildBlock(t, b1, val, []*blockchain.Transaction{reqTx})
// AddBlock must succeed — the bad tx is skipped rather than rejecting the block.
if err := c.AddBlock(b); err != nil {
t.Fatalf("AddBlock returned unexpected error: %v", err)
}
// No pending contact record must exist for bob←alice.
contacts, err := c.ContactRequests(bob.PubKeyHex())
if err != nil {
t.Fatalf("ContactRequests: %v", err)
}
if len(contacts) != 0 {
t.Errorf("expected 0 pending contacts, got %d (tx should have been skipped)", len(contacts))
}
}
// 7. InitValidators seeds keys; ValidatorSet returns them all.
func TestValidatorSetInit(t *testing.T) {
c := newChain(t)
ids := []*identity.Identity{newIdentity(t), newIdentity(t), newIdentity(t)}
keys := make([]string, len(ids))
for i, id := range ids {
keys[i] = id.PubKeyHex()
}
if err := c.InitValidators(keys); err != nil {
t.Fatalf("InitValidators: %v", err)
}
set, err := c.ValidatorSet()
if err != nil {
t.Fatalf("ValidatorSet: %v", err)
}
if len(set) != len(keys) {
t.Fatalf("expected %d validators, got %d", len(keys), len(set))
}
got := make(map[string]bool, len(set))
for _, k := range set {
got[k] = true
}
for _, k := range keys {
if !got[k] {
t.Errorf("key %s missing from validator set", k[:8])
}
}
}
// 8. EventAddValidator adds a new validator via a real block.
//
// Updated for P2.1 (stake-gated admission): the candidate must first have
// at least MinValidatorStake (1 T = 1_000_000 µT) locked via a STAKE tx
// and be credited enough balance to do so. Multi-sig approval is trivially
// met here because the initial set has only one validator — ⌈2/3⌉ of 1
// is 1, which the tx sender provides implicitly.
func TestAddValidatorTx(t *testing.T) {
c := newChain(t)
val := newIdentity(t) // initial validator
newVal := newIdentity(t) // to be added
// Seed the initial validator.
if err := c.InitValidators([]string{val.PubKeyHex()}); err != nil {
t.Fatalf("InitValidators: %v", err)
}
genesis := addGenesis(t, c, val)
// Fund the candidate enough to stake.
fundTx := makeTx(
blockchain.EventTransfer,
val.PubKeyHex(),
newVal.PubKeyHex(),
2*blockchain.MinValidatorStake, blockchain.MinFee,
mustJSON(blockchain.TransferPayload{}),
)
// Candidate stakes the minimum.
stakeTx := makeTx(
blockchain.EventStake,
newVal.PubKeyHex(),
newVal.PubKeyHex(),
blockchain.MinValidatorStake, blockchain.MinFee,
nil,
)
preBlock := buildBlock(t, genesis, val, []*blockchain.Transaction{fundTx, stakeTx})
mustAddBlock(t, c, preBlock)
tx := makeTx(
blockchain.EventAddValidator,
val.PubKeyHex(),
newVal.PubKeyHex(),
0, blockchain.MinFee,
mustJSON(blockchain.AddValidatorPayload{Reason: "test"}),
)
b1 := buildBlock(t, preBlock, val, []*blockchain.Transaction{tx})
mustAddBlock(t, c, b1)
set, err := c.ValidatorSet()
if err != nil {
t.Fatalf("ValidatorSet: %v", err)
}
found := false
for _, k := range set {
if k == newVal.PubKeyHex() {
found = true
break
}
}
if !found {
t.Errorf("new validator %s not found in set after ADD_VALIDATOR tx", newVal.PubKeyHex()[:8])
}
}
// 9. EventRemoveValidator removes a key from the set.
//
// Updated for P2.2 (multi-sig forced removal): the sender and the
// cosigners must together reach ⌈2/3⌉ of the current set. Here we have
// 3 validators, so 2 approvals are needed. `val` sends, `coSigner` adds
// a signature for RemoveDigest(removeMe.Pub).
func TestRemoveValidatorTx(t *testing.T) {
c := newChain(t)
val := newIdentity(t)
coSigner := newIdentity(t)
removeMe := newIdentity(t)
// All three start as validators (ceil(2/3 * 3) = 2 approvals needed).
if err := c.InitValidators([]string{val.PubKeyHex(), coSigner.PubKeyHex(), removeMe.PubKeyHex()}); err != nil {
t.Fatalf("InitValidators: %v", err)
}
genesis := addGenesis(t, c, val)
// coSigner produces an off-chain approval for removing removeMe.
sig := coSigner.Sign(blockchain.RemoveDigest(removeMe.PubKeyHex()))
tx := makeTx(
blockchain.EventRemoveValidator,
val.PubKeyHex(),
removeMe.PubKeyHex(),
0, blockchain.MinFee,
mustJSON(blockchain.RemoveValidatorPayload{
Reason: "test",
CoSignatures: []blockchain.ValidatorCoSig{
{PubKey: coSigner.PubKeyHex(), Signature: sig},
},
}),
)
b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{tx})
mustAddBlock(t, c, b1)
set, err := c.ValidatorSet()
if err != nil {
t.Fatalf("ValidatorSet: %v", err)
}
for _, k := range set {
if k == removeMe.PubKeyHex() {
t.Errorf("removed validator %s still in set", removeMe.PubKeyHex()[:8])
}
}
}
// 10. ADD_VALIDATOR tx from a non-validator must fail.
func TestAddValidatorNotAValidator(t *testing.T) {
c := newChain(t)
val := newIdentity(t)
nonVal := newIdentity(t)
target := newIdentity(t)
if err := c.InitValidators([]string{val.PubKeyHex()}); err != nil {
t.Fatalf("InitValidators: %v", err)
}
genesis := addGenesis(t, c, val)
// Fund nonVal so the debit doesn't fail first (it should fail on validator check).
fundTx := makeTx(blockchain.EventTransfer, val.PubKeyHex(), nonVal.PubKeyHex(),
10*blockchain.Token, blockchain.MinFee, mustJSON(blockchain.TransferPayload{}))
b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{fundTx})
mustAddBlock(t, c, b1)
badTx := makeTx(
blockchain.EventAddValidator,
nonVal.PubKeyHex(), // not a validator
target.PubKeyHex(),
0, blockchain.MinFee,
mustJSON(blockchain.AddValidatorPayload{}),
)
b2 := buildBlock(t, b1, val, []*blockchain.Transaction{badTx})
// AddBlock must succeed — the bad tx is skipped rather than rejecting the block.
if err := c.AddBlock(b2); err != nil {
t.Fatalf("AddBlock returned unexpected error: %v", err)
}
// target must NOT have been added as a validator (tx was skipped).
vset, err := c.ValidatorSet()
if err != nil {
t.Fatalf("ValidatorSet: %v", err)
}
for _, v := range vset {
if v == target.PubKeyHex() {
t.Error("target was added as validator despite tx being from a non-validator (should have been skipped)")
}
}
}
// 11. RelayProof with valid FeeSig transfers the relay fee from sender to relay.
func TestRelayProofClaimsFee(t *testing.T) {
c := newChain(t)
val := newIdentity(t)
sender := newIdentity(t)
relay := newIdentity(t)
genesis := addGenesis(t, c, val)
const relayFeeUT = 5_000 * blockchain.MicroToken
// Fund sender with enough to cover relay fee and tx fee.
fundTx := makeTx(blockchain.EventTransfer, val.PubKeyHex(), sender.PubKeyHex(),
relayFeeUT+blockchain.MinFee, blockchain.MinFee, mustJSON(blockchain.TransferPayload{}))
b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{fundTx})
mustAddBlock(t, c, b1)
senderBalBefore := mustBalance(t, c, sender.PubKeyHex())
relayBalBefore := mustBalance(t, c, relay.PubKeyHex())
envelopeID := "env-abc123"
authBytes := blockchain.FeeAuthBytes(envelopeID, relayFeeUT)
feeSig := sender.Sign(authBytes)
envelopeHash := sha256.Sum256([]byte("fake-ciphertext"))
proofPayload := blockchain.RelayProofPayload{
EnvelopeID: envelopeID,
EnvelopeHash: envelopeHash[:],
SenderPubKey: sender.PubKeyHex(),
FeeUT: relayFeeUT,
FeeSig: feeSig,
RelayPubKey: relay.PubKeyHex(),
DeliveredAt: time.Now().Unix(),
}
tx := makeTx(
blockchain.EventRelayProof,
relay.PubKeyHex(),
"",
0, blockchain.MinFee,
mustJSON(proofPayload),
)
b2 := buildBlock(t, b1, val, []*blockchain.Transaction{tx})
mustAddBlock(t, c, b2)
senderBalAfter := mustBalance(t, c, sender.PubKeyHex())
relayBalAfter := mustBalance(t, c, relay.PubKeyHex())
if senderBalAfter != senderBalBefore-relayFeeUT {
t.Errorf("sender balance: got %d, want %d (before %d - fee %d)",
senderBalAfter, senderBalBefore-relayFeeUT, senderBalBefore, relayFeeUT)
}
if relayBalAfter != relayBalBefore+relayFeeUT {
t.Errorf("relay balance: got %d, want %d (before %d + fee %d)",
relayBalAfter, relayBalBefore+relayFeeUT, relayBalBefore, relayFeeUT)
}
}
// 12. RelayProof with wrong FeeSig must fail AddBlock.
func TestRelayProofBadSig(t *testing.T) {
c := newChain(t)
val := newIdentity(t)
sender := newIdentity(t)
relay := newIdentity(t)
imposter := newIdentity(t) // signs instead of sender
genesis := addGenesis(t, c, val)
const relayFeeUT = 5_000 * blockchain.MicroToken
// Fund sender.
fundTx := makeTx(blockchain.EventTransfer, val.PubKeyHex(), sender.PubKeyHex(),
relayFeeUT+blockchain.MinFee, blockchain.MinFee, mustJSON(blockchain.TransferPayload{}))
b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{fundTx})
mustAddBlock(t, c, b1)
senderBalBefore := mustBalance(t, c, sender.PubKeyHex())
envelopeID := "env-xyz"
authBytes := blockchain.FeeAuthBytes(envelopeID, relayFeeUT)
// Imposter signs, not the actual sender.
badFeeSig := imposter.Sign(authBytes)
envelopeHash := sha256.Sum256([]byte("ciphertext"))
proofPayload := blockchain.RelayProofPayload{
EnvelopeID: envelopeID,
EnvelopeHash: envelopeHash[:],
SenderPubKey: sender.PubKeyHex(), // claims sender, but sig is from imposter
FeeUT: relayFeeUT,
FeeSig: badFeeSig,
RelayPubKey: relay.PubKeyHex(),
DeliveredAt: time.Now().Unix(),
}
tx := makeTx(
blockchain.EventRelayProof,
relay.PubKeyHex(),
"",
0, blockchain.MinFee,
mustJSON(proofPayload),
)
b2 := buildBlock(t, b1, val, []*blockchain.Transaction{tx})
// AddBlock must succeed — the bad tx is skipped rather than rejecting the block.
if err := c.AddBlock(b2); err != nil {
t.Fatalf("AddBlock returned unexpected error: %v", err)
}
// Sender's balance must be unchanged — the skipped tx had no effect.
senderBalAfter, err := c.Balance(sender.PubKeyHex())
if err != nil {
t.Fatalf("Balance: %v", err)
}
if senderBalAfter != senderBalBefore {
t.Errorf("sender balance changed despite bad-sig tx: before=%d after=%d",
senderBalBefore, senderBalAfter)
}
}
// 13. Adding the same block index twice must fail.
func TestDuplicateBlockRejected(t *testing.T) {
c := newChain(t)
val := newIdentity(t)
genesis := addGenesis(t, c, val)
// Build block 1.
b1 := buildBlock(t, genesis, val, nil)
mustAddBlock(t, c, b1)
// Build an independent block also claiming index 1 (different hash).
b1dup := &blockchain.Block{
Index: 1,
Timestamp: time.Now().Add(time.Millisecond).UTC(),
Transactions: []*blockchain.Transaction{},
PrevHash: genesis.Hash,
Validator: val.PubKeyHex(),
TotalFees: 0,
}
b1dup.ComputeHash()
b1dup.Sign(val.PrivKey)
// The chain tip is already at index 1; the new block has index 1 but a
// different prevHash (its own prev is genesis too but tip.Hash ≠ genesis.Hash).
if err := c.AddBlock(b1dup); err == nil {
t.Fatal("expected AddBlock to fail for duplicate index, but it succeeded")
}
}
// 14. Block with wrong prevHash must fail.
func TestChainLinkageRejected(t *testing.T) {
c := newChain(t)
val := newIdentity(t)
genesis := addGenesis(t, c, val)
// Create a block with a garbage prevHash.
garbagePrev := make([]byte, 32)
if _, err := rand.Read(garbagePrev); err != nil {
t.Fatalf("rand.Read: %v", err)
}
badBlock := &blockchain.Block{
Index: 1,
Timestamp: time.Now().UTC(),
Transactions: []*blockchain.Transaction{},
PrevHash: garbagePrev,
Validator: val.PubKeyHex(),
TotalFees: 0,
}
badBlock.ComputeHash()
badBlock.Sign(val.PrivKey)
if err := c.AddBlock(badBlock); err == nil {
t.Fatal("expected AddBlock to fail for wrong prevHash, but it succeeded")
}
// Tip must still be genesis.
tip := c.Tip()
if tip.Index != genesis.Index {
t.Errorf("tip index after rejection: got %d, want %d", tip.Index, genesis.Index)
}
}
// 15. Tip advances with each successfully committed block.
func TestTipUpdates(t *testing.T) {
c := newChain(t)
val := newIdentity(t)
if tip := c.Tip(); tip != nil {
t.Fatalf("tip on empty chain: expected nil, got index %d", tip.Index)
}
genesis := addGenesis(t, c, val)
if tip := c.Tip(); tip == nil || tip.Index != 0 {
t.Fatalf("tip after genesis: expected index 0, got %v", tip)
}
prev := genesis
for i := uint64(1); i <= 3; i++ {
b := buildBlock(t, prev, val, nil)
mustAddBlock(t, c, b)
tip := c.Tip()
if tip == nil {
t.Fatalf("tip is nil after block %d", i)
}
if tip.Index != i {
t.Errorf("tip.Index after block %d: got %d, want %d", i, tip.Index, i)
}
prev = b
}
}
// ─── compile-time guard ──────────────────────────────────────────────────────
// Ensure the identity package is used directly so the import is not trimmed.
var _ = identity.Generate
// Ensure ed25519 and hex are used directly (they may be used via helpers).
var _ = ed25519.PublicKey(nil)
var _ = hex.EncodeToString

101
blockchain/equivocation.go Normal file
View File

@@ -0,0 +1,101 @@
// Package blockchain — equivocation evidence verification for SLASH txs.
//
// "Equivocation" = a validator signing two different consensus messages
// at the same height+view+phase, each endorsing a different block hash.
// PBFT safety depends on validators NOT doing this; a malicious validator
// that equivocates can split honest nodes into disagreeing majorities.
//
// The SLASH tx embeds an EquivocationEvidence payload carrying both
// conflicting messages. Any node (not just the victim) can submit it;
// on-chain verification is purely cryptographic — no "trust me" from the
// submitter. If the evidence is valid, the offender's stake is burned and
// they're removed from the validator set.
package blockchain
import (
"bytes"
"crypto/ed25519"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
)
// EquivocationEvidence is embedded (as JSON bytes) in SlashPayload.Evidence
// when Reason == "equivocation". Two distinct consensus messages from the
// same validator at the same consensus position prove they are trying to
// fork the chain.
type EquivocationEvidence struct {
A *ConsensusMsg `json:"a"`
B *ConsensusMsg `json:"b"`
}
// ValidateEquivocation verifies that the two messages constitute genuine
// equivocation evidence against `offender`. Returns nil on success;
// errors are returned with enough detail for the applyTx caller to log
// why a slash was rejected.
//
// Rules:
// - Both messages must be signed by `offender` (From = offender,
// signature verifies against the offender's Ed25519 pubkey).
// - Same Type (MsgPrepare or MsgCommit — we don't slash for equivocating
// on PrePrepare since leaders can legitimately re-propose).
// - Same View, same SeqNum — equivocation is about the same consensus
// round.
// - Distinct BlockHash — otherwise the two messages are identical and
// not actually contradictory.
// - Both sigs verify against the offender's pubkey.
func ValidateEquivocation(offender string, ev *EquivocationEvidence) error {
if ev == nil || ev.A == nil || ev.B == nil {
return fmt.Errorf("equivocation: missing message(s)")
}
if ev.A.From != offender || ev.B.From != offender {
return fmt.Errorf("equivocation: messages not from offender %s", offender[:8])
}
// Only PREPARE / COMMIT equivocation is slashable. PRE-PREPARE double-
// proposals are expected during view changes — the protocol tolerates
// them.
if ev.A.Type != ev.B.Type {
return fmt.Errorf("equivocation: messages are different types (%v vs %v)", ev.A.Type, ev.B.Type)
}
if ev.A.Type != MsgPrepare && ev.A.Type != MsgCommit {
return fmt.Errorf("equivocation: only PREPARE/COMMIT are slashable (got %v)", ev.A.Type)
}
if ev.A.View != ev.B.View {
return fmt.Errorf("equivocation: different views (%d vs %d)", ev.A.View, ev.B.View)
}
if ev.A.SeqNum != ev.B.SeqNum {
return fmt.Errorf("equivocation: different seqnums (%d vs %d)", ev.A.SeqNum, ev.B.SeqNum)
}
if bytes.Equal(ev.A.BlockHash, ev.B.BlockHash) {
return fmt.Errorf("equivocation: messages endorse the same block")
}
// Decode pubkey + verify both signatures over the canonical bytes.
pubBytes, err := hex.DecodeString(offender)
if err != nil || len(pubBytes) != ed25519.PublicKeySize {
return fmt.Errorf("equivocation: bad offender pubkey")
}
pub := ed25519.PublicKey(pubBytes)
if !ed25519.Verify(pub, consensusMsgSignBytes(ev.A), ev.A.Signature) {
return fmt.Errorf("equivocation: signature A does not verify")
}
if !ed25519.Verify(pub, consensusMsgSignBytes(ev.B), ev.B.Signature) {
return fmt.Errorf("equivocation: signature B does not verify")
}
return nil
}
// consensusMsgSignBytes MUST match consensus/pbft.go:msgSignBytes exactly.
// We duplicate it here (instead of importing consensus) to keep the
// blockchain package free of a consensus dependency — consensus already
// imports blockchain for types.
func consensusMsgSignBytes(msg *ConsensusMsg) []byte {
tmp := *msg
tmp.Signature = nil
tmp.Block = nil
data, _ := json.Marshal(tmp)
h := sha256.Sum256(data)
return h[:]
}

562
blockchain/index.go Normal file
View File

@@ -0,0 +1,562 @@
package blockchain
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
badger "github.com/dgraph-io/badger/v4"
)
// Index key prefixes
const (
prefixTxRecord = "tx:" // tx:<txid> → TxRecord JSON
prefixTxByAddr = "txaddr:" // txaddr:<pubkey>:<block020d>:<txid> → "" (empty value)
prefixAddrMap = "addrmap:" // addrmap:<DCaddr> → pubkey hex
prefixNetStats = "netstats" // netstats → NetStats JSON
syntheticRewardIDPrefix = "sys-reward-"
)
// TxRecord wraps a Transaction with its on-chain context.
type TxRecord struct {
Tx *Transaction `json:"tx"`
BlockIndex uint64 `json:"block_index"`
BlockHash string `json:"block_hash"`
BlockTime time.Time `json:"block_time"`
GasUsed uint64 `json:"gas_used,omitempty"`
}
// NetStats are aggregate counters updated every block.
type NetStats struct {
TotalBlocks uint64 `json:"total_blocks"`
TotalTxs uint64 `json:"total_txs"`
TotalTransfers uint64 `json:"total_transfers"`
TotalRelayProofs uint64 `json:"total_relay_proofs"`
TotalSupply uint64 `json:"total_supply"` // µT ever minted via rewards + grants
ValidatorCount int `json:"validator_count"`
RelayCount int `json:"relay_count"`
}
// indexBlock is called inside AddBlock's db.Update() — indexes all transactions
// in the block and updates aggregate stats.
// gasUsed maps tx.ID → gas consumed for CALL_CONTRACT transactions.
func (c *Chain) indexBlock(txn *badger.Txn, b *Block, gasUsed map[string]uint64) error {
// Load existing stats
stats, err := c.readNetStats(txn)
if err != nil {
return err
}
stats.TotalBlocks = b.Index + 1
// TotalSupply is fixed at GenesisAllocation; update it once at genesis.
if b.Index == 0 {
stats.TotalSupply = GenesisAllocation
}
for seq, tx := range b.Transactions {
// Store full TxRecord — but never overwrite an existing record.
// The same TX can appear in multiple gossiped blocks due to a mempool/PBFT
// race; the first block that actually applies it (via applyTx) will have
// gasUsed > 0. Subsequent re-indexings with an empty gasUsedByTx map
// would zero out the stored GasUsed. Skip if the record already exists.
recKey := []byte(prefixTxRecord + tx.ID)
if _, existErr := txn.Get(recKey); existErr == nil {
// TxRecord already written (from an earlier block or earlier call);
// do not overwrite it.
continue
}
// Chronological index entry (txchron:<block20d>:<seq04d> → tx_id).
// Lets RecentTxs iterate tx-by-tx instead of block-by-block so chains
// with many empty blocks still answer /api/txs/recent in O(limit).
chronKey := fmt.Sprintf("%s%020d:%04d", prefixTxChron, b.Index, seq)
if err := txn.Set([]byte(chronKey), []byte(tx.ID)); err != nil {
return err
}
gasForTx := gasUsed[tx.ID]
rec := TxRecord{
Tx: tx,
BlockIndex: b.Index,
BlockHash: b.HashHex(),
BlockTime: b.Timestamp,
GasUsed: gasForTx,
}
val, err := json.Marshal(rec)
if err != nil {
return err
}
if err := txn.Set(recKey, val); err != nil {
return err
}
// Index by sender
if tx.From != "" {
addrKey := txAddrKey(tx.From, b.Index, tx.ID)
if err := txn.Set([]byte(addrKey), []byte{}); err != nil {
return err
}
// Store addr → pubkey mapping
if err := c.storeAddrMap(txn, tx.From); err != nil {
return err
}
}
// Index by recipient
if tx.To != "" && tx.To != tx.From {
addrKey := txAddrKey(tx.To, b.Index, tx.ID)
if err := txn.Set([]byte(addrKey), []byte{}); err != nil {
return err
}
if err := c.storeAddrMap(txn, tx.To); err != nil {
return err
}
}
// Update aggregate counters
stats.TotalTxs++
switch tx.Type {
case EventTransfer:
stats.TotalTransfers++
case EventRelayProof:
stats.TotalRelayProofs++
}
}
// Index synthetic block reward only when the validator actually earned fees,
// or for the genesis block (one-time allocation). Empty blocks produce no
// state change and no income, so there is nothing useful to show.
if b.TotalFees > 0 || b.Index == 0 {
rewardTarget, err := c.resolveRewardTarget(txn, b.Validator)
if err != nil {
return err
}
rewardTx, err := makeBlockRewardTx(b, rewardTarget)
if err != nil {
return err
}
rewardRec := TxRecord{
Tx: rewardTx,
BlockIndex: b.Index,
BlockHash: b.HashHex(),
BlockTime: b.Timestamp,
}
rewardVal, err := json.Marshal(rewardRec)
if err != nil {
return err
}
if err := txn.Set([]byte(prefixTxRecord+rewardTx.ID), rewardVal); err != nil {
return err
}
if rewardTx.From != "" {
if err := txn.Set([]byte(txAddrKey(rewardTx.From, b.Index, rewardTx.ID)), []byte{}); err != nil {
return err
}
if err := c.storeAddrMap(txn, rewardTx.From); err != nil {
return err
}
}
if rewardTx.To != "" && rewardTx.To != rewardTx.From {
if err := txn.Set([]byte(txAddrKey(rewardTx.To, b.Index, rewardTx.ID)), []byte{}); err != nil {
return err
}
if err := c.storeAddrMap(txn, rewardTx.To); err != nil {
return err
}
}
}
// Persist updated stats
return c.writeNetStats(txn, stats)
}
func makeBlockRewardTx(b *Block, rewardTarget string) (*Transaction, error) {
var memo string
if b.Index == 0 {
memo = fmt.Sprintf("Genesis allocation: %d µT", GenesisAllocation)
} else {
memo = fmt.Sprintf("Block fees: %d µT", b.TotalFees)
}
total := b.TotalFees
if b.Index == 0 {
total = GenesisAllocation
}
payload, err := json.Marshal(BlockRewardPayload{
ValidatorPubKey: b.Validator,
TargetPubKey: rewardTarget,
FeeReward: b.TotalFees,
TotalReward: total,
})
if err != nil {
return nil, err
}
// From is intentionally left empty: a block reward is a synthetic, freshly
// minted allocation (fees collected by the network) rather than a transfer
// from an actual account. Leaving From="" prevents the reward from appearing
// as "validator paid themselves" in the explorer/client when the validator
// has no separate wallet binding (rewardTarget == b.Validator).
// b.Validator is still recorded inside the payload (BlockRewardPayload).
return &Transaction{
ID: fmt.Sprintf("%s%020d", syntheticRewardIDPrefix, b.Index),
Type: EventBlockReward,
From: "",
To: rewardTarget,
Amount: total,
Fee: 0,
Memo: memo,
Payload: payload,
Timestamp: b.Timestamp,
}, nil
}
// txAddrKey builds the composite key: txaddr:<pubkey>:<block_020d>:<txid>
func txAddrKey(pubKey string, blockIdx uint64, txID string) string {
return fmt.Sprintf("%s%s:%020d:%s", prefixTxByAddr, pubKey, blockIdx, txID)
}
// storeAddrMap stores a DC address → pubkey mapping.
func (c *Chain) storeAddrMap(txn *badger.Txn, pubKey string) error {
addr := pubKeyToAddr(pubKey)
return txn.Set([]byte(prefixAddrMap+addr), []byte(pubKey))
}
// pubKeyToAddr converts a hex Ed25519 public key to a DC address.
// Replicates wallet.PubKeyToAddress without importing the wallet package.
func pubKeyToAddr(pubKeyHex string) string {
raw, err := hex.DecodeString(pubKeyHex)
if err != nil {
return pubKeyHex // fallback: use pubkey as-is
}
h := sha256.Sum256(raw)
return "DC" + hex.EncodeToString(h[:12])
}
// --- Public query methods ---
// TxByID returns a TxRecord by transaction ID.
func (c *Chain) TxByID(txID string) (*TxRecord, error) {
var rec TxRecord
err := c.db.View(func(txn *badger.Txn) error {
item, err := txn.Get([]byte(prefixTxRecord + txID))
if err != nil {
return err
}
return item.Value(func(val []byte) error {
return json.Unmarshal(val, &rec)
})
})
if errors.Is(err, badger.ErrKeyNotFound) {
synth, synthErr := c.syntheticTxByID(txID)
if synthErr != nil {
return nil, synthErr
}
if synth != nil {
return synth, nil
}
return nil, nil
}
return &rec, err
}
func parseSyntheticRewardIndex(txID string) (uint64, bool) {
if !strings.HasPrefix(txID, syntheticRewardIDPrefix) {
return 0, false
}
part := strings.TrimPrefix(txID, syntheticRewardIDPrefix)
idx, err := strconv.ParseUint(part, 10, 64)
if err != nil {
return 0, false
}
return idx, true
}
func (c *Chain) syntheticTxByID(txID string) (*TxRecord, error) {
idx, ok := parseSyntheticRewardIndex(txID)
if !ok {
return nil, nil
}
b, err := c.GetBlock(idx)
if errors.Is(err, badger.ErrKeyNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
rewardTarget := b.Validator
binding, err := c.WalletBinding(b.Validator)
if err == nil && binding != "" {
rewardTarget = binding
}
rewardTx, err := makeBlockRewardTx(b, rewardTarget)
if err != nil {
return nil, err
}
return &TxRecord{
Tx: rewardTx,
BlockIndex: b.Index,
BlockHash: b.HashHex(),
BlockTime: b.Timestamp,
}, nil
}
// TxsByAddress returns up to limit TxRecords for a public key, newest first,
// skipping the first offset results (for pagination).
func (c *Chain) TxsByAddress(pubKey string, limit, offset int) ([]*TxRecord, error) {
if limit <= 0 {
limit = 50
}
if offset < 0 {
offset = 0
}
prefix := prefixTxByAddr + pubKey + ":"
// First: collect TxID keys for this address (newest first via reverse iter),
// skipping `offset` entries.
var txIDs []string
err := c.db.View(func(txn *badger.Txn) error {
opts := badger.DefaultIteratorOptions
opts.Reverse = true
opts.PrefetchValues = false
it := txn.NewIterator(opts)
defer it.Close()
seekKey := prefix + "\xff\xff\xff\xff\xff\xff\xff\xff"
skipped := 0
for it.Seek([]byte(seekKey)); it.Valid(); it.Next() {
key := string(it.Item().Key())
if !strings.HasPrefix(key, prefix) {
break
}
parts := strings.SplitN(key[len(prefix):], ":", 2)
if len(parts) != 2 {
continue
}
if skipped < offset {
skipped++
continue
}
txIDs = append(txIDs, parts[1])
if len(txIDs) >= limit {
break
}
}
return nil
})
if err != nil {
return nil, err
}
// Now fetch each TxRecord
var records []*TxRecord
err = c.db.View(func(txn *badger.Txn) error {
for _, txID := range txIDs {
item, err := txn.Get([]byte(prefixTxRecord + txID))
if errors.Is(err, badger.ErrKeyNotFound) {
continue
}
if err != nil {
return err
}
var rec TxRecord
if err := item.Value(func(val []byte) error {
return json.Unmarshal(val, &rec)
}); err != nil {
return err
}
records = append(records, &rec)
}
return nil
})
return records, err
}
// RecentTxs returns the N most recent transactions across all blocks.
func (c *Chain) RecentTxs(limit int) ([]*TxRecord, error) {
if limit <= 0 {
limit = 20
}
// Primary path: iterate the chronological tx index in reverse. This is
// O(limit) regardless of how many empty blocks sit between txs.
var records []*TxRecord
err := c.db.View(func(txn *badger.Txn) error {
opts := badger.DefaultIteratorOptions
opts.Reverse = true
opts.PrefetchValues = true
it := txn.NewIterator(opts)
defer it.Close()
// Seek to the highest possible key under this prefix.
seekKey := []byte(prefixTxChron + "\xff")
for it.Seek(seekKey); it.ValidForPrefix([]byte(prefixTxChron)); it.Next() {
if len(records) >= limit {
break
}
var txID string
err := it.Item().Value(func(v []byte) error {
txID = string(v)
return nil
})
if err != nil || txID == "" {
continue
}
recItem, err := txn.Get([]byte(prefixTxRecord + txID))
if err != nil {
continue
}
var rec TxRecord
if err := recItem.Value(func(v []byte) error { return json.Unmarshal(v, &rec) }); err != nil {
continue
}
records = append(records, &rec)
}
return nil
})
if err == nil && len(records) >= limit {
return records, nil
}
// Fallback (legacy + reward-tx injection): reverse-scan blocks.
// Only blocks committed BEFORE the chronological index existed will be
// found this way; we cap the scan so it can't hang.
tipIdx := c.TipIndex()
const maxBlockScan = 5000
seen := make(map[string]bool, len(records))
for _, r := range records {
seen[r.Tx.ID] = true
}
scanned := 0
for idx := int64(tipIdx); idx >= 0 && len(records) < limit && scanned < maxBlockScan; idx-- {
scanned++
b, err := c.GetBlock(uint64(idx))
if err != nil {
break
}
for i := len(b.Transactions) - 1; i >= 0 && len(records) < limit; i-- {
tx := b.Transactions[i]
if seen[tx.ID] {
continue
}
records = append(records, &TxRecord{
Tx: tx,
BlockIndex: b.Index,
BlockHash: b.HashHex(),
BlockTime: b.Timestamp,
})
}
// Include BLOCK_REWARD only for fee-earning blocks and genesis.
if len(records) < limit && (b.TotalFees > 0 || b.Index == 0) {
rewardTarget := b.Validator
if binding, err2 := c.WalletBinding(b.Validator); err2 == nil && binding != "" {
rewardTarget = binding
}
if rewardTx, err2 := makeBlockRewardTx(b, rewardTarget); err2 == nil {
records = append(records, &TxRecord{
Tx: rewardTx,
BlockIndex: b.Index,
BlockHash: b.HashHex(),
BlockTime: b.Timestamp,
})
}
}
}
return records, nil
}
// RecentBlocks returns the N most recent blocks (tip first).
func (c *Chain) RecentBlocks(limit int) ([]*Block, error) {
if limit <= 0 {
limit = 10
}
// Lock-free tip lookup so this endpoint never blocks on consensus work.
tipIdx := c.TipIndex()
var blocks []*Block
for idx := int64(tipIdx); idx >= 0 && len(blocks) < limit; idx-- {
b, err := c.GetBlock(uint64(idx))
if err != nil {
break
}
blocks = append(blocks, b)
}
return blocks, nil
}
// NetworkStats returns aggregate counters for the chain.
// ValidatorCount and RelayCount are always live-counted from the DB so they
// are accurate even after InitValidators replaced the set or relays registered.
func (c *Chain) NetworkStats() (NetStats, error) {
var stats NetStats
err := c.db.View(func(txn *badger.Txn) error {
s, err := c.readNetStats(txn)
if err != nil {
return err
}
stats = s
opts := badger.DefaultIteratorOptions
opts.PrefetchValues = false
it := txn.NewIterator(opts)
defer it.Close()
vPrefix := []byte(prefixValidator)
for it.Seek(vPrefix); it.ValidForPrefix(vPrefix); it.Next() {
stats.ValidatorCount++
}
rPrefix := []byte(prefixRelay)
for it.Seek(rPrefix); it.ValidForPrefix(rPrefix); it.Next() {
stats.RelayCount++
}
return nil
})
return stats, err
}
// AddressToPubKey resolves a DC address to a pub key.
// Returns "" if not found.
func (c *Chain) AddressToPubKey(addr string) (string, error) {
var pubKey string
err := c.db.View(func(txn *badger.Txn) error {
item, err := txn.Get([]byte(prefixAddrMap + addr))
if errors.Is(err, badger.ErrKeyNotFound) {
return nil
}
if err != nil {
return err
}
return item.Value(func(val []byte) error {
pubKey = string(val)
return nil
})
})
return pubKey, err
}
// --- internal ---
func (c *Chain) readNetStats(txn *badger.Txn) (NetStats, error) {
var s NetStats
item, err := txn.Get([]byte(prefixNetStats))
if errors.Is(err, badger.ErrKeyNotFound) {
return s, nil
}
if err != nil {
return s, err
}
err = item.Value(func(val []byte) error {
return json.Unmarshal(val, &s)
})
return s, err
}
func (c *Chain) writeNetStats(txn *badger.Txn, s NetStats) error {
val, err := json.Marshal(s)
if err != nil {
return err
}
return txn.Set([]byte(prefixNetStats), val)
}

238
blockchain/native.go Normal file
View File

@@ -0,0 +1,238 @@
// Package blockchain — native (non-WASM) contract infrastructure.
//
// System contracts like `username_registry` are latency-sensitive and must
// never hang the chain. Running them through the WASM VM means:
//
// - 100× the CPU cost of equivalent Go code;
// - a bug in gas metering / opcode instrumentation can freeze AddBlock
// indefinitely (see the hangs that motivated this rewrite);
// - every node needs an identical wazero build — extra supply-chain risk.
//
// Native contracts are written as plain Go code against a narrow interface
// (NativeContract below). They share the same contract_id space, ABI, and
// explorer views as WASM contracts, so clients can't tell them apart — the
// dispatcher in applyTx just routes the call to Go instead of wazero when
// it sees a native contract_id.
//
// Authorship notes:
// - A native contract has full, direct access to the current BadgerDB txn
// and chain helpers via NativeContext. It MUST only read/write keys
// prefixed with `cstate:<contractID>:` or `clog:<contractID>:…` — same
// as WASM contracts see. This keeps on-chain state cleanly segregated
// so one day we can migrate a native contract back to WASM (or vice
// versa) without a storage migration.
// - A native contract MUST return deterministic errors. The dispatcher
// treats any returned error as `ErrTxFailed`-wrapped — fees stay
// debited, but state changes roll back with the enclosing Badger txn.
package blockchain
import (
"encoding/json"
"errors"
"fmt"
badger "github.com/dgraph-io/badger/v4"
)
// NativeContract is the Go-side counterpart of a WASM smart contract.
//
// Implementors are expected to be stateless (all state lives in BadgerDB
// under cstate:<ContractID>:…). An instance is created once per chain and
// reused across all calls.
type NativeContract interface {
// ID returns the deterministic contract ID used in CALL_CONTRACT txs.
// Must be stable across node restarts and identical on every node.
ID() string
// ABI returns a JSON document describing the contract's methods.
// Identical shape to the WASM contracts' *_abi.json files so the
// well-known endpoint and explorer can discover it uniformly.
ABI() string
// Call dispatches a method invocation. Returns the gas it wants to
// charge (will be multiplied by the current gas price). Returning an
// error aborts the tx; returning (0, nil) means free success.
Call(ctx *NativeContext, method string, argsJSON []byte) (gasUsed uint64, err error)
}
// NativeContext hands a native contract the minimum it needs to run,
// without exposing the full Chain type (which would tempt contracts to
// touch state they shouldn't).
type NativeContext struct {
Txn *badger.Txn
ContractID string
Caller string // hex Ed25519 pubkey of the tx sender
TxID string
BlockHeight uint64
// TxAmount is tx.Amount — the payment the caller attached to this call
// in µT. It is NOT auto-debited from the caller; the contract decides
// whether to collect it (via ctx.Debit), refund, or ignore. Exposing
// payment via tx.Amount (instead of an implicit debit inside the
// contract) makes contract costs visible in the explorer — a user can
// see exactly what a call charges by reading the tx envelope.
TxAmount uint64
// chain is kept unexported; contract code uses the helper methods below
// rather than reaching into Chain directly.
chain *Chain
}
// Balance returns the balance of the given pubkey in µT.
func (ctx *NativeContext) Balance(pubHex string) uint64 {
var bal uint64
item, err := ctx.Txn.Get([]byte(prefixBalance + pubHex))
if errors.Is(err, badger.ErrKeyNotFound) {
return 0
}
if err != nil {
return 0
}
_ = item.Value(func(val []byte) error {
return unmarshalUint64(val, &bal)
})
return bal
}
// Debit removes amt µT from pub's balance, or returns an error if insufficient.
func (ctx *NativeContext) Debit(pub string, amt uint64) error {
return ctx.chain.debitBalance(ctx.Txn, pub, amt)
}
// Credit adds amt µT to pub's balance.
func (ctx *NativeContext) Credit(pub string, amt uint64) error {
return ctx.chain.creditBalance(ctx.Txn, pub, amt)
}
// Get reads a contract-scoped state value. Returns nil if not set.
func (ctx *NativeContext) Get(key string) ([]byte, error) {
item, err := ctx.Txn.Get([]byte(prefixContractState + ctx.ContractID + ":" + key))
if errors.Is(err, badger.ErrKeyNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
var out []byte
err = item.Value(func(v []byte) error {
out = append([]byte(nil), v...)
return nil
})
return out, err
}
// Set writes a contract-scoped state value.
func (ctx *NativeContext) Set(key string, value []byte) error {
return ctx.Txn.Set([]byte(prefixContractState+ctx.ContractID+":"+key), value)
}
// Delete removes a contract-scoped state value.
func (ctx *NativeContext) Delete(key string) error {
return ctx.Txn.Delete([]byte(prefixContractState + ctx.ContractID + ":" + key))
}
// Log emits a contract log line for the explorer. Uses the same storage as
// WASM contracts' env.log() so the explorer renders them identically.
func (ctx *NativeContext) Log(msg string) error {
return ctx.chain.writeContractLog(ctx.Txn, ctx.ContractID, ctx.BlockHeight, ctx.TxID, msg)
}
// ─── Native contract registry ────────────────────────────────────────────────
// Native contracts are registered into the chain once during chain setup
// (typically right after `NewChain`). Lookups happen on every CALL_CONTRACT
// and DEPLOY_CONTRACT — they're hot path, so the registry is a plain map
// guarded by a RW mutex.
// RegisterNative associates a NativeContract with its ID on this chain.
// Panics if two contracts share an ID (clear programmer error).
// Must be called before AddBlock begins processing user transactions.
//
// Uses a DEDICATED mutex (c.nativeMu) rather than c.mu, because
// lookupNative is called from inside applyTx which runs under c.mu.Lock().
// sync.RWMutex is non-reentrant — reusing c.mu would deadlock.
func (c *Chain) RegisterNative(nc NativeContract) {
c.nativeMu.Lock()
defer c.nativeMu.Unlock()
if c.native == nil {
c.native = make(map[string]NativeContract)
}
if _, exists := c.native[nc.ID()]; exists {
panic(fmt.Sprintf("native contract %s registered twice", nc.ID()))
}
c.native[nc.ID()] = nc
}
// lookupNative returns the registered native contract for id, or nil.
// Hot path — called on every CALL_CONTRACT from applyTx. Safe to call while
// c.mu is held because we use a separate RWMutex here.
func (c *Chain) lookupNative(id string) NativeContract {
c.nativeMu.RLock()
defer c.nativeMu.RUnlock()
return c.native[id]
}
// NativeContracts returns a snapshot of every native contract registered on
// this chain. Used by the well-known endpoint so clients auto-discover
// system services without the user having to paste contract IDs.
func (c *Chain) NativeContracts() []NativeContract {
c.nativeMu.RLock()
defer c.nativeMu.RUnlock()
out := make([]NativeContract, 0, len(c.native))
for _, nc := range c.native {
out = append(out, nc)
}
return out
}
// writeContractLog is the shared log emitter for both WASM and native
// contracts. Keeping it here (on Chain) means we can change the log key
// layout in one place.
func (c *Chain) writeContractLog(txn *badger.Txn, contractID string, blockHeight uint64, txID, msg string) error {
// Best-effort: match the existing WASM log format so the explorer's
// renderer doesn't need to branch.
seq := c.nextContractLogSeq(txn, contractID, blockHeight)
entry := ContractLogEntry{
ContractID: contractID,
BlockHeight: blockHeight,
TxID: txID,
Seq: int(seq),
Message: msg,
}
val, err := json.Marshal(entry)
if err != nil {
return err
}
key := fmt.Sprintf("%s%s:%020d:%05d", prefixContractLog, contractID, blockHeight, seq)
return txn.Set([]byte(key), val)
}
// nextContractLogSeq returns the next sequence number for a (contract,block)
// pair by counting existing entries under the prefix.
func (c *Chain) nextContractLogSeq(txn *badger.Txn, contractID string, blockHeight uint64) uint32 {
prefix := []byte(fmt.Sprintf("%s%s:%020d:", prefixContractLog, contractID, blockHeight))
opts := badger.DefaultIteratorOptions
opts.PrefetchValues = false
opts.Prefix = prefix
it := txn.NewIterator(opts)
defer it.Close()
var count uint32
for it.Rewind(); it.Valid(); it.Next() {
count++
}
return count
}
// ─── Small helpers used by native contracts ──────────────────────────────────
// Uint64 is a tiny helper for reading a uint64 stored as 8 big-endian bytes.
// (We deliberately don't use JSON for hot state keys.)
func unmarshalUint64(b []byte, dst *uint64) error {
if len(b) != 8 {
return fmt.Errorf("not a uint64")
}
*dst = uint64(b[0])<<56 | uint64(b[1])<<48 | uint64(b[2])<<40 | uint64(b[3])<<32 |
uint64(b[4])<<24 | uint64(b[5])<<16 | uint64(b[6])<<8 | uint64(b[7])
return nil
}

View File

@@ -0,0 +1,371 @@
// Package blockchain — native username registry.
//
// Deterministic, in-process replacement for the WASM username_registry
// contract. Every node runs exactly the same Go code against the same
// BadgerDB txn, so state transitions are byte-identical across the network.
//
// Why native instead of WASM:
// - A single register() call via wazero takes ~10 ms; native takes ~50 µs.
// - No gas-metering edge cases (an opcode loop the listener misses would
// otherwise wedge AddBlock — which is how we wound up here).
// - We own the API surface — upgrades don't require re-deploying WASM
// and renegotiating the well-known contract_id.
//
// State layout (all keys prefixed with cstate:<ID>: by NativeContext helpers):
//
// name:<name> → owner pubkey (raw hex bytes, 64 chars)
// addr:<owner_pub> → name (raw UTF-8 bytes)
// meta:version → ABI version string (debug only)
//
// Methods:
//
// register(name) — claim a name; caller becomes owner
// resolve(name) — read-only, returns owner via log
// lookup(pub) — read-only, returns name via log
// transfer(name, new_owner_pub) — current owner transfers
// release(name) — current owner releases
//
// The same ABI JSON the WASM build exposes is reported here so the
// well-known endpoint + explorer work without modification.
package blockchain
import (
"encoding/json"
"fmt"
"strings"
)
// UsernameRegistryID is the deterministic on-chain ID for the native
// username registry. We pin it to a readable short string instead of a
// hash because there is only ever one registry per chain, and a stable
// well-known ID makes debug URLs easier (/api/contracts/username_registry).
const UsernameRegistryID = "native:username_registry"
// MinUsernameLength caps how short a name can be. Shorter names would be
// cheaper to register and quicker to grab, incentivising squatters. 4 is
// the sweet spot: long enough to avoid 2-char grabs, short enough to allow
// "alice" / "bob1" / common initials.
const MinUsernameLength = 4
// MaxUsernameLength is the upper bound. Anything longer is wasteful.
const MaxUsernameLength = 32
// UsernameRegistrationFee is a flat fee per register() call, in µT. Paid
// by the caller and burned (reduces total supply) — simpler than routing
// to a treasury account and avoids the "contract treasury" concept for
// the first native contract.
//
// 10_000 µT (0.01 T) is low enough for genuine users and high enough
// that a griefer can't squat thousands of names for nothing.
const UsernameRegistrationFee = 10_000
// usernameABI is returned by ABI(). Fields mirror the WASM registry's ABI
// JSON so the well-known endpoint / explorer discover it the same way.
const usernameABI = `{
"contract": "username_registry",
"version": "2.1.0-native",
"description": "Maps human-readable usernames (min 4 chars, lowercase a-z 0-9 _ -, must start with a letter) to wallet addresses. register requires tx.amount = 10 000 µT which is burned.",
"methods": [
{"name":"register","description":"Claim a username. Send tx.amount=10000 as the registration fee (burned). Caller becomes owner.","args":[{"name":"name","type":"string"}],"payable":10000},
{"name":"resolve","description":"Look up owner address by name. Free (tx.amount=0).","args":[{"name":"name","type":"string"}]},
{"name":"lookup","description":"Look up name by owner address. Free.","args":[{"name":"address","type":"string"}]},
{"name":"transfer","description":"Transfer ownership to a new address. Free; only current owner may call.","args":[{"name":"name","type":"string"},{"name":"new_owner","type":"string"}]},
{"name":"release","description":"Release a registered name. Free; only current owner may call.","args":[{"name":"name","type":"string"}]}
]
}`
// UsernameRegistry is the native implementation of the registry contract.
// Stateless — all state lives in the chain's BadgerDB txn passed via
// NativeContext on each call.
type UsernameRegistry struct{}
// NewUsernameRegistry returns a contract ready to register with the chain.
func NewUsernameRegistry() *UsernameRegistry { return &UsernameRegistry{} }
// Compile-time check that we satisfy the interface.
var _ NativeContract = (*UsernameRegistry)(nil)
// ID implements NativeContract.
func (UsernameRegistry) ID() string { return UsernameRegistryID }
// ABI implements NativeContract.
func (UsernameRegistry) ABI() string { return usernameABI }
// Call implements NativeContract — dispatches to the per-method handlers.
// Gas cost is a flat 1_000 units per call (native is cheap, but we charge
// something so the fee mechanics match the WASM path).
func (r UsernameRegistry) Call(ctx *NativeContext, method string, argsJSON []byte) (uint64, error) {
const gasCost uint64 = 1_000
args, err := parseArgs(argsJSON)
if err != nil {
return gasCost, fmt.Errorf("%w: bad args: %v", ErrTxFailed, err)
}
switch method {
case "register":
return gasCost, r.register(ctx, args)
case "resolve":
return gasCost, r.resolve(ctx, args)
case "lookup":
return gasCost, r.lookup(ctx, args)
case "transfer":
return gasCost, r.transfer(ctx, args)
case "release":
return gasCost, r.release(ctx, args)
default:
return gasCost, fmt.Errorf("%w: unknown method %q", ErrTxFailed, method)
}
}
// ─── Method handlers ─────────────────────────────────────────────────────────
// register claims a name for ctx.Caller. Preconditions:
// - name validates (length, charset, not reserved)
// - name is not already taken
// - caller has no existing registration (one-per-address rule)
// - tx.Amount (ctx.TxAmount) must be exactly UsernameRegistrationFee;
// that payment is debited from the caller and burned
//
// Pay-via-tx.Amount (instead of an invisible debit inside the contract)
// makes the cost explicit: the registration fee shows up as `amount_ut`
// in the transaction envelope and in the explorer, so callers know
// exactly what they paid. See the module-level doc for the full rationale.
//
// On success:
// - debit ctx.TxAmount from caller (burn — no recipient)
// - write name → caller pubkey mapping (key "name:<name>")
// - write caller → name mapping (key "addr:<caller>")
// - emit `registered: <name>` log
func (UsernameRegistry) register(ctx *NativeContext, args []json.RawMessage) error {
name, err := argString(args, 0, "name")
if err != nil {
return err
}
if err := validateName(name); err != nil {
return err
}
// Payment check — must be EXACTLY the registration fee. Under-payment
// is rejected (obvious); over-payment is also rejected to avoid
// accidental overpayment from a buggy client, and to keep the fee
// structure simple. A future `transfer` method may introduce other
// pricing.
if ctx.TxAmount != UsernameRegistrationFee {
return fmt.Errorf("%w: register requires tx.amount = %d µT (got %d µT)",
ErrTxFailed, UsernameRegistrationFee, ctx.TxAmount)
}
// Already taken?
existing, err := ctx.Get("name:" + name)
if err != nil {
return err
}
if existing != nil {
return fmt.Errorf("%w: name %q already registered", ErrTxFailed, name)
}
// Caller already has a name?
ownerKey := "addr:" + ctx.Caller
prior, err := ctx.Get(ownerKey)
if err != nil {
return err
}
if prior != nil {
return fmt.Errorf("%w: address already owns %q; release it first", ErrTxFailed, string(prior))
}
// Collect the registration fee (burn — no recipient).
if err := ctx.Debit(ctx.Caller, ctx.TxAmount); err != nil {
return fmt.Errorf("payment debit: %w", err)
}
// Persist both directions.
if err := ctx.Set("name:"+name, []byte(ctx.Caller)); err != nil {
return err
}
if err := ctx.Set(ownerKey, []byte(name)); err != nil {
return err
}
return ctx.Log("registered: " + name + " → " + ctx.Caller)
}
func (UsernameRegistry) resolve(ctx *NativeContext, args []json.RawMessage) error {
name, err := argString(args, 0, "name")
if err != nil {
return err
}
val, err := ctx.Get("name:" + name)
if err != nil {
return err
}
if val == nil {
return ctx.Log("not found: " + name)
}
return ctx.Log("owner: " + string(val))
}
func (UsernameRegistry) lookup(ctx *NativeContext, args []json.RawMessage) error {
addr, err := argString(args, 0, "address")
if err != nil {
return err
}
val, err := ctx.Get("addr:" + addr)
if err != nil {
return err
}
if val == nil {
return ctx.Log("no name: " + addr)
}
return ctx.Log("name: " + string(val))
}
func (UsernameRegistry) transfer(ctx *NativeContext, args []json.RawMessage) error {
name, err := argString(args, 0, "name")
if err != nil {
return err
}
newOwner, err := argString(args, 1, "new_owner")
if err != nil {
return err
}
if err := validatePubKey(newOwner); err != nil {
return err
}
cur, err := ctx.Get("name:" + name)
if err != nil {
return err
}
if cur == nil {
return fmt.Errorf("%w: name %q not registered", ErrTxFailed, name)
}
if string(cur) != ctx.Caller {
return fmt.Errorf("%w: only current owner can transfer", ErrTxFailed)
}
// New owner must not already have a name.
if existing, err := ctx.Get("addr:" + newOwner); err != nil {
return err
} else if existing != nil {
return fmt.Errorf("%w: new owner already owns %q", ErrTxFailed, string(existing))
}
// Update both directions.
if err := ctx.Set("name:"+name, []byte(newOwner)); err != nil {
return err
}
if err := ctx.Delete("addr:" + ctx.Caller); err != nil {
return err
}
if err := ctx.Set("addr:"+newOwner, []byte(name)); err != nil {
return err
}
return ctx.Log("transferred: " + name + " → " + newOwner)
}
func (UsernameRegistry) release(ctx *NativeContext, args []json.RawMessage) error {
name, err := argString(args, 0, "name")
if err != nil {
return err
}
cur, err := ctx.Get("name:" + name)
if err != nil {
return err
}
if cur == nil {
return fmt.Errorf("%w: name %q not registered", ErrTxFailed, name)
}
if string(cur) != ctx.Caller {
return fmt.Errorf("%w: only current owner can release", ErrTxFailed)
}
if err := ctx.Delete("name:" + name); err != nil {
return err
}
if err := ctx.Delete("addr:" + ctx.Caller); err != nil {
return err
}
return ctx.Log("released: " + name)
}
// ─── Validation helpers ──────────────────────────────────────────────────────
// validateName enforces our naming rules. Policies that appear here must
// match the client-side preview in settings.tsx: lowercase alphanumeric
// plus underscore/hyphen, length 4-32, cannot start with a digit or hyphen.
func validateName(name string) error {
if len(name) < MinUsernameLength {
return fmt.Errorf("%w: name too short: min %d chars", ErrTxFailed, MinUsernameLength)
}
if len(name) > MaxUsernameLength {
return fmt.Errorf("%w: name too long: max %d chars", ErrTxFailed, MaxUsernameLength)
}
// First char must be a-z (avoid leading digits, hyphens, underscores).
first := name[0]
if !(first >= 'a' && first <= 'z') {
return fmt.Errorf("%w: name must start with a letter a-z", ErrTxFailed)
}
for i := 0; i < len(name); i++ {
c := name[i]
switch {
case c >= 'a' && c <= 'z':
case c >= '0' && c <= '9':
case c == '_' || c == '-':
default:
return fmt.Errorf("%w: invalid character %q (lowercase letters, digits, _ and - only)", ErrTxFailed, c)
}
}
// Reserved names — clients that show system labels shouldn't be spoofable.
reserved := []string{"system", "admin", "root", "dchain", "null", "none"}
for _, r := range reserved {
if name == r {
return fmt.Errorf("%w: %q is reserved", ErrTxFailed, name)
}
}
return nil
}
// validatePubKey accepts a 64-char lowercase hex string (Ed25519 pubkey).
func validatePubKey(s string) error {
if len(s) != 64 {
return fmt.Errorf("%w: pubkey must be 64 hex chars", ErrTxFailed)
}
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c >= '0' && c <= '9':
case c >= 'a' && c <= 'f':
default:
return fmt.Errorf("%w: pubkey has non-hex character", ErrTxFailed)
}
}
return nil
}
// parseArgs turns the CallContractPayload.ArgsJSON string into a slice of
// raw JSON messages. Empty/whitespace-only input parses to an empty slice.
func parseArgs(argsJSON []byte) ([]json.RawMessage, error) {
if len(argsJSON) == 0 || strings.TrimSpace(string(argsJSON)) == "" {
return nil, nil
}
var out []json.RawMessage
if err := json.Unmarshal(argsJSON, &out); err != nil {
return nil, err
}
return out, nil
}
// argString reads args[idx] as a JSON string and returns its value.
func argString(args []json.RawMessage, idx int, name string) (string, error) {
if idx >= len(args) {
return "", fmt.Errorf("%w: missing argument %q (index %d)", ErrTxFailed, name, idx)
}
var s string
if err := json.Unmarshal(args[idx], &s); err != nil {
return "", fmt.Errorf("%w: argument %q must be a string", ErrTxFailed, name)
}
return strings.TrimSpace(s), nil
}

View File

@@ -0,0 +1,197 @@
// Package blockchain — BadgerDB schema version tracking + migration scaffold.
//
// Why this exists
// ───────────────
// The chain's on-disk layout is a flat KV store with string-prefixed keys
// (see chain.go: prefixBalance, prefixChannel, etc.). Every breaking change
// to those prefixes or value shapes would otherwise require operators to
// wipe their volume and re-sync from scratch. That's painful at 10 nodes;
// catastrophic at 1000.
//
// This file introduces a single meta-key — `schema:ver` → uint32 — that
// records the layout version the data was written in. On every chain open:
//
// 1. We read the current version (0 if missing = fresh DB or pre-migration).
// 2. We iterate forward, running each migration[k→k+1] in order, bumping
// the stored version after each successful step.
// 3. If CurrentSchemaVersion is already reached, zero migrations run, the
// call is ~1 µs (single KV read).
//
// Design principles
// ────────────────
// • Idempotent: a crashed migration can be re-run from scratch. Every
// migration either completes its write AND updates the version in the
// SAME transaction, or neither.
// • Forward-only: downgrade is not supported. If an operator needs to
// roll back the binary, they restore from a pre-upgrade backup. The
// `update.sh` operator script checkpoints before restart for this.
// • Tiny: the migration registry is a plain Go slice, not a framework.
// Each migration is ~20 lines. Adding one is purely additive.
//
// As of this commit there are ZERO migrations (CurrentSchemaVersion = 0).
// The scaffolding ships empty so the very first real migration — whenever
// it lands — has a home that all deployed nodes already understand.
package blockchain
import (
"encoding/binary"
"fmt"
"log"
badger "github.com/dgraph-io/badger/v4"
)
const (
// schemaMetaKey is the single BadgerDB key that stores this DB's current
// schema version. Not prefixed like other keys — it's a bootstrap marker
// read before any prefixed query, so conflicts with userland prefixes
// are impossible by construction.
schemaMetaKey = "schema:ver"
// CurrentSchemaVersion is the layout this binary writes. Bumped in lockstep
// with every migration added below. A fresh DB is written at this version
// directly (no migration chain to run).
CurrentSchemaVersion uint32 = 0
)
// migration represents a single step from version v to v+1.
// Apply runs inside a single badger.Update — if it returns error, nothing
// is written, and the migration can be safely retried.
type migration struct {
From uint32
To uint32
Description string
Apply func(txn *badger.Txn) error
}
// migrations is the ordered forward-migration registry.
//
// To add a migration:
//
// 1. Bump CurrentSchemaVersion above.
// 2. Append an entry here with From = previous, To = new.
// 3. In Apply, walk the relevant prefixes and rewrite keys/values.
// 4. Add a unit test in schema_migrations_test.go seeding a vN-1 DB
// and asserting the vN invariants after one NewChain open.
//
// The slice is intentionally empty right now: the scaffold ships first,
// migrations land per-feature as needed.
var migrations = []migration{
// no migrations yet
}
// readSchemaVersion returns the version stored at schemaMetaKey, or 0 if the
// key is absent (interpretation: "pre-migration DB / fresh DB treat as v0").
func readSchemaVersion(db *badger.DB) (uint32, error) {
var v uint32
err := db.View(func(txn *badger.Txn) error {
item, err := txn.Get([]byte(schemaMetaKey))
if err == badger.ErrKeyNotFound {
v = 0
return nil
}
if err != nil {
return err
}
return item.Value(func(val []byte) error {
if len(val) != 4 {
return fmt.Errorf("schema version has unexpected length %d (want 4)", len(val))
}
v = binary.BigEndian.Uint32(val)
return nil
})
})
return v, err
}
// writeSchemaVersion persists the given version under schemaMetaKey. Usually
// called inside the same txn that applied the corresponding migration, so
// version bump + data rewrite are atomic. runMigrations handles that.
func writeSchemaVersion(txn *badger.Txn, v uint32) error {
var buf [4]byte
binary.BigEndian.PutUint32(buf[:], v)
return txn.Set([]byte(schemaMetaKey), buf[:])
}
// runMigrations applies every registered migration forward from the stored
// version to CurrentSchemaVersion. Called by NewChain after badger.Open.
//
// Behavior:
// - stored == target → no-op, returns nil
// - stored < target → runs each migration[k→k+1] in sequence; if ANY
// returns error, the DB is left at the last successful version and the
// error is returned (no partial-migration corruption).
// - stored > target → FATAL: operator is running an older binary on a
// newer DB. Refuse to open rather than silently mis-interpret data.
func runMigrations(db *badger.DB) error {
cur, err := readSchemaVersion(db)
if err != nil {
return fmt.Errorf("read schema version: %w", err)
}
if cur == CurrentSchemaVersion {
return nil
}
if cur > CurrentSchemaVersion {
return fmt.Errorf(
"chain DB is at schema v%d but this binary only understands v%d — "+
"run a newer binary OR restore from a pre-upgrade backup",
cur, CurrentSchemaVersion)
}
log.Printf("[CHAIN] migrating schema v%d → v%d (%d steps)",
cur, CurrentSchemaVersion, CurrentSchemaVersion-cur)
for _, m := range migrations {
if m.From < cur {
continue
}
if m.From != cur {
return fmt.Errorf("migration gap: stored=v%d, next migration expects v%d",
cur, m.From)
}
if m.To != m.From+1 {
return fmt.Errorf("migration %d→%d is not a single step", m.From, m.To)
}
log.Printf("[CHAIN] migration v%d→v%d: %s", m.From, m.To, m.Description)
err := db.Update(func(txn *badger.Txn) error {
if err := m.Apply(txn); err != nil {
return err
}
return writeSchemaVersion(txn, m.To)
})
if err != nil {
return fmt.Errorf("migration v%d→v%d failed: %w", m.From, m.To, err)
}
cur = m.To
}
// Fresh DB with no migrations yet to run — stamp the current version so
// we don't re-read "0 = no key" forever on later opens.
if cur < CurrentSchemaVersion {
err := db.Update(func(txn *badger.Txn) error {
return writeSchemaVersion(txn, CurrentSchemaVersion)
})
if err != nil {
return fmt.Errorf("stamp schema version %d: %w", CurrentSchemaVersion, err)
}
}
// On a brand-new DB (no chain yet) cur is still 0 but
// CurrentSchemaVersion is also 0 (today), so nothing to stamp. When the
// first real migration lands, this stamp becomes active.
if CurrentSchemaVersion == 0 && cur == 0 {
err := db.Update(func(txn *badger.Txn) error {
// Only stamp if the key is absent — otherwise we already wrote it
// in the loop above.
if _, getErr := txn.Get([]byte(schemaMetaKey)); getErr == badger.ErrKeyNotFound {
return writeSchemaVersion(txn, CurrentSchemaVersion)
}
return nil
})
if err != nil {
return fmt.Errorf("stamp initial schema version 0: %w", err)
}
}
return nil
}

509
blockchain/types.go Normal file
View File

@@ -0,0 +1,509 @@
package blockchain
import (
"crypto/sha256"
"encoding/binary"
"time"
)
// EventType defines what kind of event a transaction represents.
type EventType string
const (
EventRegisterKey EventType = "REGISTER_KEY"
EventCreateChannel EventType = "CREATE_CHANNEL"
EventAddMember EventType = "ADD_MEMBER"
EventOpenPayChan EventType = "OPEN_PAY_CHAN"
EventClosePayChan EventType = "CLOSE_PAY_CHAN"
EventTransfer EventType = "TRANSFER"
EventRelayProof EventType = "RELAY_PROOF"
EventRegisterRelay EventType = "REGISTER_RELAY" // node advertises relay service
EventBindWallet EventType = "BIND_WALLET" // node binds a payout wallet address
EventSlash EventType = "SLASH" // penalise a misbehaving validator
EventHeartbeat EventType = "HEARTBEAT" // liveness ping from a node
EventBlockReward EventType = "BLOCK_REWARD" // synthetic tx indexed on block commit
EventContactRequest EventType = "CONTACT_REQUEST" // paid first-contact request (ICQ-style)
EventAcceptContact EventType = "ACCEPT_CONTACT" // recipient accepts a pending request
EventBlockContact EventType = "BLOCK_CONTACT" // recipient blocks a sender
EventAddValidator EventType = "ADD_VALIDATOR" // existing validator adds a new one
EventRemoveValidator EventType = "REMOVE_VALIDATOR" // existing validator removes one (or self-removal)
EventDeployContract EventType = "DEPLOY_CONTRACT" // deploy a WASM smart contract
EventCallContract EventType = "CALL_CONTRACT" // call a method on a deployed contract
EventStake EventType = "STAKE" // lock tokens as validator stake
EventUnstake EventType = "UNSTAKE" // release staked tokens back to balance
EventIssueToken EventType = "ISSUE_TOKEN" // create a new fungible token
EventTransferToken EventType = "TRANSFER_TOKEN" // transfer fungible tokens between addresses
EventBurnToken EventType = "BURN_TOKEN" // destroy fungible tokens
EventMintNFT EventType = "MINT_NFT" // mint a new non-fungible token
EventTransferNFT EventType = "TRANSFER_NFT" // transfer NFT ownership
EventBurnNFT EventType = "BURN_NFT" // burn (destroy) an NFT
)
// Token amounts are stored in micro-tokens (µT).
// 1 token = 1_000_000 µT
const (
MicroToken uint64 = 1
Token uint64 = 1_000_000
// MinFee is the minimum transaction fee paid to the block validator.
// Validators earn fees as their only income — no block reward minting.
MinFee uint64 = 1_000 // 0.001 T per transaction
// GenesisAllocation is a one-time mint at block 0 for the bootstrap validator.
// All subsequent token supply comes only from re-distribution of existing balances.
GenesisAllocation uint64 = 21_000_000 * Token // 21 million T, fixed supply
// SlashAmount is the penalty deducted from a misbehaving validator's balance.
SlashAmount uint64 = 50 * Token
// RegistrationFee is the one-time fee to register an identity on-chain
// (EventRegisterKey). Paid to the block validator. High enough to deter
// Sybil attacks while remaining affordable.
RegistrationFee uint64 = 1_000_000 // 1 T
// MinContactFee is the minimum amount a sender must pay the recipient when
// submitting an EventContactRequest (anti-spam; goes directly to recipient).
MinContactFee uint64 = 5_000 // 0.005 T
)
// Transaction is the atomic unit recorded in a block.
// Bodies of messages are NEVER stored here — only identity/channel events.
type Transaction struct {
ID string `json:"id"`
Type EventType `json:"type"`
From string `json:"from"` // hex-encoded Ed25519 public key
To string `json:"to"` // hex-encoded Ed25519 public key (if applicable)
Amount uint64 `json:"amount"` // µT to transfer (for TRANSFER type)
Fee uint64 `json:"fee"` // µT paid to the block validator
Memo string `json:"memo,omitempty"`
Payload []byte `json:"payload"` // JSON-encoded event-specific data
Signature []byte `json:"signature"` // Ed25519 sig over canonical bytes
Timestamp time.Time `json:"timestamp"`
}
// RegisterKeyPayload is embedded in EventRegisterKey transactions.
type RegisterKeyPayload struct {
PubKey string `json:"pub_key"` // hex-encoded Ed25519 public key
Nickname string `json:"nickname"` // human-readable, non-unique
PowNonce uint64 `json:"pow_nonce"` // proof-of-work nonce (Sybil barrier)
PowTarget string `json:"pow_target"`
X25519PubKey string `json:"x25519_pub_key,omitempty"` // hex Curve25519 key for E2E messaging
}
// CreateChannelPayload is embedded in EventCreateChannel transactions.
type CreateChannelPayload struct {
ChannelID string `json:"channel_id"`
Title string `json:"title"`
IsPublic bool `json:"is_public"`
}
// RegisterRelayPayload is embedded in EventRegisterRelay transactions.
// A node publishes this to advertise itself as a relay service provider.
// Clients look up relay nodes via GET /api/relays.
type RegisterRelayPayload struct {
// X25519PubKey is the hex-encoded Curve25519 public key for NaCl envelope encryption.
// Senders use this key to seal messages addressed to this relay node.
X25519PubKey string `json:"x25519_pub_key"`
// FeePerMsgUT is the relay fee the node charges per delivered envelope (in µT).
FeePerMsgUT uint64 `json:"fee_per_msg_ut"`
// Multiaddr is the optional libp2p multiaddr string for direct connections.
Multiaddr string `json:"multiaddr,omitempty"`
}
// RelayProofPayload proves that a relay/recipient node received an envelope.
// The sender pre-authorises the fee by signing FeeAuthBytes(EnvelopeID, FeeUT).
// On-chain the fee is pulled from the sender's balance and credited to the relay.
type RelayProofPayload struct {
// EnvelopeID is the stable identifier of the delivered envelope (hex).
EnvelopeID string `json:"envelope_id"`
// EnvelopeHash is SHA-256(nonce || ciphertext) — prevents double-claiming.
EnvelopeHash []byte `json:"envelope_hash"`
// SenderPubKey is the Ed25519 public key of the envelope sender (hex).
SenderPubKey string `json:"sender_pub_key"`
// FeeUT is the delivery fee the relay claims from the sender's balance.
FeeUT uint64 `json:"fee_ut"`
// FeeSig is the sender's Ed25519 signature over FeeAuthBytes(EnvelopeID, FeeUT).
// This authorises the relay to pull FeeUT from the sender's on-chain balance.
FeeSig []byte `json:"fee_sig"`
// RelayPubKey is the Ed25519 public key of the relay claiming the fee (hex).
RelayPubKey string `json:"relay_pub_key"`
// DeliveredAt is the unix timestamp of delivery.
DeliveredAt int64 `json:"delivered_at"`
// RecipientSig is the recipient's optional Ed25519 sig over EnvelopeHash,
// proving the message was successfully decrypted (not required for fee claim).
RecipientSig []byte `json:"recipient_sig,omitempty"`
}
// FeeAuthBytes returns the canonical byte string that the sender must sign
// to pre-authorise a relay fee pull. The relay includes this signature in
// RelayProofPayload.FeeSig when submitting the proof on-chain.
//
// Format: SHA-256("relay-fee:" || envelopeID || uint64BE(feeUT))
func FeeAuthBytes(envelopeID string, feeUT uint64) []byte {
h := sha256.New()
h.Write([]byte("relay-fee:"))
h.Write([]byte(envelopeID))
var b [8]byte
binary.BigEndian.PutUint64(b[:], feeUT)
h.Write(b[:])
return h.Sum(nil)
}
// TransferPayload carries an optional memo for token transfers.
type TransferPayload struct {
Memo string `json:"memo,omitempty"`
}
// BindWalletPayload links a node's signing key to a separate payout wallet.
// After this tx is committed, block fees and relay fees are credited to
// WalletPubKey instead of the node's own pub key.
type BindWalletPayload struct {
WalletPubKey string `json:"wallet_pub_key"`
WalletAddr string `json:"wallet_addr"`
}
// SlashPayload is submitted by a validator to penalise a misbehaving peer.
type SlashPayload struct {
OffenderPubKey string `json:"offender_pub_key"`
Reason string `json:"reason"` // "double_vote" | "downtime" | "equivocation"
Evidence []byte `json:"evidence,omitempty"`
}
// HeartbeatPayload is a periodic liveness signal published by active nodes.
// It carries the node's current chain height so peers can detect lagging nodes.
// Heartbeats cost MinFee (paid to the block validator) and earn no reward —
// they exist to build reputation and prove liveness.
type HeartbeatPayload struct {
PubKey string `json:"pub_key"`
ChainHeight uint64 `json:"chain_height"`
PeerCount int `json:"peer_count"`
Version string `json:"version"`
}
// OpenPayChanPayload locks deposits from two parties into a payment channel.
type OpenPayChanPayload struct {
ChannelID string `json:"channel_id"`
PartyA string `json:"party_a"`
PartyB string `json:"party_b"`
DepositA uint64 `json:"deposit_a_ut"`
DepositB uint64 `json:"deposit_b_ut"`
ExpiryBlock uint64 `json:"expiry_block"`
SigB []byte `json:"sig_b"` // PartyB's Ed25519 sig over channel params
}
// ClosePayChanPayload settles a payment channel and distributes balances.
type ClosePayChanPayload struct {
ChannelID string `json:"channel_id"`
BalanceA uint64 `json:"balance_a_ut"`
BalanceB uint64 `json:"balance_b_ut"`
Nonce uint64 `json:"nonce"`
SigA []byte `json:"sig_a"`
SigB []byte `json:"sig_b"`
}
// PayChanState is stored on-chain for each open payment channel.
type PayChanState struct {
ChannelID string `json:"channel_id"`
PartyA string `json:"party_a"`
PartyB string `json:"party_b"`
DepositA uint64 `json:"deposit_a_ut"`
DepositB uint64 `json:"deposit_b_ut"`
ExpiryBlock uint64 `json:"expiry_block"`
OpenedBlock uint64 `json:"opened_block"`
Nonce uint64 `json:"nonce"`
Closed bool `json:"closed"`
}
// BlockRewardPayload is attached to synthetic BLOCK_REWARD transactions.
// These are index-only records so the explorer can show validator fee income.
// There is no minting — the FeeReward comes from existing transaction fees.
type BlockRewardPayload struct {
ValidatorPubKey string `json:"validator_pub_key"`
TargetPubKey string `json:"target_pub_key"`
FeeReward uint64 `json:"fee_reward_ut"`
TotalReward uint64 `json:"total_reward_ut"`
}
// ContactRequestPayload is embedded in EventContactRequest transactions.
// The sender pays tx.Amount directly to the recipient (anti-spam fee).
// A pending contact record is stored on-chain for the recipient to accept or block.
type ContactRequestPayload struct {
Intro string `json:"intro,omitempty"` // optional plaintext intro (≤ 280 chars)
}
// AcceptContactPayload is embedded in EventAcceptContact transactions.
// tx.From accepts a pending request from tx.To.
type AcceptContactPayload struct{}
// BlockContactPayload is embedded in EventBlockContact transactions.
// tx.From blocks tx.To; future contact requests from tx.To are rejected.
type BlockContactPayload struct {
Reason string `json:"reason,omitempty"`
}
// ChannelMember records a participant in a channel together with their
// X25519 public key. The key is cached on-chain (written during ADD_MEMBER)
// so channel senders don't have to fan out a separate /api/identity lookup
// per recipient on every message — they GET /api/channels/:id/members
// once and seal N envelopes in a loop.
type ChannelMember struct {
PubKey string `json:"pub_key"` // Ed25519 hex
X25519PubKey string `json:"x25519_pub_key"` // optional; empty if member hasn't registered
Address string `json:"address"`
}
// AddMemberPayload is embedded in EventAddMember transactions.
// tx.From adds tx.To as a member of the specified channel.
// If tx.To is empty, tx.From is added (self-join for public channels).
type AddMemberPayload struct {
ChannelID string `json:"channel_id"`
}
// AddValidatorPayload is embedded in EventAddValidator transactions.
// tx.From must already be a validator; tx.To is the new validator's pub key.
//
// Admission is gated by two things:
// 1. Stake: the candidate (tx.To) must have STAKE'd at least
// MinValidatorStake beforehand. Prevents anyone spinning up a free
// validator without economic buy-in.
// 2. Multi-sig: at least ⌈2/3⌉ of the CURRENT validator set must approve.
// The tx sender counts as one; remaining approvals go in CoSignatures.
// For a 1-validator chain (fresh genesis / tests) sender alone is 2/3,
// so CoSignatures can be empty — backward-compat is preserved.
type AddValidatorPayload struct {
Reason string `json:"reason,omitempty"`
CoSignatures []ValidatorCoSig `json:"cosigs,omitempty"`
}
// ValidatorCoSig is an off-chain-assembled approval from one existing
// validator for a specific candidate admission. The signature is over the
// canonical digest returned by AdmitDigest(candidatePubKeyHex).
type ValidatorCoSig struct {
PubKey string `json:"pubkey"` // Ed25519 hex of a current validator
Signature []byte `json:"signature"` // Ed25519 signature over AdmitDigest(candidate)
}
// AdmitDigest returns the canonical bytes a validator signs to approve
// admitting `candidatePubHex` as a new validator. Stable across implementations
// so co-sigs collected off-chain verify identically on-chain.
func AdmitDigest(candidatePubHex string) []byte {
h := sha256.New()
h.Write([]byte("DCHAIN-ADD-VALIDATOR\x00"))
h.Write([]byte(candidatePubHex))
return h.Sum(nil)
}
// MinValidatorStake is the minimum µT a candidate must have locked in
// `stake:<pubkey>` before an ADD_VALIDATOR naming them is accepted.
// 1 T = 1_000_000 µT — small enough that testnets can afford it easily,
// large enough to deter "register 100 fake validators to 51%-attack".
const MinValidatorStake uint64 = 1_000_000
// RemoveValidatorPayload is embedded in EventRemoveValidator transactions.
// tx.From must be a validator; tx.To is the validator to remove.
//
// Two legitimate use cases:
// 1. Self-removal (tx.From == tx.To): always allowed, no cosigs needed.
// Lets a validator gracefully leave the set without requiring others.
// 2. Forced removal (tx.From != tx.To): requires ⌈2/3⌉ cosigs of the
// current validator set — same pattern as ADD_VALIDATOR. Stops a
// single validator from unilaterally kicking peers.
//
// The signed payload is AdmitDigest(tx.To) but with the domain byte flipped
// — see RemoveDigest below. This prevents a cosig collected for "admit X"
// from being replayed as "remove X".
type RemoveValidatorPayload struct {
Reason string `json:"reason,omitempty"`
CoSignatures []ValidatorCoSig `json:"cosigs,omitempty"`
}
// RemoveDigest is the canonical bytes a validator signs to approve removing
// `targetPubHex` from the set. Distinct from AdmitDigest so signatures
// can't be cross-replayed between add and remove operations.
func RemoveDigest(targetPubHex string) []byte {
h := sha256.New()
h.Write([]byte("DCHAIN-REMOVE-VALIDATOR\x00"))
h.Write([]byte(targetPubHex))
return h.Sum(nil)
}
// DeployContractPayload is embedded in EventDeployContract transactions.
// WASMBase64 is the base64-encoded WASM binary. It is stored in the tx so that
// nodes can replay the chain from genesis and re-derive contract state.
type DeployContractPayload struct {
WASMBase64 string `json:"wasm_b64"`
ABIJson string `json:"abi_json"`
InitArgs string `json:"init_args_json,omitempty"`
}
// CallContractPayload is embedded in EventCallContract transactions.
type CallContractPayload struct {
ContractID string `json:"contract_id"`
Method string `json:"method"`
ArgsJSON string `json:"args_json,omitempty"`
GasLimit uint64 `json:"gas_limit"`
}
// ContractRecord is stored in BadgerDB at contract:<contractID>.
// WASMBytes is NOT in the block; it is derived from the deploy tx payload on replay.
type ContractRecord struct {
ContractID string `json:"contract_id"`
WASMBytes []byte `json:"wasm_bytes"`
ABIJson string `json:"abi_json"`
DeployerPub string `json:"deployer_pub"`
DeployedAt uint64 `json:"deployed_at"` // block height
}
// MinDeployFee is the minimum fee for a DEPLOY_CONTRACT transaction.
// Covers storage costs for the WASM binary.
const MinDeployFee uint64 = 10_000 // 0.01 T
// MinCallFee is the minimum base fee for a CALL_CONTRACT transaction.
// Gas costs are billed on top of this.
const MinCallFee uint64 = MinFee
// ContractLogEntry is one log message emitted by a contract via env.log().
// Stored in BadgerDB at clog:<contractID>:<blockHeight_20d>:<seq_05d>.
type ContractLogEntry struct {
ContractID string `json:"contract_id"`
BlockHeight uint64 `json:"block_height"`
TxID string `json:"tx_id"`
Seq int `json:"seq"`
Message string `json:"message"`
}
// GasPrice is the cost in µT per 1 gas unit consumed during contract execution.
const GasPrice uint64 = 1 // 1 µT per gas unit
// MinStake is the minimum amount a validator must stake.
const MinStake uint64 = 1_000 * Token // 1000 T
// MinIssueTokenFee is the fee required to issue a new token.
const MinIssueTokenFee uint64 = 100_000 // 0.1 T
// StakePayload is embedded in EventStake transactions.
// tx.Amount holds the amount to stake; tx.Fee is the transaction fee.
type StakePayload struct{}
// UnstakePayload is embedded in EventUnstake transactions.
// The entire current stake is returned to the staker's balance.
type UnstakePayload struct{}
// IssueTokenPayload is embedded in EventIssueToken transactions.
// The new token is credited to tx.From with TotalSupply units.
type IssueTokenPayload struct {
Name string `json:"name"` // human-readable token name, e.g. "My Token"
Symbol string `json:"symbol"` // ticker symbol, e.g. "MTK"
Decimals uint8 `json:"decimals"` // decimal places, e.g. 6 → 1 token = 1_000_000 base units
TotalSupply uint64 `json:"total_supply"` // initial supply in base units
}
// TransferTokenPayload is embedded in EventTransferToken transactions.
// tx.To is the recipient; tx.Amount is ignored (use payload Amount).
type TransferTokenPayload struct {
TokenID string `json:"token_id"`
Amount uint64 `json:"amount"` // in base units
}
// BurnTokenPayload is embedded in EventBurnToken transactions.
type BurnTokenPayload struct {
TokenID string `json:"token_id"`
Amount uint64 `json:"amount"` // in base units
}
// TokenRecord is stored in BadgerDB at token:<tokenID>.
type TokenRecord struct {
TokenID string `json:"token_id"`
Name string `json:"name"`
Symbol string `json:"symbol"`
Decimals uint8 `json:"decimals"`
TotalSupply uint64 `json:"total_supply"` // current (may decrease via burns)
Issuer string `json:"issuer"` // creator pubkey
IssuedAt uint64 `json:"issued_at"` // block height
}
// MinMintNFTFee is the fee required to mint a new NFT.
const MinMintNFTFee uint64 = 10_000 // 0.01 T
// MintNFTPayload is embedded in EventMintNFT transactions.
type MintNFTPayload struct {
Name string `json:"name"` // human-readable name
Description string `json:"description,omitempty"`
URI string `json:"uri,omitempty"` // off-chain metadata URI (IPFS, https, etc.)
Attributes string `json:"attributes,omitempty"` // JSON string of trait attributes
}
// TransferNFTPayload is embedded in EventTransferNFT transactions.
// tx.To is the new owner; tx.From must be current owner.
type TransferNFTPayload struct {
NFTID string `json:"nft_id"`
}
// BurnNFTPayload is embedded in EventBurnNFT transactions.
type BurnNFTPayload struct {
NFTID string `json:"nft_id"`
}
// NFTRecord is stored in BadgerDB at nft:<nftID>.
type NFTRecord struct {
NFTID string `json:"nft_id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
URI string `json:"uri,omitempty"`
Attributes string `json:"attributes,omitempty"`
Owner string `json:"owner"` // current owner pubkey
Issuer string `json:"issuer"` // original minter pubkey
MintedAt uint64 `json:"minted_at"` // block height
Burned bool `json:"burned,omitempty"`
}
// ContactStatus is the state of a contact relationship.
type ContactStatus string
const (
ContactPending ContactStatus = "pending"
ContactAccepted ContactStatus = "accepted"
ContactBlocked ContactStatus = "blocked"
)
// ContactInfo is returned by the contacts API.
type ContactInfo struct {
RequesterPub string `json:"requester_pub"`
RequesterAddr string `json:"requester_addr"`
Status ContactStatus `json:"status"`
Intro string `json:"intro,omitempty"`
FeeUT uint64 `json:"fee_ut"`
TxID string `json:"tx_id"`
CreatedAt int64 `json:"created_at"`
}
// IdentityInfo is returned by GET /api/identity/{pubkey}.
type IdentityInfo struct {
PubKey string `json:"pub_key"`
Address string `json:"address"`
X25519Pub string `json:"x25519_pub"` // hex Curve25519 key; empty if not published
Nickname string `json:"nickname"`
Registered bool `json:"registered"` // true if REGISTER_KEY tx was committed
}
// ConsensusMessage types used by the PBFT engine over the P2P layer.
type MsgType string
const (
MsgPrePrepare MsgType = "PRE_PREPARE"
MsgPrepare MsgType = "PREPARE"
MsgCommit MsgType = "COMMIT"
MsgViewChange MsgType = "VIEW_CHANGE"
MsgNewView MsgType = "NEW_VIEW"
)
// ConsensusMsg is the envelope sent between validators.
type ConsensusMsg struct {
Type MsgType `json:"type"`
View uint64 `json:"view"`
SeqNum uint64 `json:"seq_num"`
BlockHash []byte `json:"block_hash"`
Block *Block `json:"block,omitempty"`
From string `json:"from"`
Signature []byte `json:"signature"`
}