3 Commits

Author SHA1 Message Date
vsecoder
423d307125 feat(client): multi-device fan-out + auto-link (v2.2.0-alpha2)
PR #2 of the multi-device roadmap — wires the messenger pipeline
against the on-chain registry landed in v2.2.0-alpha1.

lib/api.ts:
  - DeviceInfo type mirroring blockchain.DeviceInfo.
  - IdentityInfo.device_count (optional; populated from /api/identity).
  - fetchDevices(masterPub) → /api/devices/{master_pub}, returns [].
    Errors swallowed so a downed endpoint doesn't block messaging.
  - resolveRecipientKeys(masterPub) — the routing primitive. Returns
    devices[] if registered, else falls back to IdentityInfo.x25519_pub
    (pre-v2.2.0 path). Empty only when recipient has published nothing.
  - buildLinkDeviceTx / buildUnlinkDeviceTx — signed by master Ed25519,
    min-fee cost, canonical JSON payload matching the chain-side
    LinkDevicePayload / UnlinkDevicePayload.

app/(app)/chats/[id].tsx:
  - sendCore now fans out: encrypts once per recipient device pub
    (Promise.all, any failure rejects the batch), falls back to the
    cached contact.x25519Pub if the registry lookup returns nothing.
  - Saved Messages short-circuit preserved; no devices lookup for self.

app/(app)/_layout.tsx:
  - On every sign-in, auto-submit LINK_DEVICE for this device if its
    X25519 pub isn't already in the master's registry. Device name
    picks "iPhone" / "Android phone" / "Device" by Platform. Errors
    (insufficient balance / legacy chain without LINK_DEVICE support)
    are silent — next launch retries.

Backward compatibility: senders fall back to identity.x25519_pub when
the recipient has no registry entries, so pre-v2.2.0 clients still
receive messages. Chain-side already gates new validation on the event
types existing; old clients simply never emit LINK_DEVICE and keep
working with a single X25519.

Next — PR #3 (Settings → Devices screen + QR pairing flow + receive-side
self-wipe on revoke detection).
2026-04-22 16:24:36 +03:00
vsecoder
1d9206494a feat(chain): multi-device registry (v2.2.0-alpha1)
PR #1 of the multi-device roadmap. Adds per-device X25519 keys registered
on-chain so senders can fan out envelopes across all of a recipient's
physical devices — fixes the single-device limitation where a second
phone / desktop loses messages as soon as the first one reads them.

Chain (blockchain/):
  - New event types LINK_DEVICE / UNLINK_DEVICE, signed by the identity's
    master Ed25519.
  - LinkDevicePayload {x25519_pub_key, device_name} +
    UnlinkDevicePayload {x25519_pub_key} on the wire.
  - State: prefixDevice + x25519_pub → DeviceRecord{owner, name,
    added_at, revoked_at?}; reverse index prefixDevicesByOwner for
    O(k) listing. Revoke is a soft-delete — the row stays as a visible
    tombstone so offline clients can detect their own revocation and
    wipe local state.
  - MaxDevicesPerOwner = 10 slot cap; MaxDeviceNameLen = 64.
  - Strict lowercase-hex validation on x25519_pub so clients can't
    desync on letter case.
  - Same-owner re-link is a rename/refresh (recreates reverse index too
    — needed after a revoke).
  - Chain.DevicesOf(master_pub) returns the active records; empty slice
    for legacy identities so senders can fall back to IdentityInfo.X25519Pub.

HTTP (node/):
  - GET /api/devices/{master_pub_or_addr} — returns {master_pub, count,
    devices[]}. Revoked records filtered out.
  - /api/identity/{pub} gains `device_count` so senders can decide
    upfront whether to fan out or take the legacy path.

Tests (blockchain/devices_test.go):
  - Happy paths (1, 3 devices), foreign-owner rejection, same-owner
    refresh after revoke, unlink removes from active set,
    foreign-signer unlink rejection, idempotent double-unlink,
    malformed pub/name rejection, MaxDevices cap + recovery after
    unlink frees a slot, empty list for unknown master.

Also in this commit:
  - deploy/single/join.sh — convenience script operators have been
    iterating on in this session (joiner-node bring-up + firewall
    port patching + Caddy opt-out).
  - client-app/app.json — `usesCleartextTraffic: true` on Android so
    installed APKs can talk to http:// dev nodes without TLS.

