Skip to content

fix(policy): a peer's 0 sat/kB is an absent fee, not a free miner - #321

Open
galt-tr wants to merge 1 commit into
mainfrom
fix/policy-zero-fee-not-an-observation
Open

fix(policy): a peer's 0 sat/kB is an absent fee, not a free miner#321
galt-tr wants to merge 1 commit into
mainfrom
fix/policy-zero-fee-not-an-observation

Conversation

@galt-tr

@galt-tr galt-tr commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

What Changed

  • lowestObservedFeePerKB skips peer rows with MiningFeeSatoshis == 0, treating them as "did not advertise a fee" — the same sentinel highestObservedLimit already applies to the size limits.
  • minDiscoveredFeePerKB and its clamp are deleted. With zeros discarded the branch is unreachable (ceilFeePerKB returns 0 only for a 0 satoshi input), and its doc comment asserted the reading that caused the bug.
  • isFreshObservation replaces the inlined freshness check in both aggregators. An undated row is now stale rather than immortal.
  • recordPeerPolicy takes the registered datahub URL and labels the three per-peer gauges with it instead of msg.BaseURL; label renamed base_urldatahub_url.
  • refreshPolicyOnce logs fee_unadvertised_peers alongside cheapest_peer_id.
  • Docs corrected in config.example.yaml, config/config.go, store/store.go — all three still described the clamp as the design.

Why It Was Necessary

arcade-v2-us-1 advertised miningFee 1 sat/kB on GET /policy while all six healthy endpoints in GET /health advertised 100. maxtxsizepolicy/maxscriptsizepolicy were the discovered values, so discovery was working — only the fee was wrong. ARC clients read /policy.miningFee to compute what they pay, so arcade was telling wallets to build transactions at 1/100th of the rate mainnet miners require.

This is the same defect #319 reported, at a different number. That report saw a 0 floor; #319 clamped the observed minimum up to 1. Its own commit message named the cause — "teranode advertises min_mining_tx_fee=0 when its policy settings are nil" — which says 0 means not advertised. Clamping the result rather than discarding the non-observation moved the symptom from 0 to 1 and left the cause in place.

The production peer_policies rows confirm it:

fee (sat/kB) rows last_seen
0 1 02:09:50
1 2 02:09:45, 02:09:53
100 6 live

The three anomalous rows stop at the exact second the p2p-client rolled from v0.13.2 to v0.13.3 (2026-08-18T02:09:45Z) — last writes of the pre-#319 binary, which recorded policy ahead of the datahub-URL gate. They aged out after the 15-minute TTL and /policy returned to 100 on its own, so this PR is hardening rather than an outage fix. The defect is still live: any peer that does register a URL and advertises 0 puts /policy back at 1.

Testing Performed

  • go test ./services/api_server/... ./services/p2p_client/... ./store/... ./validator/... ./config/... — all pass.
  • go test -race -run 'Policy|Fee|Peer' ./services/api_server/... ./services/p2p_client/... — pass.
  • golangci-lint run on all touched packages — 0 issues.
  • Every new assertion confirmed red before the fix (10 total). TestRefreshPolicyOnce_MainnetRegression reproduces the exact production row shape — six peers at 100 plus one nil-policy node — and returned 1 before, 100 now. TestRecordPeerPolicy_GaugeCoversPropagationOnlyPeers was verified by temporarily restoring the old label source: it reported 0, want 100.
  • TestRefreshPolicyOnce_AcceptZeroFeeStillPinsZero untouched and passing — teratestnet (accept_zero_fee: true) must keep advertising 0.
  • Pre-existing unrelated failure: logfields.TestNoCanonicalKeyLiteralsOutsidePackage, which fails identically on clean main (all findings come from a stale local .claude/worktrees copy).

Impact / Risk

Low. Behaviour changes only where a peer advertised no fee, which previously produced a floor no node on the network would honour. A peer genuinely advertising 1 sat/kB is still tracked — pinned by its own test so this cannot silently become a blanket floor.

The base_urldatahub_url label rename affects three gauges (arcade_p2p_peer_min_mining_fee, ..._max_tx_size_policy, ..._max_script_size_policy). Nothing in this repo or bsva-infra-flux references them; arcade_p2p_peer_best_height is genuinely base-URL-keyed and is left alone.

Two follow-ups this PR deliberately does not take on:

  1. Advertised vs enforced fee. validator.MinFeePerKB() still serves both the intake floor and the /policy advertisement. MIN-across-peers is right for the first and wrong for the second — and mainnet currently has two peers genuinely advertising 1 sat/kB, so this is concrete rather than hypothetical.
  2. Stale rows are never deleted from peer_policies, only TTL-filtered. The three poisoned rows are still in the production table, inert but re-armable if those peer IDs ever register a URL again.

Notifications

https://claude.ai/code/session_01K8pDVtifV8xhKfhEya8wcJ

Production report: arcade-v2-us-1 advertised miningFee 1 sat/kB on GET
/policy while all six healthy endpoints in GET /health advertised 100. The
size limits were the discovered 100000000/500000 rather than the built-in
defaults, so discovery was working — only the fee was wrong.

This is the same defect #319 reported, at a different number. That report
saw a 0 floor; #319 added minDiscoveredFeePerKB and clamped the observed
minimum up to 1. Its commit message named the real cause — "teranode
advertises min_mining_tx_fee=0 when its policy settings are nil" — which
says 0 means *not advertised*. Clamping the result instead of discarding
the non-observation moved the symptom from 0 to 1 and left the cause in
place, so the cheapest peer was still a node that had never quoted a fee.

