chore: initial commit for v0.0.1

DChain single-node blockchain + React Native messenger client.

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

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

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

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

139
relay/envelope.go Normal file
View 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
View 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
View 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
View 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
View 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
}