See docs/ROADMAP.md for PRs #2..#4 (client fan-out, pairing flow,
desktop Electron shell).
2026-04-22 16:20:07 +03:00
vsecoder
217b374789 docs: add v2.2.0 multi-device + desktop Electron roadmap
Living doc that captures:
  * Multi-device plan — per-device X25519 keys + on-chain device registry
    (LINK_DEVICE / UNLINK_DEVICE tx types, sender fan-out, revoke + wipe).
    Broken into four PRs (#1 chain, #2 client fan-out, #3 pairing flow,
    #4 desktop shell) tagged v2.2.0-alphaN.
  * Desktop Electron client — 3-panel layout, section map, reused lib/,
    keybinds, packaging targets. Queued after v2.2.0-alpha3.
  * Known smaller tails — gossip dup log noise, seed self-announce
    fallback, group chats (post-v2.2.0), reputation-weighted leader.
2026-04-22 16:05:06 +03:00
12 changed files with 1388 additions and 13 deletions

View File

@@ -54,6 +54,9 @@ const (
prefixRelay = "relay:" // relay:<node_pubkey> → RegisterRelayPayload JSON prefixRelay = "relay:" // relay:<node_pubkey> → RegisterRelayPayload JSON
prefixRelayHB = "relayhb:" // relayhb:<node_pubkey> → unix seconds (int64) of last HB prefixRelayHB = "relayhb:" // relayhb:<node_pubkey> → unix seconds (int64) of last HB
prefixRelayProof = "relayproof:" // relayproof:<envelopeID> → claimant node_pubkey (1 claim per envelope) prefixRelayProof = "relayproof:" // relayproof:<envelopeID> → claimant node_pubkey (1 claim per envelope)
// Multi-device registry (v2.2.0)
prefixDevice = "device:" // device:<x25519_pub> → DeviceRecord JSON
prefixDevicesByOwner = "devicesbyowner:" // devicesbyowner:<master_pub>:<x25519_pub> → "" (reverse index for O(k) listing)
prefixContactIn = "contact_in:" // contact_in:<targetPub>:<requesterPub> → contactRecord JSON prefixContactIn = "contact_in:" // contact_in:<targetPub>:<requesterPub> → contactRecord JSON
prefixValidator = "validator:" // validator:<pubkey> → "" (presence = active) prefixValidator = "validator:" // validator:<pubkey> → "" (presence = active)
prefixContract = "contract:" // contract:<contractID> → ContractRecord JSON prefixContract = "contract:" // contract:<contractID> → ContractRecord JSON
@@ -771,6 +774,99 @@ func PayChanCloseSigPayload(channelID string, balanceA, balanceB, nonce uint64)
return payChanCloseSigPayload(channelID, balanceA, balanceB, nonce) return payChanCloseSigPayload(channelID, balanceA, balanceB, nonce)
} }
// DevicesOf returns the active (non-revoked) device records for a master
// identity. Sender fan-out reads this to decide how many envelopes to
// produce per outgoing message. Empty slice (not error) for identities
// with no registry entries yet — caller should fall back to the legacy
// single-X25519 path via IdentityInfo.
func (c *Chain) DevicesOf(masterPub string) ([]DeviceRecord, error) {
var out []DeviceRecord
err := c.db.View(func(txn *badger.Txn) error {
prefix := []byte(prefixDevicesByOwner + masterPub + ":")
it := txn.NewIterator(badger.IteratorOptions{Prefix: prefix, PrefetchValues: false})
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
key := it.Item().KeyCopy(nil)
// key layout: devicesbyowner:<master>:<x25519> — take the suffix
x25519Pub := string(key[len(prefix):])
recItem, err := txn.Get([]byte(prefixDevice + x25519Pub))
if err != nil {
// Reverse index points to a missing record — stale entry,
// skip silently (shouldn't happen unless the DB was tampered
// with, but we don't want to fail the whole call).
continue
}
var rec DeviceRecord
if verr := recItem.Value(func(v []byte) error {
return json.Unmarshal(v, &rec)
}); verr != nil {
continue
}
if rec.RevokedAt != 0 {
continue
}
out = append(out, rec)
}
return nil
})
return out, err
}
// countActiveDevicesForOwner walks the reverse index (entries there are,
// by construction, non-revoked — UNLINK_DEVICE deletes the index row).
// O(k) where k = active device count, bounded by MaxDevicesPerOwner.
func (c *Chain) countActiveDevicesForOwner(txn *badger.Txn, masterPub string) (int, error) {
prefix := []byte(prefixDevicesByOwner + masterPub + ":")
it := txn.NewIterator(badger.IteratorOptions{Prefix: prefix, PrefetchValues: false})
defer it.Close()
n := 0
for it.Rewind(); it.Valid(); it.Next() {
n++
}
return n, nil
}
// validateDevicePubKey checks the wire-level format of a device X25519
// public key: 64 hex characters (32 bytes). Does NOT check that the key
// is a valid curve point — senders learn that at encrypt time, and an
// invalid point there just yields garbage ciphertext which the device
// can't decrypt (self-punishing).
func validateDevicePubKey(hexPub string) error {
if len(hexPub) != 64 {
return fmt.Errorf("x25519_pub_key must be 64 hex chars (got %d)", len(hexPub))
}
// Enforce strict lowercase-hex to keep keys canonical across clients —
// two clients hashing the same bytes with different letter cases would
// produce different registry lookups, which breaks sender fan-out.
for _, r := range hexPub {
switch {
case r >= '0' && r <= '9':
case r >= 'a' && r <= 'f':
default:
return fmt.Errorf("x25519_pub_key must be lowercase hex")
}
}
return nil
}
// validateDeviceName enforces the UX constraint that device labels stay
// short and printable — this field gets displayed in Settings → Devices
// and has no business carrying newlines, control chars, or 2KB blobs.
func validateDeviceName(name string) error {
if name == "" {
return fmt.Errorf("device_name is required")
}
if len(name) > MaxDeviceNameLen {
return fmt.Errorf("device_name %d bytes > max %d", len(name), MaxDeviceNameLen)
}
for _, r := range name {
if r < 0x20 || r == 0x7f {
return fmt.Errorf("device_name contains a control character")
}
}
return nil
}
// Reputation returns the reputation stats for a public key. // Reputation returns the reputation stats for a public key.
func (c *Chain) Reputation(pubKeyHex string) (RepStats, error) { func (c *Chain) Reputation(pubKeyHex string) (RepStats, error) {
var r RepStats var r RepStats
@@ -1261,6 +1357,127 @@ func (c *Chain) applyTx(txn *badger.Txn, tx *Transaction) (uint64, error) {
return 0, err return 0, err
} }
case EventLinkDevice:
// Master Ed25519 (= tx.From) publishes a per-device X25519 pub.
// Validation:
// 1. Payload well-formed, X25519 pub is hex(32 bytes).
// 2. Device name is short + printable.
// 3. X25519 pub isn't already registered to a DIFFERENT owner.
// 4. Same owner re-linking the same pub is a no-op refresh
// (updates device_name / added_at — useful for rename).
// 5. Owner has < MaxDevicesPerOwner active (non-revoked) devices.
//
// Fee: standard tx.Fee is debited from master's balance — cheap
// anti-spam. Block validation already enforced `tx.Fee >= MinFee`.
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
return 0, fmt.Errorf("LINK_DEVICE debit: %w", err)
}
var p LinkDevicePayload
if err := json.Unmarshal(tx.Payload, &p); err != nil {
return 0, fmt.Errorf("%w: LINK_DEVICE bad payload: %v", ErrTxFailed, err)
}
if err := validateDevicePubKey(p.X25519PubKey); err != nil {
return 0, fmt.Errorf("%w: LINK_DEVICE: %v", ErrTxFailed, err)
}
if err := validateDeviceName(p.DeviceName); err != nil {
return 0, fmt.Errorf("%w: LINK_DEVICE: %v", ErrTxFailed, err)
}
devKey := []byte(prefixDevice + p.X25519PubKey)
if item, err := txn.Get(devKey); err == nil {
var existing DeviceRecord
if verr := item.Value(func(v []byte) error {
return json.Unmarshal(v, &existing)
}); verr == nil {
if existing.Owner != tx.From {
return 0, fmt.Errorf("%w: LINK_DEVICE: x25519 pub already linked to a different owner",
ErrTxFailed)
}
// Same owner — rename/refresh path. Keep AddedAt.
existing.DeviceName = p.DeviceName
existing.RevokedAt = 0 // re-link cancels a previous revoke
refreshed, _ := json.Marshal(existing)
if err := txn.Set(devKey, refreshed); err != nil {
return 0, err
}
// Restore reverse index too — UNLINK_DEVICE deletes it, so
// a re-link after revoke must recreate it or DevicesOf
// will skip the record.
idxKey := []byte(prefixDevicesByOwner + tx.From + ":" + p.X25519PubKey)
if err := txn.Set(idxKey, []byte{}); err != nil {
return 0, err
}
break
}
}
// Count owner's active devices.
active, err := c.countActiveDevicesForOwner(txn, tx.From)
if err != nil {
return 0, fmt.Errorf("LINK_DEVICE count devices: %w", err)
}
if active >= MaxDevicesPerOwner {
return 0, fmt.Errorf("%w: LINK_DEVICE: owner already has %d devices (max %d)",
ErrTxFailed, active, MaxDevicesPerOwner)
}
rec := DeviceRecord{
Owner: tx.From,
X25519PubKey: p.X25519PubKey,
DeviceName: p.DeviceName,
AddedAt: tx.Timestamp.Unix(),
}
val, _ := json.Marshal(rec)
if err := txn.Set(devKey, val); err != nil {
return 0, err
}
idxKey := []byte(prefixDevicesByOwner + tx.From + ":" + p.X25519PubKey)
if err := txn.Set(idxKey, []byte{}); err != nil {
return 0, err
}
case EventUnlinkDevice:
// Revoke (soft-delete) a device record. Mark RevokedAt and keep
// the row — clients wake up, notice their own pub is revoked, and
// wipe local state. A permanent delete would let a sender that
// missed the revoke keep encrypting for the old pub silently.
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
return 0, fmt.Errorf("UNLINK_DEVICE debit: %w", err)
}
var p UnlinkDevicePayload
if err := json.Unmarshal(tx.Payload, &p); err != nil {
return 0, fmt.Errorf("%w: UNLINK_DEVICE bad payload: %v", ErrTxFailed, err)
}
if err := validateDevicePubKey(p.X25519PubKey); err != nil {
return 0, fmt.Errorf("%w: UNLINK_DEVICE: %v", ErrTxFailed, err)
}
devKey := []byte(prefixDevice + p.X25519PubKey)
item, err := txn.Get(devKey)
if err != nil {
return 0, fmt.Errorf("%w: UNLINK_DEVICE: device not found", ErrTxFailed)
}
var existing DeviceRecord
if verr := item.Value(func(v []byte) error {
return json.Unmarshal(v, &existing)
}); verr != nil {
return 0, fmt.Errorf("UNLINK_DEVICE decode: %w", verr)
}
if existing.Owner != tx.From {
return 0, fmt.Errorf("%w: UNLINK_DEVICE: signer is not the owner of this device",
ErrTxFailed)
}
if existing.RevokedAt != 0 {
// Already revoked — idempotent success, don't error.
break
}
existing.RevokedAt = tx.Timestamp.Unix()
updated, _ := json.Marshal(existing)
if err := txn.Set(devKey, updated); err != nil {
return 0, err
}
// Drop reverse index so countActiveDevicesForOwner is O(k_active).
idxKey := []byte(prefixDevicesByOwner + tx.From + ":" + p.X25519PubKey)
if err := txn.Delete(idxKey); err != nil {
return 0, err
}
case EventContactRequest: case EventContactRequest:
var p ContactRequestPayload var p ContactRequestPayload
if err := json.Unmarshal(tx.Payload, &p); err != nil { if err := json.Unmarshal(tx.Payload, &p); err != nil {

413
blockchain/devices_test.go Normal file
View File

@@ -0,0 +1,413 @@
package blockchain_test
// Multi-device registry tests (v2.2.0). Cover happy-path link + list,
// unlink idempotency, ownership validation, max-devices guard, and
// backward-compat sender fall-back semantics (empty list for unknown
// master). HTTP-layer tests live in the node package; these stay at
// chain level — applyTx + state-shape only.
import (
"crypto/rand"
"encoding/hex"
"strings"
"sync/atomic"
"testing"
"time"
"go-blockchain/blockchain"
"go-blockchain/identity"
)
// uniqueTxID sidesteps the `chain_test.go:txID` race where two helper
// calls in the same nanosecond produce identical tx IDs → chain.AddBlock
// dedupes the second as "already committed". We add a strictly-monotonic
// counter so every tx built in the same test gets a fresh ID even on
// fast machines.
var devicesTxIDCounter atomic.Uint64
func uniqueTxID() string {
n := devicesTxIDCounter.Add(1)
h := make([]byte, 16)
_, _ = rand.Read(h)
return hex.EncodeToString(h) + ":" + time.Now().Format("150405.000000000") + ":" + itoa(n)
}
func itoa(n uint64) string {
// tiny local conversion to avoid importing strconv just for tests.
if n == 0 {
return "0"
}
var buf [20]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
return string(buf[i:])
}
// linkDeviceRawTx / unlinkDeviceRawTx build txs with guaranteed-unique
// IDs so we can stuff many of them into a single block during tests.
func linkDeviceRawTx(master *identity.Identity, x25519Pub, name string) *blockchain.Transaction {
payload := mustJSON(blockchain.LinkDevicePayload{
X25519PubKey: x25519Pub,
DeviceName: name,
})
return &blockchain.Transaction{
ID: uniqueTxID(),
Type: blockchain.EventLinkDevice,
From: master.PubKeyHex(),
Fee: blockchain.MinFee,
Payload: payload,
Timestamp: time.Now().UTC(),
}
}
func unlinkDeviceRawTx(master *identity.Identity, x25519Pub string) *blockchain.Transaction {
payload := mustJSON(blockchain.UnlinkDevicePayload{X25519PubKey: x25519Pub})
return &blockchain.Transaction{
ID: uniqueTxID(),
Type: blockchain.EventUnlinkDevice,
From: master.PubKeyHex(),
Fee: blockchain.MinFee,
Payload: payload,
Timestamp: time.Now().UTC(),
}
}
// randX25519Pub returns a throw-away 32-byte hex string suitable for the
// x25519_pub_key field. We don't need the matching private key — the
// chain only stores the pub and doesn't verify it's a valid curve point.
func randX25519Pub(t *testing.T) string {
t.Helper()
var b [32]byte
if _, err := rand.Read(b[:]); err != nil {
t.Fatalf("rand: %v", err)
}
return hex.EncodeToString(b[:])
}
// fundIdentity credits enough balance to cover `nFees` worth of MinFee
// payments. Every test needs this because LINK_DEVICE and UNLINK_DEVICE
// debit tx.Fee from the master's balance — without funding the first tx
// errors with "insufficient funds" and no state changes.
func fundIdentity(t *testing.T, c *blockchain.Chain, val, recipient *identity.Identity, nFees int) {
t.Helper()
amount := uint64(nFees+2) * blockchain.MinFee
tx := makeTx(blockchain.EventTransfer, val.PubKeyHex(), recipient.PubKeyHex(),
amount, blockchain.MinFee, mustJSON(blockchain.TransferPayload{}))
mustAddBlock(t, c, buildBlock(t, c.Tip(), val, []*blockchain.Transaction{tx}))
}
// TestLinkDeviceHappyPath: after a single LINK_DEVICE, DevicesOf returns
// exactly one record with the right owner/name/pub.
func TestLinkDeviceHappyPath(t *testing.T) {
c := newChain(t)
val := newIdentity(t)
addGenesis(t, c, val)
alice := newIdentity(t)
fundIdentity(t, c, val, alice, 1)
dev1 := randX25519Pub(t)
tx := linkDeviceRawTx(alice, dev1, "Alice's iPhone")
b := buildBlock(t, c.Tip(), val, []*blockchain.Transaction{tx})
mustAddBlock(t, c, b)
devs, err := c.DevicesOf(alice.PubKeyHex())
if err != nil {
t.Fatalf("DevicesOf: %v", err)
}
if len(devs) != 1 {
t.Fatalf("expected 1 device, got %d", len(devs))
}
got := devs[0]
if got.Owner != alice.PubKeyHex() {
t.Errorf("owner: got %s, want %s", got.Owner, alice.PubKeyHex())
}
if got.X25519PubKey != dev1 {
t.Errorf("x25519: got %s, want %s", got.X25519PubKey, dev1)
}
if got.DeviceName != "Alice's iPhone" {
t.Errorf("name: got %q, want %q", got.DeviceName, "Alice's iPhone")
}
if got.RevokedAt != 0 {
t.Errorf("freshly linked device should have RevokedAt=0, got %d", got.RevokedAt)
}
}
// TestLinkDeviceMultiple: multiple devices for the same owner all show up
// in DevicesOf, and the count matches.
func TestLinkDeviceMultiple(t *testing.T) {
c := newChain(t)
val := newIdentity(t)
addGenesis(t, c, val)
alice := newIdentity(t)
fundIdentity(t, c, val, alice, 3)
pubs := []string{randX25519Pub(t), randX25519Pub(t), randX25519Pub(t)}
var txs []*blockchain.Transaction
for i, p := range pubs {
txs = append(txs, linkDeviceRawTx(alice, p, "device-"+string(rune('A'+i))))
}
mustAddBlock(t, c, buildBlock(t, c.Tip(), val, txs))
devs, err := c.DevicesOf(alice.PubKeyHex())
if err != nil {
t.Fatalf("DevicesOf: %v", err)
}
if len(devs) != 3 {
t.Fatalf("expected 3 devices, got %d", len(devs))
}
}
// TestLinkDeviceRejectsForeignOwner: if Alice already linked a X25519 pub
// and Bob tries to link the SAME pub to himself, the tx is rejected.
// This protects against a malicious actor hijacking someone else's
// device-pub and trying to claim it.
func TestLinkDeviceRejectsForeignOwner(t *testing.T) {
c := newChain(t)
val := newIdentity(t)
addGenesis(t, c, val)
alice := newIdentity(t)
bob := newIdentity(t)
fundIdentity(t, c, val, alice, 1)
fundIdentity(t, c, val, bob, 1)
dev := randX25519Pub(t)
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
[]*blockchain.Transaction{linkDeviceRawTx(alice, dev, "alice phone")}))
// Bob tries to claim the same pub. The chain silently drops invalid
// txs (they're non-fatal for block commit), so we verify the *state*:
// Alice still owns the pub, Bob has zero devices.
bobTx := linkDeviceRawTx(bob, dev, "bob phone")
mustAddBlock(t, c, buildBlock(t, c.Tip(), val, []*blockchain.Transaction{bobTx}))
aliceDevs, _ := c.DevicesOf(alice.PubKeyHex())
if len(aliceDevs) != 1 || aliceDevs[0].DeviceName != "alice phone" {
t.Fatalf("Alice should still own the device, got %+v", aliceDevs)
}
bobDevs, _ := c.DevicesOf(bob.PubKeyHex())
if len(bobDevs) != 0 {
t.Fatalf("Bob's hijack should have been rejected, got %+v", bobDevs)
}
}
// TestLinkDeviceSameOwnerRefresh: the same owner re-linking the same
// x25519_pub is a rename/refresh (not an error), and the revoked flag
// gets cleared if it was set.
func TestLinkDeviceSameOwnerRefresh(t *testing.T) {
c := newChain(t)
val := newIdentity(t)
addGenesis(t, c, val)
alice := newIdentity(t)
fundIdentity(t, c, val, alice, 3)
dev := randX25519Pub(t)
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
[]*blockchain.Transaction{linkDeviceRawTx(alice, dev, "old name")}))
// Unlink then re-link — should restore the record.
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
[]*blockchain.Transaction{unlinkDeviceRawTx(alice, dev)}))
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
[]*blockchain.Transaction{linkDeviceRawTx(alice, dev, "new name")}))
devs, err := c.DevicesOf(alice.PubKeyHex())
if err != nil {
t.Fatalf("DevicesOf: %v", err)
}
if len(devs) != 1 {
t.Fatalf("expected 1 active device after re-link, got %d", len(devs))
}
if devs[0].DeviceName != "new name" {
t.Errorf("expected refreshed name 'new name', got %q", devs[0].DeviceName)
}
}
// TestUnlinkDeviceRemovesFromActive: after UNLINK_DEVICE, DevicesOf no
// longer returns that record — senders stop fanning out to it.
func TestUnlinkDeviceRemovesFromActive(t *testing.T) {
c := newChain(t)
val := newIdentity(t)
addGenesis(t, c, val)
alice := newIdentity(t)
fundIdentity(t, c, val, alice, 3)
keep := randX25519Pub(t)
revoke := randX25519Pub(t)
mustAddBlock(t, c, buildBlock(t, c.Tip(), val, []*blockchain.Transaction{
linkDeviceRawTx(alice, keep, "kept"),
linkDeviceRawTx(alice, revoke, "revoked"),
}))
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
[]*blockchain.Transaction{unlinkDeviceRawTx(alice, revoke)}))
devs, err := c.DevicesOf(alice.PubKeyHex())
if err != nil {
t.Fatalf("DevicesOf: %v", err)
}
if len(devs) != 1 || devs[0].X25519PubKey != keep {
t.Fatalf("expected only the kept device, got %+v", devs)
}
}
// TestUnlinkDeviceRejectsForeignSigner: Bob can't unlink Alice's device
// even if he knows the X25519 pub — only the owner's signature (= tx.From)
// authorises revoke.
func TestUnlinkDeviceRejectsForeignSigner(t *testing.T) {
c := newChain(t)
val := newIdentity(t)
addGenesis(t, c, val)
alice := newIdentity(t)
bob := newIdentity(t)
fundIdentity(t, c, val, alice, 1)
fundIdentity(t, c, val, bob, 1)
dev := randX25519Pub(t)
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
[]*blockchain.Transaction{linkDeviceRawTx(alice, dev, "alice phone")}))
// Bob tries to unlink Alice's device — tx is silently dropped, Alice
// still has it in her active list.
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
[]*blockchain.Transaction{unlinkDeviceRawTx(bob, dev)}))
aliceDevs, _ := c.DevicesOf(alice.PubKeyHex())
if len(aliceDevs) != 1 || aliceDevs[0].X25519PubKey != dev {
t.Fatalf("Alice's device should still be active, got %+v", aliceDevs)
}
}
// TestUnlinkDeviceIdempotent: unlinking a device that's already revoked
// does NOT error — this is needed for recovery scenarios where a client
// retries the tx after a spotty connection.
func TestUnlinkDeviceIdempotent(t *testing.T) {
c := newChain(t)
val := newIdentity(t)
addGenesis(t, c, val)
alice := newIdentity(t)
fundIdentity(t, c, val, alice, 3)
dev := randX25519Pub(t)
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
[]*blockchain.Transaction{linkDeviceRawTx(alice, dev, "phone")}))
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
[]*blockchain.Transaction{unlinkDeviceRawTx(alice, dev)}))
// Second unlink of the same device must apply cleanly.
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
[]*blockchain.Transaction{unlinkDeviceRawTx(alice, dev)}))
}
// TestLinkDeviceRejectsMalformedPub: payload.X25519PubKey must be 64-char
// lowercase hex. Garbage / short / uppercase values are rejected at
// applyTx time before any state change.
func TestLinkDeviceRejectsMalformedPub(t *testing.T) {
c := newChain(t)
val := newIdentity(t)
addGenesis(t, c, val)
alice := newIdentity(t)
fundIdentity(t, c, val, alice, 5)
bad := []string{
"", // empty
"abc", // too short
strings.Repeat("x", 64), // non-hex
strings.Repeat("A", 64), // uppercase
}
for _, p := range bad {
tx := linkDeviceRawTx(alice, p, "dev")
mustAddBlock(t, c, buildBlock(t, c.Tip(), val, []*blockchain.Transaction{tx}))
}
devs, _ := c.DevicesOf(alice.PubKeyHex())
if len(devs) != 0 {
t.Fatalf("expected 0 devices after %d malformed link attempts, got %d",
len(bad), len(devs))
}
}
// TestLinkDeviceRejectsBadDeviceName: empty, too long, or control-char
// names are refused — this field is rendered verbatim in clients.
func TestLinkDeviceRejectsBadDeviceName(t *testing.T) {
c := newChain(t)
val := newIdentity(t)
addGenesis(t, c, val)
alice := newIdentity(t)
fundIdentity(t, c, val, alice, 5)
cases := map[string]string{
"empty": "",
"too long": strings.Repeat("a", blockchain.MaxDeviceNameLen+1),
"control char": "device\x01name",
}
for name, devName := range cases {
t.Run(name, func(t *testing.T) {
before, _ := c.DevicesOf(alice.PubKeyHex())
tx := linkDeviceRawTx(alice, randX25519Pub(t), devName)
mustAddBlock(t, c, buildBlock(t, c.Tip(), val, []*blockchain.Transaction{tx}))
after, _ := c.DevicesOf(alice.PubKeyHex())
if len(after) != len(before) {
t.Fatalf("bad %s name should not have been linked (before=%d, after=%d)",
name, len(before), len(after))
}
})
}
}
// TestLinkDeviceMaxDevices: after MaxDevicesPerOwner active devices, any
// further LINK_DEVICE is rejected. Revoking one frees a slot.
func TestLinkDeviceMaxDevices(t *testing.T) {
c := newChain(t)
val := newIdentity(t)
addGenesis(t, c, val)
alice := newIdentity(t)
// Enough for cap + one overflow attempt + one unlink + one replacement.
fundIdentity(t, c, val, alice, blockchain.MaxDevicesPerOwner+4)
// Fill to the cap — each in its own block so timestamps differ.
pubs := make([]string, blockchain.MaxDevicesPerOwner)
for i := 0; i < blockchain.MaxDevicesPerOwner; i++ {
pubs[i] = randX25519Pub(t)
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
[]*blockchain.Transaction{linkDeviceRawTx(alice, pubs[i], "d")}))
}
// One more — the tx is dropped, cap stays exact.
over := linkDeviceRawTx(alice, randX25519Pub(t), "overflow")
mustAddBlock(t, c, buildBlock(t, c.Tip(), val, []*blockchain.Transaction{over}))
if devs, _ := c.DevicesOf(alice.PubKeyHex()); len(devs) != blockchain.MaxDevicesPerOwner {
t.Fatalf("overflow link should have been rejected, count=%d", len(devs))
}
// Revoke one, then a fresh link succeeds.
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
[]*blockchain.Transaction{unlinkDeviceRawTx(alice, pubs[0])}))
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
[]*blockchain.Transaction{linkDeviceRawTx(alice, randX25519Pub(t), "replacement")}))
if devs, _ := c.DevicesOf(alice.PubKeyHex()); len(devs) != blockchain.MaxDevicesPerOwner {
t.Fatalf("after unlink+relink, should be %d active, got %d",
blockchain.MaxDevicesPerOwner, len(devs))
}
}
// TestDevicesOfEmptyForUnknown: unknown master pubs return an empty list
// (not an error) — this preserves the sender's ability to fall back to
// IdentityInfo.X25519Pub for legacy identities.
func TestDevicesOfEmptyForUnknown(t *testing.T) {
c := newChain(t)
val := newIdentity(t)
addGenesis(t, c, val)
alice := newIdentity(t)
devs, err := c.DevicesOf(alice.PubKeyHex())
if err != nil {
t.Fatalf("DevicesOf: %v", err)
}
if len(devs) != 0 {
t.Fatalf("expected 0 devices for unknown master, got %d", len(devs))
}
}