lowestObservedFeePerKB now skips MiningFeeSatoshis == 0, which is the
sentinel highestObservedLimit has always applied to the size limits ("0
means the peer did not advertise a limit, not that it accepts nothing").
With zeros discarded the clamp is unreachable — ceilFeePerKB returns 0 only
for a 0 satoshi input — so minDiscoveredFeePerKB is deleted rather than
left as dead code whose doc comment asserts the discredited reading. When
no fresh peer advertised a fee at all the built-in 100 applies, and
accept_zero_fee remains the only route to a 0 floor.

Two things that made this undiagnosable from outside are fixed with it.

An undated row was fresh forever. Both aggregators inlined
`!LastSeen.IsZero() && LastSeen.Before(cutoff)`, so a row with no LastSeen
never expired — and every backend can produce one (a missing aerospike bin,
a zero pebble timestamp, a postgres zero-value date). isFreshObservation
replaces both copies and treats an undated row as stale: an observation
that cannot be dated cannot be shown to be current.

The per-peer gauges could not see the peer that set the floor. They were
labelled by msg.BaseURL, but the row they accompany — and so the minimum
/policy derives from it — is keyed off pickDatahubURL, which prefers
PropagationURL. A peer announcing only a propagation URL set the floor for
the whole instance while appearing in no series at all, which is exactly
why an operator could see only peers advertising 100. recordPeerPolicy now
takes the registered URL and labels all three gauges with it; the label is
renamed base_url -> datahub_url to match, and the values now join to
/health's datahub_urls[].url, normalized the same way. refreshPolicyOnce
also logs fee_unadvertised_peers beside cheapest_peer_id.

Tests fail against the previous behaviour, including a regression built
from the exact production peer_policies rows: six datahub peers at 100
alongside one nil-policy node, which returned 1 before and returns 100 now.

Claude-Session: https://claude.ai/code/session_01K8pDVtifV8xhKfhEya8wcJ
@galt-tr
galt-tr requested a review from mrz1836 as a code owner August 18, 2026 10:43
Copilot AI lite review requested due to automatic review settings August 18, 2026 10:43
@github-actions github-actions Bot added the bug-P3 Lowest rated bug, affects nearly none or low-impact label Aug 18, 2026
@github-actions github-actions Bot added the size/L Large change (201-500 lines) label Aug 18, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR hardens network policy discovery by treating a peer’s MiningFeeSatoshis == 0 as “fee not advertised” (rather than “mines for free”), aligning fee handling with the existing “0 means not advertised” sentinel used for size limits, and improving operational explainability via shared freshness logic and richer logging/metrics labeling.

Changes:

  • Discard fee observations where MiningFeeSatoshis == 0 and remove the now-unreachable fee clamp, falling back to the built-in default when no fresh peer advertises a fee.
  • Introduce isFreshObservation to consistently TTL-filter both fee and size aggregations, making undated rows stale instead of immortal.
  • Relabel per-peer policy gauges using the registered datahub_url (instead of base_url) and add log visibility for how many fresh peers did not advertise a fee.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
store/store.go Documents MiningFeeSatoshis == 0 as “not advertised” to match downstream aggregation behavior.
services/p2p_client/client.go Passes the registered datahub URL into recordPeerPolicy and uses it for policy metrics labeling.
services/p2p_client/client_test.go Adds a regression test ensuring propagation-only peers are visible in per-peer fee metrics.
services/api_server/policy_refresher.go Removes the fee clamp, discards zero-fee “non-observations,” centralizes freshness logic, and logs unadvertised-fee counts.
services/api_server/policy_refresher_test.go Expands coverage for zero-fee-as-absent semantics and undated-row staleness; adds a mainnet-shape regression.
metrics/metrics.go Renames relevant per-peer gauge label from base_url to datahub_url and updates help text/docs.
config/config.go Updates config docs to reflect “0 fee means not advertised” and default fallback behavior.
config.example.yaml Updates example config comments to match the corrected fee observation semantics.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +423 to 426
if datahubURL != "" {
// Normalize to satoshis per 1000 bytes for a comparable gauge.
metrics.P2PPeerMinMiningFee.WithLabelValues(msg.BaseURL).Set(float64(sats) * 1000 / float64(byts))
metrics.P2PPeerMinMiningFee.WithLabelValues(datahubURL).Set(float64(sats) * 1000 / float64(byts))
// Only report a size limit the peer actually advertised: a gauge stuck
@mrz1836 mrz1836 assigned galt-tr and unassigned mrz1836 Aug 18, 2026
@raspi-user

Copy link
Copy Markdown

I reviewed PR head de69274f33f11e1858337f39c77398420086936f.

No blocking issues found.

This looks like the right correction. Treating MiningFeeSatoshis == 0 as "not advertised" avoids letting one nil-policy peer drag /policy.miningFee down to zero or to the previous clamp. The important cases appear covered: real 1 sat/kB fees still pass through, accept_zero_fee remains the explicit route for intentional zero-fee advertising, undated rows are stale, malformed zero-byte fee rows are skipped, and size limits remain floored at Arcade defaults.

The observability change also looks sound. Recording peer policy only after datahub URL validation keeps /policy derived from the same peer set exposed through /health, and using datahub_url makes propagation-only peers visible in metrics.

Two non-blocking follow-ups I would want tracked:

  1. The metric label rename from base_url to datahub_url may break external dashboards or alerts. If there are no out-of-band consumers, or this is called out in release notes, that is fine.

  2. Stale peer_policies rows are still TTL-filtered rather than deleted. Acceptable for this fix, but worth keeping as a follow-up so the table does not grow indefinitely.

Checks reviewed: GitHub shows the relevant test/lint/smoke/security checks passing. I did not rerun the full suite locally.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug-P3 Lowest rated bug, affects nearly none or low-impact size/L Large change (201-500 lines)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants