Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
d44631b
feat(merkle): register with multiple merkle-services and fan out repr…
galt-tr Oct 8, 2026
413da31
fix(merkle): queue per-endpoint registration misses instead of a time…
galt-tr Oct 9, 2026
4883ecb
Merge branch 'main' into feat/multi-merkle-service
galt-tr Oct 14, 2026
25be7ec
fix(merkle): stop black-holed endpoints holding batches; survive rest…
galt-tr Oct 14, 2026
3a36cd3
fix(merkle): require a trial /watch before closing a breaker; probe i…
galt-tr Oct 14, 2026
0366c51
fix(merkle): stop waiting on straggler endpoints once every tx is reg…
galt-tr Oct 15, 2026
5f2e54b
test(merkle): a canceled straggler's owed registrations drain once it…
galt-tr Oct 15, 2026
05f6bbb
Merge branch 'main' into feat/multi-merkle-service
galt-tr Oct 16, 2026
c6111ff
fix(merkle): per-tx straggler grace, per-request skipped_open, retain…
galt-tr Oct 16, 2026
71dd3c3
fix(config): reject user info in merkle_service URLs
galt-tr Oct 16, 2026
0d61935
docs(metrics): any process that calls /reprocess can report an endpoi…
galt-tr Oct 16, 2026
bea148e
fix(bump-builder): only dedup MINED events for blocks an earlier deli…
galt-tr Oct 16, 2026
21c4906
fix(merkle,bump-builder): withhold processed_at on a failed MINED pub…
galt-tr Oct 16, 2026
11cf909
fix(bump-builder): republish the stored set on the short-circuit
galt-tr Oct 16, 2026
6826a2b
fix(merkle): skipped endpoints join the error merge; spare slower end…
galt-tr Oct 16, 2026
68135b1
fix(config,docs): redact every merkle_service URL in validation error…
galt-tr Oct 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@ store:

merkle_service:
url: "https://merkle-service-us-1.bsvb.tech"
# optional extra instances for redundancy; see docs/getting-started.md
# urls: ["https://merkle-service-us-2.bsvb.tech"]

