fix(policy): a peer's 0 sat/kB is an absent fee, not a free miner - #321
fix(policy): a peer's 0 sat/kB is an absent fee, not a free miner#321galt-tr wants to merge 1 commit into
Conversation
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
There was a problem hiding this comment.
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 == 0and remove the now-unreachable fee clamp, falling back to the built-in default when no fresh peer advertises a fee. - Introduce
isFreshObservationto 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 ofbase_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.
| 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 |
|
I reviewed PR head No blocking issues found. This looks like the right correction. Treating The observability change also looks sound. Recording peer policy only after datahub URL validation keeps Two non-blocking follow-ups I would want tracked:
Checks reviewed: GitHub shows the relevant test/lint/smoke/security checks passing. I did not rerun the full suite locally. |
What Changed
lowestObservedFeePerKBskips peer rows withMiningFeeSatoshis == 0, treating them as "did not advertise a fee" — the same sentinelhighestObservedLimitalready applies to the size limits.minDiscoveredFeePerKBand its clamp are deleted. With zeros discarded the branch is unreachable (ceilFeePerKBreturns 0 only for a 0 satoshi input), and its doc comment asserted the reading that caused the bug.isFreshObservationreplaces the inlined freshness check in both aggregators. An undated row is now stale rather than immortal.recordPeerPolicytakes the registered datahub URL and labels the three per-peer gauges with it instead ofmsg.BaseURL; label renamedbase_url→datahub_url.refreshPolicyOncelogsfee_unadvertised_peersalongsidecheapest_peer_id.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-1advertisedminingFee 1 sat/kBonGET /policywhile all six healthy endpoints inGET /healthadvertised 100.maxtxsizepolicy/maxscriptsizepolicywere the discovered values, so discovery was working — only the fee was wrong. ARC clients read/policy.miningFeeto 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_policiesrows confirm it:The three anomalous rows stop at the exact second the p2p-client rolled from
v0.13.2tov0.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/policyreturned 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/policyback 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 runon all touched packages — 0 issues.TestRefreshPolicyOnce_MainnetRegressionreproduces the exact production row shape — six peers at 100 plus one nil-policy node — and returned 1 before, 100 now.TestRecordPeerPolicy_GaugeCoversPropagationOnlyPeerswas verified by temporarily restoring the old label source: it reported0, want 100.TestRefreshPolicyOnce_AcceptZeroFeeStillPinsZerountouched and passing — teratestnet (accept_zero_fee: true) must keep advertising 0.logfields.TestNoCanonicalKeyLiteralsOutsidePackage, which fails identically on cleanmain(all findings come from a stale local.claude/worktreescopy).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_url→datahub_urllabel rename affects three gauges (arcade_p2p_peer_min_mining_fee,..._max_tx_size_policy,..._max_script_size_policy). Nothing in this repo orbsva-infra-fluxreferences them;arcade_p2p_peer_best_heightis genuinely base-URL-keyed and is left alone.Two follow-ups this PR deliberately does not take on:
validator.MinFeePerKB()still serves both the intake floor and the/policyadvertisement. 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.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