View File

@@ -46,6 +46,14 @@ const (
EventUnfollow EventType = "UNFOLLOW" // unfollow an author EventUnfollow EventType = "UNFOLLOW" // unfollow an author
EventLikePost EventType = "LIKE_POST" // like a post EventLikePost EventType = "LIKE_POST" // like a post
EventUnlikePost EventType = "UNLIKE_POST" // remove a previous like EventUnlikePost EventType = "UNLIKE_POST" // remove a previous like
// Multi-device support (v2.2.0): each physical device of an identity gets
// its own X25519 keypair for the messenger mailbox. LINK/UNLINK_DEVICE
// publish/revoke those pubs on-chain so senders can fan-out envelopes
// across the recipient's active devices. Signed by the identity's
// master Ed25519 key.
EventLinkDevice EventType = "LINK_DEVICE"
EventUnlinkDevice EventType = "UNLINK_DEVICE"
) )
// Token amounts are stored in micro-tokens (µT). // Token amounts are stored in micro-tokens (µT).
@@ -555,6 +563,70 @@ type IdentityInfo struct {
X25519Pub string `json:"x25519_pub"` // hex Curve25519 key; empty if not published X25519Pub string `json:"x25519_pub"` // hex Curve25519 key; empty if not published
Nickname string `json:"nickname"` Nickname string `json:"nickname"`
Registered bool `json:"registered"` // true if REGISTER_KEY tx was committed Registered bool `json:"registered"` // true if REGISTER_KEY tx was committed
// DeviceCount is the number of currently-linked (non-revoked) devices in
// this identity's multi-device registry. `0` for legacy identities that
// only published a single X25519 via REGISTER_KEY — senders fall back
// to IdentityInfo.X25519Pub in that case.
DeviceCount int `json:"device_count"`
}
// ── Multi-device registry (v2.2.0) ───────────────────────────────────────
// MaxDevicesPerOwner caps how many devices a single identity can have
// linked concurrently. A sender must encrypt + relay one envelope per
// device, so the multiplier bounds the per-message cost. Ten covers
// typical users (phone + tablet + laptop + desktop + work phone + …)
// without letting an abuse case blow up mailbox traffic. Revoked devices
// don't count.
const MaxDevicesPerOwner = 10
// MaxDeviceNameLen caps the length of a human-friendly device label
// ("Alice's iPhone 14", "Work MacBook Pro"). Longer names get rejected
// at validation. Kept short to discourage using this field as a free-form
// comment/profile channel.
const MaxDeviceNameLen = 64
// LinkDevicePayload is embedded in EventLinkDevice transactions. Master
// Ed25519 (= tx.From) asserts that the given X25519 pub belongs to one
// of its physical devices, publishing it so senders can fan out envelopes.
type LinkDevicePayload struct {
// X25519PubKey is the hex-encoded Curve25519 pub the device generated
// locally. Must be unique in the device registry — senders index
// envelopes by this key in the relay mailbox.
X25519PubKey string `json:"x25519_pub_key"`
// DeviceName is a short human label shown in Settings → Devices.
// Purely informational; not used for routing.
DeviceName string `json:"device_name"`
}
// UnlinkDevicePayload is embedded in EventUnlinkDevice transactions. Signed
// by master Ed25519; marks the referenced device as revoked so senders
// stop shipping envelopes to its X25519 pub. The revoked device itself,
// once it sees its pub in the revoked list, is expected to wipe its
// local state (master Ed25519 priv + chat cache).
type UnlinkDevicePayload struct {
X25519PubKey string `json:"x25519_pub_key"`
}
// DeviceRecord is the on-chain persisted state for one device link.
// Stored at key `prefixDevice + x25519_pub`; the reverse index
// `prefixDevicesByOwner + master_pub` keeps a slice of x25519 pubs per
// owner for efficient listing.
type DeviceRecord struct {
Owner string `json:"owner"` // master Ed25519 pub hex
X25519PubKey string `json:"x25519_pub_key"` // device X25519 pub hex
DeviceName string `json:"device_name"`
AddedAt int64 `json:"added_at"` // unix seconds, from tx timestamp
RevokedAt int64 `json:"revoked_at,omitempty"` // 0 = active; >0 = revoked
}
// DeviceInfo is the public view of a DeviceRecord served by
// GET /api/devices/{master_pub}. Revoked records are not included
// in the response (intentionally — sender only needs the active set).
type DeviceInfo struct {
X25519PubKey string `json:"x25519_pub_key"`
DeviceName string `json:"device_name"`
AddedAt int64 `json:"added_at"`
} }
// ConsensusMessage types used by the PBFT engine over the P2P layer. // ConsensusMessage types used by the PBFT engine over the P2P layer.

