Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 7 additions & 6 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -161,12 +161,13 @@ health:
# # announcements — and BOTH enforces it at intake and advertises it via
# # GET /policy (issue #212). Until a peer is heard it uses the built-in
# # 100 sat/kB floor. Set a non-zero value to pin and enforce an exact floor.
# # An observed fee is floored at 1 sat/kB — peer announcements are
# # unauthenticated, so one peer advertising 0 must not be able to disable
# # fee enforcement for the whole instance. Only accept_zero_fee below can
# # produce a 0 floor. Only peers whose datahub URL is registered count
# # toward the observation, so /policy's inputs are the endpoints /health
# # shows.
# # A peer advertising 0 sat/kB has advertised nothing, not offered to mine
# # for free — teranode reports min_mining_tx_fee=0 whenever its policy
# # settings are nil — so such an announcement is discarded rather than
# # counted. If no fresh peer advertises a fee at all, the built-in
# # 100 sat/kB applies. Only accept_zero_fee below can produce a 0 floor.
# # Only peers whose datahub URL is registered count toward the observation,
# # so /policy's inputs are the endpoints /health shows.
# min_fee_per_kb: 0
# # accept_zero_fee, when true, pins the floor to 0 sat/kB so any tx
# # (including fee=0) is accepted at intake, and /policy advertises 0. This
Expand Down
9 changes: 5 additions & 4 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -933,10 +933,11 @@ type ValidatorConfig struct {
// DefaultValidatorMinFeePerKB when no peer has been heard. A non-zero value
// (or AcceptZeroFee) is reported verbatim.
//
// An observed fee is floored at 1 sat/kB: peer announcements are
// unauthenticated and the rule is a bare minimum, so a single peer
// advertising 0 would otherwise disable fee enforcement entirely. Setting
// AcceptZeroFee is the only way to reach a 0 floor.
// A peer advertising 0 sat/kB is treated as having advertised nothing and
// is discarded, not counted as a node that mines for free: teranode
// reports min_mining_tx_fee=0 whenever its policy settings are nil. When
// no fresh peer advertises a fee at all, DefaultValidatorMinFeePerKB
// applies. Setting AcceptZeroFee is the only way to reach a 0 floor.
MinFeePerKB uint64 `mapstructure:"min_fee_per_kb"`

// AcceptZeroFee, when true, pins the validator's fee floor to exactly
Expand Down
24 changes: 14 additions & 10 deletions metrics/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -1001,32 +1001,36 @@ var P2PPeerBestHeight = promauto.NewGaugeVec(prometheus.GaugeOpts{
// P2PPeerMinMiningFee reports the minimum mining fee (satoshis per 1000 bytes)
// each peer advertises in its node_status FeePolicy. The GET /policy endpoint
// advertises the network-wide minimum of these (issue #212); this gauge makes
// the per-peer inputs visible. Labelled by base_url for the same
// bounded-cardinality reason as P2PPeerBestHeight; peers with no base_url are
// still persisted for the minimum but not surfaced here.
// the per-peer inputs visible.
//
// Labelled by the peer's registered datahub URL — the same key the stored
// policy row uses and the same value GET /health lists — for the
// bounded-cardinality reason described on P2PPeerBestHeight. It was previously
// labelled by base_url, which silently omitted any peer announcing only a
// propagation URL even though such a peer still set the network minimum.
var P2PPeerMinMiningFee = promauto.NewGaugeVec(prometheus.GaugeOpts{
Name: "arcade_p2p_peer_min_mining_fee",
Help: "Minimum mining fee (satoshis per 1000 bytes) advertised by a peer via p2p node_status, by base_url.",
}, []string{"base_url"})
Help: "Minimum mining fee (satoshis per 1000 bytes) advertised by a peer via p2p node_status, by datahub_url.",
}, []string{"datahub_url"})

// P2PPeerMaxTxSizePolicy and P2PPeerMaxScriptSizePolicy report the size limits
// each peer advertises in its node_status FeePolicy. GET /policy advertises the
// network-wide maximum of these — the most permissive peer, mirroring the
// cheapest-peer rule used for the fee — and intake enforces it, so these gauges
// are how an operator sees which peer is setting the ceiling and spots one
// advertising an outlier. Labelled by base_url for the same
// advertising an outlier. Labelled by datahub_url for the same
// bounded-cardinality reason as P2PPeerMinMiningFee. Only set for peers that
// actually advertise a limit: a legacy peer's absent limit would otherwise show
// as 0, reading as "accepts nothing".
var P2PPeerMaxTxSizePolicy = promauto.NewGaugeVec(prometheus.GaugeOpts{
Name: "arcade_p2p_peer_max_tx_size_policy",
Help: "Maximum transaction size (bytes) advertised by a peer via p2p node_status, by base_url.",
}, []string{"base_url"})
Help: "Maximum transaction size (bytes) advertised by a peer via p2p node_status, by datahub_url.",
}, []string{"datahub_url"})

