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
relay/envelope.go
Normal file
139
relay/envelope.go
Normal file
@@ -0,0 +1,139 @@
|
||||
// Package relay implements NaCl-box encrypted envelope routing over gossipsub.
|
||||
// Messages are sealed for a specific recipient's X25519 public key; relay nodes
|
||||
// propagate them without being able to read the contents.
|
||||
//
|
||||
// Economic model: the sender pre-authorises a delivery fee by signing
|
||||
// FeeAuthBytes(envelopeID, feeUT) with their Ed25519 identity key. When the
|
||||
// relay delivers the envelope, it submits a RELAY_PROOF transaction on-chain
|
||||
// that pulls feeUT from the sender's balance and credits the relay.
|
||||
package relay
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/crypto/nacl/box"
|
||||
|
||||
"go-blockchain/blockchain"
|
||||
"go-blockchain/identity"
|
||||
)
|
||||
|
||||
// KeyPair holds an X25519 keypair used exclusively for relay envelope encryption.
|
||||
// This is separate from the node's Ed25519 identity keypair.
|
||||
type KeyPair struct {
|
||||
Pub [32]byte
|
||||
Priv [32]byte
|
||||
}
|
||||
|
||||
// GenerateKeyPair creates a fresh X25519 keypair.
|
||||
func GenerateKeyPair() (*KeyPair, error) {
|
||||
pub, priv, err := box.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate relay keypair: %w", err)
|
||||
}
|
||||
return &KeyPair{Pub: *pub, Priv: *priv}, nil
|
||||
}
|
||||
|
||||
// PubHex returns the hex-encoded X25519 public key.
|
||||
func (kp *KeyPair) PubHex() string {
|
||||
return hex.EncodeToString(kp.Pub[:])
|
||||
}
|
||||
|
||||
// Envelope is a sealed message routed via relay nodes.
|
||||
// Only the holder of the matching X25519 private key can decrypt it.
|
||||
type Envelope struct {
|
||||
// ID is the hex-encoded first 16 bytes of SHA-256(nonce || ciphertext).
|
||||
ID string `json:"id"`
|
||||
RecipientPub string `json:"recipient_pub"` // hex X25519 public key
|
||||
SenderPub string `json:"sender_pub"` // hex X25519 public key (for decryption)
|
||||
|
||||
// Fee authorization: sender pre-signs permission for relay to pull FeeUT.
|
||||
SenderEd25519PubKey string `json:"sender_ed25519_pub"` // sender's blockchain identity key (hex)
|
||||
FeeUT uint64 `json:"fee_ut"` // µT the relay may claim on delivery
|
||||
FeeSig []byte `json:"fee_sig"` // Ed25519 sig over FeeAuthBytes(ID, FeeUT)
|
||||
|
||||
Nonce []byte `json:"nonce"` // 24 bytes
|
||||
Ciphertext []byte `json:"ciphertext"` // NaCl box ciphertext
|
||||
SentAt int64 `json:"sent_at"` // unix timestamp (informational)
|
||||
}
|
||||
|
||||
// Seal encrypts msg for recipientPub and attaches a fee authorization.
|
||||
// senderID is the sender's Ed25519 identity used to sign the fee authorisation.
|
||||
// feeUT is the delivery fee offered to the relay — 0 means free delivery.
|
||||
func Seal(
|
||||
sender *KeyPair,
|
||||
senderID *identity.Identity,
|
||||
recipientPub [32]byte,
|
||||
msg []byte,
|
||||
feeUT uint64,
|
||||
sentAt int64,
|
||||
) (*Envelope, error) {
|
||||
var nonce [24]byte
|
||||
if _, err := rand.Read(nonce[:]); err != nil {
|
||||
return nil, fmt.Errorf("generate nonce: %w", err)
|
||||
}
|
||||
ct := box.Seal(nil, msg, &nonce, &recipientPub, &sender.Priv)
|
||||
envID := envelopeID(nonce[:], ct)
|
||||
|
||||
var feeSig []byte
|
||||
if feeUT > 0 && senderID != nil {
|
||||
authBytes := blockchain.FeeAuthBytes(envID, feeUT)
|
||||
feeSig = senderID.Sign(authBytes)
|
||||
}
|
||||
|
||||
return &Envelope{
|
||||
ID: envID,
|
||||
RecipientPub: hex.EncodeToString(recipientPub[:]),
|
||||
SenderPub: sender.PubHex(),
|
||||
SenderEd25519PubKey: func() string {
|
||||
if senderID != nil {
|
||||
return senderID.PubKeyHex()
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
FeeUT: feeUT,
|
||||
FeeSig: feeSig,
|
||||
Nonce: nonce[:],
|
||||
Ciphertext: ct,
|
||||
SentAt: sentAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Open decrypts an envelope using the recipient's private key.
|
||||
func Open(recipient *KeyPair, env *Envelope) ([]byte, error) {
|
||||
senderBytes, err := hex.DecodeString(env.SenderPub)
|
||||
if err != nil || len(senderBytes) != 32 {
|
||||
return nil, fmt.Errorf("invalid sender pub key")
|
||||
}
|
||||
if len(env.Nonce) != 24 {
|
||||
return nil, fmt.Errorf("invalid nonce: expected 24 bytes, got %d", len(env.Nonce))
|
||||
}
|
||||
var senderPub [32]byte
|
||||
var nonce [24]byte
|
||||
copy(senderPub[:], senderBytes)
|
||||
copy(nonce[:], env.Nonce)
|
||||
|
||||
msg, ok := box.Open(nil, env.Ciphertext, &nonce, &senderPub, &recipient.Priv)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("decryption failed: not addressed to this key or data is corrupt")
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// Hash returns the SHA-256 of (nonce || ciphertext), used in RELAY_PROOF payloads.
|
||||
func Hash(env *Envelope) []byte {
|
||||
h := sha256.Sum256(append(env.Nonce, env.Ciphertext...))
|
||||
return h[:]
|
||||
}
|
||||
|
||||
// IsAddressedTo reports whether the envelope is addressed to the given keypair.
|
||||
func (e *Envelope) IsAddressedTo(kp *KeyPair) bool {
|
||||
return e.RecipientPub == kp.PubHex()
|
||||
}
|
||||
|
||||
func envelopeID(nonce, ct []byte) string {
|
||||
h := sha256.Sum256(append(nonce, ct...))
|
||||
return hex.EncodeToString(h[:16])
|
||||
}
|
||||
191
relay/envelope_test.go
Normal file
191
relay/envelope_test.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package relay_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-blockchain/identity"
|
||||
"go-blockchain/relay"
|
||||
)
|
||||
|
||||
func mustGenerateKeyPair(t *testing.T) *relay.KeyPair {
|
||||
t.Helper()
|
||||
kp, err := relay.GenerateKeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKeyPair: %v", err)
|
||||
}
|
||||
return kp
|
||||
}
|
||||
|
||||
func mustGenerateIdentity(t *testing.T) *identity.Identity {
|
||||
t.Helper()
|
||||
id, err := identity.Generate()
|
||||
if err != nil {
|
||||
t.Fatalf("identity.Generate: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// TestSealOpenRoundTrip seals a message and opens it with the correct recipient key.
|
||||
func TestSealOpenRoundTrip(t *testing.T) {
|
||||
sender := mustGenerateKeyPair(t)
|
||||
recipient := mustGenerateKeyPair(t)
|
||||
plaintext := []byte("hello relay world")
|
||||
|
||||
env, err := relay.Seal(sender, nil, recipient.Pub, plaintext, 0, time.Now().Unix())
|
||||
if err != nil {
|
||||
t.Fatalf("Seal: %v", err)
|
||||
}
|
||||
|
||||
got, err := relay.Open(recipient, env)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, plaintext) {
|
||||
t.Errorf("plaintext mismatch: got %q, want %q", got, plaintext)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenWrongKey attempts to open an envelope with a different keypair.
|
||||
func TestOpenWrongKey(t *testing.T) {
|
||||
sender := mustGenerateKeyPair(t)
|
||||
recipient := mustGenerateKeyPair(t)
|
||||
wrong := mustGenerateKeyPair(t)
|
||||
|
||||
env, err := relay.Seal(sender, nil, recipient.Pub, []byte("secret"), 0, time.Now().Unix())
|
||||
if err != nil {
|
||||
t.Fatalf("Seal: %v", err)
|
||||
}
|
||||
|
||||
_, err = relay.Open(wrong, env)
|
||||
if err == nil {
|
||||
t.Fatal("Open with wrong key should return an error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsAddressedTo checks that IsAddressedTo returns true for the correct
|
||||
// recipient and false for a different keypair.
|
||||
func TestIsAddressedTo(t *testing.T) {
|
||||
sender := mustGenerateKeyPair(t)
|
||||
recipient := mustGenerateKeyPair(t)
|
||||
other := mustGenerateKeyPair(t)
|
||||
|
||||
env, err := relay.Seal(sender, nil, recipient.Pub, []byte("msg"), 0, time.Now().Unix())
|
||||
if err != nil {
|
||||
t.Fatalf("Seal: %v", err)
|
||||
}
|
||||
|
||||
if !env.IsAddressedTo(recipient) {
|
||||
t.Error("IsAddressedTo should return true for the correct recipient")
|
||||
}
|
||||
if env.IsAddressedTo(other) {
|
||||
t.Error("IsAddressedTo should return false for a different keypair")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSealWithFee seals a message with a positive fee and a real sender identity.
|
||||
// FeeSig must be non-nil and SenderEd25519PubKey must be populated.
|
||||
func TestSealWithFee(t *testing.T) {
|
||||
sender := mustGenerateKeyPair(t)
|
||||
senderID := mustGenerateIdentity(t)
|
||||
recipient := mustGenerateKeyPair(t)
|
||||
|
||||
env, err := relay.Seal(sender, senderID, recipient.Pub, []byte("paid msg"), 1000, time.Now().Unix())
|
||||
if err != nil {
|
||||
t.Fatalf("Seal: %v", err)
|
||||
}
|
||||
|
||||
if len(env.FeeSig) == 0 {
|
||||
t.Error("FeeSig should be non-nil when feeUT > 0 and senderID is provided")
|
||||
}
|
||||
if env.SenderEd25519PubKey == "" {
|
||||
t.Error("SenderEd25519PubKey should be set when senderID is provided")
|
||||
}
|
||||
if env.SenderEd25519PubKey != senderID.PubKeyHex() {
|
||||
t.Errorf("SenderEd25519PubKey mismatch: got %s, want %s",
|
||||
env.SenderEd25519PubKey, senderID.PubKeyHex())
|
||||
}
|
||||
if env.FeeUT != 1000 {
|
||||
t.Errorf("FeeUT: got %d, want 1000", env.FeeUT)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSealNilSenderID seals with nil senderID and feeUT=0.
|
||||
// FeeSig should be nil and SenderEd25519PubKey should be empty.
|
||||
func TestSealNilSenderID(t *testing.T) {
|
||||
sender := mustGenerateKeyPair(t)
|
||||
recipient := mustGenerateKeyPair(t)
|
||||
|
||||
env, err := relay.Seal(sender, nil, recipient.Pub, []byte("free msg"), 0, time.Now().Unix())
|
||||
if err != nil {
|
||||
t.Fatalf("Seal: %v", err)
|
||||
}
|
||||
|
||||
if env.FeeSig != nil {
|
||||
t.Error("FeeSig should be nil for zero-fee envelope with nil senderID")
|
||||
}
|
||||
if env.SenderEd25519PubKey != "" {
|
||||
t.Errorf("SenderEd25519PubKey should be empty, got %s", env.SenderEd25519PubKey)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnvelopeIDUnique verifies that two seals of the same message produce
|
||||
// different IDs because random nonces are used each time.
|
||||
func TestEnvelopeIDUnique(t *testing.T) {
|
||||
sender := mustGenerateKeyPair(t)
|
||||
recipient := mustGenerateKeyPair(t)
|
||||
msg := []byte("same message")
|
||||
sentAt := time.Now().Unix()
|
||||
|
||||
env1, err := relay.Seal(sender, nil, recipient.Pub, msg, 0, sentAt)
|
||||
if err != nil {
|
||||
t.Fatalf("Seal 1: %v", err)
|
||||
}
|
||||
env2, err := relay.Seal(sender, nil, recipient.Pub, msg, 0, sentAt)
|
||||
if err != nil {
|
||||
t.Fatalf("Seal 2: %v", err)
|
||||
}
|
||||
|
||||
if env1.ID == env2.ID {
|
||||
t.Error("two seals of the same message should produce different IDs")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenTamperedCiphertext flips a byte in the ciphertext and expects Open to fail.
|
||||
func TestOpenTamperedCiphertext(t *testing.T) {
|
||||
sender := mustGenerateKeyPair(t)
|
||||
recipient := mustGenerateKeyPair(t)
|
||||
|
||||
env, err := relay.Seal(sender, nil, recipient.Pub, []byte("tamper me"), 0, time.Now().Unix())
|
||||
if err != nil {
|
||||
t.Fatalf("Seal: %v", err)
|
||||
}
|
||||
|
||||
// Flip the first byte of the ciphertext.
|
||||
env.Ciphertext[0] ^= 0xFF
|
||||
|
||||
_, err = relay.Open(recipient, env)
|
||||
if err == nil {
|
||||
t.Fatal("Open with tampered ciphertext should return an error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenTamperedNonce flips a byte in the nonce and expects Open to fail.
|
||||
func TestOpenTamperedNonce(t *testing.T) {
|
||||
sender := mustGenerateKeyPair(t)
|
||||
recipient := mustGenerateKeyPair(t)
|
||||
|
||||
env, err := relay.Seal(sender, nil, recipient.Pub, []byte("tamper nonce"), 0, time.Now().Unix())
|
||||
if err != nil {
|
||||
t.Fatalf("Seal: %v", err)
|
||||
}
|
||||
|
||||
// Flip the first byte of the nonce.
|
||||
env.Nonce[0] ^= 0xFF
|
||||
|
||||
_, err = relay.Open(recipient, env)
|
||||
if err == nil {
|
||||
t.Fatal("Open with tampered nonce should return an error")
|
||||
}
|
||||
}
|
||||
54
relay/keypair.go
Normal file
54
relay/keypair.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package relay
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
type keypairFile struct {
|
||||
Pub string `json:"pub"`
|
||||
Priv string `json:"priv"`
|
||||
}
|
||||
|
||||
// LoadOrCreateKeyPair loads an X25519 relay keypair from path, creating it if absent.
|
||||
func LoadOrCreateKeyPair(path string) (*KeyPair, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
var f keypairFile
|
||||
if err := json.Unmarshal(data, &f); err != nil {
|
||||
return nil, fmt.Errorf("parse relay key file: %w", err)
|
||||
}
|
||||
pubBytes, err := hex.DecodeString(f.Pub)
|
||||
if err != nil || len(pubBytes) != 32 {
|
||||
return nil, fmt.Errorf("invalid relay pub key in %s", path)
|
||||
}
|
||||
privBytes, err := hex.DecodeString(f.Priv)
|
||||
if err != nil || len(privBytes) != 32 {
|
||||
return nil, fmt.Errorf("invalid relay priv key in %s", path)
|
||||
}
|
||||
kp := &KeyPair{}
|
||||
copy(kp.Pub[:], pubBytes)
|
||||
copy(kp.Priv[:], privBytes)
|
||||
return kp, nil
|
||||
}
|
||||
if !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("read relay key file: %w", err)
|
||||
}
|
||||
|
||||
// Generate new keypair and persist.
|
||||
kp, err := GenerateKeyPair()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f := keypairFile{
|
||||
Pub: kp.PubHex(),
|
||||
Priv: hex.EncodeToString(kp.Priv[:]),
|
||||
}
|
||||
out, _ := json.MarshalIndent(f, "", " ")
|
||||
if err := os.WriteFile(path, out, 0600); err != nil {
|
||||
return nil, fmt.Errorf("write relay key file: %w", err)
|
||||
}
|
||||
return kp, nil
|
||||
}
|
||||
265
relay/mailbox.go
Normal file
265
relay/mailbox.go
Normal file
@@ -0,0 +1,265 @@
|
||||
package relay
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
badger "github.com/dgraph-io/badger/v4"
|
||||
)
|
||||
|
||||
const (
|
||||
// mailboxTTL is how long undelivered envelopes are retained.
|
||||
mailboxTTL = 7 * 24 * time.Hour
|
||||
|
||||
// mailboxPrefix is the BadgerDB key prefix for stored envelopes.
|
||||
// Key format: mail:<recipientX25519Hex>:<sentAt_20d>:<envelopeID>
|
||||
mailboxPrefix = "mail:"
|
||||
|
||||
// MailboxMaxLimit caps the number of envelopes returned per single query.
|
||||
MailboxMaxLimit = 200
|
||||
|
||||
// MailboxPerRecipientCap is the maximum number of envelopes stored per
|
||||
// recipient key. When the cap is reached, the oldest envelope is evicted
|
||||
// before the new one is written (sliding window, FIFO).
|
||||
// At 64 KB max per envelope, one recipient occupies at most ~32 MB.
|
||||
MailboxPerRecipientCap = 500
|
||||
|
||||
// MailboxMaxEnvelopeSize is the maximum allowed ciphertext length in bytes.
|
||||
// Rejects oversized envelopes before writing to disk.
|
||||
MailboxMaxEnvelopeSize = 64 * 1024 // 64 KB
|
||||
)
|
||||
|
||||
// ErrEnvelopeTooLarge is returned by Store when the envelope exceeds the size limit.
|
||||
var ErrEnvelopeTooLarge = errors.New("envelope ciphertext exceeds maximum allowed size")
|
||||
|
||||
// ErrMailboxFull is never returned externally (oldest entry is evicted instead),
|
||||
// but kept as a sentinel for internal logic.
|
||||
var errMailboxFull = errors.New("recipient mailbox is at capacity")
|
||||
|
||||
// Mailbox is a BadgerDB-backed store for relay envelopes awaiting pickup.
|
||||
// Every received envelope is stored with a 7-day TTL regardless of whether
|
||||
// the recipient is currently online. Recipients poll GET /relay/inbox to fetch
|
||||
// and DELETE /relay/inbox/{id} to acknowledge delivery.
|
||||
//
|
||||
// Anti-spam guarantees:
|
||||
// - Envelopes larger than MailboxMaxEnvelopeSize (64 KB) are rejected.
|
||||
// - At most MailboxPerRecipientCap (500) envelopes per recipient are stored;
|
||||
// when the cap is hit the oldest entry is silently evicted (FIFO).
|
||||
// - All entries expire automatically after 7 days (BadgerDB TTL).
|
||||
//
|
||||
// Messages are stored encrypted — the relay cannot read their contents.
|
||||
type Mailbox struct {
|
||||
db *badger.DB
|
||||
|
||||
// onStore, if set, is invoked after every successful Store. Used by the
|
||||
// node to push a WebSocket `inbox` event to subscribers of the
|
||||
// recipient's x25519 pubkey so the mobile client stops polling
|
||||
// /relay/inbox every 3 seconds.
|
||||
//
|
||||
// The callback MUST NOT block — it runs on the writer goroutine. Long
|
||||
// work should be fanned out to a goroutine by the callback itself.
|
||||
onStore func(*Envelope)
|
||||
}
|
||||
|
||||
// SetOnStore registers a post-Store hook. Pass nil to clear. Safe to call
|
||||
// before accepting traffic (wired once at node startup in main.go).
|
||||
func (m *Mailbox) SetOnStore(cb func(*Envelope)) {
|
||||
m.onStore = cb
|
||||
}
|
||||
|
||||
// NewMailbox creates a Mailbox backed by the given BadgerDB instance.
|
||||
func NewMailbox(db *badger.DB) *Mailbox {
|
||||
return &Mailbox{db: db}
|
||||
}
|
||||
|
||||
// OpenMailbox opens (or creates) a dedicated BadgerDB at dbPath for the mailbox.
|
||||
//
|
||||
// Storage tuning matches blockchain/chain.NewChain — 64 MiB vlog files
|
||||
// (instead of 1 GiB default) so GC can actually shrink the DB, and single
|
||||
// version retention since envelopes are either present or deleted.
|
||||
func OpenMailbox(dbPath string) (*Mailbox, error) {
|
||||
opts := badger.DefaultOptions(dbPath).
|
||||
WithLogger(nil).
|
||||
WithValueLogFileSize(64 << 20).
|
||||
WithNumVersionsToKeep(1).
|
||||
WithCompactL0OnClose(true)
|
||||
db, err := badger.Open(opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open mailbox db: %w", err)
|
||||
}
|
||||
return &Mailbox{db: db}, nil
|
||||
}
|
||||
|
||||
// Close closes the underlying database.
|
||||
func (m *Mailbox) Close() error { return m.db.Close() }
|
||||
|
||||
// Store persists an envelope with a 7-day TTL.
|
||||
//
|
||||
// Anti-spam checks (in order):
|
||||
// 1. Ciphertext > MailboxMaxEnvelopeSize → returns ErrEnvelopeTooLarge.
|
||||
// 2. Duplicate envelope ID → silently overwritten (idempotent).
|
||||
// 3. Recipient already has MailboxPerRecipientCap entries → oldest evicted first.
|
||||
func (m *Mailbox) Store(env *Envelope) error {
|
||||
if len(env.Ciphertext) > MailboxMaxEnvelopeSize {
|
||||
return ErrEnvelopeTooLarge
|
||||
}
|
||||
|
||||
key := mailboxKey(env.RecipientPub, env.SentAt, env.ID)
|
||||
val, err := json.Marshal(env)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal envelope: %w", err)
|
||||
}
|
||||
|
||||
// Track whether this was a fresh insert (vs. duplicate) so we can skip
|
||||
// firing the WS hook for idempotent resubmits — otherwise a misbehaving
|
||||
// sender could amplify events by spamming the same envelope ID.
|
||||
fresh := false
|
||||
err = m.db.Update(func(txn *badger.Txn) error {
|
||||
// Check if this exact envelope is already stored (idempotent).
|
||||
if _, err := txn.Get([]byte(key)); err == nil {
|
||||
return nil // already present, no-op
|
||||
}
|
||||
|
||||
// Count existing envelopes for this recipient and collect the oldest key.
|
||||
prefix := []byte(fmt.Sprintf("%s%s:", mailboxPrefix, env.RecipientPub))
|
||||
opts := badger.DefaultIteratorOptions
|
||||
opts.PrefetchValues = false
|
||||
opts.Prefix = prefix
|
||||
|
||||
var count int
|
||||
var oldestKey []byte
|
||||
it := txn.NewIterator(opts)
|
||||
for it.Rewind(); it.Valid(); it.Next() {
|
||||
if count == 0 {
|
||||
oldestKey = it.Item().KeyCopy(nil) // first = oldest (sorted by sentAt)
|
||||
}
|
||||
count++
|
||||
}
|
||||
it.Close()
|
||||
|
||||
// Evict the oldest envelope if cap is reached.
|
||||
if count >= MailboxPerRecipientCap && oldestKey != nil {
|
||||
if err := txn.Delete(oldestKey); err != nil {
|
||||
return fmt.Errorf("evict oldest envelope: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
e := badger.NewEntry([]byte(key), val).WithTTL(mailboxTTL)
|
||||
if err := txn.SetEntry(e); err != nil {
|
||||
return err
|
||||
}
|
||||
fresh = true
|
||||
return nil
|
||||
})
|
||||
if err == nil && fresh && m.onStore != nil {
|
||||
m.onStore(env)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// List returns up to limit envelopes for recipientPubHex, ordered oldest-first.
|
||||
// Pass since > 0 to skip envelopes with SentAt < since (unix timestamp).
|
||||
func (m *Mailbox) List(recipientPubHex string, since int64, limit int) ([]*Envelope, error) {
|
||||
if limit <= 0 || limit > MailboxMaxLimit {
|
||||
limit = MailboxMaxLimit
|
||||
}
|
||||
prefix := []byte(fmt.Sprintf("%s%s:", mailboxPrefix, recipientPubHex))
|
||||
var out []*Envelope
|
||||
|
||||
err := m.db.View(func(txn *badger.Txn) error {
|
||||
opts := badger.DefaultIteratorOptions
|
||||
opts.Prefix = prefix
|
||||
it := txn.NewIterator(opts)
|
||||
defer it.Close()
|
||||
|
||||
for it.Rewind(); it.Valid() && len(out) < limit; it.Next() {
|
||||
if err := it.Item().Value(func(val []byte) error {
|
||||
var env Envelope
|
||||
if err := json.Unmarshal(val, &env); err != nil {
|
||||
return nil // skip corrupt entries
|
||||
}
|
||||
if since > 0 && env.SentAt < since {
|
||||
return nil
|
||||
}
|
||||
out = append(out, &env)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// Delete removes an envelope by ID.
|
||||
// It scans the recipient's prefix to locate the full key (sentAt is not known by caller).
|
||||
// Returns nil if the envelope is not found (already expired or never stored).
|
||||
func (m *Mailbox) Delete(recipientPubHex, envelopeID string) error {
|
||||
prefix := []byte(fmt.Sprintf("%s%s:", mailboxPrefix, recipientPubHex))
|
||||
var found []byte
|
||||
|
||||
err := m.db.View(func(txn *badger.Txn) error {
|
||||
opts := badger.DefaultIteratorOptions
|
||||
opts.PrefetchValues = false
|
||||
opts.Prefix = prefix
|
||||
it := txn.NewIterator(opts)
|
||||
defer it.Close()
|
||||
|
||||
suffix := ":" + envelopeID
|
||||
for it.Rewind(); it.Valid(); it.Next() {
|
||||
key := it.Item().KeyCopy(nil)
|
||||
if strings.HasSuffix(string(key), suffix) {
|
||||
found = key
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil || found == nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return m.db.Update(func(txn *badger.Txn) error {
|
||||
return txn.Delete(found)
|
||||
})
|
||||
}
|
||||
|
||||
// Count returns the number of stored envelopes for a recipient.
|
||||
func (m *Mailbox) Count(recipientPubHex string) (int, error) {
|
||||
prefix := []byte(fmt.Sprintf("%s%s:", mailboxPrefix, recipientPubHex))
|
||||
count := 0
|
||||
err := m.db.View(func(txn *badger.Txn) error {
|
||||
opts := badger.DefaultIteratorOptions
|
||||
opts.PrefetchValues = false
|
||||
opts.Prefix = prefix
|
||||
it := txn.NewIterator(opts)
|
||||
defer it.Close()
|
||||
for it.Rewind(); it.Valid(); it.Next() {
|
||||
count++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return count, err
|
||||
}
|
||||
|
||||
// RunGC periodically runs BadgerDB value log garbage collection.
|
||||
// Call in a goroutine — blocks until cancelled via channel close or process exit.
|
||||
func (m *Mailbox) RunGC() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
for m.db.RunValueLogGC(0.5) == nil {
|
||||
// drain until nothing left to collect
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mailboxKey(recipientPubHex string, sentAt int64, envelopeID string) string {
|
||||
// Zero-padded sentAt keeps lexicographic order == chronological order.
|
||||
// Oldest entry = first key in iterator — used for FIFO eviction.
|
||||
return fmt.Sprintf("%s%s:%020d:%s", mailboxPrefix, recipientPubHex, sentAt, envelopeID)
|
||||
}
|
||||
227
relay/router.go
Normal file
227
relay/router.go
Normal file
@@ -0,0 +1,227 @@
|
||||
package relay
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
pubsub "github.com/libp2p/go-libp2p-pubsub"
|
||||
"github.com/libp2p/go-libp2p/core/host"
|
||||
|
||||
"go-blockchain/blockchain"
|
||||
"go-blockchain/identity"
|
||||
)
|
||||
|
||||
const (
|
||||
// TopicRelay is the gossipsub topic for encrypted envelope routing.
|
||||
TopicRelay = "dchain/relay/v1"
|
||||
)
|
||||
|
||||
// DeliverFunc is called when a message addressed to this node is decrypted.
|
||||
type DeliverFunc func(envelopeID, senderEd25519PubKey string, msg []byte)
|
||||
|
||||
// SubmitTxFunc submits a signed transaction to the local node's mempool.
|
||||
type SubmitTxFunc func(*blockchain.Transaction) error
|
||||
|
||||
// Router manages relay envelope routing for a single node.
|
||||
// It subscribes to TopicRelay, decrypts messages addressed to this node,
|
||||
// and submits RELAY_PROOF transactions to claim delivery fees from senders.
|
||||
//
|
||||
// All received envelopes (regardless of recipient) are stored in the Mailbox
|
||||
// so offline recipients can pull them later via GET /relay/inbox.
|
||||
type Router struct {
|
||||
h host.Host
|
||||
topic *pubsub.Topic
|
||||
sub *pubsub.Subscription
|
||||
kp *KeyPair // X25519 keypair for envelope encryption
|
||||
id *identity.Identity // Ed25519 identity for signing RELAY_PROOF txs
|
||||
mailbox *Mailbox // nil disables mailbox storage
|
||||
onDeliver DeliverFunc
|
||||
submitTx SubmitTxFunc
|
||||
}
|
||||
|
||||
// NewRouter creates and starts a relay Router.
|
||||
// mailbox may be nil to disable offline message storage.
|
||||
func NewRouter(
|
||||
h host.Host,
|
||||
ps *pubsub.PubSub,
|
||||
kp *KeyPair,
|
||||
id *identity.Identity,
|
||||
mailbox *Mailbox,
|
||||
onDeliver DeliverFunc,
|
||||
submitTx SubmitTxFunc,
|
||||
) (*Router, error) {
|
||||
topic, err := ps.Join(TopicRelay)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("join relay topic: %w", err)
|
||||
}
|
||||
sub, err := topic.Subscribe()
|
||||
if err != nil {
|
||||
topic.Close()
|
||||
return nil, fmt.Errorf("subscribe relay topic: %w", err)
|
||||
}
|
||||
return &Router{
|
||||
h: h,
|
||||
topic: topic,
|
||||
sub: sub,
|
||||
kp: kp,
|
||||
id: id,
|
||||
mailbox: mailbox,
|
||||
onDeliver: onDeliver,
|
||||
submitTx: submitTx,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Send seals msg for recipientPub and broadcasts it on the relay topic.
|
||||
// recipientPubHex is the hex X25519 public key of the recipient.
|
||||
// feeUT is the delivery fee offered to the relay (0 = free delivery).
|
||||
// Returns the envelope ID on success.
|
||||
func (r *Router) Send(recipientPubHex string, msg []byte, feeUT uint64) (string, error) {
|
||||
recipBytes, err := hex.DecodeString(recipientPubHex)
|
||||
if err != nil || len(recipBytes) != 32 {
|
||||
return "", fmt.Errorf("invalid recipient pub key: %s", recipientPubHex)
|
||||
}
|
||||
var recipPub [32]byte
|
||||
copy(recipPub[:], recipBytes)
|
||||
|
||||
env, err := Seal(r.kp, r.id, recipPub, msg, feeUT, time.Now().Unix())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("seal envelope: %w", err)
|
||||
}
|
||||
data, err := json.Marshal(env)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := r.topic.Publish(context.Background(), data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return env.ID, nil
|
||||
}
|
||||
|
||||
// Run processes incoming relay envelopes until ctx is cancelled.
|
||||
// Envelopes addressed to this node are decrypted and acknowledged; others
|
||||
// are ignored (gossipsub handles propagation automatically).
|
||||
func (r *Router) Run(ctx context.Context) {
|
||||
for {
|
||||
m, err := r.sub.Next(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if m.ReceivedFrom == r.h.ID() {
|
||||
continue
|
||||
}
|
||||
var env Envelope
|
||||
if err := json.Unmarshal(m.Data, &env); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Store every valid envelope in the mailbox for offline delivery.
|
||||
// Messages are encrypted — the relay cannot read them.
|
||||
// ErrEnvelopeTooLarge is silently dropped (anti-spam); other errors are logged.
|
||||
if r.mailbox != nil {
|
||||
if err := r.mailbox.Store(&env); err != nil {
|
||||
if err == ErrEnvelopeTooLarge {
|
||||
log.Printf("[relay] dropped oversized envelope %s (%d bytes ciphertext)",
|
||||
env.ID, len(env.Ciphertext))
|
||||
} else {
|
||||
log.Printf("[relay] mailbox store error for %s: %v", env.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !env.IsAddressedTo(r.kp) {
|
||||
continue
|
||||
}
|
||||
msg, err := Open(r.kp, &env)
|
||||
if err != nil {
|
||||
log.Printf("[relay] decryption error for envelope %s: %v", env.ID, err)
|
||||
continue
|
||||
}
|
||||
if r.onDeliver != nil {
|
||||
r.onDeliver(env.ID, env.SenderEd25519PubKey, msg)
|
||||
}
|
||||
if r.submitTx != nil && env.FeeUT > 0 && env.SenderEd25519PubKey != "" {
|
||||
if err := r.submitRelayProof(&env); err != nil {
|
||||
log.Printf("[relay] relay proof submission failed for %s: %v", env.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast stores env in the mailbox and publishes it on the relay gossipsub topic.
|
||||
// Used by the HTTP API so light clients can send pre-sealed envelopes without
|
||||
// needing a direct libp2p connection.
|
||||
func (r *Router) Broadcast(env *Envelope) error {
|
||||
if r.mailbox != nil {
|
||||
if err := r.mailbox.Store(env); err != nil && err != ErrEnvelopeTooLarge {
|
||||
log.Printf("[relay] broadcast mailbox store error %s: %v", env.ID, err)
|
||||
}
|
||||
}
|
||||
data, err := json.Marshal(env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return r.topic.Publish(context.Background(), data)
|
||||
}
|
||||
|
||||
// RelayPubHex returns the hex X25519 public key for this node's relay keypair.
|
||||
func (r *Router) RelayPubHex() string {
|
||||
return r.kp.PubHex()
|
||||
}
|
||||
|
||||
// submitRelayProof builds and submits a RELAY_PROOF tx to claim the delivery fee.
|
||||
func (r *Router) submitRelayProof(env *Envelope) error {
|
||||
envHash := Hash(env)
|
||||
relayPubKey := r.id.PubKeyHex()
|
||||
|
||||
// Recipient signs envelope hash — proves the message was actually decrypted.
|
||||
recipientSig := r.id.Sign(envHash)
|
||||
|
||||
payload := blockchain.RelayProofPayload{
|
||||
EnvelopeID: env.ID,
|
||||
EnvelopeHash: envHash,
|
||||
SenderPubKey: env.SenderEd25519PubKey,
|
||||
FeeUT: env.FeeUT,
|
||||
FeeSig: env.FeeSig,
|
||||
RelayPubKey: relayPubKey,
|
||||
DeliveredAt: time.Now().Unix(),
|
||||
RecipientSig: recipientSig,
|
||||
}
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
idBytes := sha256.Sum256(append([]byte(relayPubKey), []byte(env.ID)...))
|
||||
now := time.Now().UTC()
|
||||
tx := &blockchain.Transaction{
|
||||
ID: hex.EncodeToString(idBytes[:16]),
|
||||
Type: blockchain.EventRelayProof,
|
||||
From: relayPubKey,
|
||||
Fee: blockchain.MinFee,
|
||||
Payload: payloadBytes,
|
||||
Timestamp: now,
|
||||
}
|
||||
tx.Signature = r.id.Sign(txSignBytes(tx))
|
||||
return r.submitTx(tx)
|
||||
}
|
||||
|
||||
// txSignBytes returns the canonical bytes that are signed for a transaction,
|
||||
// matching the format used in identity.txSignBytes.
|
||||
func txSignBytes(tx *blockchain.Transaction) []byte {
|
||||
data, _ := json.Marshal(struct {
|
||||
ID string `json:"id"`
|
||||
Type blockchain.EventType `json:"type"`
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Amount uint64 `json:"amount"`
|
||||
Fee uint64 `json:"fee"`
|
||||
Payload []byte `json:"payload"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}{tx.ID, tx.Type, tx.From, tx.To, tx.Amount, tx.Fee, tx.Payload, tx.Timestamp})
|
||||
return data
|
||||
}
|
||||
Reference in New Issue
Block a user