View File

@@ -12,12 +12,14 @@
"infoPlist": { "infoPlist": {
"NSMicrophoneUsageDescription": "Allow DChain to record voice messages and video.", "NSMicrophoneUsageDescription": "Allow DChain to record voice messages and video.",
"NSCameraUsageDescription": "Allow DChain to record video messages and scan QR codes.", "NSCameraUsageDescription": "Allow DChain to record video messages and scan QR codes.",
"NSPhotoLibraryUsageDescription": "Allow DChain to attach photos and videos from your library." "NSPhotoLibraryUsageDescription": "Allow DChain to attach photos and videos from your library.",
"ITSAppUsesNonExemptEncryption": false
} }
}, },
"android": { "android": {
"package": "com.dchain.messenger", "package": "com.dchain.messenger",
"softwareKeyboardLayoutMode": "pan", "softwareKeyboardLayoutMode": "pan",
"usesCleartextTraffic": true,
"permissions": [ "permissions": [
"android.permission.RECORD_AUDIO", "android.permission.RECORD_AUDIO",
"android.permission.CAMERA", "android.permission.CAMERA",

View File

@@ -13,7 +13,7 @@
* один раз; переходы между tab'ами их не перезапускают. * один раз; переходы между tab'ами их не перезапускают.
*/ */
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { View } from 'react-native'; import { View, Platform } from 'react-native';
import { router, usePathname } from 'expo-router'; import { router, usePathname } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useStore } from '@/lib/store'; import { useStore } from '@/lib/store';
@@ -26,6 +26,9 @@ import { getWSClient } from '@/lib/ws';
import { NavBar } from '@/components/NavBar'; import { NavBar } from '@/components/NavBar';
import { AnimatedSlot } from '@/components/AnimatedSlot'; import { AnimatedSlot } from '@/components/AnimatedSlot';
import { saveContact } from '@/lib/storage'; import { saveContact } from '@/lib/storage';
import {
fetchDevices, buildLinkDeviceTx, submitTx,
} from '@/lib/api';
export default function AppLayout() { export default function AppLayout() {
const keyFile = useStore(s => s.keyFile); const keyFile = useStore(s => s.keyFile);
@@ -73,6 +76,44 @@ export default function AppLayout() {
else ws.setAuthCreds(null); else ws.setAuthCreds(null);
}, [keyFile]); }, [keyFile]);
// Multi-device registry bootstrap (v2.2.0). On every sign-in:
// 1. Fetch our own device list from chain.
// 2. If our local X25519 pub isn't in the active set, submit a
// LINK_DEVICE tx for it — this makes "this device" discoverable
// to senders. No-ops after the first successful submission on
// a given chain + master key pair.
// Failures are swallowed: insufficient balance, offline node, or a
// chain that hasn't upgraded to v2.2.0 all surface the same way
// (our device just isn't registered yet; next sign-in tries again).
useEffect(() => {
if (!keyFile) return;
let cancelled = false;
(async () => {
try {
const devs = await fetchDevices(keyFile.pub_key);
if (cancelled) return;
if (devs.some(d => d.x25519_pub_key === keyFile.x25519_pub)) {
return; // already registered
}
const deviceName = Platform.select({
ios: 'iPhone',
android: 'Android phone',
default: 'Device',
}) ?? 'Device';
const tx = buildLinkDeviceTx({
from: keyFile.pub_key,
x25519Pub: keyFile.x25519_pub,
deviceName,
privKey: keyFile.priv_key,
});
await submitTx(tx);
} catch {
/* best-effort — next launch retries */
}
})();
return () => { cancelled = true; };
}, [keyFile]);
useEffect(() => { useEffect(() => {
if (keyFile === null) { if (keyFile === null) {
const t = setTimeout(() => { const t = setTimeout(() => {

View File

@@ -23,7 +23,7 @@ import * as Clipboard from 'expo-clipboard';
import { useStore } from '@/lib/store'; import { useStore } from '@/lib/store';
import { useMessages } from '@/hooks/useMessages'; import { useMessages } from '@/hooks/useMessages';
import { encryptMessage } from '@/lib/crypto'; import { encryptMessage } from '@/lib/crypto';
import { sendEnvelope } from '@/lib/api'; import { sendEnvelope, resolveRecipientKeys } from '@/lib/api';
import { getWSClient } from '@/lib/ws'; import { getWSClient } from '@/lib/ws';
import { appendMessage, loadMessages } from '@/lib/storage'; import { appendMessage, loadMessages } from '@/lib/storage';
import { randomId, safeBack } from '@/lib/utils'; import { randomId, safeBack } from '@/lib/utils';
@@ -212,15 +212,34 @@ export default function ChatScreen() {
// leaves the device, so no encryption/fee/network round-trip is needed. // leaves the device, so no encryption/fee/network round-trip is needed.
// Regular chats still go through the NaCl + relay pipeline below. // Regular chats still go through the NaCl + relay pipeline below.
if (hasText && !isSavedMessages) { if (hasText && !isSavedMessages) {
// Multi-device fan-out (v2.2.0): resolve the recipient's active
// device X25519 pubs via /api/devices. Legacy identities (no
// devices registered) fall back to their published identity
// x25519 pub, preserving the pre-v2.2.0 single-device path.
// `contact.x25519Pub` stays the floor — if both network calls
// fail we still attempt delivery to the cached pub so a flaky
// connection doesn't block outgoing messages.
let recipientPubs = await resolveRecipientKeys(contact.address);
if (recipientPubs.length === 0 && contact.x25519Pub) {
recipientPubs = [contact.x25519Pub];
}
if (recipientPubs.length === 0) {
throw new Error('recipient has no encryption key published');
}
// One sealed envelope per recipient device. Parallel — slow
// relays don't block each other; any individual failure
// rejects the whole send (user retries).
await Promise.all(recipientPubs.map(async (rpub) => {
const { nonce, ciphertext } = encryptMessage( const { nonce, ciphertext } = encryptMessage(
actualText.trim(), keyFile.x25519_priv, contact.x25519Pub, actualText.trim(), keyFile.x25519_priv, rpub,
); );
await sendEnvelope({ await sendEnvelope({
senderPub: keyFile.x25519_pub, senderPub: keyFile.x25519_pub,
recipientPub: contact.x25519Pub, recipientPub: rpub,
senderEd25519Pub: keyFile.pub_key, senderEd25519Pub: keyFile.pub_key,
nonce, ciphertext, nonce, ciphertext,
}); });
}));
} }
const msg: Message = { const msg: Message = {

View File

@@ -365,6 +365,25 @@ export interface IdentityInfo {
x25519_pub: string; // hex Curve25519 key; empty string if not published x25519_pub: string; // hex Curve25519 key; empty string if not published
nickname: string; nickname: string;
registered: boolean; registered: boolean;
/**
* Number of active (non-revoked) devices linked to this master identity
* via LINK_DEVICE (v2.2.0). 0 for legacy identities that only published
* a single X25519 via REGISTER_KEY — senders should fall back to
* `x25519_pub` above and skip the device fan-out path.
*/
device_count?: number;
}
/**
* One active device in an identity's multi-device registry. Returned by
* GET /api/devices/{master_pub} as part of `devices[]`. Senders use the
* list to fan out one sealed envelope per X25519 pub so all of the
* recipient's devices receive the message.
*/
export interface DeviceInfo {
x25519_pub_key: string;
device_name: string;
added_at: number; // unix seconds
} }
/** /**
@@ -409,6 +428,56 @@ export async function getIdentity(pubkeyOrAddr: string): Promise<IdentityInfo |
} }
} }
// ─── Multi-device registry (v2.2.0) ───────────────────────────────────────
interface DevicesResponse {
master_pub: string;
count: number;
devices: DeviceInfo[];
}
/**
* GET /api/devices/{master_pub} — all active (non-revoked) device records
* for the given master identity. Returns an empty array for a legacy
* identity (device_count == 0) or a network error — callers should treat
* both the same way and fall back to IdentityInfo.x25519_pub so the
* pre-v2.2.0 single-device path keeps working.
*/
export async function fetchDevices(masterPub: string): Promise<DeviceInfo[]> {
try {
const resp = await get<DevicesResponse>(`/api/devices/${masterPub}`);
return resp.devices ?? [];
} catch {
return [];
}
}
/**
* Pick the right set of recipient X25519 pubs for a sender's fan-out.
* Two paths, in priority order:
*
* 1. New path — /api/devices returns ≥1 entry. Send to each device.
* 2. Legacy path — identity published an X25519 via REGISTER_KEY
* (pre-v2.2.0 clients). Send to just that one.
*
* Returns an empty array only when the recipient has published nothing
* at all — caller must surface "no encryption key" to the user rather
* than drop the message on the floor.
*/
export async function resolveRecipientKeys(
recipientMasterPub: string,
): Promise<string[]> {
const devs = await fetchDevices(recipientMasterPub);
if (devs.length > 0) {
return devs.map(d => d.x25519_pub_key);
}
const identity = await getIdentity(recipientMasterPub);
if (identity?.x25519_pub) {
return [identity.x25519_pub];
}
return [];
}
// ─── Contract API ───────────────────────────────────────────────────────────── // ─── Contract API ─────────────────────────────────────────────────────────────
/** /**
@@ -712,6 +781,68 @@ export function buildTransferTx(params: {
}; };
} }
/**
* LINK_DEVICE transaction — publish a per-device X25519 pub in the
* identity's device registry so senders can fan out envelopes across
* every active device. Signed by the master Ed25519 (= `from`).
*
* `deviceName` is a short human label shown in Settings → Devices
* (≤ 64 bytes, printable ASCII/UTF-8, no control chars).
*/
export function buildLinkDeviceTx(params: {
from: string; // master Ed25519 pubkey
x25519Pub: string; // per-device X25519 pubkey (64 hex chars, lowercase)
deviceName: string;
privKey: string;
}): RawTx {
const id = newTxID();
const timestamp = rfc3339Now();
const payloadObj = {
x25519_pub_key: params.x25519Pub,
device_name: params.deviceName,
};
const payload = strToBase64(JSON.stringify(payloadObj));
const canonical = txCanonicalBytes({
id, type: 'LINK_DEVICE', from: params.from, to: '',
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
});
return {
id, type: 'LINK_DEVICE', from: params.from, to: '',
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
signature: signBase64(canonical, params.privKey),
};
}
/**
* UNLINK_DEVICE transaction — revoke a previously-linked device so senders
* stop shipping envelopes to its X25519 pub. The revoked device itself,
* when it next comes online and sees its own pub in the revoked list,
* is expected to wipe local state (master priv + cached chats).
*/
export function buildUnlinkDeviceTx(params: {
from: string; // master Ed25519 pubkey
x25519Pub: string; // pub to revoke
privKey: string;
}): RawTx {
const id = newTxID();
const timestamp = rfc3339Now();
const payloadObj = { x25519_pub_key: params.x25519Pub };
const payload = strToBase64(JSON.stringify(payloadObj));
const canonical = txCanonicalBytes({
id, type: 'UNLINK_DEVICE', from: params.from, to: '',
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
});
return {
id, type: 'UNLINK_DEVICE', from: params.from, to: '',
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
signature: signBase64(canonical, params.privKey),
};
}
/** /**
* CONTACT_REQUEST transaction. * CONTACT_REQUEST transaction.
* *

View File

@@ -878,6 +878,7 @@ func main() {
IdentityInfo: func(pubKeyOrAddr string) (*blockchain.IdentityInfo, error) { IdentityInfo: func(pubKeyOrAddr string) (*blockchain.IdentityInfo, error) {
return chain.IdentityInfo(pubKeyOrAddr) return chain.IdentityInfo(pubKeyOrAddr)
}, },
DevicesOf: chain.DevicesOf,
ValidatorSet: chain.ValidatorSet, ValidatorSet: chain.ValidatorSet,
SubmitTx: func(tx *blockchain.Transaction) error { SubmitTx: func(tx *blockchain.Transaction) error {
if err := engine.AddTransaction(tx); err != nil { if err := engine.AddTransaction(tx); err != nil {

202
deploy/single/join.sh Normal file
View File

@@ -0,0 +1,202 @@
#!/usr/bin/env bash
# rejoin.sh — полная переустановка dchain joiner-ноды с нуля.
# Публичный доступ без Caddy/TLS, без API-токена (на свой страх и риск).
# Запускать БЕЗ sudo.
set -euo pipefail
# ── КОНФИГ ─────────────────────────────────────────────────────────────
SEED_HTTP="${SEED_HTTP:-http://62.171.151.182:8082}"
SEED_P2P_IP="${SEED_P2P_IP:-62.171.151.182}"
SEED_P2P_PORT="${SEED_P2P_PORT:-4005}"
REPO_URL="${REPO_URL:-https://git.vsecoder.vodka/vsecoder/dchain.git}"
WORKDIR="${WORKDIR:-$HOME/dchain}"
LOCAL_P2P_PORT="${LOCAL_P2P_PORT:-4001}"
LOCAL_HTTP_PORT="${LOCAL_HTTP_PORT:-8082}"
# ───────────────────────────────────────────────────────────────────────
log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m!!\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31mXX\033[0m %s\n' "$*" >&2; exit 1; }
need() { command -v "$1" >/dev/null || die "need $1 installed"; }
# ── 0. Требования ──────────────────────────────────────────────────────
[[ $EUID -eq 0 ]] && die "don't run as root — use your user account, sudo is used per-command"
need docker; need git; need curl; need jq
sudo -v
# ── 1. Снести предыдущее состояние ─────────────────────────────────────
log "stopping any existing dchain stack & purging volumes"
if [[ -d "$WORKDIR/deploy/single" ]]; then
(cd "$WORKDIR/deploy/single" && sudo docker compose down -v 2>/dev/null) || true
fi
sudo docker rm -f dchain_node dchain_caddy dchain 2>/dev/null || true
mapfile -t stale_vols < <(sudo docker volume ls -q | grep -E '^(dchain|dchain-single)' || true)
(( ${#stale_vols[@]} > 0 )) && sudo docker volume rm "${stale_vols[@]}" 2>/dev/null || true
# ── 2. Свежий репозиторий ──────────────────────────────────────────────
log "fetching repo → $WORKDIR"
if [[ -d "$WORKDIR/.git" ]]; then
git -C "$WORKDIR" fetch --all --tags --prune
git -C "$WORKDIR" reset --hard origin/main
else
rm -rf "$WORKDIR"
git clone "$REPO_URL" "$WORKDIR"
fi
cd "$WORKDIR/deploy/single"
# ── 3. keys/ под UID 100 ───────────────────────────────────────────────
log "preparing keys/ for container UID 100:101"
mkdir -p keys
sudo chown 100:101 keys
sudo chmod 755 keys
[[ -f keys/node.json ]] && { sudo chown 100:101 keys/node.json; sudo chmod 600 keys/node.json; }
# ── 4. Slim-образ ──────────────────────────────────────────────────────
log "building dchain image (slim)"
IMAGE=$(sudo docker build -q -f ../prod/Dockerfile.slim ../..)
[[ -z "$IMAGE" ]] && die "docker build failed"
# ── 5. Ключ ноды ───────────────────────────────────────────────────────
if [[ ! -f keys/node.json ]]; then
log "generating node identity"
sudo docker run --rm --entrypoint /usr/local/bin/client \
--user 100:101 \
-v "$PWD/keys:/out" \
"$IMAGE" \
keygen --out /out/node.json
else
log "reusing existing keys/node.json"
fi
sudo chown 100:101 keys/node.json
sudo chmod 600 keys/node.json
# ── 6. peer_id seed'a ──────────────────────────────────────────────────
log "fetching seed peer_id from $SEED_HTTP/stats"
SEED_PEER_ID=$(curl -sfL "$SEED_HTTP/stats" | jq -r '.node.peer_id // empty')
[[ -z "$SEED_PEER_ID" ]] && die "can't reach seed at $SEED_HTTP or peer_id missing"
log "seed peer_id = $SEED_PEER_ID"
# ── 7. Публичный IP ────────────────────────────────────────────────────
PUBLIC_IP="${PUBLIC_IP:-$(curl -sfL https://api.ipify.org || true)}"
[[ -z "$PUBLIC_IP" ]] && die "can't detect public IP; export PUBLIC_IP=x.x.x.x и перезапусти"
log "public IP = $PUBLIC_IP"
# ── 8. node.env (без токена, без genesis) ──────────────────────────────
log "writing node.env"
cp -f node.env.example node.env
# удалить из example'а значения, которые мы хотим держать под своим контролем,
# чтобы они не перекрыли наши append'ы снизу
sudo sed -i -E '/^(#\s*)?DCHAIN_(GENESIS|API_TOKEN|API_PRIVATE|JOIN|PEERS|ANNOUNCE|DB|MAILBOX_DB|FEED_DB|REGISTER_RELAY|RELAY_FEE|FEED_DISK_LIMIT_MB|CHAIN_DISK_LIMIT_MB|UPDATE_SOURCE_URL)=/d' node.env
sudo tee -a node.env > /dev/null <<EOF
# ── AUTO-GENERATED BY rejoin.sh ─────────────────────────────────────
# joiner → seed $SEED_HTTP (public node, no token)
DCHAIN_JOIN=$SEED_HTTP
DCHAIN_PEERS=/ip4/$SEED_P2P_IP/tcp/$SEED_P2P_PORT/p2p/$SEED_PEER_ID
DCHAIN_ANNOUNCE=/ip4/$PUBLIC_IP/tcp/$LOCAL_P2P_PORT
DCHAIN_DB=/data/chain
DCHAIN_MAILBOX_DB=/data/mailbox
DCHAIN_FEED_DB=/data/feed
DCHAIN_REGISTER_RELAY=true
DCHAIN_RELAY_FEE=1000
DCHAIN_FEED_DISK_LIMIT_MB=4096
DCHAIN_CHAIN_DISK_LIMIT_MB=20480
DCHAIN_UPDATE_SOURCE_URL=https://git.vsecoder.vodka/api/v1/repos/vsecoder/dchain/releases/latest
EOF
# ── 9. Compose: прямой проброс 4001+8080 наружу, Caddy снести навсегда ─
log "patching docker-compose.yml: direct ports, caddy removed"
python3 - <<PY
import re, pathlib
p = pathlib.Path('docker-compose.yml')
src = p.read_text()
# 9a. Вырезать весь сервис caddy (от " caddy:" до следующего сервиса
# того же уровня или конца блока services).
src = re.sub(
r'(?ms)^ caddy:\n(?:(?: .*\n)|\n)+?(?=^ [A-Za-z_-]+:|\Z)',
'', src,
)
# 9b. Убрать зависимости от caddy, если где-то остались.
src = re.sub(r'(?m)^\s*-\s*caddy\s*$\n', '', src)
src = re.sub(r'(?m)^\s*depends_on:\s*\n(\s*-\s*caddy\s*\n)+', '', src)
# 9c. Заставить node светить наружу 4001 (libp2p) и 8080 (HTTP).
# Ищем первый ports: в сервисе node; если его нет — инжектим.
m = re.search(r'(?ms)^ node:\n(.*?)(?=^ [A-Za-z_-]+:|\Z)', src)
if not m:
raise SystemExit('no `node:` service in compose')
node_block = m.group(1)
wanted = f' ports:\n - "$LOCAL_P2P_PORT:4001"\n - "$LOCAL_HTTP_PORT:8080"\n'
if re.search(r'(?m)^ ports:', node_block):
# Заменить существующий блок ports целиком
node_block_new = re.sub(
r'(?ms)^ ports:\n(?: -.*\n)+',
wanted, node_block,
)
else:
# Добавить сразу после строки "container_name:" (или restart:)
node_block_new = re.sub(
r'(?m)^( (?:container_name|restart):.*\n)',
r'\\1' + wanted, node_block, count=1,
)
if node_block_new == node_block: # fallback — в начало блока
node_block_new = wanted + node_block
src = src[:m.start(1)] + node_block_new + src[m.end(1):]
# 9d. Убрать блок expose у node — не нужен, раз ports наружу.
src = re.sub(
r'(?ms)^ expose:\n(?: -.*\n)+', '', src,
)
p.write_text(src)
PY
# ── 10. Подъём ─────────────────────────────────────────────────────────
log "docker compose up -d"
sudo docker compose up -d --build
# ── 11. Sanity checks ──────────────────────────────────────────────────
sleep 3
log "sanity checks (дай ~30 сек чтобы healthcheck подхватился):"
set +e
echo "──── docker compose ps ────"
sudo docker compose ps
echo "──── docker logs dchain_node | tail ────"
sudo docker logs --tail 30 dchain_node 2>&1
echo "──── /api/netstats ────"
curl -s "http://localhost:$LOCAL_HTTP_PORT/api/netstats" | jq '.'
echo "──── seed height (для сверки) ────"
curl -s "$SEED_HTTP/api/netstats" | jq '.total_blocks'
cat <<EOM
======================================================================
✓ Готово. Нода публичная, без токена и без TLS.
API:
http://$PUBLIC_IP:$LOCAL_HTTP_PORT/api/netstats
http://$PUBLIC_IP:$LOCAL_HTTP_PORT/swagger
Следи за sync'ом:
sudo docker logs -f dchain_node | grep -E 'applied|height|peer'
Повторный полный сброс:
./rejoin.sh
Открой в firewall/security-group на этом VPS:
- $LOCAL_P2P_PORT/tcp (libp2p)
- $LOCAL_HTTP_PORT/tcp (HTTP API)
======================================================================
EOM

219
docs/ROADMAP.md Normal file
View File

@@ -0,0 +1,219 @@
# Roadmap
Living doc — «куда идём дальше». Два активных вектора: **v2.2.0 multi-device**
и **desktop Electron client**. Всё что tentative — помечено `?`; всё что
решено и принято к работе — без знаков.
---
## v2.2.0 — Multi-device (per-device X25519, on-chain device registry)
### Проблема
Сейчас у пользователя одна пара X25519 (хранится в `keyFile`), и relay-mailbox —
queue с DELETE-после-чтения. При двух устройствах:
- Конверт забирает то устройство, что первое дёрнуло `/relay/inbox` — второе
теряет сообщение навсегда.
- История чатов per-device, не синхронизируется (`AsyncStorage` на каждом
устройстве свой).
On-chain часть (posts, follows, tx, wallet) работает нормально — оба устройства
читают один чейн и видят одно и то же. Чинить надо только мессенджер.
### Решение — путь А (Signal-style): X25519 на устройство + device registry
1. **Master identity = Ed25519** (как сейчас). Подписывает tx, владеет
балансом, — один на всю identity.
2. **X25519 — per-device**. Каждое устройство при первом старте генерит
свою пару. Relay-mailbox остаётся queue'ом, но адресуется по
**устройству**, не по identity.
3. **Device registry on-chain**: новые tx-типы `LINK_DEVICE` / `UNLINK_DEVICE`,
подписываются master Ed25519, публикуют/отзывают связку
`(master_pub → device_x25519_pub, device_name)`.
4. **Sender-side fan-out**: при отправке — один envelope на каждое
активное устройство получателя. Мессадж → N конвертов.
5. **Revoke**: master подписывает `UNLINK_DEVICE`, клиент, обнаруживший
свой `x25519_pub` в revoked — wipe'ит локальную БД (zeroize master
Ed25519 + delete chats).
### План работ (PR-by-PR)
#### PR #1 — Chain-side (backend) — _v2.2.0-alpha1_
- [ ] `blockchain/types.go`: `EventLinkDevice`, `EventUnlinkDevice`
+ payload structs `LinkDevicePayload`, `UnlinkDevicePayload`.
- [ ] `blockchain/chain.go`: applyTx-ветки, state persistence:
- `prefixDevice + x25519_pub → DeviceRecord{owner, name, added_at, revoked_at?}`.
- Обратный индекс `prefixDevicesByOwner + master_pub → [x25519_pub, …]`.
- [ ] Константа `MaxDevicesPerOwner = 10`.
- [ ] Validation:
- Tx подписана master'ом; подпись matches `payload.Owner`.
- `x25519_pub` уникален в registry (не занят другим master'ом).
- `device_name` ≤ 64 символов; printable.
- При `UNLINK_DEVICE`: запись существует, owner совпадает с signer'ом.
- [ ] HTTP: `GET /api/devices/{master_pub}``[{x25519_pub, device_name, added_at}, …]`,
фильтрует revoked.
- [ ] В `/api/identity/{pub}` добавить поле `device_count`.
- [ ] Unit-тесты:
- Happy path link + list.
- Unlink → list сократился.
- Лимит `MaxDevicesPerOwner`.
- Чужая подпись → reject.
- Duplicate x25519_pub → reject.
- [ ] Swagger + `docs/api/devices.md`.
Совместимость: старые клиенты, которые не обновили identity, продолжают
работать по old-schema (envelope на published X25519 из identity). Sender
fall-back'ит на identity.x25519, если `device_count == 0`.
#### PR #2 — Client fan-out (mobile) — _v2.2.0-alpha2_
- [ ] `lib/api.ts`: `fetchDevices(masterPub): Promise<DeviceRecord[]>`,
с кэшем в zustand store + инвалидацией по WS-ивенту `tx:LINK_DEVICE|UNLINK_DEVICE`.
- [ ] `chats/[id].tsx``sendCore`: `Promise.all` по `devices[]`;
если `devices.length == 0` — fall-back на identity.x25519 (legacy path).
- [ ] При первом старте (онбординг): если identity ещё не зарегистрирована —
`PUBLISH_KEY` tx + `LINK_DEVICE` tx (**этот** девайс как первый в списке).
- [ ] UI: в chat-list tile бейдж `2/2` на своих сообщениях (доставлено
на N устройств получателя).
- [ ] Тест: два клиента на одну identity, отправляем на contact — оба
получают.
#### PR #3 — Devices screen + pairing flow (mobile + desktop) — _v2.2.0-alpha3_
- [ ] Settings → **Devices**: список активных, unlink-кнопка у каждого
(кроме `this device`).
- [ ] «Link new device» flow:
- Новое устройство генерит свой X25519, показывает QR
`{x25519_pub, device_name, nonce, rendezvous_id}`.
- Старое сканирует → Confirm → подписывает `LINK_DEVICE` tx +
шлёт через relay envelope `{master_ed25519_priv, recent_history}`
encrypted for new device's X25519, TTL ≤ 60 сек.
- Новое читает envelope, сохраняет master + history, готово.
- [ ] Self-wipe при обнаружении своего `x25519_pub` в revoked:
zeroize master, clear AsyncStorage/keychain, redirect на onboarding.
#### PR #4 — Desktop Electron shell — _v2.2.0-rc1_
См. отдельный раздел ниже.
### Нерешённые вопросы
- **QR-pairing UX на desktop'е**: у ноутбука камеры часто нет. Вариант:
master на phone шлёт invite-envelope по pre-shared secret (6-digit code,
вводится в обе стороны), без QR. Подумать после PR #2.
- **History-sync backup формат**: JSON export всей ChatStorage shreds'ов?
Ограничение по размеру? TTL у backup-конверта? — в PR #3.
- **Revoked-device-wipe race**: если устройство оффлайн 6 месяцев, потом
поднимается — первое что видит это свой revoked. Должен успеть wipe'нуться
**до** попытки послать tx с master-ключом. Продумать порядок bootstrap'а.
---
## Desktop client (Electron)
Цель: 1:1 функциональный паритет с мобильным, десктопная эргономика, keyboard-first.
### Архитектура
- **Electron** + **React (Vite)** + **TypeScript**.
- 80% `lib/` переиспользуется из `client-app/lib/` (api, feed, crypto, store, types).
- Нативные модули: `electron-store` (+ `safeStorage`) для keys, `clipboard`,
`Notification`, `dialog.showOpenDialog`.
- Custom title bar (frame-less), 3-panel layout.
### Экраны
Full design — `docs/desktop-design.md` (TODO). Sections:
- **Messages** — список чатов + conversation panel. Saved Messages pinned top.
- **Feed** — tabs (For You / Following / Trending 24h / 7d / Hashtag) +
post list + thread panel.
- **Wallet** — account overview + tx history + tx detail.
- **Contacts** — grouped list (All / Online / Blocked / Requests) + contact profile.
- **Settings** — grouped: Node, Identity, **Devices**, Privacy, Feed, Notifications,
Advanced, About.
- **Profile** (внизу nav) — self profile; чужой — в detail pane.
- **Auth/Onboarding** — Welcome / Create / Import / Pair-with-phone (QR/code).
### Стилистика (переносим)
Чёрный `#000`, карточки `#0a0a0a`, бордеры `#1f1f1f`, текст `#fff`/`#8b8b8b`.
Синий accent `#1d9bf0`, оранж warning `#f0b35a`. Синий bookmark-avatar для Saved Messages.
### Не переносим
Все React-Native-анимации (Pressable-animations, `scaleY: -1`, gesture-handler
long-press). На десктопе — правый клик, hover-states, keyboard shortcuts,
обычный scroll вместо inverted FlatList.
### Global keybinds
| Key | Action |
|---|---|
| `Ctrl/Cmd+1..5` | Переключение секций |
| `Ctrl/Cmd+N` | Новый пост / новый контакт (контекстно) |
| `Ctrl/Cmd+K` | Global search |
| `Ctrl/Cmd+F` | Search в текущей list pane |
| `Ctrl/Cmd+,` | Settings |
| `Ctrl/Cmd+Enter` | Send (chat / publish post) |
| `Esc` | Close modal / cancel selection |
### Структура папок
```
desktop/
├── electron/
│ ├── main.ts # BrowserWindow, IPC, auto-update
│ ├── preload.ts # contextBridge — safe API
│ ├── menu.ts # native menu bar
│ └── deep-link.ts # dchain:// protocol handler
├── src/ # renderer
│ ├── App.tsx
│ ├── shell/ # nav, status bar, title bar
│ ├── sections/
│ │ ├── messages/
│ │ ├── feed/
│ │ ├── wallet/
│ │ ├── contacts/
│ │ ├── settings/
│ │ └── profile/
│ ├── modals/
│ ├── auth/
│ ├── lib/ # reuse из client-app/lib
│ └── styles/
└── package.json
```
### План работ (отдельно, после v2.2.0-alpha3)
- [ ] Boilerplate: Electron + Vite + React + TS, frame-less window, 3-panel shell.
- [ ] Сопрягание `lib/` из client-app (заменить expo-* на Electron-эквиваленты).
- [ ] Sections по порядку: Messages → Feed → Wallet → Contacts → Settings → Profile.
- [ ] Multi-device pairing flow (использует v2.2.0 registry).
- [ ] Auto-update через electron-updater или через тот же `/api/update-check`.
- [ ] Packaging: `electron-builder``.dmg`, `.exe`, `.AppImage`, `.deb`.
### Открытые вопросы (desktop)
- **Auto-update channel**: гнать через Gitea releases (как node) или отдельный S3/Gitea-attachments?
- **Sandbox**: desktop хранит master Ed25519 — обязательно `safeStorage` (macOS Keychain, Windows DPAPI, libsecret на Linux). На Linux без libsecret — fallback на plaintext + warning.
- **Deep-link**: `dchain://chat/<pub>` для шаринга профилей/постов из браузера.
---
## Меньшие хвосты (неприоритетно)
- `prev_hash mismatch` при двойной gossip-доставке — понизить до Debug уровня,
не пугать операторов (см. history с joiner-node bring-up).
- `/api/network-info.peers` у seed'а возвращает `[]` если
`DCHAIN_ANNOUNCE` не задан — добавить fallback:
если announce пустой, публиковать *первый listen-addr + свой peer_id*
(не `127.0.0.1`), чтобы joiner'ы не упирались в `peers:[]`.
- **Groups (3+ участников)** в чатах — тип `Contact.kind == 'group'`
в клиенте есть, а на backend'е не реализован. После v2.2.0, потому что
pairwise messaging ↔ group messaging на multi-device — это другой
математический слой (MLS или Sender Keys).
- Repa как weight в selection-lider PBFT — сейчас только scoring; для
настоящего proof-of-reputation нужно переписать leader-rotation.

View File

@@ -510,10 +510,63 @@ func apiIdentity(q ExplorerQuery) http.HandlerFunc {
jsonErr(w, err, 500) jsonErr(w, err, 500)
return return
} }
// Multi-device (v2.2.0): stuff the active device count into the
// identity payload so a sender can decide upfront whether to
// fan out envelopes or fall back to the legacy single-X25519
// path (device_count == 0).
if q.DevicesOf != nil {
if devs, derr := q.DevicesOf(pubKey); derr == nil {
info.DeviceCount = len(devs)
}
}
jsonOK(w, info) jsonOK(w, info)
} }
} }
// apiDevices — GET /api/devices/{master_pub_or_addr}
//
// Returns the active (non-revoked) devices linked to a master Ed25519
// identity. Used by senders to fan out one envelope per device. Legacy
// identities (count=0) should be handled by the caller by falling back
// to IdentityInfo.X25519Pub — this endpoint is strictly the new registry.
func apiDevices(q ExplorerQuery) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
input := strings.TrimPrefix(r.URL.Path, "/api/devices/")
input = strings.Trim(input, "/")
if input == "" {
jsonErr(w, fmt.Errorf("master pubkey or address required"), 400)
return
}
if q.DevicesOf == nil {
jsonErr(w, fmt.Errorf("device registry not available"), 503)
return
}
pubKey, err := resolveAccountID(q, input)
if err != nil {
jsonErr(w, err, 404)
return
}
recs, err := q.DevicesOf(pubKey)
if err != nil {
jsonErr(w, err, 500)
return
}
out := make([]blockchain.DeviceInfo, 0, len(recs))
for _, rec := range recs {
out = append(out, blockchain.DeviceInfo{
X25519PubKey: rec.X25519PubKey,
DeviceName: rec.DeviceName,
AddedAt: rec.AddedAt,
})
}
jsonOK(w, map[string]any{
"master_pub": pubKey,
"count": len(out),
"devices": out,
})
}
}
func apiSubmitTx(q ExplorerQuery) http.HandlerFunc { func apiSubmitTx(q ExplorerQuery) http.HandlerFunc {
// The returned handler is wrapped in withSubmitTxGuards() by the caller: // The returned handler is wrapped in withSubmitTxGuards() by the caller:
// body size is capped at MaxTxRequestBytes and per-IP rate limiting is // body size is capped at MaxTxRequestBytes and per-IP rate limiting is

View File

@@ -53,6 +53,10 @@ type ExplorerQuery struct {
NetStats func() (blockchain.NetStats, error) NetStats func() (blockchain.NetStats, error)
RegisteredRelays func() ([]blockchain.RegisteredRelayInfo, error) RegisteredRelays func() ([]blockchain.RegisteredRelayInfo, error)
IdentityInfo func(pubKeyOrAddr string) (*blockchain.IdentityInfo, error) IdentityInfo func(pubKeyOrAddr string) (*blockchain.IdentityInfo, error)
// DevicesOf (multi-device v2.2.0) returns the identity's non-revoked
// device records. Empty slice if the identity hasn't linked any yet
// — senders fall back to IdentityInfo.X25519Pub for legacy clients.
DevicesOf func(masterPub string) ([]blockchain.DeviceRecord, error)
ValidatorSet func() ([]string, error) ValidatorSet func() ([]string, error)
SubmitTx func(tx *blockchain.Transaction) error SubmitTx func(tx *blockchain.Transaction) error
// ConnectedPeers (optional) returns the local libp2p view of currently // ConnectedPeers (optional) returns the local libp2p view of currently
@@ -222,6 +226,7 @@ func registerExplorerAPI(mux *http.ServeMux, q ExplorerQuery) {
mux.HandleFunc("/api/node/", apiNode(q)) // GET /api/node/{pubkey|DC...} mux.HandleFunc("/api/node/", apiNode(q)) // GET /api/node/{pubkey|DC...}
mux.HandleFunc("/api/relays", apiRelays(q)) // GET /api/relays mux.HandleFunc("/api/relays", apiRelays(q)) // GET /api/relays
mux.HandleFunc("/api/identity/", apiIdentity(q)) // GET /api/identity/{pubkey|addr} mux.HandleFunc("/api/identity/", apiIdentity(q)) // GET /api/identity/{pubkey|addr}
mux.HandleFunc("/api/devices/", apiDevices(q)) // GET /api/devices/{master_pub} — multi-device registry (v2.2.0)
mux.HandleFunc("/api/validators", apiValidators(q))// GET /api/validators mux.HandleFunc("/api/validators", apiValidators(q))// GET /api/validators
mux.HandleFunc("/api/tx", withWriteTokenGuard(withSubmitTxGuards(apiSubmitTx(q)))) // POST /api/tx (body size + per-IP rate limit + optional token gate) mux.HandleFunc("/api/tx", withWriteTokenGuard(withSubmitTxGuards(apiSubmitTx(q)))) // POST /api/tx (body size + per-IP rate limit + optional token gate)
// Live event stream (SSE) — GET /api/events // Live event stream (SSE) — GET /api/events