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:
139
blockchain/block.go
Normal file
139
blockchain/block.go
Normal 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
2589
blockchain/chain.go
Normal file
File diff suppressed because it is too large
Load Diff
796
blockchain/chain_test.go
Normal file
796
blockchain/chain_test.go
Normal 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
101
blockchain/equivocation.go
Normal 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
562
blockchain/index.go
Normal 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
238
blockchain/native.go
Normal 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
|
||||
}
|
||||
|
||||
371
blockchain/native_username.go
Normal file
371
blockchain/native_username.go
Normal 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
|
||||
}
|
||||
197
blockchain/schema_migrations.go
Normal file
197
blockchain/schema_migrations.go
Normal 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
509
blockchain/types.go
Normal 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"`
|
||||
}
|
||||
Reference in New Issue
Block a user