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:
320
node/api_relay.go
Normal file
320
node/api_relay.go
Normal file
@@ -0,0 +1,320 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-blockchain/blockchain"
|
||||
"go-blockchain/relay"
|
||||
)
|
||||
|
||||
// RelayConfig holds dependencies for the relay HTTP API.
|
||||
type RelayConfig struct {
|
||||
Mailbox *relay.Mailbox
|
||||
|
||||
// Send seals a message for recipientX25519PubHex and broadcasts it.
|
||||
// Returns the envelope ID. nil disables POST /relay/send.
|
||||
Send func(recipientPubHex string, msg []byte) (string, error)
|
||||
|
||||
// Broadcast publishes a pre-sealed Envelope on gossipsub and stores it in the mailbox.
|
||||
// nil disables POST /relay/broadcast.
|
||||
Broadcast func(env *relay.Envelope) error
|
||||
|
||||
// ContactRequests returns incoming contact records for the given Ed25519 pubkey.
|
||||
ContactRequests func(pubKey string) ([]blockchain.ContactInfo, error)
|
||||
}
|
||||
|
||||
// registerRelayRoutes wires relay mailbox endpoints onto mux.
|
||||
//
|
||||
// POST /relay/send {recipient_pub, msg_b64}
|
||||
// POST /relay/broadcast {envelope: <Envelope JSON>}
|
||||
// GET /relay/inbox ?pub=<x25519hex>[&since=<unix_ts>][&limit=N]
|
||||
// GET /relay/inbox/count ?pub=<x25519hex>
|
||||
// DELETE /relay/inbox/{envID} ?pub=<x25519hex>
|
||||
// GET /relay/contacts ?pub=<ed25519hex>
|
||||
func registerRelayRoutes(mux *http.ServeMux, rc RelayConfig) {
|
||||
mux.HandleFunc("/relay/send", relaySend(rc))
|
||||
mux.HandleFunc("/relay/broadcast", relayBroadcast(rc))
|
||||
mux.HandleFunc("/relay/inbox/count", relayInboxCount(rc))
|
||||
mux.HandleFunc("/relay/inbox/", relayInboxDelete(rc))
|
||||
mux.HandleFunc("/relay/inbox", relayInboxList(rc))
|
||||
mux.HandleFunc("/relay/contacts", relayContacts(rc))
|
||||
}
|
||||
|
||||
// relayInboxList handles GET /relay/inbox?pub=<hex>[&since=<ts>][&limit=N]
|
||||
func relayInboxList(rc RelayConfig) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
jsonErr(w, fmt.Errorf("method not allowed"), 405)
|
||||
return
|
||||
}
|
||||
pub := r.URL.Query().Get("pub")
|
||||
if pub == "" {
|
||||
jsonErr(w, fmt.Errorf("pub parameter required"), 400)
|
||||
return
|
||||
}
|
||||
since := int64(0)
|
||||
if s := r.URL.Query().Get("since"); s != "" {
|
||||
if v, err := parseInt64(s); err == nil && v > 0 {
|
||||
since = v
|
||||
}
|
||||
}
|
||||
limit := queryIntMin0(r, "limit")
|
||||
if limit == 0 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
envelopes, err := rc.Mailbox.List(pub, since, limit)
|
||||
if err != nil {
|
||||
jsonErr(w, err, 500)
|
||||
return
|
||||
}
|
||||
|
||||
type item struct {
|
||||
ID string `json:"id"`
|
||||
SenderPub string `json:"sender_pub"`
|
||||
RecipientPub string `json:"recipient_pub"`
|
||||
FeeUT uint64 `json:"fee_ut,omitempty"`
|
||||
SentAt int64 `json:"sent_at"`
|
||||
SentAtHuman string `json:"sent_at_human"`
|
||||
Nonce []byte `json:"nonce"`
|
||||
Ciphertext []byte `json:"ciphertext"`
|
||||
}
|
||||
|
||||
out := make([]item, 0, len(envelopes))
|
||||
for _, env := range envelopes {
|
||||
out = append(out, item{
|
||||
ID: env.ID,
|
||||
SenderPub: env.SenderPub,
|
||||
RecipientPub: env.RecipientPub,
|
||||
FeeUT: env.FeeUT,
|
||||
SentAt: env.SentAt,
|
||||
SentAtHuman: time.Unix(env.SentAt, 0).UTC().Format(time.RFC3339),
|
||||
Nonce: env.Nonce,
|
||||
Ciphertext: env.Ciphertext,
|
||||
})
|
||||
}
|
||||
|
||||
hasMore := len(out) == limit
|
||||
jsonOK(w, map[string]any{
|
||||
"pub": pub,
|
||||
"count": len(out),
|
||||
"has_more": hasMore,
|
||||
"items": out,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// relayInboxDelete handles DELETE /relay/inbox/{envelopeID}?pub=<hex>
|
||||
func relayInboxDelete(rc RelayConfig) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
// Also serve GET /relay/inbox/{id} for convenience (fetch single envelope)
|
||||
if r.Method == http.MethodGet {
|
||||
relayInboxList(rc)(w, r)
|
||||
return
|
||||
}
|
||||
jsonErr(w, fmt.Errorf("method not allowed"), 405)
|
||||
return
|
||||
}
|
||||
|
||||
envID := strings.TrimPrefix(r.URL.Path, "/relay/inbox/")
|
||||
if envID == "" {
|
||||
jsonErr(w, fmt.Errorf("envelope ID required in path"), 400)
|
||||
return
|
||||
}
|
||||
pub := r.URL.Query().Get("pub")
|
||||
if pub == "" {
|
||||
jsonErr(w, fmt.Errorf("pub parameter required"), 400)
|
||||
return
|
||||
}
|
||||
|
||||
if err := rc.Mailbox.Delete(pub, envID); err != nil {
|
||||
jsonErr(w, err, 500)
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]string{"id": envID, "status": "deleted"})
|
||||
}
|
||||
}
|
||||
|
||||
// relayInboxCount handles GET /relay/inbox/count?pub=<hex>
|
||||
func relayInboxCount(rc RelayConfig) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
pub := r.URL.Query().Get("pub")
|
||||
if pub == "" {
|
||||
jsonErr(w, fmt.Errorf("pub parameter required"), 400)
|
||||
return
|
||||
}
|
||||
count, err := rc.Mailbox.Count(pub)
|
||||
if err != nil {
|
||||
jsonErr(w, err, 500)
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]any{"pub": pub, "count": count})
|
||||
}
|
||||
}
|
||||
|
||||
// relaySend handles POST /relay/send
|
||||
//
|
||||
// Request body:
|
||||
//
|
||||
// {
|
||||
// "recipient_pub": "<hex X25519 pub key>",
|
||||
// "msg_b64": "<base64-encoded plaintext>",
|
||||
// }
|
||||
//
|
||||
// The relay node seals the message using its own X25519 keypair and broadcasts
|
||||
// it on the relay gossipsub topic. No on-chain fee is attached — delivery is
|
||||
// free for light clients using this endpoint.
|
||||
func relaySend(rc RelayConfig) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
jsonErr(w, fmt.Errorf("method not allowed"), 405)
|
||||
return
|
||||
}
|
||||
if rc.Send == nil {
|
||||
jsonErr(w, fmt.Errorf("relay send not available on this node"), 503)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
RecipientPub string `json:"recipient_pub"`
|
||||
MsgB64 string `json:"msg_b64"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonErr(w, fmt.Errorf("invalid JSON: %w", err), 400)
|
||||
return
|
||||
}
|
||||
if req.RecipientPub == "" {
|
||||
jsonErr(w, fmt.Errorf("recipient_pub is required"), 400)
|
||||
return
|
||||
}
|
||||
if req.MsgB64 == "" {
|
||||
jsonErr(w, fmt.Errorf("msg_b64 is required"), 400)
|
||||
return
|
||||
}
|
||||
|
||||
msg, err := decodeBase64(req.MsgB64)
|
||||
if err != nil {
|
||||
jsonErr(w, fmt.Errorf("msg_b64: %w", err), 400)
|
||||
return
|
||||
}
|
||||
if len(msg) == 0 {
|
||||
jsonErr(w, fmt.Errorf("msg_b64: empty message"), 400)
|
||||
return
|
||||
}
|
||||
|
||||
envID, err := rc.Send(req.RecipientPub, msg)
|
||||
if err != nil {
|
||||
jsonErr(w, fmt.Errorf("send failed: %w", err), 500)
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]string{
|
||||
"id": envID,
|
||||
"recipient_pub": req.RecipientPub,
|
||||
"status": "sent",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// decodeBase64 accepts both standard and URL-safe base64.
|
||||
func decodeBase64(s string) ([]byte, error) {
|
||||
// Try URL-safe first (no padding required), then standard.
|
||||
if b, err := base64.RawURLEncoding.DecodeString(s); err == nil {
|
||||
return b, nil
|
||||
}
|
||||
return base64.StdEncoding.DecodeString(s)
|
||||
}
|
||||
|
||||
// relayBroadcast handles POST /relay/broadcast
|
||||
//
|
||||
// Request body: {"envelope": <relay.Envelope JSON>}
|
||||
//
|
||||
// Light clients use this to publish pre-sealed envelopes without a direct
|
||||
// libp2p connection. The relay node stores it in the mailbox and gossips it.
|
||||
func relayBroadcast(rc RelayConfig) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
jsonErr(w, fmt.Errorf("method not allowed"), 405)
|
||||
return
|
||||
}
|
||||
if rc.Broadcast == nil {
|
||||
jsonErr(w, fmt.Errorf("relay broadcast not available on this node"), 503)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Envelope *relay.Envelope `json:"envelope"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonErr(w, fmt.Errorf("invalid JSON: %w", err), 400)
|
||||
return
|
||||
}
|
||||
if req.Envelope == nil {
|
||||
jsonErr(w, fmt.Errorf("envelope is required"), 400)
|
||||
return
|
||||
}
|
||||
if req.Envelope.ID == "" {
|
||||
jsonErr(w, fmt.Errorf("envelope.id is required"), 400)
|
||||
return
|
||||
}
|
||||
if len(req.Envelope.Ciphertext) == 0 {
|
||||
jsonErr(w, fmt.Errorf("envelope.ciphertext is required"), 400)
|
||||
return
|
||||
}
|
||||
|
||||
if err := rc.Broadcast(req.Envelope); err != nil {
|
||||
jsonErr(w, fmt.Errorf("broadcast failed: %w", err), 500)
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]string{
|
||||
"id": req.Envelope.ID,
|
||||
"status": "broadcast",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// relayContacts handles GET /relay/contacts?pub=<ed25519hex>
|
||||
//
|
||||
// Returns all incoming contact requests for the given Ed25519 public key.
|
||||
func relayContacts(rc RelayConfig) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
jsonErr(w, fmt.Errorf("method not allowed"), 405)
|
||||
return
|
||||
}
|
||||
if rc.ContactRequests == nil {
|
||||
jsonErr(w, fmt.Errorf("contacts not available on this node"), 503)
|
||||
return
|
||||
}
|
||||
pub := r.URL.Query().Get("pub")
|
||||
if pub == "" {
|
||||
jsonErr(w, fmt.Errorf("pub parameter required"), 400)
|
||||
return
|
||||
}
|
||||
|
||||
contacts, err := rc.ContactRequests(pub)
|
||||
if err != nil {
|
||||
jsonErr(w, err, 500)
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]any{
|
||||
"pub": pub,
|
||||
"count": len(contacts),
|
||||
"contacts": contacts,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// parseInt64 parses a string as int64.
|
||||
func parseInt64(s string) (int64, error) {
|
||||
var v int64
|
||||
if err := json.Unmarshal([]byte(s), &v); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
Reference in New Issue
Block a user