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).
607 lines
18 KiB
Go
607 lines
18 KiB
Go
package node
|
|
|
|
import (
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"go-blockchain/blockchain"
|
|
"go-blockchain/economy"
|
|
"go-blockchain/wallet"
|
|
)
|
|
|
|
type txListEntry struct {
|
|
ID string `json:"id"`
|
|
Type blockchain.EventType `json:"type"`
|
|
Memo string `json:"memo,omitempty"`
|
|
From string `json:"from"`
|
|
FromAddr string `json:"from_addr,omitempty"`
|
|
To string `json:"to,omitempty"`
|
|
ToAddr string `json:"to_addr,omitempty"`
|
|
Amount uint64 `json:"amount_ut"`
|
|
AmountDisp string `json:"amount"`
|
|
Fee uint64 `json:"fee_ut"`
|
|
FeeDisp string `json:"fee"`
|
|
Time string `json:"time"`
|
|
BlockIndex uint64 `json:"block_index"`
|
|
BlockHash string `json:"block_hash,omitempty"`
|
|
}
|
|
|
|
func asTxListEntry(rec *blockchain.TxRecord) txListEntry {
|
|
tx := rec.Tx
|
|
out := txListEntry{
|
|
ID: tx.ID,
|
|
Type: tx.Type,
|
|
Memo: txMemo(tx),
|
|
From: tx.From,
|
|
To: tx.To,
|
|
Amount: tx.Amount,
|
|
AmountDisp: economy.FormatTokens(tx.Amount),
|
|
Fee: tx.Fee,
|
|
FeeDisp: economy.FormatTokens(tx.Fee),
|
|
Time: tx.Timestamp.UTC().Format("2006-01-02T15:04:05Z"),
|
|
BlockIndex: rec.BlockIndex,
|
|
BlockHash: rec.BlockHash,
|
|
}
|
|
if tx.From != "" {
|
|
out.FromAddr = wallet.PubKeyToAddress(tx.From)
|
|
}
|
|
if tx.To != "" {
|
|
out.ToAddr = wallet.PubKeyToAddress(tx.To)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func apiNetStats(q ExplorerQuery) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
stats, err := q.NetStats()
|
|
if err != nil {
|
|
jsonErr(w, err, 500)
|
|
return
|
|
}
|
|
jsonOK(w, stats)
|
|
}
|
|
}
|
|
|
|
func apiRecentBlocks(q ExplorerQuery) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
limit := queryInt(r, "limit", 20)
|
|
blocks, err := q.RecentBlocks(limit)
|
|
if err != nil {
|
|
jsonErr(w, err, 500)
|
|
return
|
|
}
|
|
type blockSummary struct {
|
|
Index uint64 `json:"index"`
|
|
Hash string `json:"hash"`
|
|
Time string `json:"time"`
|
|
Validator string `json:"validator"`
|
|
TxCount int `json:"tx_count"`
|
|
TotalFees uint64 `json:"total_fees_ut"`
|
|
}
|
|
out := make([]blockSummary, len(blocks))
|
|
for i, b := range blocks {
|
|
out[i] = blockSummary{
|
|
Index: b.Index,
|
|
Hash: b.HashHex(),
|
|
Time: b.Timestamp.UTC().Format("2006-01-02T15:04:05Z"),
|
|
Validator: wallet.PubKeyToAddress(b.Validator),
|
|
TxCount: len(b.Transactions),
|
|
TotalFees: b.TotalFees,
|
|
}
|
|
}
|
|
jsonOK(w, out)
|
|
}
|
|
}
|
|
|
|
func apiRecentTxs(q ExplorerQuery) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
limit := queryInt(r, "limit", 20)
|
|
recs, err := q.RecentTxs(limit)
|
|
if err != nil {
|
|
jsonErr(w, err, 500)
|
|
return
|
|
}
|
|
out := make([]txListEntry, len(recs))
|
|
for i := range recs {
|
|
out[i] = asTxListEntry(recs[i])
|
|
}
|
|
jsonOK(w, out)
|
|
}
|
|
}
|
|
|
|
func apiBlock(q ExplorerQuery) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
idxStr := strings.TrimPrefix(r.URL.Path, "/api/block/")
|
|
idx, err := strconv.ParseUint(idxStr, 10, 64)
|
|
if err != nil {
|
|
jsonErr(w, fmt.Errorf("invalid block index: %s", idxStr), 400)
|
|
return
|
|
}
|
|
b, err := q.GetBlock(idx)
|
|
if err != nil {
|
|
jsonErr(w, err, 404)
|
|
return
|
|
}
|
|
type txSummary struct {
|
|
ID string `json:"id"`
|
|
Type blockchain.EventType `json:"type"`
|
|
Memo string `json:"memo,omitempty"`
|
|
From string `json:"from"`
|
|
To string `json:"to,omitempty"`
|
|
Amount uint64 `json:"amount_ut,omitempty"`
|
|
Fee uint64 `json:"fee_ut"`
|
|
}
|
|
type blockDetail struct {
|
|
Index uint64 `json:"index"`
|
|
Hash string `json:"hash"`
|
|
PrevHash string `json:"prev_hash"`
|
|
Time string `json:"time"`
|
|
Validator string `json:"validator"`
|
|
ValidatorAddr string `json:"validator_addr"`
|
|
TxCount int `json:"tx_count"`
|
|
TotalFees uint64 `json:"total_fees_ut"`
|
|
Transactions []txSummary `json:"transactions"`
|
|
}
|
|
txs := make([]txSummary, len(b.Transactions))
|
|
for i, tx := range b.Transactions {
|
|
txs[i] = txSummary{
|
|
ID: tx.ID,
|
|
Type: tx.Type,
|
|
Memo: txMemo(tx),
|
|
From: tx.From,
|
|
To: tx.To,
|
|
Amount: tx.Amount,
|
|
Fee: tx.Fee,
|
|
}
|
|
}
|
|
jsonOK(w, blockDetail{
|
|
Index: b.Index,
|
|
Hash: b.HashHex(),
|
|
PrevHash: fmt.Sprintf("%x", b.PrevHash),
|
|
Time: b.Timestamp.UTC().Format("2006-01-02T15:04:05Z"),
|
|
Validator: b.Validator,
|
|
ValidatorAddr: wallet.PubKeyToAddress(b.Validator),
|
|
TxCount: len(b.Transactions),
|
|
TotalFees: b.TotalFees,
|
|
Transactions: txs,
|
|
})
|
|
}
|
|
}
|
|
|
|
func apiTxByID(q ExplorerQuery) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
txID := strings.TrimPrefix(r.URL.Path, "/api/tx/")
|
|
if txID == "" {
|
|
jsonErr(w, fmt.Errorf("tx id required"), 400)
|
|
return
|
|
}
|
|
rec, err := q.GetTx(txID)
|
|
if err != nil {
|
|
jsonErr(w, err, 500)
|
|
return
|
|
}
|
|
if rec == nil {
|
|
jsonErr(w, fmt.Errorf("transaction not found"), 404)
|
|
return
|
|
}
|
|
type txDetail struct {
|
|
ID string `json:"id"`
|
|
Type blockchain.EventType `json:"type"`
|
|
Memo string `json:"memo,omitempty"`
|
|
From string `json:"from"`
|
|
FromAddr string `json:"from_addr,omitempty"`
|
|
To string `json:"to,omitempty"`
|
|
ToAddr string `json:"to_addr,omitempty"`
|
|
Amount uint64 `json:"amount_ut"`
|
|
AmountDisp string `json:"amount"`
|
|
Fee uint64 `json:"fee_ut"`
|
|
FeeDisp string `json:"fee"`
|
|
Time string `json:"time"`
|
|
BlockIndex uint64 `json:"block_index"`
|
|
BlockHash string `json:"block_hash"`
|
|
BlockTime string `json:"block_time"`
|
|
GasUsed uint64 `json:"gas_used,omitempty"`
|
|
Payload any `json:"payload,omitempty"`
|
|
PayloadHex string `json:"payload_hex,omitempty"`
|
|
SignatureHex string `json:"signature_hex,omitempty"`
|
|
}
|
|
tx := rec.Tx
|
|
payload, payloadHex := decodeTxPayload(tx.Payload)
|
|
out := txDetail{
|
|
ID: tx.ID,
|
|
Type: tx.Type,
|
|
Memo: txMemo(tx),
|
|
From: tx.From,
|
|
To: tx.To,
|
|
Amount: tx.Amount,
|
|
AmountDisp: economy.FormatTokens(tx.Amount),
|
|
Fee: tx.Fee,
|
|
FeeDisp: economy.FormatTokens(tx.Fee),
|
|
Time: tx.Timestamp.UTC().Format("2006-01-02T15:04:05Z"),
|
|
BlockIndex: rec.BlockIndex,
|
|
BlockHash: rec.BlockHash,
|
|
BlockTime: rec.BlockTime.UTC().Format("2006-01-02T15:04:05Z"),
|
|
GasUsed: rec.GasUsed,
|
|
Payload: payload,
|
|
PayloadHex: payloadHex,
|
|
SignatureHex: hex.EncodeToString(tx.Signature),
|
|
}
|
|
if tx.From != "" {
|
|
out.FromAddr = wallet.PubKeyToAddress(tx.From)
|
|
}
|
|
if tx.To != "" {
|
|
out.ToAddr = wallet.PubKeyToAddress(tx.To)
|
|
}
|
|
jsonOK(w, out)
|
|
}
|
|
}
|
|
|
|
func apiAddress(q ExplorerQuery) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
addr := strings.TrimPrefix(r.URL.Path, "/api/address/")
|
|
if addr == "" {
|
|
jsonErr(w, fmt.Errorf("address required"), 400)
|
|
return
|
|
}
|
|
|
|
pubKey, err := resolveAccountID(q, addr)
|
|
if err != nil {
|
|
jsonErr(w, err, 404)
|
|
return
|
|
}
|
|
|
|
limit := queryInt(r, "limit", 50)
|
|
offset := queryIntMin0(r, "offset")
|
|
bal, err := q.Balance(pubKey)
|
|
if err != nil {
|
|
jsonErr(w, err, 500)
|
|
return
|
|
}
|
|
txs, err := q.TxsByAddress(pubKey, limit, offset)
|
|
if err != nil {
|
|
jsonErr(w, err, 500)
|
|
return
|
|
}
|
|
|
|
type txEntry struct {
|
|
ID string `json:"id"`
|
|
Type blockchain.EventType `json:"type"`
|
|
Memo string `json:"memo,omitempty"`
|
|
From string `json:"from"`
|
|
FromAddr string `json:"from_addr,omitempty"`
|
|
To string `json:"to,omitempty"`
|
|
ToAddr string `json:"to_addr,omitempty"`
|
|
Amount uint64 `json:"amount_ut"`
|
|
AmountDisp string `json:"amount"`
|
|
Fee uint64 `json:"fee_ut"`
|
|
Time string `json:"time"`
|
|
BlockIndex uint64 `json:"block_index"`
|
|
}
|
|
entries := make([]txEntry, len(txs))
|
|
for i, rec := range txs {
|
|
tx := rec.Tx
|
|
entries[i] = txEntry{
|
|
ID: tx.ID,
|
|
Type: tx.Type,
|
|
Memo: txMemo(tx),
|
|
From: tx.From,
|
|
To: tx.To,
|
|
Amount: tx.Amount,
|
|
AmountDisp: economy.FormatTokens(tx.Amount),
|
|
Fee: tx.Fee,
|
|
Time: tx.Timestamp.UTC().Format("2006-01-02T15:04:05Z"),
|
|
BlockIndex: rec.BlockIndex,
|
|
}
|
|
if tx.From != "" {
|
|
entries[i].FromAddr = wallet.PubKeyToAddress(tx.From)
|
|
}
|
|
if tx.To != "" {
|
|
entries[i].ToAddr = wallet.PubKeyToAddress(tx.To)
|
|
}
|
|
}
|
|
type addrResp struct {
|
|
Address string `json:"address"`
|
|
PubKey string `json:"pub_key"`
|
|
BalanceMicroT uint64 `json:"balance_ut"`
|
|
Balance string `json:"balance"`
|
|
TxCount int `json:"tx_count"`
|
|
Offset int `json:"offset"`
|
|
Limit int `json:"limit"`
|
|
HasMore bool `json:"has_more"`
|
|
NextOffset int `json:"next_offset"`
|
|
Transactions []txEntry `json:"transactions"`
|
|
}
|
|
hasMore := len(entries) == limit
|
|
jsonOK(w, addrResp{
|
|
Address: wallet.PubKeyToAddress(pubKey),
|
|
PubKey: pubKey,
|
|
BalanceMicroT: bal,
|
|
Balance: economy.FormatTokens(bal),
|
|
TxCount: len(entries),
|
|
Offset: offset,
|
|
Limit: limit,
|
|
HasMore: hasMore,
|
|
NextOffset: offset + len(entries),
|
|
Transactions: entries,
|
|
})
|
|
}
|
|
}
|
|
|
|
func apiNode(q ExplorerQuery) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
input := strings.TrimPrefix(r.URL.Path, "/api/node/")
|
|
if input == "" {
|
|
jsonErr(w, fmt.Errorf("node id required"), 400)
|
|
return
|
|
}
|
|
|
|
pubKey, err := resolveAccountID(q, input)
|
|
if err != nil {
|
|
jsonErr(w, err, 404)
|
|
return
|
|
}
|
|
|
|
rep, err := q.Reputation(pubKey)
|
|
if err != nil {
|
|
jsonErr(w, err, 500)
|
|
return
|
|
}
|
|
nodeBal, err := q.Balance(pubKey)
|
|
if err != nil {
|
|
jsonErr(w, err, 500)
|
|
return
|
|
}
|
|
walletPubKey, err := q.WalletBinding(pubKey)
|
|
if err != nil {
|
|
jsonErr(w, err, 500)
|
|
return
|
|
}
|
|
|
|
var walletAddr string
|
|
var walletBalance uint64
|
|
if walletPubKey != "" {
|
|
walletAddr = wallet.PubKeyToAddress(walletPubKey)
|
|
walletBalance, _ = q.Balance(walletPubKey)
|
|
}
|
|
|
|
window := queryInt(r, "window", 200)
|
|
recentBlocks, err := q.RecentBlocks(window)
|
|
if err != nil {
|
|
jsonErr(w, err, 500)
|
|
return
|
|
}
|
|
|
|
recentProduced := 0
|
|
var recentRewardsUT uint64
|
|
for _, b := range recentBlocks {
|
|
if b.Validator != pubKey {
|
|
continue
|
|
}
|
|
recentProduced++
|
|
recentRewardsUT += b.TotalFees
|
|
}
|
|
|
|
type nodeResp struct {
|
|
QueryInput string `json:"query_input"`
|
|
PubKey string `json:"pub_key"`
|
|
Address string `json:"address"`
|
|
NodeBalanceUT uint64 `json:"node_balance_ut"`
|
|
NodeBalance string `json:"node_balance"`
|
|
WalletBindingPubKey string `json:"wallet_binding_pub_key,omitempty"`
|
|
WalletBindingAddress string `json:"wallet_binding_address,omitempty"`
|
|
WalletBindingBalanceUT uint64 `json:"wallet_binding_balance_ut,omitempty"`
|
|
WalletBindingBalance string `json:"wallet_binding_balance,omitempty"`
|
|
ReputationScore int64 `json:"reputation_score"`
|
|
ReputationRank string `json:"reputation_rank"`
|
|
BlocksProduced uint64 `json:"blocks_produced"`
|
|
RelayProofs uint64 `json:"relay_proofs"`
|
|
SlashCount uint64 `json:"slash_count"`
|
|
Heartbeats uint64 `json:"heartbeats"`
|
|
LifetimeBaseRewardUT uint64 `json:"lifetime_base_reward_ut"`
|
|
LifetimeBaseReward string `json:"lifetime_base_reward"`
|
|
RecentWindowBlocks int `json:"recent_window_blocks"`
|
|
RecentBlocksProduced int `json:"recent_blocks_produced"`
|
|
RecentRewardsUT uint64 `json:"recent_rewards_ut"`
|
|
RecentRewards string `json:"recent_rewards"`
|
|
}
|
|
|
|
jsonOK(w, nodeResp{
|
|
QueryInput: input,
|
|
PubKey: pubKey,
|
|
Address: wallet.PubKeyToAddress(pubKey),
|
|
NodeBalanceUT: nodeBal,
|
|
NodeBalance: economy.FormatTokens(nodeBal),
|
|
WalletBindingPubKey: walletPubKey,
|
|
WalletBindingAddress: walletAddr,
|
|
WalletBindingBalanceUT: walletBalance,
|
|
WalletBindingBalance: economy.FormatTokens(walletBalance),
|
|
ReputationScore: rep.Score,
|
|
ReputationRank: rep.Rank(),
|
|
BlocksProduced: rep.BlocksProduced,
|
|
RelayProofs: rep.RelayProofs,
|
|
SlashCount: rep.SlashCount,
|
|
Heartbeats: rep.Heartbeats,
|
|
LifetimeBaseRewardUT: 0,
|
|
LifetimeBaseReward: economy.FormatTokens(0),
|
|
RecentWindowBlocks: len(recentBlocks),
|
|
RecentBlocksProduced: recentProduced,
|
|
RecentRewardsUT: recentRewardsUT,
|
|
RecentRewards: economy.FormatTokens(recentRewardsUT),
|
|
})
|
|
}
|
|
}
|
|
|
|
func apiRelays(q ExplorerQuery) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if q.RegisteredRelays == nil {
|
|
jsonOK(w, []any{})
|
|
return
|
|
}
|
|
relays, err := q.RegisteredRelays()
|
|
if err != nil {
|
|
jsonErr(w, err, 500)
|
|
return
|
|
}
|
|
if relays == nil {
|
|
relays = []blockchain.RegisteredRelayInfo{}
|
|
}
|
|
jsonOK(w, relays)
|
|
}
|
|
}
|
|
|
|
func apiValidators(q ExplorerQuery) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if q.ValidatorSet == nil {
|
|
jsonOK(w, []any{})
|
|
return
|
|
}
|
|
validators, err := q.ValidatorSet()
|
|
if err != nil {
|
|
jsonErr(w, err, 500)
|
|
return
|
|
}
|
|
type validatorEntry struct {
|
|
PubKey string `json:"pub_key"`
|
|
Address string `json:"address"`
|
|
Staked uint64 `json:"staked_ut,omitempty"`
|
|
}
|
|
out := make([]validatorEntry, len(validators))
|
|
for i, pk := range validators {
|
|
var staked uint64
|
|
if q.Stake != nil {
|
|
staked, _ = q.Stake(pk)
|
|
}
|
|
out[i] = validatorEntry{
|
|
PubKey: pk,
|
|
Address: wallet.PubKeyToAddress(pk),
|
|
Staked: staked,
|
|
}
|
|
}
|
|
jsonOK(w, map[string]any{
|
|
"count": len(out),
|
|
"validators": out,
|
|
})
|
|
}
|
|
}
|
|
|
|
func apiIdentity(q ExplorerQuery) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
input := strings.TrimPrefix(r.URL.Path, "/api/identity/")
|
|
if input == "" {
|
|
jsonErr(w, fmt.Errorf("pubkey or address required"), 400)
|
|
return
|
|
}
|
|
if q.IdentityInfo == nil {
|
|
jsonErr(w, fmt.Errorf("identity lookup not available"), 503)
|
|
return
|
|
}
|
|
// Resolve DC address → pubkey if needed.
|
|
pubKey, err := resolveAccountID(q, input)
|
|
if err != nil {
|
|
jsonErr(w, err, 404)
|
|
return
|
|
}
|
|
info, err := q.IdentityInfo(pubKey)
|
|
if err != nil {
|
|
jsonErr(w, err, 500)
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
// The returned handler is wrapped in withSubmitTxGuards() by the caller:
|
|
// body size is capped at MaxTxRequestBytes and per-IP rate limiting is
|
|
// applied upstream (see api_guards.go). This function therefore only
|
|
// handles the semantics — shape, signature, timestamp window, dispatch.
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
jsonErr(w, fmt.Errorf("method not allowed"), 405)
|
|
return
|
|
}
|
|
var tx blockchain.Transaction
|
|
if err := json.NewDecoder(r.Body).Decode(&tx); err != nil {
|
|
jsonErr(w, fmt.Errorf("invalid JSON: %w", err), 400)
|
|
return
|
|
}
|
|
// Reject txs with an obviously-bad clock value before we even verify
|
|
// the signature — cheaper failure, and cuts a replay window against
|
|
// long-rotated dedup caches.
|
|
if err := ValidateTxTimestamp(&tx); err != nil {
|
|
MetricTxSubmitRejected.Inc()
|
|
jsonErr(w, fmt.Errorf("bad timestamp: %w", err), 400)
|
|
return
|
|
}
|
|
if err := verifyTransactionSignature(&tx); err != nil {
|
|
MetricTxSubmitRejected.Inc()
|
|
jsonErr(w, err, 400)
|
|
return
|
|
}
|
|
if err := q.SubmitTx(&tx); err != nil {
|
|
MetricTxSubmitRejected.Inc()
|
|
jsonErr(w, err, 500)
|
|
return
|
|
}
|
|
MetricTxSubmitAccepted.Inc()
|
|
jsonOK(w, map[string]string{"id": tx.ID, "status": "accepted"})
|
|
}
|
|
}
|