var P2PPeerMaxScriptSizePolicy = promauto.NewGaugeVec(prometheus.GaugeOpts{
Name: "arcade_p2p_peer_max_script_size_policy",
Help: "Maximum script size (bytes) advertised by a peer via p2p node_status, by base_url.",
}, []string{"base_url"})
Help: "Maximum script size (bytes) advertised by a peer via p2p node_status, by datahub_url.",
}, []string{"datahub_url"})

// ChainTipHeight reports arcade's own view of the active chain tip — the
// highest block_processing row marked active. Refreshed by the api-server's
Expand Down
79 changes: 52 additions & 27 deletions services/api_server/policy_refresher.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,11 @@ func (s *Server) refreshPolicyOnce(ctx context.Context) {
s.logger.Info("intake policy updated from network observations",
zap.Uint64("prev_fee_sat_per_kb", prevFee),
zap.Uint64("new_fee_sat_per_kb", newFee),
// Which peer set the floor. Without it, "why is the floor 1?" can
// only be answered by querying the store by hand.
// Which peer set the floor, and how many fresh peers advertised no
// fee at all. Without these, "why is the floor 1?" can only be
// answered by querying the store by hand.
zap.String("cheapest_peer_id", cheapest),
zap.Int("fee_unadvertised_peers", countUnadvertisedFees(peers, ttl, now)),
zap.Int("prev_max_tx_size", prevTxSize),
zap.Int("new_max_tx_size", newTxSize),
zap.Int("prev_max_script_size", prevScriptSize),
Expand Down Expand Up @@ -163,45 +165,54 @@ func discoveredLimit(peers []store.PeerPolicy, ttl time.Duration, now time.Time,
return observed
}

// minDiscoveredFeePerKB is the floor under the network-observed fee: discovery
// may track the cheapest node all the way down to 1 sat/kB, but never to 0.
//
// This is the fee's counterpart to the floor discoveredLimit applies to the
// size limits, and it exists for the same reason: node_status is
// unauthenticated gossip and the rule is a bare minimum, so a single peer
// advertising 0 — misconfigured, or simply a node with nil policy settings,
// which teranode advertises as min_mining_tx_fee=0 — silently disabled fee
// enforcement for an entire arcade instance. Accepting zero-fee transactions is
// a deliberate operator decision, so accept_zero_fee is the only thing that may
// produce a 0 floor.
const minDiscoveredFeePerKB = 1

// discoveredFeePerKB resolves the network-tracked fee floor: the lowest rate a
// fresh peer will accept, bounded below by minDiscoveredFeePerKB, or the
// built-in default when nothing fresh has been observed. It also returns the id
// of the peer that set the floor, so the change is explainable in a log line
// rather than only by querying the store.
// fresh peer actually advertised, or the built-in default when no fresh peer
// advertised one at all. It also returns the id of the peer that set the floor,
// so the value is explainable from a log line rather than only by querying the
// store.
func discoveredFeePerKB(peers []store.PeerPolicy, ttl time.Duration, now time.Time) (uint64, string) {
observed, peerID, ok := lowestObservedFeePerKB(peers, ttl, now)
if !ok {
return uint64(config.DefaultValidatorMinFeePerKB), ""
}
if observed < minDiscoveredFeePerKB {
return minDiscoveredFeePerKB, peerID
}
return observed, peerID
}

// isFreshObservation reports whether a peer row is a live observation: re-heard
// within the TTL.
//
// A row with no LastSeen at all is stale, not fresh. An observation that cannot
// be dated cannot be shown to be current, and the TTL is the only thing that
// stops a departed peer from pinning the network policy forever — every backend
// can produce an undated row (a missing aerospike bin, a zero pebble timestamp,
// a postgres zero-value date), and treating one as perpetually fresh makes it
// immortal. Shared by both aggregators so they cannot drift on the question.
func isFreshObservation(p store.PeerPolicy, cutoff time.Time) bool {
return !p.LastSeen.Before(cutoff)
}

// countUnadvertisedFees reports how many fresh peers advertised no fee at all.
// It exists for the log line: it is the number that explains why the floor is
// what it is, and its absence is what made a production report of a 1 sat/kB
// floor unanswerable without a store query.
func countUnadvertisedFees(peers []store.PeerPolicy, ttl time.Duration, now time.Time) int {
cutoff := now.Add(-ttl)
var n int
for _, p := range peers {
if isFreshObservation(p, cutoff) && p.MiningFeeSatoshis == 0 {
n++
}
}
return n
}

// lowestObservedFeePerKB returns the minimum mining fee rate (in satoshis per
// 1000 bytes) advertised by peers re-heard within ttl, and the id of the peer
// advertising it. ok is false when no fresh observation exists. A peer's rate
// is normalized to sat/kB using ceil division so a non-1000 byte basis never
// rounds the enforced floor *below* what the peer requires (e.g. 1 sat / 1001
// bytes must map to 1 sat/kB, not 0, or arcade would accept fee=0 that no node
// would).
//
// Callers should use discoveredFeePerKB rather than this directly: the raw
// minimum is unbounded below and must not reach the validator unfloored.
func lowestObservedFeePerKB(peers []store.PeerPolicy, ttl time.Duration, now time.Time) (uint64, string, bool) {
cutoff := now.Add(-ttl)
var (
Expand All @@ -210,10 +221,24 @@ func lowestObservedFeePerKB(peers []store.PeerPolicy, ttl time.Duration, now tim
found bool
)
for _, p := range peers {
if p.MiningFeeSatoshis == 0 {
// 0 satoshis is not an observation of a node that mines for free,
// it is the absence of an advertisement: teranode reports
// min_mining_tx_fee=0 whenever its policy settings are nil, and the
// legacy BSV/kB path converts that 0.0 straight through. Counting
// it as a real rate makes one silent non-advertisement the network
// minimum for the whole instance — which is what production did,
// first as a 0 sat/kB floor and then, once the 0 was clamped rather
// than discarded, as 1 sat/kB while every peer in /health required
// 100. This is the same sentinel highestObservedLimit applies to
// the size limits. A network that really does mine for free is an
// operator decision: that is what accept_zero_fee is for.
continue
}
if p.MiningFeeBytes == 0 {
continue // avoid divide-by-zero on a malformed row
}
if !p.LastSeen.IsZero() && p.LastSeen.Before(cutoff) {
if !isFreshObservation(p, cutoff) {
continue // peer not re-heard within TTL
}
perKB := ceilFeePerKB(p.MiningFeeSatoshis, p.MiningFeeBytes)
Expand Down Expand Up @@ -248,7 +273,7 @@ func highestObservedLimit(peers []store.PeerPolicy, ttl time.Duration, now time.
if v == 0 {
continue // peer did not advertise this limit
}
if !p.LastSeen.IsZero() && p.LastSeen.Before(cutoff) {
if !isFreshObservation(p, cutoff) {
continue // peer not re-heard within TTL
}
if !found || v > best {
Expand Down
Loading