p2p:
datahub_discovery: true
Expand Down
28 changes: 20 additions & 8 deletions app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ type Deps struct {
Leaser store.Leaser
TxTracker *store.TxTracker
TeranodeClient *teranode.Client
MerkleClient *merkleservice.Client // nil when MerkleService.URL is unset
MerkleClient merkleservice.Service // nil when no merkle_service endpoint is configured
Validator *validator.Validator
// Chaintracks is the shared in-process header tracker. nil when
// chaintracks_server is disabled (regtest, or explicit opt-out) — services
Expand Down Expand Up @@ -184,10 +184,22 @@ func Bootstrap(ctx context.Context, cfg *config.Config, logger *zap.Logger) (*De
seedCancel()
}

var merkleClient *merkleservice.Client
if cfg.MerkleService.URL != "" {
merkleClient = merkleservice.NewClient(cfg.MerkleService.URL, cfg.MerkleService.AuthToken, 0)
merkleClient.SetLogger(logger.Named("merkle-client"))
// The interface stays nil when the integration is disabled; a typed-nil
// *Pool assigned here would make every consumer's nil guard pass and
// dereference it.
var merkleClient merkleservice.Service
if cfg.MerkleService.Enabled() {
endpoints := cfg.MerkleService.Endpoints()
// One Pool for every endpoint, single or many: each /watch and
// /reprocess fans out to all of them, a tx counts as registered once
// any endpoint accepts it, and a per-endpoint breaker keeps a dead
// merkle-service from adding its timeout to every batch. Start runs
// the /health probe that closes breakers again.
pool := merkleservice.NewPool(endpoints, cfg.MerkleService.AuthToken, 0)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 → Client's 30s defaultTimeout. That number sets the cost of every hung-endpoint path: before 0366c51 it was the stall per batch, and it is still the stall for a single-endpoint deployment and for the all-down case until breakers trip (~31–38s here). Worth exposing as merkle_service.timeout_ms (same default) so operators can tighten it; /watch answers in ~100–300ms on a healthy service. Follow-up, not blocking.

pool.SetLogger(logger.Named("merkle-client"))
pool.Start(ctx)
merkleClient = pool
logger.Info("merkle-service endpoints configured", zap.Strings("endpoints", endpoints))
if cfg.MerkleService.AuthToken == "" {
// Not fatal: many merkle-service deployments don't enforce auth, and
// the standalone/no-auth profile is legitimate. But if the target
Expand All @@ -196,8 +208,8 @@ func Bootstrap(ctx context.Context, cfg *config.Config, logger *zap.Logger) (*De
// this loudly (issue #269) rather than misreporting it as a
// consensus problem. Warn at startup so the cause is obvious.
logger.Warn(
"merkle_service.url is set but merkle_service.auth_token is empty; " +
"if the target merkle-service enforces auth, tx registration (/watch) " +
"merkle_service endpoints are set but merkle_service.auth_token is empty; " +
"if a target merkle-service enforces auth, tx registration (/watch) " +
"and block-processed recovery (/reprocess) will fail with 401 at runtime — " +
"set merkle_service.auth_token to authenticate",
)
Expand Down Expand Up @@ -632,7 +644,7 @@ func BuildServices(d *Deps) []services.Service {
if wd := watchdog.NewService(cfg, d.Logger, d.Store, d.Leaser, d.MerkleClient); wd != nil {
svcs = append(svcs, wd)
} else {
d.Logger.Info("watchdog skipped: merkle_service.url or leaser not configured")
d.Logger.Info("watchdog skipped: merkle_service endpoints or leaser not configured")
}
}
if shouldRun("sse") {
Expand Down
4 changes: 4 additions & 0 deletions compose/arcade-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ validator:

merkle_service:
url: http://merkle-service:8080
# Add further merkle-service instances for redundancy; every tx is
# registered with all of them and duplicate callbacks are no-ops.
# urls:
# - http://merkle-service-2:8080

# Every chaintracks consumer (bump-builder header lookups, the validator
# finality gate in api-server) reads headers from the chaintracks service
Expand Down
1 change: 1 addition & 0 deletions config.example.standalone.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ teranode:

merkle_service:
url: ""
urls: []
auth_token: ""

# P2P discovery via go-teranode-p2p-client. When enabled, arcade joins the
Expand Down
23 changes: 19 additions & 4 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -121,11 +121,26 @@ teranode:
auth_token: ""

merkle_service:
# Merkle Service base URL. Leaving this empty disables the Merkle integration
# (standalone profiles). When set, the top-level `callback_token` becomes
# REQUIRED — arcade fails to start otherwise so that the inbound callback
# endpoint never runs unauthenticated. See issue #76 / finding F-018.
# Merkle Service base URL(s). Leaving both `url` and `urls` empty disables
# the Merkle integration (standalone profiles). When at least one endpoint
# is set, the top-level `callback_token` becomes REQUIRED — arcade fails to
# start otherwise so that the inbound callback endpoint never runs
# unauthenticated. See issue #76 / finding F-018.
#
# `url` is the single-endpoint form; `urls` adds more. Every tx is
# registered with every endpoint and each endpoint delivers its own
# callbacks, so one merkle-service going down neither blocks registration
# (a tx is registered once any endpoint accepts it) nor stops SEEN/STUMP/
# BLOCK_PROCESSED delivery. Duplicate callbacks are no-ops. A registration
# one endpoint accepted but another missed is re-sent to it in the
# background. An endpoint that fails three consecutive requests is skipped
# until GET /health on it answers.
# All endpoints share `auth_token`. Env form: ARCADE_MERKLE_SERVICE_URLS as
# a comma-separated list.
url: ""
urls: []
# - "https://merkle-service-us-1.bsvb.tech"
# - "https://merkle-service-us-2.bsvb.tech"
auth_token: ""

p2p:
Expand Down
142 changes: 123 additions & 19 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package config
import (
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -310,9 +311,52 @@ type TeranodeConfig struct {
AuthToken string `mapstructure:"auth_token"`
}

// MerkleServiceConfig selects the merkle-service endpoints arcade registers
// transactions with. URL is the original single-endpoint form; URLs is the
// multi-endpoint form. Both feed Endpoints(), so an existing config with only
// `url` keeps working and `urls` may be added alongside it.
//
// With several endpoints every tx is registered with all of them and every
// endpoint delivers callbacks; arcade treats a callback that carries nothing
// new as a no-op. A tx counts as registered once at least one endpoint
// accepted it, so a single unreachable merkle-service no longer blocks
// broadcast (F-024 still holds: zero acceptances means no broadcast).
type MerkleServiceConfig struct {
URL string `mapstructure:"url"`
AuthToken string `mapstructure:"auth_token"`
URL string `mapstructure:"url"`
URLs []string `mapstructure:"urls"`
AuthToken string `mapstructure:"auth_token"`
}

// Endpoints returns every configured merkle-service base URL, in config
// order (URL first, then URLs), trimmed, without a trailing slash, and with
// duplicates removed. Empty entries are dropped here; validate rejects them so
// a typo in `urls` fails loudly at startup instead of silently shrinking the
// redundancy set.
func (c MerkleServiceConfig) Endpoints() []string {
raw := make([]string, 0, 1+len(c.URLs))
raw = append(raw, c.URL)
raw = append(raw, c.URLs...)
seen := make(map[string]struct{}, len(raw))
out := make([]string, 0, len(raw))
for _, u := range raw {
u = strings.TrimSuffix(strings.TrimSpace(u), "/")
if u == "" {
continue
}
if _, dup := seen[u]; dup {
continue
}
seen[u] = struct{}{}
out = append(out, u)
}
return out
}

// Enabled reports whether the Merkle integration is configured at all. It is
// the single toggle every consumer checks: no endpoints means no /watch
// registration, no /reprocess recovery, and no watchdog.
func (c MerkleServiceConfig) Enabled() bool {
return len(c.Endpoints()) > 0
}

// P2PConfig controls the libp2p-based peer discovery service. Seeds is the
Expand Down Expand Up @@ -548,10 +592,13 @@ type PropagationConfig struct {
// MerkleRegisteredAt is within this window. /watch is INSERT ... ON
// CONFLICT DO NOTHING on the merkle-service side and does not refresh
// expires_at, so re-registering a tx merkle-service already knows about
// is wasted work. Default 30 (matches merkle-service postMineTTLSec).
// Set to 0 to disable the skip and re-register every non-terminal tx —
// useful for forcing a full re-sync after a known merkle-service wipe.
// Issue #145.
// is wasted work. Default -1 (auto): 30 (matches merkle-service
// postMineTTLSec) with a single merkle-service, 0 with several, because
// the stamp is set once any one of them accepted the tx and the pool's
// queue of registrations owed to a lagging endpoint is in memory, so a
// restart must re-send them. Set to 0 to disable the skip and
// re-register every non-terminal tx — useful for forcing a full re-sync
// after a known merkle-service wipe. Issue #145.
MerkleReplaySkipRecentMinutes int `mapstructure:"merkle_replay_skip_recent_minutes"`
// MerkleReplayRPS caps the average requests-per-second the startup
// replay issues against merkle-service. Implemented as an inter-batch
Expand Down Expand Up @@ -751,8 +798,8 @@ type ReconcilerConfig struct {

// WatchdogConfig tunes the stale-block recovery watchdog. Defaults are
// applied in setDefaults; zero values for everything except Enabled fall
// back to those defaults. The watchdog requires merkle_service.url to be
// set (no /reprocess target otherwise) and runs as a standalone arcade
// back to those defaults. The watchdog requires at least one merkle_service
// endpoint (no /reprocess target otherwise) and runs as a standalone arcade
// service (mode=watchdog) or alongside other services when mode=all. At
// most one replica fires per tick — coordination via the
// `block-processing-watchdog` lease.
Expand Down Expand Up @@ -1190,6 +1237,17 @@ func setDefaults() {
viper.SetDefault("api.host", "0.0.0.0")
viper.SetDefault("api.port", 8080)
viper.SetDefault("kafka.backend", "sarama")
// callback_* and merkle_service.* defaults exist so viper.AutomaticEnv
// knows the keys: without a registered key an ARCADE_MERKLE_SERVICE_URLS
// or ARCADE_CALLBACK_TOKEN override is silently ignored (see
// TestPropagationBatchCapsBind for the same trap). Empty values keep the
// integration disabled.
viper.SetDefault("callback_url", "")
viper.SetDefault("callback_token", "")
viper.SetDefault("merkle_service.url", "")
viper.SetDefault("merkle_service.urls", []string{})
viper.SetDefault("merkle_service.auth_token", "")

viper.SetDefault("kafka.brokers", []string{"localhost:9092"})
viper.SetDefault("kafka.consumer_group", "arcade")
viper.SetDefault("kafka.max_retries", 5)
Expand Down Expand Up @@ -1276,7 +1334,7 @@ func setDefaults() {
// callbacks for everything in-flight.
viper.SetDefault("propagation.register_replay_on_start", true)
viper.SetDefault("propagation.register_replay_lookback_hours", 24)
viper.SetDefault("propagation.merkle_replay_skip_recent_minutes", 30)
viper.SetDefault("propagation.merkle_replay_skip_recent_minutes", -1)
viper.SetDefault("propagation.merkle_replay_rps", 50)

viper.SetDefault("network", NetworkMainnet)
Expand Down Expand Up @@ -1345,7 +1403,7 @@ func setDefaults() {
// Block-processing watchdog (standalone arcade service — mode=watchdog
// in production, in-process under mode=all): on by default. The runtime
// nil-guards the merkle-service client; an unconfigured deployment
// (merkle_service.url unset) skips the watchdog regardless of this flag.
// (no merkle_service.url/urls) skips the watchdog regardless of this flag.
viper.SetDefault("watchdog.enabled", true)
viper.SetDefault("watchdog.interval_ms", 30000)
viper.SetDefault("watchdog.stale_threshold_ms", 120000)
Expand Down Expand Up @@ -1401,6 +1459,52 @@ func setDefaults() {
viper.SetDefault("validator.observed_fee_refresh_ms", DefaultValidatorObservedFeeRefreshMs)
}

// validateMerkleService checks the merkle_service block. Every entry of
// `url`/`urls` must be an absolute http(s) URL; a blank entry in `urls` is a
// misconfiguration rather than "disabled" (only an entirely empty block
// disables the integration). When at least one endpoint is set the top-level
// callback_token is mandatory — see the comment in validate.
func validateMerkleService(cfg *Config) error {
for i, u := range cfg.MerkleService.URLs {
if strings.TrimSpace(u) == "" {
return fmt.Errorf("merkle_service.urls[%d] is empty; remove the entry or set a URL", i)
}
}
for _, u := range cfg.MerkleService.Endpoints() {
parsed, err := url.Parse(u)
if err != nil {
return fmt.Errorf("merkle_service url %q: %w", u, err)
}
// The URL is shown in /health, metric labels and startup logs, so
// credentials in it would leak there; auth_token is the supported
// way to authenticate.
if parsed.User != nil {
return fmt.Errorf("merkle_service url %q must not contain user info; use merkle_service.auth_token for authentication", redactUserinfo(parsed))
}
if (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
return fmt.Errorf("merkle_service url %q must be an absolute http(s) URL", u)
}
Comment thread
Copilot marked this conversation as resolved.
Comment on lines +1489 to +1491

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 71dd3c3: URLs with user info are rejected at config validation (the error redacts the password); auth_token is the supported mechanism. Test: TestValidate_MerkleServiceRejectsUserinfo.

// The client appends /watch, /reprocess and /health to the base URL
// as a string, so a query or fragment would swallow the path.
if parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" {
return fmt.Errorf("merkle_service url %q must not contain a query or fragment", u)
}
}
if cfg.MerkleService.Enabled() && cfg.CallbackToken == "" {
return fmt.Errorf("callback_token is required when merkle_service.url/urls is set " +
"(unauthenticated /api/v1/merkle-service/callback would accept forged callbacks; see issue #76)")
}
return nil
}

// redactUserinfo renders u with its user info replaced, so the validation
// error that rejects it does not print the password it is protecting.
func redactUserinfo(u *url.URL) string {
c := *u
c.User = url.User("REDACTED")
return c.String()
}

func validate(cfg *Config) error {
switch cfg.Kafka.Backend {
case "", "sarama":
Expand Down Expand Up @@ -1456,15 +1560,16 @@ func validate(cfg *Config) error {
default:
return fmt.Errorf("unknown store.backend %q (expected aerospike, pebble, postgres, or mongodb)", cfg.Store.Backend)
}
// merkle_service.url is intentionally optional: an empty value means the
// Merkle integration is disabled. The runtime treats URL-presence as the
// toggle — cmd/arcade/main.go only constructs a merkleservice.Client when
// the URL is set, and propagation.Propagator nil-guards every dereference
// of the client. The documented standalone and zero-dependency profiles
// merkle_service.url / urls are intentionally optional: no endpoints means
// the Merkle integration is disabled. The runtime treats
// MerkleServiceConfig.Enabled() as the toggle — app.Bootstrap only
// constructs a merkleservice.Pool when at least one endpoint is set, and
// propagation.Propagator nil-guards every dereference of the client. The
// documented standalone and zero-dependency profiles
// (config.example.standalone.yaml) ship with merkle_service.url: "" for
// exactly this reason. See issue #59 / finding F-001.
//
// When the Merkle integration IS enabled (URL set), callback_token is
// When the Merkle integration IS enabled, callback_token is
// mandatory. The /api/v1/merkle-service/callback endpoint accepts forged
// status updates for any txid in the system if it runs without bearer-token
// auth, so we fail-closed here at config load rather than silently exposing
Expand All @@ -1476,9 +1581,8 @@ func validate(cfg *Config) error {
// callbacks. Without a configured token there's nothing to forward AND the
// inbound receiver would 401 anyway — the same fail-closed posture covers
// both ends, so a duplicate "outbound token required" check is unnecessary.
if cfg.MerkleService.URL != "" && cfg.CallbackToken == "" {
return fmt.Errorf("callback_token is required when merkle_service.url is set " +
"(unauthenticated /api/v1/merkle-service/callback would accept forged callbacks; see issue #76)")
if err := validateMerkleService(cfg); err != nil {
return err
}
if cfg.Network == "" {
cfg.Network = NetworkMainnet
Expand Down
Loading
Loading