From 4a43aea8db20d02b7c17247a258997a52f435886 Mon Sep 17 00:00:00 2001 From: Artem Rootman <4586640+artemrootman@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:08:10 +0000 Subject: [PATCH 01/15] docs: bitcoin family support design spec --- .../2026-07-17-bitcoin-support-design.md | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-17-bitcoin-support-design.md diff --git a/docs/superpowers/specs/2026-07-17-bitcoin-support-design.md b/docs/superpowers/specs/2026-07-17-bitcoin-support-design.md new file mode 100644 index 00000000..e412dba4 --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-bitcoin-support-design.md @@ -0,0 +1,186 @@ +# Bitcoin family support — design + +Date: 2026-07-17 +Status: approved (pre-implementation) + +## Goal + +Add first-class support for the **Bitcoin** blockchain family to nodecore +(`BlockchainType = "bitcoin"`), covering **bitcoin** and **dogecoin** (mainnet +and testnet networks as present in `chains.yaml`), with full gRPC (emerald) +API support, so these chains can be removed from dshackle. + +## Scope + +- **In scope:** bitcoind-style JSON-RPC upstreams (Bitcoin Core, Dogecoin + Core) with HTTP basic auth; the 19-method surface currently served by + dshackle (see method table); optional **esplora** (electrs) secondary + endpoint backing `listunspent`; head tracking, chain/health validation, + client labels, prune-aware lower bounds; gRPC ChainRef support (already in + the emerald proto for BITCOIN/DOGECOIN). +- **Out of scope (YAGNI):** wallet methods beyond the dshackle surface; ZMQ + or WebSocket-style head subscriptions (bitcoind has none we consume); + Litecoin/other UTXO chains (nothing registered in Consul today); deployment automation + ansible filter changes (deferred until the family passes live validation + from a laptop-run nodecore instance — see Testing). + +## Approach + +Bitcoin is modelled as a **JSON-RPC blockchain family** following the +Algorand/Aptos template: a `bitcoin_specific` chain-specific package plus a +data-driven method spec. A runtime-only extension is not viable for a new +family (compile-time factory switch), consistent with prior family additions. + +One family covers both bitcoin and dogecoin: Dogecoin Core is a Bitcoin Core +fork with an API-compatible RPC surface for every method in scope. + +### Verified live API shapes (from our production nodes) + +`getblockchaininfo` (Bitcoin Core 26.1, mainnet; JSON-RPC 1.0 envelope, +`error: null` on success): + +```json +{"result":{"chain":"main","blocks":958407,"headers":958407, + "bestblockhash":"00000000000000000000d7c68a3b5e0794da056f7996c668620eb2b53591a8cf", + "difficulty":127170500429035.2,"time":1784290128,"mediantime":1784286851, + "verificationprogress":1,"initialblockdownload":false, + "size_on_disk":860307387461,"pruned":false,"warnings":""},"error":null,"id":1} +``` + +Dogecoin Core (mainnet) returns the same shape with `"chain":"main"` as well — +**the `chain` field cannot distinguish bitcoin from dogecoin**; only the +genesis hash can (see Chain validation). Dogecoin's payload has no `time` +top-level field on older cores; head tracking must not depend on it. + +`getnetworkinfo`: `{"version":260100,"subversion":"/Satoshi:26.1.0/", +"connections":10,...}` — source for the client version label and peer count. + +`getblockhash 0`: bitcoin mainnet `000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f`, +dogecoin mainnet `1a91e3dace36e2be3bf030a65679fe821aa1d6ef92e7c9902eb318182c355691`. + +Esplora (electrs) `GET /address/{addr}/utxo`: + +```json +[{"txid":"548030c7...","vout":0, + "status":{"confirmed":true,"block_height":903979,"block_hash":"000...f2f", + "block_time":1751639616},"value":927}] +``` + +`GET /blocks/tip/height` → plain-text height (used only by the live-test +harness, not by nodecore). + +## Architecture + +### Factory and type plumbing + +- `upstream_factory.go`: new `case chains.Bitcoin` returning + `bitcoin_specific.NewBitcoinChainSpecificObject(...)`. +- `chains.Bitcoin` already exists in `chains.go`; `IsValidBlockchainType` + already accepts it. `chains.yaml` already registers bitcoin/dogecoin with + `type: bitcoin` — no registry work. + +### Head tracking (`bitcoin_specific`) + +RPC polling head (no subscriptions), same lifecycle as Algorand's: + +1. Poll `getbestblockhash` at the configured poll interval. +2. On hash change, fetch `getblockheader ` (verbose) for + `height`/`time` and publish the new head. + +`getblockheader` verbose is available on both Bitcoin Core and Dogecoin Core +and is far cheaper than a verbose `getblock`. + +### Chain validation + +`BitcoinChainValidator` fetches `getblockhash 0` once and compares against +the expected genesis hash for the configured chain. Expected hashes live in +the validator keyed by `chains.Chain` (BITCOIN, BITCOIN_TESTNET, DOGECOIN, +DOGECOIN_TESTNET); mainnet values verified above, testnet values verified +against our nodes during implementation. `getblockchaininfo.chain` +(`main`/`test`) is checked as a secondary signal. This mirrors what the +`chain-id` check does for EVM — genesis hash is the only reliable +discriminator between bitcoin and dogecoin. + +### Health validation + +`BitcoinHealthValidator` uses `getblockchaininfo`: +- syncing: `initialblockdownload == true` or `headers - blocks` above the + chain's lag threshold; +- peers (when `validate-peers`): `getconnectioncount > 0`. + +### Labels and lower bounds + +- `client_version` label from `getnetworkinfo.subversion` + (e.g. `/Satoshi:26.1.0/` → `26.1.0`). +- Lower bounds: if `getblockchaininfo.pruned == true`, `pruneheight` becomes + the BLOCK/TX bound; otherwise bound 1 (archive). Our nodes are unpruned; + the pruned path still gets unit coverage. + +### Method spec (`pkg/methods/specs/bitcoin.json`) + +The 19 methods dshackle serves, with equivalent semantics: + +| group | methods | routing/quorum | +|---|---|---| +| fresh | getblock, gettransaction, gettxout, getmemorypool, getrawmempool, getmempoolinfo, getblockheader | default | +| not-null retry | getblockhash, getrawtransaction, estimatesmartfee | retry on null result | +| head-verified | getbestblockhash, getblocknumber, getblockcount, getreceivedbyaddress, getblockchaininfo | route to best-head upstream | + +`getblocknumber` is not a real bitcoind method — it is a dshackle-era alias +kept for client compatibility; nodecore translates it to `getblockcount`. +`getmemorypool` is likewise ancient (dropped by modern Bitcoin Core) and is +kept passthrough-only for surface parity — the node's own error is returned. +| passthrough | getconnectioncount, getnetworkinfo | default (no dshackle-style hardcoding — these are our own nodes) | +| broadcast | sendrawtransaction | broadcast to all upstreams | +| esplora-backed | listunspent | translated, see below | + +### Esplora translation (`listunspent`) + +Esplora is an optional `rest-additional` connector on the upstream (the same +mechanism tron uses for its solidity endpoint). `listunspent` is intercepted +in `bitcoin_specific`: the address argument maps to +`GET /address/{addr}/utxo`, and the esplora response is converted to the +bitcoind `listunspent` result shape (txid, vout, address, amount in BTC from +sats, confirmations computed from current head vs `status.block_height`). +Upstreams without an esplora connector get `listunspent` banned via the +existing method-ban mechanism, so routing skips them (dogecoin has no +esplora today). + +### Authentication + +bitcoind requires HTTP basic auth. Connector URLs support userinfo +(`http://user:pass@host:port`) or an explicit `Authorization` header via the +connector `headers` field — whichever the existing connector plumbing already +handles; verified during implementation with a live node. + +## Testing + +1. **Unit tests** (template parity with aptos/algorand): head parsing, + genesis validation incl. bitcoin-vs-dogecoin cross-check, health/syncing + states, subversion label parsing, prune bounds, esplora→listunspent + mapping on captured fixtures, method-spec loading. +2. **Live comparison harness (laptop, before any infra change).** nodecore + runs locally on the laptop with a hand-written config; upstreams are our + production nodes reached through ssh port-forwards (node RPC/esplora ports + are firewalled to internal hosts). A corpus runner exercises **every** + method in the spec against (a) the node directly and (b) nodecore + (`/queries/bitcoin`), asserting identical results modulo volatile fields + (connection counts, mempool contents, relative confirmations). Corpus + includes: fresh blocks by height and hash, deep historical blocks, raw + transactions, txout lookups, an address with known UTXOs (esplora path), + estimatesmartfee, invalid-parameter negative cases, and unknown-method + handling. `sendrawtransaction` is exercised for real only on + **dogecoin-testnet**; on mainnets only with a deterministically invalid + transaction (expected error passthrough), nothing is broadcast. +3. **Staged rollout (after laptop validation, separate step).** Wire the + deployment automation (basic auth + esplora meta), enable bitcoin on + one production nodecore instance while dshackle still serves the chain, + compare error rates and responses, then drop `bitcoin`/`dogecoin` from + `INCLUDE_TO_DSHACKLE`. + +## Out of scope / follow-ups + +- Deployment automation changes ship only after step 2 passes. +- near, ripple, starknet, ton follow as separate family designs reusing this + template; celestia/stellar additionally need `chains.yaml` registry entries + (they are absent there today). From 0db46b63b98b6a344a904c37ed8d88d4310d9a65 Mon Sep 17 00:00:00 2001 From: Artem Rootman <4586640+artemrootman@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:21:56 +0000 Subject: [PATCH 02/15] feat(bitcoin): method spec bundle --- pkg/chains/chains.go | 2 + pkg/chains/chains_test.go | 5 + pkg/methods/methods_spec_test.go | 22 ++++ pkg/methods/specs/bitcoin-esplora.json | 22 ++++ pkg/methods/specs/bitcoin-json-rpc.json | 139 ++++++++++++++++++++++++ pkg/methods/specs/bitcoin.json | 12 ++ 6 files changed, 202 insertions(+) create mode 100644 pkg/methods/specs/bitcoin-esplora.json create mode 100644 pkg/methods/specs/bitcoin-json-rpc.json create mode 100644 pkg/methods/specs/bitcoin.json diff --git a/pkg/chains/chains.go b/pkg/chains/chains.go index d887904a..fd486d8b 100644 --- a/pkg/chains/chains.go +++ b/pkg/chains/chains.go @@ -355,6 +355,8 @@ func getMethodSpecName(blockchainType BlockchainType, methodSpecName string) str return "eth-beacon-chain" case Aptos: return "aptos" + case Bitcoin: + return "bitcoin" } return "" diff --git a/pkg/chains/chains_test.go b/pkg/chains/chains_test.go index 0c2930a1..e3d0fca5 100644 --- a/pkg/chains/chains_test.go +++ b/pkg/chains/chains_test.go @@ -44,6 +44,11 @@ func TestAptosBlockchainTypeIsValid(t *testing.T) { assert.True(t, IsValidBlockchainType("aptos")) } +func TestBitcoinAndDogecoinMethodSpecNameIsBitcoin(t *testing.T) { + assert.Equal(t, "bitcoin", GetMethodSpecNameByChain(BITCOIN)) + assert.Equal(t, "bitcoin", GetMethodSpecNameByChain(DOGECOIN)) +} + func TestGetChainByChainIdAndVersionScopesByType(t *testing.T) { // Ethereum mainnet is registered with chain-id 0x1 / net-version 1. eth := GetChainByChainIdAndVersion(Ethereum, "0x1", "1") diff --git a/pkg/methods/methods_spec_test.go b/pkg/methods/methods_spec_test.go index b201b229..fe1e1d13 100644 --- a/pkg/methods/methods_spec_test.go +++ b/pkg/methods/methods_spec_test.go @@ -229,6 +229,28 @@ func TestAptosSpecLoadsAndMatchesRestRoutes(t *testing.T) { assert.Equal(t, []string{"12345"}, params) } +func TestBitcoinSpecLoads(t *testing.T) { + err := specs.NewMethodSpecLoader().Load() + assert.NoError(t, err) + + spec := specs.GetSpecMethod("bitcoin", "getblock") + assert.NotNil(t, spec) + + spec = specs.GetSpecMethod("bitcoin", "sendrawtransaction") + assert.NotNil(t, spec) + + spec = specs.GetSpecMethod("bitcoin", "listunspent") + assert.NotNil(t, spec) + + spec = specs.GetSpecMethod("bitcoin", "eth_call") + assert.Nil(t, spec) + + template, params, ok := specs.MatchRestMethod("bitcoin", "GET#/address/bc1qxyz/utxo") + assert.True(t, ok) + assert.Equal(t, "GET#/address/*/utxo", template) + assert.Equal(t, []string{"bc1qxyz"}, params) +} + func TestAptosSpecMatchesNestedAccountAndTableRoutes(t *testing.T) { loader := specs.NewMethodSpecLoader() err := loader.Load() diff --git a/pkg/methods/specs/bitcoin-esplora.json b/pkg/methods/specs/bitcoin-esplora.json new file mode 100644 index 00000000..bdb344a3 --- /dev/null +++ b/pkg/methods/specs/bitcoin-esplora.json @@ -0,0 +1,22 @@ +{ + "openrpc": "1.0.0", + "info": { + "title": "Bitcoin esplora methods", + "version": "1.0.0" + }, + "spec": { + "name": "bitcoin-esplora", + "api-connectors": ["rest-additional"], + "type": "plain" + }, + "methods": [ + { + "name": "GET#/address/*/utxo", + "group": "additional", + "params": [], + "settings": { + "cacheable": false + } + } + ] +} diff --git a/pkg/methods/specs/bitcoin-json-rpc.json b/pkg/methods/specs/bitcoin-json-rpc.json new file mode 100644 index 00000000..02b65540 --- /dev/null +++ b/pkg/methods/specs/bitcoin-json-rpc.json @@ -0,0 +1,139 @@ +{ + "openrpc": "1.0.0", + "info": { + "title": "Bitcoin JSON-RPC methods", + "version": "1.0.0" + }, + "spec": { + "name": "bitcoin-json-rpc", + "api-connectors": ["json-rpc"], + "type": "plain" + }, + "methods": [ + { + "name": "getblock", + "params": [] + }, + { + "name": "gettransaction", + "params": [], + "settings": { + "cacheable": false + } + }, + { + "name": "gettxout", + "params": [], + "settings": { + "cacheable": false + } + }, + { + "name": "getmemorypool", + "params": [], + "settings": { + "cacheable": false + } + }, + { + "name": "getrawmempool", + "params": [], + "settings": { + "cacheable": false + } + }, + { + "name": "getmempoolinfo", + "params": [], + "settings": { + "cacheable": false + } + }, + { + "name": "getblockheader", + "params": [] + }, + { + "name": "getblockhash", + "params": [], + "settings": { + "cacheable": false + } + }, + { + "name": "getrawtransaction", + "params": [] + }, + { + "name": "estimatesmartfee", + "params": [], + "settings": { + "cacheable": false + } + }, + { + "name": "getbestblockhash", + "params": [], + "settings": { + "cacheable": false + } + }, + { + "name": "getblocknumber", + "params": [], + "settings": { + "cacheable": false + } + }, + { + "name": "getblockcount", + "params": [], + "settings": { + "cacheable": false + } + }, + { + "name": "getreceivedbyaddress", + "params": [], + "settings": { + "cacheable": false + } + }, + { + "name": "getblockchaininfo", + "params": [], + "settings": { + "cacheable": false + } + }, + { + "name": "getconnectioncount", + "params": [], + "settings": { + "cacheable": false + } + }, + { + "name": "getnetworkinfo", + "params": [], + "settings": { + "cacheable": false + } + }, + { + "name": "sendrawtransaction", + "params": [], + "settings": { + "cacheable": false, + "dispatch": "broadcast" + } + }, + { + "name": "listunspent", + "params": [], + "settings": { + "cacheable": false + } + } + ] +} diff --git a/pkg/methods/specs/bitcoin.json b/pkg/methods/specs/bitcoin.json new file mode 100644 index 00000000..fdb7b89d --- /dev/null +++ b/pkg/methods/specs/bitcoin.json @@ -0,0 +1,12 @@ +{ + "openrpc": "1.0.0", + "info": { + "title": "Bitcoin methods", + "version": "1.0.0" + }, + "spec": { + "name": "bitcoin", + "type": "bundle" + }, + "spec-imports": ["bitcoin-json-rpc", "bitcoin-esplora"] +} From 3d0307569634a0c712101793c9108bb925ebfe7a Mon Sep 17 00:00:00 2001 From: Artem Rootman <4586640+artemrootman@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:42:06 +0000 Subject: [PATCH 03/15] feat(bitcoin): chain-specific head tracking Adds the bitcoin_specific package: GetLatestBlock/GetFinalizedBlock poll getbestblockhash + getblockheader (bitcoind has no head subscription), ParseBlock decodes the verbose getblockheader shape shared by Bitcoin Core and Dogecoin Core. Validators/labels/lower-bounds are left as empty/nil-safe stubs for later tasks; only the disable-option short-circuits and the ChainSpecific interface shape are wired up now. Also fixes parseJsonRpcBody (internal/protocol/parse_response.go) to treat a JSON `null` "error" field as no-error instead of a failure - bitcoind always sends an explicit "error":null on success, unlike the other JSON-RPC 2.0 upstreams in this codebase which omit the field entirely, so every real bitcoind response was being misparsed as an error. --- internal/protocol/parse_response.go | 7 +- .../bitcoin_chain_specific.go | 184 ++++++++++++++++++ .../bitcoin_chain_specific_test.go | 167 ++++++++++++++++ pkg/test_utils/test_helpers.go | 11 ++ 4 files changed, 368 insertions(+), 1 deletion(-) create mode 100644 internal/upstreams/chains_specific/bitcoin_specific/bitcoin_chain_specific.go create mode 100644 internal/upstreams/chains_specific/bitcoin_specific/bitcoin_chain_specific_test.go diff --git a/internal/protocol/parse_response.go b/internal/protocol/parse_response.go index 72ae7d0f..3476fc35 100644 --- a/internal/protocol/parse_response.go +++ b/internal/protocol/parse_response.go @@ -29,7 +29,12 @@ func parseJsonRpcBody(id string, body []byte, responseCode int) *BaseUpstreamRes result = []byte(rawResult) } } - if errorNode, err := searcher.GetByPath("error"); err == nil { + // A JSON `null` error is how bitcoind (and other JSON-RPC 1.0-style + // servers) spell "no error" - it always sends the "error" key, even on + // success. Treat it the same as an absent key rather than as an error, + // otherwise every successful bitcoind response would be misparsed as a + // failure. + if errorNode, err := searcher.GetByPath("error"); err == nil && errorNode.TypeSafe() != ast.V_NULL { if errorRaw, err := errorNode.Raw(); err == nil { bodyBytes := []byte(errorRaw) if errorNode.TypeSafe() == ast.V_STRING { diff --git a/internal/upstreams/chains_specific/bitcoin_specific/bitcoin_chain_specific.go b/internal/upstreams/chains_specific/bitcoin_specific/bitcoin_chain_specific.go new file mode 100644 index 00000000..d6ae1f27 --- /dev/null +++ b/internal/upstreams/chains_specific/bitcoin_specific/bitcoin_chain_specific.go @@ -0,0 +1,184 @@ +package bitcoin_specific + +import ( + "context" + "fmt" + "time" + + "github.com/bytedance/sonic" + "github.com/drpcorg/nodecore/internal/protocol" + "github.com/drpcorg/nodecore/internal/upstreams/blocks" + "github.com/drpcorg/nodecore/internal/upstreams/caps" + "github.com/drpcorg/nodecore/internal/upstreams/chains_specific" + "github.com/drpcorg/nodecore/internal/upstreams/connectors" + "github.com/drpcorg/nodecore/internal/upstreams/labels" + "github.com/drpcorg/nodecore/internal/upstreams/lower_bounds" + "github.com/drpcorg/nodecore/internal/upstreams/validations" + "github.com/drpcorg/nodecore/pkg/blockchain" + "github.com/drpcorg/nodecore/pkg/chains" +) + +// errUnsupportedHeadSubscriptions is returned by SubscribeHeadRequest and +// ParseSubscriptionBlock: bitcoind has no push-style head notification we can +// consume (no ZMQ/WS in scope), so head tracking is poll-only. +var errUnsupportedHeadSubscriptions = fmt.Errorf("bitcoin: head subscriptions are not supported") + +type BitcoinChainSpecificObject struct { + ctx context.Context + upstreamId string + connector connectors.ApiConnector + options *chains.Options + internalTimeout time.Duration + labelsDelay time.Duration + configuredChain *chains.ConfiguredChain +} + +func NewBitcoinChainSpecificObject( + ctx context.Context, + configuredChain *chains.ConfiguredChain, + upstreamId string, + connector connectors.ApiConnector, + options *chains.Options, +) *BitcoinChainSpecificObject { + return &BitcoinChainSpecificObject{ + ctx: ctx, + upstreamId: upstreamId, + connector: connector, + options: options, + internalTimeout: options.InternalTimeout, + labelsDelay: options.ValidationInterval * 5, + configuredChain: configuredChain, + } +} + +func (b *BitcoinChainSpecificObject) BlockProcessor() blocks.BlockProcessor { + return nil +} + +// LabelsProcessor has no detectors yet - client-version labelling lands in a +// later task. NewBaseLabelsProcessor returns nil for an empty detector list, +// which satisfies the labels.LabelsProcessor interface as a no-op. +func (b *BitcoinChainSpecificObject) LabelsProcessor() labels.LabelsProcessor { + return labels.NewBaseLabelsProcessor(b.ctx, b.upstreamId, []labels.LabelsDetector{}, b.labelsDelay) +} + +func (b *BitcoinChainSpecificObject) CapDetectors(input caps.DetectorInput) []caps.CapDetector { + return caps.DefaultCapDetectors(b.upstreamId, input.WsConnector) +} + +// LowerBoundProcessor has no detectors yet - prune-aware bounds land in a +// later task. NewBaseLowerBoundProcessor returns nil for an empty detector +// list, which satisfies the lower_bounds.LowerBoundProcessor interface as a +// no-op. +func (b *BitcoinChainSpecificObject) LowerBoundProcessor() lower_bounds.LowerBoundProcessor { + return lower_bounds.NewBaseLowerBoundProcessor( + b.ctx, + b.upstreamId, + b.configuredChain.AverageRemoveSpeed(), + []lower_bounds.LowerBoundDetector{}, + ) +} + +// HealthValidators has no validators yet - health/syncing checks land in a +// later task; the disable-option short-circuit is kept so the shape matches +// what every other family already does. +func (b *BitcoinChainSpecificObject) HealthValidators() []validations.Validator[protocol.AvailabilityStatus] { + if b.options != nil && *b.options.DisableHealthValidation { + return []validations.Validator[protocol.AvailabilityStatus]{} + } + return []validations.Validator[protocol.AvailabilityStatus]{} +} + +// SettingsValidators has no validators yet - genesis-hash chain validation +// lands in a later task; the disable-option short-circuit is kept so the +// shape matches what every other family already does. +func (b *BitcoinChainSpecificObject) SettingsValidators() []validations.Validator[validations.ValidationSettingResult] { + if b.configuredChain == nil || b.configuredChain.ChainId == "" { + return nil + } + if b.options != nil && *b.options.DisableChainValidation { + return []validations.Validator[validations.ValidationSettingResult]{} + } + return []validations.Validator[validations.ValidationSettingResult]{} +} + +// GetLatestBlock polls getbestblockhash, then getblockheader on that hash to +// pick up height/hash/parent-hash. bitcoind has no head subscription, so this +// poll is the only way head tracking learns about a new block. +func (b *BitcoinChainSpecificObject) GetLatestBlock(ctx context.Context) (protocol.Block, error) { + hashRequest, err := protocol.NewInternalUpstreamJsonRpcRequest("getbestblockhash", []any{}, b.configuredChain.Chain) + if err != nil { + return protocol.ZeroBlock{}, err + } + hashResponse := b.connector.SendRequest(ctx, hashRequest) + if hashResponse.HasError() { + return protocol.ZeroBlock{}, hashResponse.GetError() + } + hash := protocol.ResultAsString(hashResponse.ResponseResult()) + if hash == "" { + return protocol.ZeroBlock{}, fmt.Errorf("bitcoin upstream '%s' has no best block hash", b.upstreamId) + } + + headerRequest, err := protocol.NewInternalUpstreamJsonRpcRequest("getblockheader", []any{hash}, b.configuredChain.Chain) + if err != nil { + return protocol.ZeroBlock{}, err + } + headerResponse := b.connector.SendRequest(ctx, headerRequest) + if headerResponse.HasError() { + return protocol.ZeroBlock{}, headerResponse.GetError() + } + + block, err := b.ParseBlock(headerResponse.ResponseResult()) + if err != nil { + return protocol.ZeroBlock{}, fmt.Errorf("couldn't parse bitcoin block header for hash '%s': %w", hash, err) + } + return block, nil +} + +// GetFinalizedBlock delegates to GetLatestBlock: bitcoind has no separate +// finalized-head notion in scope here (confirmations-based finality is a +// follow-up), same contract as algorand's pure-poll head tracking. +func (b *BitcoinChainSpecificObject) GetFinalizedBlock(ctx context.Context) (protocol.Block, error) { + return b.GetLatestBlock(ctx) +} + +// ParseBlock expects the payload shape of getblockheader (verbose, the +// default): {"hash", "height", "previousblockhash", ...}. Both Bitcoin Core +// and Dogecoin Core share this shape. +func (b *BitcoinChainSpecificObject) ParseBlock(blockBytes []byte) (protocol.Block, error) { + header := bitcoinBlockHeader{} + if err := sonic.Unmarshal(blockBytes, &header); err != nil { + return protocol.ZeroBlock{}, fmt.Errorf("couldn't parse the bitcoin block header, reason - %s", err.Error()) + } + if header.Hash == "" { + return protocol.ZeroBlock{}, fmt.Errorf("couldn't parse the bitcoin block header, got '%s'", string(blockBytes)) + } + + parentHash := blockchain.EmptyHash + if header.PreviousBlockHash != "" { + parentHash = blockchain.NewHashIdFromString(header.PreviousBlockHash) + } + + return protocol.NewBlock( + header.Height, + 0, + blockchain.NewHashIdFromString(header.Hash), + parentHash, + ), nil +} + +func (b *BitcoinChainSpecificObject) ParseSubscriptionBlock(_ []byte) (protocol.Block, error) { + return protocol.ZeroBlock{}, errUnsupportedHeadSubscriptions +} + +func (b *BitcoinChainSpecificObject) SubscribeHeadRequest() (protocol.RequestHolder, error) { + return nil, errUnsupportedHeadSubscriptions +} + +type bitcoinBlockHeader struct { + Hash string `json:"hash"` + Height uint64 `json:"height"` + PreviousBlockHash string `json:"previousblockhash"` +} + +var _ chains_specific.ChainSpecific = (*BitcoinChainSpecificObject)(nil) diff --git a/internal/upstreams/chains_specific/bitcoin_specific/bitcoin_chain_specific_test.go b/internal/upstreams/chains_specific/bitcoin_specific/bitcoin_chain_specific_test.go new file mode 100644 index 00000000..fc518d35 --- /dev/null +++ b/internal/upstreams/chains_specific/bitcoin_specific/bitcoin_chain_specific_test.go @@ -0,0 +1,167 @@ +package bitcoin_specific_test + +import ( + "context" + "errors" + "testing" + + "github.com/drpcorg/nodecore/internal/protocol" + "github.com/drpcorg/nodecore/pkg/blockchain" + "github.com/drpcorg/nodecore/pkg/test_utils" + "github.com/drpcorg/nodecore/pkg/test_utils/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +const ( + bestBlockHash = "00000000000000000000d7c68a3b5e0794da056f7996c668620eb2b53591a8cf" + prevBlockHash = "0000000000000000000000000000000000000000000000000000000000003f2f" +) + +func methodIs(method string) any { + return mock.MatchedBy(func(r protocol.RequestHolder) bool { return r.Method() == method }) +} + +func headerFixture(hash, prev string) []byte { + return []byte(`{ + "hash": "` + hash + `", + "confirmations": 1, + "height": 958407, + "version": 537427968, + "time": 1784290128, + "mediantime": 1784286851, + "nTx": 3011, + "previousblockhash": "` + prev + `" + }`) +} + +// jsonRpcEnvelope wraps a raw JSON-RPC "result" payload the way a real +// bitcoind response is shaped: {"result":,"error":null,"id":1}. +// bitcoind always sends an explicit "error":null on success (unlike the +// other JSON-RPC 2.0 upstreams in this codebase, which omit "error" +// entirely) - see the parse_response.go fix that makes a null error parse +// as "no error" instead of a failure. +func jsonRpcEnvelope(result string) []byte { + return []byte(`{"result":` + result + `,"error":null,"id":1}`) +} + +func TestBitcoinSubscribeHeadRequestUnsupported(t *testing.T) { + req, err := test_utils.NewBitcoinChainSpecific(context.Background(), nil).SubscribeHeadRequest() + assert.Nil(t, req) + assert.EqualError(t, err, "bitcoin: head subscriptions are not supported") +} + +func TestBitcoinParseSubscriptionBlock(t *testing.T) { + block, err := test_utils.NewBitcoinChainSpecific(context.Background(), nil).ParseSubscriptionBlock([]byte(`{}`)) + assert.True(t, block.IsFullEmpty()) + assert.EqualError(t, err, "bitcoin: head subscriptions are not supported") +} + +func TestBitcoinParseBlockHeader(t *testing.T) { + block, err := test_utils.NewBitcoinChainSpecific(context.Background(), nil).ParseBlock(headerFixture(bestBlockHash, prevBlockHash)) + assert.Nil(t, err) + + expected := protocol.NewBlock( + 958407, 0, + blockchain.NewHashIdFromString(bestBlockHash), + blockchain.NewHashIdFromString(prevBlockHash), + ) + assert.Equal(t, expected, block) +} + +func TestBitcoinParseBlockInvalidJSON(t *testing.T) { + block, err := test_utils.NewBitcoinChainSpecific(context.Background(), nil).ParseBlock([]byte(`not json`)) + assert.True(t, block.IsFullEmpty()) + assert.ErrorContains(t, err, "couldn't parse the bitcoin block header") +} + +func TestBitcoinParseBlockEmptyHash(t *testing.T) { + block, err := test_utils.NewBitcoinChainSpecific(context.Background(), nil).ParseBlock([]byte(`{"height": 100}`)) + assert.True(t, block.IsFullEmpty()) + assert.ErrorContains(t, err, "couldn't parse the bitcoin block header") +} + +func TestBitcoinGetLatestBlock(t *testing.T) { + ctx := context.Background() + connector := mocks.NewConnectorMock() + + hashResp := protocol.NewHttpUpstreamResponse("1", jsonRpcEnvelope(`"`+bestBlockHash+`"`), 200, protocol.JsonRpc) + + headerResp := protocol.NewHttpUpstreamResponse("1", jsonRpcEnvelope(string(headerFixture(bestBlockHash, prevBlockHash))), 200, protocol.JsonRpc) + + connector.On("SendRequest", ctx, methodIs("getbestblockhash")).Return(hashResp).Once() + connector.On("SendRequest", ctx, methodIs("getblockheader")).Return(headerResp).Once() + + block, err := test_utils.NewBitcoinChainSpecific(context.Background(), connector).GetLatestBlock(ctx) + assert.Nil(t, err) + connector.AssertExpectations(t) + + expected := protocol.NewBlock( + 958407, 0, + blockchain.NewHashIdFromString(bestBlockHash), + blockchain.NewHashIdFromString(prevBlockHash), + ) + assert.Equal(t, expected, block) +} + +func TestBitcoinGetLatestBlockBestHashError(t *testing.T) { + ctx := context.Background() + connector := mocks.NewConnectorMock() + response := protocol.NewHttpUpstreamResponseWithError(protocol.ResponseErrorWithData(1, "best hash error", nil)) + + connector.On("SendRequest", ctx, methodIs("getbestblockhash")).Return(response).Once() + + block, err := test_utils.NewBitcoinChainSpecific(context.Background(), connector).GetLatestBlock(ctx) + connector.AssertExpectations(t) + assert.True(t, block.IsFullEmpty()) + + var upErr *protocol.ResponseError + assert.True(t, errors.As(err, &upErr)) + assert.Equal(t, 1, upErr.Code) + assert.Equal(t, "best hash error", upErr.Message) +} + +func TestBitcoinGetLatestBlockHeaderError(t *testing.T) { + ctx := context.Background() + connector := mocks.NewConnectorMock() + + hashResp := protocol.NewHttpUpstreamResponse("1", jsonRpcEnvelope(`"`+bestBlockHash+`"`), 200, protocol.JsonRpc) + headerErr := protocol.NewHttpUpstreamResponseWithError(protocol.ResponseErrorWithData(2, "header error", nil)) + + connector.On("SendRequest", ctx, methodIs("getbestblockhash")).Return(hashResp).Once() + connector.On("SendRequest", ctx, methodIs("getblockheader")).Return(headerErr).Once() + + block, err := test_utils.NewBitcoinChainSpecific(context.Background(), connector).GetLatestBlock(ctx) + connector.AssertExpectations(t) + assert.True(t, block.IsFullEmpty()) + + var upErr *protocol.ResponseError + assert.True(t, errors.As(err, &upErr)) + assert.Equal(t, 2, upErr.Code) + assert.Equal(t, "header error", upErr.Message) +} + +// TestBitcoinGetFinalizedBlockDelegatesToLatest mirrors algorand: bitcoind has +// no separate finalized-head concept in scope here, so GetFinalizedBlock just +// forwards to GetLatestBlock. +func TestBitcoinGetFinalizedBlockDelegatesToLatest(t *testing.T) { + ctx := context.Background() + connector := mocks.NewConnectorMock() + + hashResp := protocol.NewHttpUpstreamResponse("1", jsonRpcEnvelope(`"`+bestBlockHash+`"`), 200, protocol.JsonRpc) + headerResp := protocol.NewHttpUpstreamResponse("1", jsonRpcEnvelope(string(headerFixture(bestBlockHash, prevBlockHash))), 200, protocol.JsonRpc) + + connector.On("SendRequest", ctx, methodIs("getbestblockhash")).Return(hashResp).Once() + connector.On("SendRequest", ctx, methodIs("getblockheader")).Return(headerResp).Once() + + block, err := test_utils.NewBitcoinChainSpecific(context.Background(), connector).GetFinalizedBlock(ctx) + assert.Nil(t, err) + connector.AssertExpectations(t) + + expected := protocol.NewBlock( + 958407, 0, + blockchain.NewHashIdFromString(bestBlockHash), + blockchain.NewHashIdFromString(prevBlockHash), + ) + assert.Equal(t, expected, block) +} diff --git a/pkg/test_utils/test_helpers.go b/pkg/test_utils/test_helpers.go index 5db7713a..a91cf7e7 100644 --- a/pkg/test_utils/test_helpers.go +++ b/pkg/test_utils/test_helpers.go @@ -17,6 +17,7 @@ import ( "github.com/drpcorg/nodecore/internal/upstreams/chains_specific/aptos_specific" "github.com/drpcorg/nodecore/internal/upstreams/chains_specific/aztec_specific" "github.com/drpcorg/nodecore/internal/upstreams/chains_specific/beacon_specific" + "github.com/drpcorg/nodecore/internal/upstreams/chains_specific/bitcoin_specific" "github.com/drpcorg/nodecore/internal/upstreams/chains_specific/evm_specific" specific "github.com/drpcorg/nodecore/internal/upstreams/chains_specific/solana_specific" "github.com/drpcorg/nodecore/internal/upstreams/connectors" @@ -264,6 +265,16 @@ func NewAptosChainSpecific(ctx context.Context, connector connectors.ApiConnecto return aptos_specific.NewAptosChainSpecificObject(ctx, chains.GetChain("aptos-mainnet"), "id", connector, newTestChainOptions()) } +func NewBitcoinChainSpecific(ctx context.Context, connector connectors.ApiConnector) *bitcoin_specific.BitcoinChainSpecificObject { + options := &chains.Options{ + InternalTimeout: 5 * time.Second, + ValidationInterval: 10 * time.Second, + DisableChainValidation: new(false), + DisableHealthValidation: new(false), + } + return bitcoin_specific.NewBitcoinChainSpecificObject(ctx, chains.GetChain("bitcoin-mainnet"), "id", connector, options) +} + func newTestChainOptions() *chains.Options { return &chains.Options{ InternalTimeout: 5 * time.Second, From ebf038e0718fea0858275799d534320e044b6ca6 Mon Sep 17 00:00:00 2001 From: Artem Rootman <4586640+artemrootman@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:01:35 +0000 Subject: [PATCH 04/15] feat(bitcoin): validators, labels, lower bounds, factory wiring - bitcoin_validations: genesis-hash chain validator (getblockhash 0 keyed by chains.Chain; unmapped chains like litecoin skip with a warn) and health validator (initialblockdownload / headers-blocks lag -> Syncing, optional getconnectioncount peer check -> Unavailable). - bitcoin_labels: client version/type from getnetworkinfo.subversion (/Satoshi:26.1.0/ -> satoshi 26.1.0, dogecoin's /Shibetoshi:1.14.9/ works too). - bitcoin_bounds: prune-aware lower bounds - pruneheight when pruned, else 1 (archive), emitted as BlockBound+TxBound with algorand-style cached fallback. - upstream_factory: case chains.Bitcoin now constructs the family; the bitcoin_specific stubs are replaced with the real processors/validators. --- .../bitcoin_chain_specific.go | 47 ++++-- .../bitcoin_labels/bitcoin_detectors.go | 42 +++++ .../bitcoin_labels/bitcoin_detectors_test.go | 86 +++++++++++ .../bitcoin_bounds/bitcoin_lower_bound.go | 136 ++++++++++++++++ .../bitcoin_lower_bound_test.go | 145 ++++++++++++++++++ internal/upstreams/upstream_factory.go | 9 ++ .../bitcoin_chain_validator.go | 93 +++++++++++ .../bitcoin_health_validator.go | 122 +++++++++++++++ .../bitcoin_validators_test.go | 125 +++++++++++++++ 9 files changed, 788 insertions(+), 17 deletions(-) create mode 100644 internal/upstreams/labels/bitcoin_labels/bitcoin_detectors.go create mode 100644 internal/upstreams/labels/bitcoin_labels/bitcoin_detectors_test.go create mode 100644 internal/upstreams/lower_bounds/bitcoin_bounds/bitcoin_lower_bound.go create mode 100644 internal/upstreams/lower_bounds/bitcoin_bounds/bitcoin_lower_bound_test.go create mode 100644 internal/upstreams/validations/bitcoin_validations/bitcoin_chain_validator.go create mode 100644 internal/upstreams/validations/bitcoin_validations/bitcoin_health_validator.go create mode 100644 internal/upstreams/validations/bitcoin_validations/bitcoin_validators_test.go diff --git a/internal/upstreams/chains_specific/bitcoin_specific/bitcoin_chain_specific.go b/internal/upstreams/chains_specific/bitcoin_specific/bitcoin_chain_specific.go index d6ae1f27..4f405e69 100644 --- a/internal/upstreams/chains_specific/bitcoin_specific/bitcoin_chain_specific.go +++ b/internal/upstreams/chains_specific/bitcoin_specific/bitcoin_chain_specific.go @@ -12,8 +12,11 @@ import ( "github.com/drpcorg/nodecore/internal/upstreams/chains_specific" "github.com/drpcorg/nodecore/internal/upstreams/connectors" "github.com/drpcorg/nodecore/internal/upstreams/labels" + "github.com/drpcorg/nodecore/internal/upstreams/labels/bitcoin_labels" "github.com/drpcorg/nodecore/internal/upstreams/lower_bounds" + "github.com/drpcorg/nodecore/internal/upstreams/lower_bounds/bitcoin_bounds" "github.com/drpcorg/nodecore/internal/upstreams/validations" + "github.com/drpcorg/nodecore/internal/upstreams/validations/bitcoin_validations" "github.com/drpcorg/nodecore/pkg/blockchain" "github.com/drpcorg/nodecore/pkg/chains" ) @@ -55,43 +58,51 @@ func (b *BitcoinChainSpecificObject) BlockProcessor() blocks.BlockProcessor { return nil } -// LabelsProcessor has no detectors yet - client-version labelling lands in a -// later task. NewBaseLabelsProcessor returns nil for an empty detector list, -// which satisfies the labels.LabelsProcessor interface as a no-op. func (b *BitcoinChainSpecificObject) LabelsProcessor() labels.LabelsProcessor { - return labels.NewBaseLabelsProcessor(b.ctx, b.upstreamId, []labels.LabelsDetector{}, b.labelsDelay) + labelsDetectors := []labels.LabelsDetector{ + labels.NewClientLabelDetectorHandler( + b.upstreamId, + b.connector, + bitcoin_labels.NewBitcoinClientLabelsDetector(b.configuredChain.Chain), + b.internalTimeout, + ), + } + return labels.NewBaseLabelsProcessor(b.ctx, b.upstreamId, labelsDetectors, b.labelsDelay) } func (b *BitcoinChainSpecificObject) CapDetectors(input caps.DetectorInput) []caps.CapDetector { return caps.DefaultCapDetectors(b.upstreamId, input.WsConnector) } -// LowerBoundProcessor has no detectors yet - prune-aware bounds land in a -// later task. NewBaseLowerBoundProcessor returns nil for an empty detector -// list, which satisfies the lower_bounds.LowerBoundProcessor interface as a -// no-op. func (b *BitcoinChainSpecificObject) LowerBoundProcessor() lower_bounds.LowerBoundProcessor { + detectors := []lower_bounds.LowerBoundDetector{ + bitcoin_bounds.NewBitcoinLowerBoundDetector( + b.upstreamId, + b.configuredChain.Chain, + b.internalTimeout, + b.connector, + ), + } return lower_bounds.NewBaseLowerBoundProcessor( b.ctx, b.upstreamId, b.configuredChain.AverageRemoveSpeed(), - []lower_bounds.LowerBoundDetector{}, + detectors, ) } -// HealthValidators has no validators yet - health/syncing checks land in a -// later task; the disable-option short-circuit is kept so the shape matches -// what every other family already does. func (b *BitcoinChainSpecificObject) HealthValidators() []validations.Validator[protocol.AvailabilityStatus] { if b.options != nil && *b.options.DisableHealthValidation { return []validations.Validator[protocol.AvailabilityStatus]{} } - return []validations.Validator[protocol.AvailabilityStatus]{} + validatePeers := b.options != nil && b.options.ValidatePeers != nil && *b.options.ValidatePeers + return []validations.Validator[protocol.AvailabilityStatus]{ + bitcoin_validations.NewBitcoinHealthValidator( + b.upstreamId, b.connector, b.configuredChain, b.internalTimeout, validatePeers, + ), + } } -// SettingsValidators has no validators yet - genesis-hash chain validation -// lands in a later task; the disable-option short-circuit is kept so the -// shape matches what every other family already does. func (b *BitcoinChainSpecificObject) SettingsValidators() []validations.Validator[validations.ValidationSettingResult] { if b.configuredChain == nil || b.configuredChain.ChainId == "" { return nil @@ -99,7 +110,9 @@ func (b *BitcoinChainSpecificObject) SettingsValidators() []validations.Validato if b.options != nil && *b.options.DisableChainValidation { return []validations.Validator[validations.ValidationSettingResult]{} } - return []validations.Validator[validations.ValidationSettingResult]{} + return []validations.Validator[validations.ValidationSettingResult]{ + bitcoin_validations.NewBitcoinChainValidator(b.upstreamId, b.connector, b.configuredChain, b.internalTimeout), + } } // GetLatestBlock polls getbestblockhash, then getblockheader on that hash to diff --git a/internal/upstreams/labels/bitcoin_labels/bitcoin_detectors.go b/internal/upstreams/labels/bitcoin_labels/bitcoin_detectors.go new file mode 100644 index 00000000..a7c06dc1 --- /dev/null +++ b/internal/upstreams/labels/bitcoin_labels/bitcoin_detectors.go @@ -0,0 +1,42 @@ +package bitcoin_labels + +import ( + "fmt" + "strings" + + "github.com/bytedance/sonic" + "github.com/drpcorg/nodecore/internal/protocol" + "github.com/drpcorg/nodecore/internal/upstreams/labels" + "github.com/drpcorg/nodecore/pkg/chains" +) + +type BitcoinClientLabelsDetector struct { + chain chains.Chain +} + +func NewBitcoinClientLabelsDetector(chain chains.Chain) *BitcoinClientLabelsDetector { + return &BitcoinClientLabelsDetector{chain: chain} +} + +func (b *BitcoinClientLabelsDetector) NodeTypeRequest() (protocol.RequestHolder, error) { + return protocol.NewInternalUpstreamJsonRpcRequest("getnetworkinfo", []any{}, b.chain) +} + +type bitcoinNetworkInfoResponse struct { + Subversion string `json:"subversion"` +} + +func (b *BitcoinClientLabelsDetector) ClientVersionAndType(data []byte) (string, string, error) { + var networkInfo bitcoinNetworkInfoResponse + if err := sonic.Unmarshal(data, &networkInfo); err != nil { + return "", "", fmt.Errorf("bitcoin getnetworkinfo payload unparseable: %w", err) + } + // subversion looks like "/Satoshi:26.1.0/" (Dogecoin Core reports "/Shibetoshi:1.14.9/") + clientType, version, found := strings.Cut(strings.Trim(networkInfo.Subversion, "/"), ":") + if !found || clientType == "" || version == "" { + return "", "bitcoind", nil + } + return version, strings.ToLower(clientType), nil +} + +var _ labels.ClientLabelsDetector = (*BitcoinClientLabelsDetector)(nil) diff --git a/internal/upstreams/labels/bitcoin_labels/bitcoin_detectors_test.go b/internal/upstreams/labels/bitcoin_labels/bitcoin_detectors_test.go new file mode 100644 index 00000000..625d0d6e --- /dev/null +++ b/internal/upstreams/labels/bitcoin_labels/bitcoin_detectors_test.go @@ -0,0 +1,86 @@ +package bitcoin_labels_test + +import ( + "testing" + + "github.com/drpcorg/nodecore/internal/protocol" + "github.com/drpcorg/nodecore/internal/upstreams/labels/bitcoin_labels" + "github.com/drpcorg/nodecore/pkg/chains" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBitcoinClientLabelsDetectorNodeTypeRequest(t *testing.T) { + detector := bitcoin_labels.NewBitcoinClientLabelsDetector(chains.BITCOIN) + + request, err := detector.NodeTypeRequest() + require.NoError(t, err) + require.NotNil(t, request) + + assert.Equal(t, "getnetworkinfo", request.Method()) + assert.Equal(t, protocol.JsonRpc, request.RequestType()) + + body, err := request.Body() + require.NoError(t, err) + + assert.JSONEq(t, `{"id":"1","jsonrpc":"2.0","method":"getnetworkinfo","params":[]}`, string(body)) +} + +func TestBitcoinClientLabelsDetectorClientVersionAndType(t *testing.T) { + tests := []struct { + name string + data []byte + expectedVersion string + expectedClientType string + expectErr bool + }{ + { + name: "parses bitcoin core subversion", + data: []byte(`{"version":260100,"subversion":"/Satoshi:26.1.0/","connections":10}`), + expectedVersion: "26.1.0", + expectedClientType: "satoshi", + }, + { + name: "parses dogecoin core subversion", + data: []byte(`{"version":1140900,"subversion":"/Shibetoshi:1.14.9/","connections":8}`), + expectedVersion: "1.14.9", + expectedClientType: "shibetoshi", + }, + { + name: "falls back on missing subversion", + data: []byte(`{"version":260100,"connections":10}`), + expectedVersion: "", + expectedClientType: "bitcoind", + }, + { + name: "falls back on subversion without version", + data: []byte(`{"subversion":"/Satoshi/"}`), + expectedVersion: "", + expectedClientType: "bitcoind", + }, + { + name: "fails on invalid json", + data: []byte(`{"subversion":`), + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + detector := bitcoin_labels.NewBitcoinClientLabelsDetector(chains.BITCOIN) + + version, clientType, err := detector.ClientVersionAndType(tt.data) + + if tt.expectErr { + require.Error(t, err) + assert.Empty(t, version) + assert.Empty(t, clientType) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.expectedVersion, version) + assert.Equal(t, tt.expectedClientType, clientType) + }) + } +} diff --git a/internal/upstreams/lower_bounds/bitcoin_bounds/bitcoin_lower_bound.go b/internal/upstreams/lower_bounds/bitcoin_bounds/bitcoin_lower_bound.go new file mode 100644 index 00000000..dd0b8263 --- /dev/null +++ b/internal/upstreams/lower_bounds/bitcoin_bounds/bitcoin_lower_bound.go @@ -0,0 +1,136 @@ +package bitcoin_bounds + +import ( + "context" + "errors" + "fmt" + "sync/atomic" + "time" + + "github.com/bytedance/sonic" + "github.com/drpcorg/nodecore/internal/protocol" + "github.com/drpcorg/nodecore/internal/upstreams/connectors" + "github.com/drpcorg/nodecore/internal/upstreams/lower_bounds" + "github.com/drpcorg/nodecore/pkg/chains" + "github.com/rs/zerolog/log" +) + +// The prune point moves slowly (bitcoind trims in large chunks), so a long +// period is enough. +const bitcoinPeriod = 15 * time.Minute + +var errBitcoinNoPruneHeight = errors.New("bitcoin node is pruned but reported no pruneheight") + +var bitcoinEmittedBoundTypes = []protocol.LowerBoundType{ + protocol.BlockBound, + protocol.TxBound, +} + +type BitcoinLowerBoundDetector struct { + upstreamId string + connector connectors.ApiConnector + chain chains.Chain + internalTimeout time.Duration + + lastBound atomic.Int64 +} + +func NewBitcoinLowerBoundDetector( + upstreamId string, + chain chains.Chain, + internalTimeout time.Duration, + connector connectors.ApiConnector, +) *BitcoinLowerBoundDetector { + return &BitcoinLowerBoundDetector{ + upstreamId: upstreamId, + connector: connector, + chain: chain, + internalTimeout: internalTimeout, + } +} + +func (b *BitcoinLowerBoundDetector) DetectLowerBound(ctx context.Context) ([]protocol.LowerBoundData, error) { + info, err := b.fetchBlockchainInfo(ctx) + if err != nil { + return b.fallback(fmt.Errorf("cannot fetch blockchain info: %w", err)), nil + } + + bound := int64(1) + if info.Pruned { + if info.PruneHeight <= 0 { + return b.fallback(errBitcoinNoPruneHeight), nil + } + bound = info.PruneHeight + } + b.lastBound.Store(bound) + + return bitcoinBounds(bound), nil +} + +func (b *BitcoinLowerBoundDetector) SupportedTypes() []protocol.LowerBoundType { + return append(append([]protocol.LowerBoundType{}, bitcoinEmittedBoundTypes...), protocol.UnknownBound) +} + +func (b *BitcoinLowerBoundDetector) Period() time.Duration { + return bitcoinPeriod +} + +// fallback decides what to publish when the calculation cannot complete. +// If a previous tick produced a bound, re-emit it so the router keeps using +// the last known good value. Otherwise emit UnknownBound=0 so consumers get +// an explicit "we don't know" signal instead of silence. +func (b *BitcoinLowerBoundDetector) fallback(reason error) []protocol.LowerBoundData { + if cached := b.lastBound.Load(); cached > 0 { + log.Warn().Err(reason).Msgf( + "bitcoin upstream '%s' lower-bound calculation failed; retaining cached bound=%d", + b.upstreamId, cached, + ) + return bitcoinBounds(cached) + } + log.Warn().Err(reason).Msgf( + "bitcoin upstream '%s' lower-bound calculation failed and no cache available; emitting UnknownBound", + b.upstreamId, + ) + return []protocol.LowerBoundData{ + protocol.NewLowerBoundDataNow(0, protocol.UnknownBound), + } +} + +func bitcoinBounds(bound int64) []protocol.LowerBoundData { + bounds := make([]protocol.LowerBoundData, 0, len(bitcoinEmittedBoundTypes)) + for _, bt := range bitcoinEmittedBoundTypes { + bounds = append(bounds, protocol.NewLowerBoundDataNow(bound, bt)) + } + return bounds +} + +func (b *BitcoinLowerBoundDetector) fetchBlockchainInfo(ctx context.Context) (*bitcoinBlockchainInfo, error) { + ctx, cancel := context.WithTimeout(ctx, b.internalTimeout) + defer cancel() + + request, err := protocol.NewInternalUpstreamJsonRpcRequest("getblockchaininfo", []any{}, b.chain) + if err != nil { + return nil, err + } + + response := b.connector.SendRequest(ctx, request) + if response.HasError() { + return nil, response.GetError() + } + raw := response.ResponseResult() + if len(raw) == 0 { + return nil, fmt.Errorf("bitcoin upstream '%s' getblockchaininfo returned an empty body", b.upstreamId) + } + var info bitcoinBlockchainInfo + if err := sonic.Unmarshal(raw, &info); err != nil { + return nil, fmt.Errorf("bitcoin upstream '%s' getblockchaininfo unparseable: %w", b.upstreamId, err) + } + return &info, nil +} + +type bitcoinBlockchainInfo struct { + Pruned bool `json:"pruned"` + PruneHeight int64 `json:"pruneheight"` +} + +var _ lower_bounds.LowerBoundDetector = (*BitcoinLowerBoundDetector)(nil) diff --git a/internal/upstreams/lower_bounds/bitcoin_bounds/bitcoin_lower_bound_test.go b/internal/upstreams/lower_bounds/bitcoin_bounds/bitcoin_lower_bound_test.go new file mode 100644 index 00000000..539491fe --- /dev/null +++ b/internal/upstreams/lower_bounds/bitcoin_bounds/bitcoin_lower_bound_test.go @@ -0,0 +1,145 @@ +package bitcoin_bounds_test + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/drpcorg/nodecore/internal/protocol" + "github.com/drpcorg/nodecore/internal/upstreams/lower_bounds/bitcoin_bounds" + "github.com/drpcorg/nodecore/pkg/chains" + "github.com/drpcorg/nodecore/pkg/test_utils/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func bitcoinInfoResponse(pruned bool, pruneHeight int64) protocol.ResponseHolder { + body := fmt.Sprintf( + `{"jsonrpc":"2.0","id":"1","result":{"chain":"main","blocks":850000,"pruned":%t,"pruneheight":%d}}`, + pruned, pruneHeight, + ) + return protocol.NewHttpUpstreamResponse("1", []byte(body), 200, protocol.JsonRpc) +} + +func bitcoinUnprunedResponse() protocol.ResponseHolder { + body := `{"jsonrpc":"2.0","id":"1","result":{"chain":"main","blocks":850000,"pruned":false}}` + return protocol.NewHttpUpstreamResponse("1", []byte(body), 200, protocol.JsonRpc) +} + +func matchBitcoinInfoRequest() func(protocol.RequestHolder) bool { + return func(req protocol.RequestHolder) bool { + return req.Method() == "getblockchaininfo" && req.RequestType() == protocol.JsonRpc + } +} + +func boundsByType(t *testing.T, result []protocol.LowerBoundData) map[protocol.LowerBoundType]int64 { + t.Helper() + got := make(map[protocol.LowerBoundType]int64, len(result)) + for _, b := range result { + got[b.Type] = b.Bound + } + return got +} + +func TestBitcoinLowerBoundDetector_SupportedTypesAndPeriod(t *testing.T) { + connector := mocks.NewConnectorMock() + detector := bitcoin_bounds.NewBitcoinLowerBoundDetector("id", chains.BITCOIN, time.Second, connector) + + assert.ElementsMatch(t, + []protocol.LowerBoundType{ + protocol.BlockBound, + protocol.TxBound, + protocol.UnknownBound, + }, + detector.SupportedTypes(), + ) + assert.Equal(t, 15*time.Minute, detector.Period()) +} + +func TestBitcoinLowerBoundDetector_UnprunedReturnsOne(t *testing.T) { + connector := mocks.NewConnectorMock() + connector. + On("SendRequest", mock.Anything, mock.MatchedBy(matchBitcoinInfoRequest())). + Return(bitcoinUnprunedResponse()) + + detector := bitcoin_bounds.NewBitcoinLowerBoundDetector("id", chains.BITCOIN, time.Second, connector) + + result, err := detector.DetectLowerBound(context.Background()) + require.NoError(t, err) + require.Len(t, result, 2) + got := boundsByType(t, result) + assert.Equal(t, int64(1), got[protocol.BlockBound]) + assert.Equal(t, int64(1), got[protocol.TxBound]) +} + +func TestBitcoinLowerBoundDetector_PrunedReturnsPruneHeight(t *testing.T) { + connector := mocks.NewConnectorMock() + connector. + On("SendRequest", mock.Anything, mock.MatchedBy(matchBitcoinInfoRequest())). + Return(bitcoinInfoResponse(true, 812345)) + + detector := bitcoin_bounds.NewBitcoinLowerBoundDetector("id", chains.BITCOIN, time.Second, connector) + + result, err := detector.DetectLowerBound(context.Background()) + require.NoError(t, err) + require.Len(t, result, 2) + got := boundsByType(t, result) + assert.Equal(t, int64(812345), got[protocol.BlockBound]) + assert.Equal(t, int64(812345), got[protocol.TxBound]) +} + +func TestBitcoinLowerBoundDetector_ErrorWithCacheRetainsCachedBound(t *testing.T) { + connector := mocks.NewConnectorMock() + connector. + On("SendRequest", mock.Anything, mock.MatchedBy(matchBitcoinInfoRequest())). + Return(bitcoinInfoResponse(true, 812345)). + Once() + connector. + On("SendRequest", mock.Anything, mock.MatchedBy(matchBitcoinInfoRequest())). + Return(protocol.NewHttpUpstreamResponseWithError(protocol.ServerError())) + + detector := bitcoin_bounds.NewBitcoinLowerBoundDetector("id", chains.BITCOIN, time.Second, connector) + + result, err := detector.DetectLowerBound(context.Background()) + require.NoError(t, err) + require.Len(t, result, 2) + + result, err = detector.DetectLowerBound(context.Background()) + require.NoError(t, err) + require.Len(t, result, 2) + got := boundsByType(t, result) + assert.Equal(t, int64(812345), got[protocol.BlockBound]) + assert.Equal(t, int64(812345), got[protocol.TxBound]) +} + +func TestBitcoinLowerBoundDetector_ErrorWithoutCacheEmitsUnknownBound(t *testing.T) { + connector := mocks.NewConnectorMock() + connector. + On("SendRequest", mock.Anything, mock.MatchedBy(matchBitcoinInfoRequest())). + Return(protocol.NewHttpUpstreamResponseWithError(protocol.ServerError())) + + detector := bitcoin_bounds.NewBitcoinLowerBoundDetector("id", chains.BITCOIN, time.Second, connector) + + result, err := detector.DetectLowerBound(context.Background()) + require.NoError(t, err) + require.Len(t, result, 1) + assert.Equal(t, protocol.UnknownBound, result[0].Type) + assert.Equal(t, int64(0), result[0].Bound) +} + +func TestBitcoinLowerBoundDetector_PrunedWithoutPruneHeightFallsBack(t *testing.T) { + connector := mocks.NewConnectorMock() + connector. + On("SendRequest", mock.Anything, mock.MatchedBy(matchBitcoinInfoRequest())). + Return(bitcoinInfoResponse(true, 0)) + + detector := bitcoin_bounds.NewBitcoinLowerBoundDetector("id", chains.BITCOIN, time.Second, connector) + + result, err := detector.DetectLowerBound(context.Background()) + require.NoError(t, err) + require.Len(t, result, 1) + assert.Equal(t, protocol.UnknownBound, result[0].Type) + assert.Equal(t, int64(0), result[0].Bound) +} diff --git a/internal/upstreams/upstream_factory.go b/internal/upstreams/upstream_factory.go index 8d21d89a..aeac69bd 100644 --- a/internal/upstreams/upstream_factory.go +++ b/internal/upstreams/upstream_factory.go @@ -10,6 +10,7 @@ import ( "github.com/drpcorg/nodecore/internal/upstreams/chains_specific/aptos_specific" "github.com/drpcorg/nodecore/internal/upstreams/chains_specific/aztec_specific" "github.com/drpcorg/nodecore/internal/upstreams/chains_specific/beacon_specific" + "github.com/drpcorg/nodecore/internal/upstreams/chains_specific/bitcoin_specific" "github.com/drpcorg/nodecore/internal/upstreams/chains_specific/evm_specific" "github.com/drpcorg/nodecore/internal/upstreams/chains_specific/solana_specific" "github.com/drpcorg/nodecore/internal/upstreams/chains_specific/tron_specific" @@ -226,6 +227,14 @@ func getChainSpecific( upstreamConnectorsInfo.internalRequestConnector, conf.Options, ), nil + case chains.Bitcoin: + return bitcoin_specific.NewBitcoinChainSpecificObject( + ctx, + configuredChain, + conf.Id, + upstreamConnectorsInfo.internalRequestConnector, + conf.Options, + ), nil case chains.EthereumBeaconChain: return beacon_specific.NewBeaconChainSpecificObject( ctx, diff --git a/internal/upstreams/validations/bitcoin_validations/bitcoin_chain_validator.go b/internal/upstreams/validations/bitcoin_validations/bitcoin_chain_validator.go new file mode 100644 index 00000000..7d19618f --- /dev/null +++ b/internal/upstreams/validations/bitcoin_validations/bitcoin_chain_validator.go @@ -0,0 +1,93 @@ +package bitcoin_validations + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/drpcorg/nodecore/internal/protocol" + "github.com/drpcorg/nodecore/internal/upstreams/connectors" + "github.com/drpcorg/nodecore/internal/upstreams/validations" + "github.com/drpcorg/nodecore/pkg/chains" + "github.com/rs/zerolog/log" +) + +var errBitcoinEmptyGenesis = errors.New("bitcoin node returned empty genesis hash") + +var expectedGenesisHashes = map[chains.Chain]string{ + chains.BITCOIN: "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f", + chains.BITCOIN_TESTNET: "000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943", + chains.DOGECOIN: "1a91e3dace36e2be3bf030a65679fe821aa1d6ef92e7c9902eb318182c355691", + chains.DOGECOIN_TESTNET: "bb0a78264637406b6360aad926284d544d7049f45189db5664f3c4d07350559e", +} + +type BitcoinChainValidator struct { + upstreamId string + connector connectors.ApiConnector + chain *chains.ConfiguredChain + internalTimeout time.Duration +} + +func NewBitcoinChainValidator( + upstreamId string, + connector connectors.ApiConnector, + chain *chains.ConfiguredChain, + internalTimeout time.Duration, +) *BitcoinChainValidator { + return &BitcoinChainValidator{ + upstreamId: upstreamId, + connector: connector, + chain: chain, + internalTimeout: internalTimeout, + } +} + +func (b *BitcoinChainValidator) Validate() validations.ValidationSettingResult { + expected, hasMapping := expectedGenesisHashes[b.chain.Chain] + if !hasMapping { + log.Warn().Msgf( + "no expected genesis hash for chain '%s', skipping chain validation of bitcoin upstream '%s'", + b.chain.Chain.String(), + b.upstreamId, + ) + return validations.Valid + } + genesis, err := b.fetchGenesisHash() + if err != nil { + log.Error().Err(err).Msgf("failed to fetch bitcoin genesis hash for upstream '%s'", b.upstreamId) + return validations.SettingsError + } + if strings.EqualFold(genesis, expected) { + return validations.Valid + } + log.Error().Msgf( + "'%s' expects genesis hash '%s' but bitcoin upstream '%s' reports '%s'", + b.chain.Chain.String(), + expected, + b.upstreamId, + genesis, + ) + return validations.FatalSettingError +} + +func (b *BitcoinChainValidator) fetchGenesisHash() (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), b.internalTimeout) + defer cancel() + + request, err := protocol.NewInternalUpstreamJsonRpcRequest("getblockhash", []any{0}, b.chain.Chain) + if err != nil { + return "", err + } + response := b.connector.SendRequest(ctx, request) + if response.HasError() { + return "", response.GetError() + } + genesis := protocol.ResultAsString(response.ResponseResult()) + if genesis == "" { + return "", errBitcoinEmptyGenesis + } + return genesis, nil +} + +var _ validations.SettingsValidator = (*BitcoinChainValidator)(nil) diff --git a/internal/upstreams/validations/bitcoin_validations/bitcoin_health_validator.go b/internal/upstreams/validations/bitcoin_validations/bitcoin_health_validator.go new file mode 100644 index 00000000..7587d339 --- /dev/null +++ b/internal/upstreams/validations/bitcoin_validations/bitcoin_health_validator.go @@ -0,0 +1,122 @@ +package bitcoin_validations + +import ( + "context" + "errors" + "strconv" + "time" + + "github.com/bytedance/sonic" + "github.com/drpcorg/nodecore/internal/protocol" + "github.com/drpcorg/nodecore/internal/upstreams/connectors" + "github.com/drpcorg/nodecore/internal/upstreams/validations" + "github.com/drpcorg/nodecore/pkg/chains" + "github.com/rs/zerolog/log" +) + +var ( + errBitcoinInitialBlockDownload = errors.New("bitcoin node is in initial block download") + errBitcoinNoPeers = errors.New("bitcoin node has no peers") +) + +type BitcoinHealthValidator struct { + upstreamId string + connector connectors.ApiConnector + chain *chains.ConfiguredChain + internalTimeout time.Duration + validatePeers bool +} + +func NewBitcoinHealthValidator( + upstreamId string, + connector connectors.ApiConnector, + chain *chains.ConfiguredChain, + internalTimeout time.Duration, + validatePeers bool, +) *BitcoinHealthValidator { + return &BitcoinHealthValidator{ + upstreamId: upstreamId, + connector: connector, + chain: chain, + internalTimeout: internalTimeout, + validatePeers: validatePeers, + } +} + +func (b *BitcoinHealthValidator) Validate() protocol.AvailabilityStatus { + info, err := b.fetchBlockchainInfo() + if err != nil { + log.Error().Err(err).Msgf("bitcoin upstream '%s' health validation failed", b.upstreamId) + return protocol.Unavailable + } + if info.InitialBlockDownload { + log.Warn().Err(errBitcoinInitialBlockDownload).Msgf("bitcoin upstream '%s' is in initial block download", b.upstreamId) + return protocol.Syncing + } + syncingLag := b.chain.Settings.Lags.Syncing + if syncingLag > 0 && info.Headers >= info.Blocks && int64(info.Headers-info.Blocks) > syncingLag { + log.Warn().Msgf( + "bitcoin upstream '%s' is syncing, headers=%d blocks=%d", + b.upstreamId, + info.Headers, + info.Blocks, + ) + return protocol.Syncing + } + if b.validatePeers { + peers, err := b.fetchConnectionCount() + if err != nil { + log.Error().Err(err).Msgf("failed to fetch bitcoin connection count for upstream '%s'", b.upstreamId) + return protocol.Unavailable + } + if peers == 0 { + log.Error().Err(errBitcoinNoPeers).Msgf("bitcoin upstream '%s' has no peers", b.upstreamId) + return protocol.Unavailable + } + } + return protocol.Available +} + +func (b *BitcoinHealthValidator) fetchBlockchainInfo() (*BitcoinBlockchainInfo, error) { + ctx, cancel := context.WithTimeout(context.Background(), b.internalTimeout) + defer cancel() + + request, err := protocol.NewInternalUpstreamJsonRpcRequest("getblockchaininfo", []any{}, b.chain.Chain) + if err != nil { + return nil, err + } + response := b.connector.SendRequest(ctx, request) + if response.HasError() { + return nil, response.GetError() + } + var info BitcoinBlockchainInfo + if err := sonic.Unmarshal(response.ResponseResult(), &info); err != nil { + return nil, err + } + return &info, nil +} + +func (b *BitcoinHealthValidator) fetchConnectionCount() (uint64, error) { + ctx, cancel := context.WithTimeout(context.Background(), b.internalTimeout) + defer cancel() + + request, err := protocol.NewInternalUpstreamJsonRpcRequest("getconnectioncount", []any{}, b.chain.Chain) + if err != nil { + return 0, err + } + response := b.connector.SendRequest(ctx, request) + if response.HasError() { + return 0, response.GetError() + } + return strconv.ParseUint(protocol.ResultAsString(response.ResponseResult()), 10, 64) +} + +type BitcoinBlockchainInfo struct { + Blocks uint64 `json:"blocks"` + Headers uint64 `json:"headers"` + InitialBlockDownload bool `json:"initialblockdownload"` + Pruned bool `json:"pruned"` + PruneHeight uint64 `json:"pruneheight"` +} + +var _ validations.HealthValidator = (*BitcoinHealthValidator)(nil) diff --git a/internal/upstreams/validations/bitcoin_validations/bitcoin_validators_test.go b/internal/upstreams/validations/bitcoin_validations/bitcoin_validators_test.go new file mode 100644 index 00000000..619c7a85 --- /dev/null +++ b/internal/upstreams/validations/bitcoin_validations/bitcoin_validators_test.go @@ -0,0 +1,125 @@ +package bitcoin_validations_test + +import ( + "fmt" + "testing" + "time" + + "github.com/drpcorg/nodecore/internal/protocol" + "github.com/drpcorg/nodecore/internal/upstreams/validations" + "github.com/drpcorg/nodecore/internal/upstreams/validations/bitcoin_validations" + "github.com/drpcorg/nodecore/pkg/chains" + "github.com/drpcorg/nodecore/pkg/test_utils/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +const ( + bitcoinGenesis = `"000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f"` + dogecoinGenesis = `"1a91e3dace36e2be3bf030a65679fe821aa1d6ef92e7c9902eb318182c355691"` +) + +func isGetBlockHash(r protocol.RequestHolder) bool { return r.Method() == "getblockhash" } +func isGetBlockchainInfo(r protocol.RequestHolder) bool { return r.Method() == "getblockchaininfo" } +func isGetConnectionCount(r protocol.RequestHolder) bool { return r.Method() == "getconnectioncount" } + +func blockchainInfoBody(blocks, headers uint64, ibd bool) []byte { + return []byte(fmt.Sprintf( + `{"blocks":%d,"headers":%d,"initialblockdownload":%t,"pruned":false}`, + blocks, headers, ibd, + )) +} + +func TestBitcoinChainValidatorValidOnMatch(t *testing.T) { + conn := mocks.NewConnectorMock() + conn.On("SendRequest", mock.Anything, mock.MatchedBy(isGetBlockHash)). + Return(protocol.NewSimpleHttpUpstreamResponse("1", []byte(bitcoinGenesis), protocol.JsonRpc)) + v := bitcoin_validations.NewBitcoinChainValidator("id", conn, chains.GetChain("bitcoin"), time.Second) + assert.Equal(t, validations.Valid, v.Validate()) +} + +func TestBitcoinChainValidatorFatalOnMismatch(t *testing.T) { + conn := mocks.NewConnectorMock() + // a dogecoin node behind a config that says bitcoin + conn.On("SendRequest", mock.Anything, mock.MatchedBy(isGetBlockHash)). + Return(protocol.NewSimpleHttpUpstreamResponse("1", []byte(dogecoinGenesis), protocol.JsonRpc)) + v := bitcoin_validations.NewBitcoinChainValidator("id", conn, chains.GetChain("bitcoin"), time.Second) + assert.Equal(t, validations.FatalSettingError, v.Validate()) +} + +func TestBitcoinChainValidatorSkipsUnmappedChain(t *testing.T) { + conn := mocks.NewConnectorMock() + v := bitcoin_validations.NewBitcoinChainValidator("id", conn, chains.GetChain("litecoin"), time.Second) + assert.Equal(t, validations.Valid, v.Validate()) + conn.AssertNotCalled(t, "SendRequest", mock.Anything, mock.Anything) +} + +func TestBitcoinChainValidatorRetriesOnFetchError(t *testing.T) { + conn := mocks.NewConnectorMock() + conn.On("SendRequest", mock.Anything, mock.MatchedBy(isGetBlockHash)). + Return(protocol.NewHttpUpstreamResponseWithError(protocol.ResponseErrorWithData(1, "boom", nil))) + v := bitcoin_validations.NewBitcoinChainValidator("id", conn, chains.GetChain("bitcoin"), time.Second) + assert.Equal(t, validations.SettingsError, v.Validate()) +} + +func TestBitcoinHealthAvailable(t *testing.T) { + conn := mocks.NewConnectorMock() + conn.On("SendRequest", mock.Anything, mock.MatchedBy(isGetBlockchainInfo)). + Return(protocol.NewSimpleHttpUpstreamResponse("1", blockchainInfoBody(100, 100, false), protocol.JsonRpc)) + v := bitcoin_validations.NewBitcoinHealthValidator("id", conn, chains.GetChain("bitcoin"), time.Second, false) + assert.Equal(t, protocol.Available, v.Validate()) +} + +func TestBitcoinHealthSyncingOnInitialBlockDownload(t *testing.T) { + conn := mocks.NewConnectorMock() + conn.On("SendRequest", mock.Anything, mock.MatchedBy(isGetBlockchainInfo)). + Return(protocol.NewSimpleHttpUpstreamResponse("1", blockchainInfoBody(100, 100, true), protocol.JsonRpc)) + v := bitcoin_validations.NewBitcoinHealthValidator("id", conn, chains.GetChain("bitcoin"), time.Second, false) + assert.Equal(t, protocol.Syncing, v.Validate()) +} + +func TestBitcoinHealthSyncingOnHeadersLag(t *testing.T) { + conn := mocks.NewConnectorMock() + // bitcoin's syncing lag is 3; headers are 10 blocks ahead + conn.On("SendRequest", mock.Anything, mock.MatchedBy(isGetBlockchainInfo)). + Return(protocol.NewSimpleHttpUpstreamResponse("1", blockchainInfoBody(100, 110, false), protocol.JsonRpc)) + v := bitcoin_validations.NewBitcoinHealthValidator("id", conn, chains.GetChain("bitcoin"), time.Second, false) + assert.Equal(t, protocol.Syncing, v.Validate()) +} + +func TestBitcoinHealthUnavailableOnZeroPeers(t *testing.T) { + conn := mocks.NewConnectorMock() + conn.On("SendRequest", mock.Anything, mock.MatchedBy(isGetBlockchainInfo)). + Return(protocol.NewSimpleHttpUpstreamResponse("1", blockchainInfoBody(100, 100, false), protocol.JsonRpc)) + conn.On("SendRequest", mock.Anything, mock.MatchedBy(isGetConnectionCount)). + Return(protocol.NewSimpleHttpUpstreamResponse("1", []byte(`0`), protocol.JsonRpc)) + v := bitcoin_validations.NewBitcoinHealthValidator("id", conn, chains.GetChain("bitcoin"), time.Second, true) + assert.Equal(t, protocol.Unavailable, v.Validate()) +} + +func TestBitcoinHealthAvailableWithPeers(t *testing.T) { + conn := mocks.NewConnectorMock() + conn.On("SendRequest", mock.Anything, mock.MatchedBy(isGetBlockchainInfo)). + Return(protocol.NewSimpleHttpUpstreamResponse("1", blockchainInfoBody(100, 100, false), protocol.JsonRpc)) + conn.On("SendRequest", mock.Anything, mock.MatchedBy(isGetConnectionCount)). + Return(protocol.NewSimpleHttpUpstreamResponse("1", []byte(`8`), protocol.JsonRpc)) + v := bitcoin_validations.NewBitcoinHealthValidator("id", conn, chains.GetChain("bitcoin"), time.Second, true) + assert.Equal(t, protocol.Available, v.Validate()) +} + +func TestBitcoinHealthIgnoresPeersWhenDisabled(t *testing.T) { + conn := mocks.NewConnectorMock() + conn.On("SendRequest", mock.Anything, mock.MatchedBy(isGetBlockchainInfo)). + Return(protocol.NewSimpleHttpUpstreamResponse("1", blockchainInfoBody(100, 100, false), protocol.JsonRpc)) + v := bitcoin_validations.NewBitcoinHealthValidator("id", conn, chains.GetChain("bitcoin"), time.Second, false) + assert.Equal(t, protocol.Available, v.Validate()) + conn.AssertNotCalled(t, "SendRequest", mock.Anything, mock.MatchedBy(isGetConnectionCount)) +} + +func TestBitcoinHealthUnavailableOnError(t *testing.T) { + conn := mocks.NewConnectorMock() + conn.On("SendRequest", mock.Anything, mock.MatchedBy(isGetBlockchainInfo)). + Return(protocol.NewHttpUpstreamResponseWithError(protocol.ResponseErrorWithData(1, "boom", nil))) + v := bitcoin_validations.NewBitcoinHealthValidator("id", conn, chains.GetChain("bitcoin"), time.Second, false) + assert.Equal(t, protocol.Unavailable, v.Validate()) +} From 427f7845df1951947180301f475d16646f3e8102 Mon Sep 17 00:00:00 2001 From: Artem Rootman <4586640+artemrootman@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:14:13 +0000 Subject: [PATCH 05/15] feat(bitcoin): esplora listunspent translation and getblocknumber alias nodecore has no per-chain request-rewrite hook (tron solidity is plain REST methods, not a translation), so this adds a minimal generic seam: a (spec, method)-keyed translator registry in the flow layer, consulted in sendUnaryRequest before the connector call. - getblocknumber -> getblockcount via a reusable jsonRpcMethodAlias (request-only rewrite, same wire id/params). - listunspent -> GET /address/{addr}/utxo on the rest-additional esplora connector; response reshaped to the bitcoind listunspent result (sats->BTC as exact 8-decimal JSON numbers, confirmations from the upstream's tracked head height, no extra RPC calls). Declared only in bitcoin-esplora.json, so upstreams without esplora never advertise it and routing skips them. - protocol.InvalidParamsError (-32602) for malformed listunspent params. - design doc updated to reflect the flow-layer seam. --- .../2026-07-17-bitcoin-support-design.md | 18 +- internal/protocol/errors.go | 7 + internal/upstreams/flow/method_translation.go | 177 +++++++++++++ .../upstreams/flow/method_translation_test.go | 237 ++++++++++++++++++ internal/upstreams/flow/request_processor.go | 21 +- pkg/methods/methods_spec_test.go | 9 + pkg/methods/specs/bitcoin-esplora.json | 8 + pkg/methods/specs/bitcoin-json-rpc.json | 7 - 8 files changed, 469 insertions(+), 15 deletions(-) create mode 100644 internal/upstreams/flow/method_translation.go create mode 100644 internal/upstreams/flow/method_translation_test.go diff --git a/docs/superpowers/specs/2026-07-17-bitcoin-support-design.md b/docs/superpowers/specs/2026-07-17-bitcoin-support-design.md index e412dba4..67ed04b9 100644 --- a/docs/superpowers/specs/2026-07-17-bitcoin-support-design.md +++ b/docs/superpowers/specs/2026-07-17-bitcoin-support-design.md @@ -136,15 +136,21 @@ kept passthrough-only for surface parity — the node's own error is returned. ### Esplora translation (`listunspent`) -Esplora is an optional `rest-additional` connector on the upstream (the same -mechanism tron uses for its solidity endpoint). `listunspent` is intercepted -in `bitcoin_specific`: the address argument maps to +Esplora is an optional `rest-additional` connector on the upstream. Design +note (discovered during implementation): nodecore has no per-chain +request-rewrite hook — tron's solidity endpoint is a set of first-class REST +methods, not a translation — so the interception lives in a small generic +`(spec, method) → {buildRequest, reshapeResponse}` transformer registry in +the flow layer (consulted in `sendUnaryRequest` before the connector call), +not in `bitcoin_specific`. For `listunspent` the address argument maps to `GET /address/{addr}/utxo`, and the esplora response is converted to the bitcoind `listunspent` result shape (txid, vout, address, amount in BTC from sats, confirmations computed from current head vs `status.block_height`). -Upstreams without an esplora connector get `listunspent` banned via the -existing method-ban mechanism, so routing skips them (dogecoin has no -esplora today). +`listunspent` is declared only in the esplora spec (`rest-additional`), so +upstreams without an esplora connector never advertise it and routing skips +them statically — no runtime ban needed (dogecoin has no esplora today). +`getblocknumber` → `getblockcount` uses the same registry as a pure +request-rewrite. ### Authentication diff --git a/internal/protocol/errors.go b/internal/protocol/errors.go index 20508849..4a8e9206 100644 --- a/internal/protocol/errors.go +++ b/internal/protocol/errors.go @@ -227,6 +227,13 @@ func QuorumNotSupportedError(reason string) *ResponseError { const InvalidParams = -32602 +func InvalidParamsError(message string) *ResponseError { + return &ResponseError{ + Code: InvalidParams, + Message: message, + } +} + func UnsupportedBlockTagError(chain, tag string) *ResponseError { return &ResponseError{ Code: InvalidParams, diff --git a/internal/upstreams/flow/method_translation.go b/internal/upstreams/flow/method_translation.go new file mode 100644 index 00000000..724404a0 --- /dev/null +++ b/internal/upstreams/flow/method_translation.go @@ -0,0 +1,177 @@ +package flow + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/bytedance/sonic" + "github.com/drpcorg/nodecore/internal/protocol" +) + +// methodTranslator rewrites a client method into the request an upstream +// actually understands and reshapes the upstream response back into the shape +// the client expects. Translators are looked up by (spec name, client method) +// in sendUnaryRequest right before the request hits an api connector, so the +// translated request's spec method drives the connector choice. +type methodTranslator interface { + TranslateRequest(ctx context.Context, request protocol.RequestHolder) (protocol.RequestHolder, error) + TranslateResponse(request, upstreamRequest protocol.RequestHolder, upstreamHeadHeight uint64, response protocol.ResponseHolder) protocol.ResponseHolder +} + +var methodTranslators = map[string]map[string]methodTranslator{ + "bitcoin": { + "getblocknumber": &jsonRpcMethodAlias{specName: "bitcoin", target: "getblockcount"}, + "listunspent": &bitcoinListUnspentTranslator{specName: "bitcoin"}, + }, +} + +func getMethodTranslator(specName, method string) methodTranslator { + return methodTranslators[specName][method] +} + +// jsonRpcMethodAlias resends the request under another JSON-RPC method name, +// keeping the id and params intact. The response passes through untouched. +type jsonRpcMethodAlias struct { + specName string + target string +} + +func (a *jsonRpcMethodAlias) TranslateRequest(_ context.Context, request protocol.RequestHolder) (protocol.RequestHolder, error) { + body, err := jsonRpcBodyOf(request) + if err != nil { + return nil, err + } + body.Method = a.target + return protocol.NewUpstreamJsonRpcRequest(request.Id(), *body, false, a.specName, request.Selectors()...), nil +} + +func (a *jsonRpcMethodAlias) TranslateResponse(_, _ protocol.RequestHolder, _ uint64, response protocol.ResponseHolder) protocol.ResponseHolder { + return response +} + +const esploraAddressUtxoMethod = "GET#/address/*/utxo" + +// bitcoinListUnspentTranslator serves bitcoind's listunspent from an esplora +// upstream: GET /address/{addr}/utxo, then reshapes the utxo list into the +// bitcoind response shape. +type bitcoinListUnspentTranslator struct { + specName string +} + +func (t *bitcoinListUnspentTranslator) TranslateRequest(_ context.Context, request protocol.RequestHolder) (protocol.RequestHolder, error) { + body, err := jsonRpcBodyOf(request) + if err != nil { + return nil, err + } + address, err := listUnspentAddress(body.Params) + if err != nil { + return nil, err + } + params := &protocol.RequestParams{PathParams: []string{address}} + return protocol.NewUpstreamRestRequest(request.Id(), esploraAddressUtxoMethod, params, nil, t.specName, request.Selectors()...), nil +} + +func (t *bitcoinListUnspentTranslator) TranslateResponse(request, upstreamRequest protocol.RequestHolder, upstreamHeadHeight uint64, response protocol.ResponseHolder) protocol.ResponseHolder { + if response.HasError() { + return response + } + var utxos []esploraUtxo + if err := sonic.Unmarshal(response.ResponseResult(), &utxos); err != nil { + return protocol.NewTotalFailureFromErr(request.Id(), protocol.IncorrectResponseBodyError(err), protocol.JsonRpc) + } + address := "" + if params := upstreamRequest.RequestParams(); params != nil && len(params.PathParams) > 0 { + address = params.PathParams[0] + } + unspent := make([]listUnspentUtxo, 0, len(utxos)) + for _, utxo := range utxos { + unspent = append(unspent, listUnspentUtxo{ + Txid: utxo.Txid, + Vout: utxo.Vout, + Address: address, + Amount: satsToBtc(utxo.Value), + Confirmations: utxoConfirmations(utxo.Status, upstreamHeadHeight), + }) + } + result, err := sonic.Marshal(unspent) + if err != nil { + return protocol.NewTotalFailureFromErr(request.Id(), protocol.IncorrectResponseBodyError(err), protocol.JsonRpc) + } + return protocol.NewSimpleHttpUpstreamResponse(request.Id(), result, protocol.JsonRpc) +} + +type esploraUtxo struct { + Txid string `json:"txid"` + Vout uint32 `json:"vout"` + Status esploraUtxoStatus `json:"status"` + Value uint64 `json:"value"` +} + +type esploraUtxoStatus struct { + Confirmed bool `json:"confirmed"` + BlockHeight uint64 `json:"block_height"` +} + +type listUnspentUtxo struct { + Txid string `json:"txid"` + Vout uint32 `json:"vout"` + Address string `json:"address"` + Amount json.RawMessage `json:"amount"` + Confirmations uint64 `json:"confirmations"` +} + +func jsonRpcBodyOf(request protocol.RequestHolder) (*protocol.JsonRpcRequestBody, error) { + rawBody, err := request.Body() + if err != nil { + return nil, protocol.ClientError(err) + } + var body protocol.JsonRpcRequestBody + if err := sonic.Unmarshal(rawBody, &body); err != nil { + return nil, protocol.ClientError(err) + } + return &body, nil +} + +// listUnspentAddress extracts the single queried address from bitcoind-style +// listunspent params - [minconf, maxconf, [addresses]] - accepting a bare +// address string in place of the array defensively. +func listUnspentAddress(rawParams json.RawMessage) (string, error) { + var params []any + if len(rawParams) > 0 { + if err := sonic.Unmarshal(rawParams, ¶ms); err != nil { + return "", protocol.InvalidParamsError("listunspent params must be an array") + } + } + for _, param := range params { + switch value := param.(type) { + case string: + return value, nil + case []any: + if len(value) != 1 { + return "", protocol.InvalidParamsError("listunspent supports exactly one address") + } + address, ok := value[0].(string) + if !ok { + return "", protocol.InvalidParamsError("listunspent address must be a string") + } + return address, nil + } + } + return "", protocol.InvalidParamsError("listunspent requires an address") +} + +const satsPerBtc = 100_000_000 + +// satsToBtc renders a satoshi amount as a fixed 8-decimal BTC JSON number, +// avoiding float64 artifacts. +func satsToBtc(sats uint64) json.RawMessage { + return json.RawMessage(fmt.Sprintf("%d.%08d", sats/satsPerBtc, sats%satsPerBtc)) +} + +func utxoConfirmations(status esploraUtxoStatus, headHeight uint64) uint64 { + if !status.Confirmed || status.BlockHeight == 0 || headHeight < status.BlockHeight { + return 0 + } + return headHeight - status.BlockHeight + 1 +} diff --git a/internal/upstreams/flow/method_translation_test.go b/internal/upstreams/flow/method_translation_test.go new file mode 100644 index 00000000..94be03c8 --- /dev/null +++ b/internal/upstreams/flow/method_translation_test.go @@ -0,0 +1,237 @@ +package flow + +import ( + "context" + "encoding/json" + "testing" + "time" + + mapset "github.com/deckarep/golang-set/v2" + "github.com/drpcorg/nodecore/internal/config" + "github.com/drpcorg/nodecore/internal/protocol" + "github.com/drpcorg/nodecore/internal/upstreams" + "github.com/drpcorg/nodecore/internal/upstreams/connectors" + "github.com/drpcorg/nodecore/pkg/chains" + specs "github.com/drpcorg/nodecore/pkg/methods" + "github.com/drpcorg/nodecore/pkg/test_utils" + "github.com/drpcorg/nodecore/pkg/test_utils/mocks" + "github.com/drpcorg/nodecore/pkg/utils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func bitcoinJsonRpcRequest(t *testing.T, method string, params string) protocol.RequestHolder { + t.Helper() + require.NoError(t, specs.NewMethodSpecLoader().Load()) + body := protocol.JsonRpcRequestBody{Id: []byte(`5`), Method: method, Params: json.RawMessage(params)} + return protocol.NewUpstreamJsonRpcRequest("223", body, false, "bitcoin") +} + +func TestGetBlockNumberTranslatesToGetBlockCount(t *testing.T) { + request := bitcoinJsonRpcRequest(t, "getblocknumber", `[]`) + translator := getMethodTranslator("bitcoin", "getblocknumber") + require.NotNil(t, translator) + + translated, err := translator.TranslateRequest(context.Background(), request) + + require.NoError(t, err) + assert.Equal(t, "getblockcount", translated.Method()) + assert.Equal(t, "223", translated.Id()) + body, err := translated.Body() + require.NoError(t, err) + assert.JSONEq(t, `{"id":5,"jsonrpc":"2.0","method":"getblockcount","params":[]}`, string(body)) + + response := protocol.NewSimpleHttpUpstreamResponse("223", []byte(`850000`), protocol.JsonRpc) + assert.Same(t, response, translator.TranslateResponse(request, translated, 850_000, response)) +} + +func TestListUnspentTranslatesToEsploraUtxoRequest(t *testing.T) { + translator := getMethodTranslator("bitcoin", "listunspent") + require.NotNil(t, translator) + + for name, params := range map[string]string{ + "bitcoind style": `[1, 9999999, ["bc1qaddress"]]`, + "address in args": `[1, 9999999, "bc1qaddress"]`, + "single address": `["bc1qaddress"]`, + } { + t.Run(name, func(t *testing.T) { + request := bitcoinJsonRpcRequest(t, "listunspent", params) + + translated, err := translator.TranslateRequest(context.Background(), request) + + require.NoError(t, err) + assert.Equal(t, "GET#/address/*/utxo", translated.Method()) + assert.Equal(t, protocol.Rest, translated.RequestType()) + assert.Equal(t, "223", translated.Id()) + require.NotNil(t, translated.RequestParams()) + assert.Equal(t, []string{"bc1qaddress"}, translated.RequestParams().PathParams) + }) + } +} + +func TestListUnspentInvalidParams(t *testing.T) { + translator := getMethodTranslator("bitcoin", "listunspent") + require.NotNil(t, translator) + + for name, params := range map[string]string{ + "no params": `[]`, + "no address": `[1, 9999999]`, + "multiple addresses": `[1, 9999999, ["addr1", "addr2"]]`, + "non-string address": `[1, 9999999, [42]]`, + "not an array": `{"minconf": 1}`, + } { + t.Run(name, func(t *testing.T) { + request := bitcoinJsonRpcRequest(t, "listunspent", params) + + _, err := translator.TranslateRequest(context.Background(), request) + + require.Error(t, err) + respErr, ok := err.(*protocol.ResponseError) + require.True(t, ok) + assert.Equal(t, protocol.InvalidParams, respErr.Code) + }) + } +} + +func TestListUnspentReshapesEsploraResponse(t *testing.T) { + translator := getMethodTranslator("bitcoin", "listunspent") + require.NotNil(t, translator) + request := bitcoinJsonRpcRequest(t, "listunspent", `[1, 9999999, ["bc1qaddress"]]`) + translated, err := translator.TranslateRequest(context.Background(), request) + require.NoError(t, err) + + esploraBody := `[ + {"txid":"aa11","vout":1,"status":{"confirmed":true,"block_height":849000,"block_hash":"00","block_time":1},"value":927}, + {"txid":"bb22","vout":0,"status":{"confirmed":true,"block_height":850000},"value":150000000}, + {"txid":"cc33","vout":2,"status":{"confirmed":false},"value":100000000} + ]` + response := protocol.NewSimpleHttpUpstreamResponse("223", []byte(esploraBody), protocol.Rest) + + reshaped := translator.TranslateResponse(request, translated, 850_000, response) + + require.False(t, reshaped.HasError()) + assert.Equal(t, "223", reshaped.Id()) + assert.JSONEq(t, `[ + {"txid":"aa11","vout":1,"address":"bc1qaddress","amount":0.00000927,"confirmations":1001}, + {"txid":"bb22","vout":0,"address":"bc1qaddress","amount":1.50000000,"confirmations":1}, + {"txid":"cc33","vout":2,"address":"bc1qaddress","amount":1.00000000,"confirmations":0} + ]`, string(reshaped.ResponseResult())) + assert.Contains(t, string(reshaped.ResponseResult()), `"amount":0.00000927`) +} + +func TestListUnspentPassesThroughUpstreamError(t *testing.T) { + translator := getMethodTranslator("bitcoin", "listunspent") + require.NotNil(t, translator) + request := bitcoinJsonRpcRequest(t, "listunspent", `[1, 9999999, ["bc1qaddress"]]`) + translated, err := translator.TranslateRequest(context.Background(), request) + require.NoError(t, err) + + response := protocol.NewHttpUpstreamResponseWithError(protocol.ResponseErrorWithMessage("esplora is down")) + + assert.Same(t, response, translator.TranslateResponse(request, translated, 850_000, response)) +} + +func TestSatsToBtc(t *testing.T) { + assert.Equal(t, "0.00000927", string(satsToBtc(927))) + assert.Equal(t, "0.00000000", string(satsToBtc(0))) + assert.Equal(t, "1.00000000", string(satsToBtc(100_000_000))) + assert.Equal(t, "21.12345678", string(satsToBtc(2_112_345_678))) +} + +func bitcoinTestUpstream(connector connectors.ApiConnector, headHeight uint64) *upstreams.BaseUpstream { + upState := utils.NewAtomic[protocol.UpstreamState]() + state := protocol.DefaultUpstreamState(mocks.NewMethodsMock(), mapset.NewThreadUnsafeSet[protocol.Cap](), "00012", nil, nil) + state.HeadData = protocol.NewBlockWithHeight(headHeight) + upState.Store(state) + + return upstreams.NewBaseUpstreamWithParams( + "id", + chains.BITCOIN, + []connectors.ApiConnector{connector}, + &config.Upstream{Id: "id", PollInterval: 10 * time.Millisecond, Options: &chains.Options{InternalTimeout: 5 * time.Second}}, + "00012", + upState, + nil, + nil, + nil, + ) +} + +func TestUnaryRequestProcessorTranslatesListUnspent(t *testing.T) { + request := bitcoinJsonRpcRequest(t, "listunspent", `[1, 9999999, ["bc1qaddress"]]`) + + apiConnector := mocks.NewConnectorMockWithType(specs.RestAdditional) + upstream := bitcoinTestUpstream(apiConnector, 850_000) + upSupervisor := mocks.NewUpstreamSupervisorMock() + strategy := mocks.NewMockStrategy() + + upSupervisor.On("GetExecutor").Return(test_utils.CreateExecutor()) + strategy.On("SelectUpstream", request).Return("id", nil) + upSupervisor.On("GetUpstream", "id").Return(upstream) + apiConnector.On("SendRequest", mock.Anything, mock.MatchedBy(func(req protocol.RequestHolder) bool { + return req.Method() == "GET#/address/*/utxo" && + req.RequestType() == protocol.Rest && + req.RequestParams() != nil && + len(req.RequestParams().PathParams) == 1 && + req.RequestParams().PathParams[0] == "bc1qaddress" + })).Return(protocol.NewSimpleHttpUpstreamResponse("223", []byte(`[{"txid":"aa11","vout":1,"status":{"confirmed":true,"block_height":849000},"value":927}]`), protocol.Rest)) + + processor := NewUnaryRequestProcessor(chains.BITCOIN, upSupervisor) + response := processor.ProcessRequest(context.Background(), strategy, request) + + unaryRespWrapper := response.(*UnaryResponse).ResponseWrapper + apiConnector.AssertExpectations(t) + assert.Equal(t, "223", unaryRespWrapper.RequestId) + assert.Equal(t, "id", unaryRespWrapper.UpstreamId) + require.False(t, unaryRespWrapper.Response.HasError()) + assert.JSONEq(t, `[{"txid":"aa11","vout":1,"address":"bc1qaddress","amount":0.00000927,"confirmations":1001}]`, string(unaryRespWrapper.Response.ResponseResult())) +} + +func TestUnaryRequestProcessorTranslatesGetBlockNumber(t *testing.T) { + request := bitcoinJsonRpcRequest(t, "getblocknumber", `[]`) + + apiConnector := mocks.NewConnectorMockWithType(specs.JsonRpcConnector) + upstream := bitcoinTestUpstream(apiConnector, 850_000) + upSupervisor := mocks.NewUpstreamSupervisorMock() + strategy := mocks.NewMockStrategy() + + upSupervisor.On("GetExecutor").Return(test_utils.CreateExecutor()) + strategy.On("SelectUpstream", request).Return("id", nil) + upSupervisor.On("GetUpstream", "id").Return(upstream) + apiConnector.On("SendRequest", mock.Anything, mock.MatchedBy(func(req protocol.RequestHolder) bool { + body, err := req.Body() + return err == nil && req.Method() == "getblockcount" && string(body) == `{"id":5,"jsonrpc":"2.0","method":"getblockcount","params":[]}` + })).Return(protocol.NewSimpleHttpUpstreamResponse("223", []byte(`850000`), protocol.JsonRpc)) + + processor := NewUnaryRequestProcessor(chains.BITCOIN, upSupervisor) + response := processor.ProcessRequest(context.Background(), strategy, request) + + unaryRespWrapper := response.(*UnaryResponse).ResponseWrapper + apiConnector.AssertExpectations(t) + assert.Equal(t, "223", unaryRespWrapper.RequestId) + require.False(t, unaryRespWrapper.Response.HasError()) + assert.Equal(t, `850000`, string(unaryRespWrapper.Response.ResponseResult())) +} + +func TestUnaryRequestProcessorListUnspentInvalidParamsNoUpstreamCall(t *testing.T) { + request := bitcoinJsonRpcRequest(t, "listunspent", `[1, 9999999]`) + + apiConnector := mocks.NewConnectorMockWithType(specs.RestAdditional) + upstream := bitcoinTestUpstream(apiConnector, 850_000) + upSupervisor := mocks.NewUpstreamSupervisorMock() + strategy := mocks.NewMockStrategy() + + upSupervisor.On("GetExecutor").Return(test_utils.CreateExecutor()) + strategy.On("SelectUpstream", request).Return("id", nil) + upSupervisor.On("GetUpstream", "id").Return(upstream) + + processor := NewUnaryRequestProcessor(chains.BITCOIN, upSupervisor) + response := processor.ProcessRequest(context.Background(), strategy, request) + + unaryRespWrapper := response.(*UnaryResponse).ResponseWrapper + apiConnector.AssertNotCalled(t, "SendRequest") + assert.Equal(t, "223", unaryRespWrapper.RequestId) + require.True(t, unaryRespWrapper.Response.HasError()) + assert.Equal(t, protocol.InvalidParams, unaryRespWrapper.Response.GetError().Code) +} diff --git a/internal/upstreams/flow/request_processor.go b/internal/upstreams/flow/request_processor.go index b31f2a3a..f8250a61 100644 --- a/internal/upstreams/flow/request_processor.go +++ b/internal/upstreams/flow/request_processor.go @@ -189,12 +189,29 @@ func sendUnaryRequest( ) (*protocol.ResponseHolderWrapper, error) { zerolog.Ctx(ctx).Debug().Msgf("sending a request %s to upstream %s", request.Method(), upstream.GetId()) - apiConnector := getMethodConnector(upstream, request.SpecMethod()) + upstreamRequest := request + translator := getMethodTranslator(chains.GetMethodSpecNameByChain(upstream.GetChain()), request.Method()) + if translator != nil { + translated, err := translator.TranslateRequest(ctx, request) + if err != nil { + return &protocol.ResponseHolderWrapper{ + RequestId: request.Id(), + UpstreamId: upstream.GetId(), + Response: protocol.NewTotalFailureFromErr(request.Id(), err, request.RequestType()), + }, nil + } + upstreamRequest = translated + } + + apiConnector := getMethodConnector(upstream, upstreamRequest.SpecMethod()) if apiConnector == nil { return nil, protocol.NoApiConnectorsError(request.Method()) } - response := apiConnector.SendRequest(ctx, request) + response := apiConnector.SendRequest(ctx, upstreamRequest) + if translator != nil { + response = translator.TranslateResponse(request, upstreamRequest, upstream.GetCurrentHeadHeight(), response) + } if response.ResponseCode() == http.StatusTooManyRequests && upstream.GetUpstreamState().AutoTuneRateLimiter != nil { upstream.GetUpstreamState().AutoTuneRateLimiter.IncErrors() diff --git a/pkg/methods/methods_spec_test.go b/pkg/methods/methods_spec_test.go index fe1e1d13..8390dac4 100644 --- a/pkg/methods/methods_spec_test.go +++ b/pkg/methods/methods_spec_test.go @@ -245,6 +245,15 @@ func TestBitcoinSpecLoads(t *testing.T) { spec = specs.GetSpecMethod("bitcoin", "eth_call") assert.Nil(t, spec) + // listunspent is served via esplora, so it resolves only for upstreams with + // the rest-additional connector + jsonRpcMethods := specs.GetSpecMethodsByConnectors("bitcoin", []specs.ApiConnectorType{specs.JsonRpcConnector}) + assert.NotContains(t, jsonRpcMethods[specs.DefaultMethodGroup], "listunspent") + assert.Contains(t, jsonRpcMethods[specs.DefaultMethodGroup], "getblocknumber") + + restAdditionalMethods := specs.GetSpecMethodsByConnectors("bitcoin", []specs.ApiConnectorType{specs.RestAdditional}) + assert.Contains(t, restAdditionalMethods[specs.DefaultMethodGroup], "listunspent") + template, params, ok := specs.MatchRestMethod("bitcoin", "GET#/address/bc1qxyz/utxo") assert.True(t, ok) assert.Equal(t, "GET#/address/*/utxo", template) diff --git a/pkg/methods/specs/bitcoin-esplora.json b/pkg/methods/specs/bitcoin-esplora.json index bdb344a3..c0190513 100644 --- a/pkg/methods/specs/bitcoin-esplora.json +++ b/pkg/methods/specs/bitcoin-esplora.json @@ -17,6 +17,14 @@ "settings": { "cacheable": false } + }, + { + "name": "listunspent", + "group": "additional", + "params": [], + "settings": { + "cacheable": false + } } ] } diff --git a/pkg/methods/specs/bitcoin-json-rpc.json b/pkg/methods/specs/bitcoin-json-rpc.json index 02b65540..bc1dd0e0 100644 --- a/pkg/methods/specs/bitcoin-json-rpc.json +++ b/pkg/methods/specs/bitcoin-json-rpc.json @@ -127,13 +127,6 @@ "cacheable": false, "dispatch": "broadcast" } - }, - { - "name": "listunspent", - "params": [], - "settings": { - "cacheable": false - } } ] } From 3f8e6447d3f74bcaa417d0cc68e8453bf16d74aa Mon Sep 17 00:00:00 2001 From: Artem Rootman <4586640+artemrootman@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:34:28 +0000 Subject: [PATCH 06/15] docs: record live laptop validation results for bitcoin family Corpus run against a production bitcoin node (mainnet + esplora) and a production dogecoin node (mainnet/testnet): 65/68 identical; the 3 diffs are nodecore's own unknown-method -32601 message (local allowlist rejection, standard for all families). --- .../2026-07-17-bitcoin-support-design.md | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/docs/superpowers/specs/2026-07-17-bitcoin-support-design.md b/docs/superpowers/specs/2026-07-17-bitcoin-support-design.md index 67ed04b9..70154c76 100644 --- a/docs/superpowers/specs/2026-07-17-bitcoin-support-design.md +++ b/docs/superpowers/specs/2026-07-17-bitcoin-support-design.md @@ -20,9 +20,9 @@ API support, so these chains can be removed from dshackle. the emerald proto for BITCOIN/DOGECOIN). - **Out of scope (YAGNI):** wallet methods beyond the dshackle surface; ZMQ or WebSocket-style head subscriptions (bitcoind has none we consume); - Litecoin/other UTXO chains (nothing registered in Consul today); deployment automation - ansible filter changes (deferred until the family passes live validation - from a laptop-run nodecore instance — see Testing). + Litecoin/other UTXO chains (no nodes deployed today); deployment automation + changes (deferred until the family passes live validation from a laptop-run + nodecore instance — see Testing). ## Approach @@ -178,15 +178,24 @@ handles; verified during implementation with a live node. handling. `sendrawtransaction` is exercised for real only on **dogecoin-testnet**; on mainnets only with a deterministically invalid transaction (expected error passthrough), nothing is broadcast. -3. **Staged rollout (after laptop validation, separate step).** Wire the - deployment automation (basic auth + esplora meta), enable bitcoin on - one production nodecore instance while dshackle still serves the chain, - compare error rates and responses, then drop `bitcoin`/`dogecoin` from - `INCLUDE_TO_DSHACKLE`. + **Executed 2026-07-17 — passed.** nodecore ran locally against + a production bitcoin node (mainnet + esplora) and a production dogecoin + node (mainnet/testnet): + 65 PASS / 3 FAIL / 0 SKIP. The only failures are the unknown-method error + *text* (`foobarmethod`): nodecore rejects unknown methods locally from the + method spec and synthesizes its own -32601 message instead of forwarding — + standard nodecore behavior for every family, code preserved, accepted. + Findings: dogecoin testnet genesis `bb0a78…59e` verified live; wallet-built + bitcoind returns `-18 No wallet is loaded` (not -32601) for + `getreceivedbyaddress` and nodecore passes it through verbatim; + `listunspent` UTXO sets match esplora exactly (7 UTXOs on the probe + address); lower-bounds detection is off by default outside strict mode + (`option_defaults.go`), enabled explicitly it published BLOCK=1/TX=1 for + the unpruned node; dogecoin 1.14 accepts int verbosity for + getblock/getrawtransaction, so no bool-form divergence exists in practice. ## Out of scope / follow-ups -- Deployment automation changes ship only after step 2 passes. - near, ripple, starknet, ton follow as separate family designs reusing this template; celestia/stellar additionally need `chains.yaml` registry entries (they are absent there today). From 0f72ea57028c402811a1f52362b6a8aca22ada16 Mon Sep 17 00:00:00 2001 From: Artem Rootman <4586640+artemrootman@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:51:46 +0000 Subject: [PATCH 07/15] docs: near family support design spec --- .../specs/2026-07-17-near-support-design.md | 166 ++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-17-near-support-design.md diff --git a/docs/superpowers/specs/2026-07-17-near-support-design.md b/docs/superpowers/specs/2026-07-17-near-support-design.md new file mode 100644 index 00000000..5858e22f --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-near-support-design.md @@ -0,0 +1,166 @@ +# NEAR family support — design + +Date: 2026-07-17 +Status: draft (pending approval) + +## Goal + +Add first-class support for the **NEAR** blockchain family to nodecore +(`BlockchainType = "near"`), covering near mainnet and testnet (betanet is +registered in `chains.yaml` and gets family support for free), with full gRPC +(emerald) API support, so these chains can be served by nodecore instead of +dshackle. + +## Scope + +- **In scope:** neard JSON-RPC 2.0 upstreams (plain HTTP, no auth, no WS — + nearcore has no subscriptions at all); the 15-method surface currently + served by dshackle (see method table); optimistic head tracking; chain-id + validation; syncing/peers health; client version labels; dynamic GC-window + lower bounds. +- **Out of scope (YAGNI):** stable aliases nearcore added later + (`changes`, `genesis_config`, `block_effects`, `maintenance_windows`, + `light_client_proof`) and `broadcast_tx_async`/`broadcast_tx_commit`/ + `health`/`client_config`/light-client methods — none are served by dshackle + today, so no client depends on them through us; per-method cache tag + parsers (v1 ships everything `cacheable: false`, parity with dshackle which + does not cache NEAR); a separately tracked finalized head (see Head + tracking); archival-aware routing (we run no archival NEAR nodes). + +## Approach + +NEAR follows the **bitcoin family template** (JSON-RPC, poll-only head, +data-driven method spec): a `near_specific` chain-specific package plus +`pkg/methods/specs/near.json`. It is strictly simpler than bitcoin — a single +`status` call powers health, chain validation, labels and lower bounds; there +is no esplora-style secondary connector and no method aliases to translate. + +### Verified live API shapes (from our production nodes, nearcore 2.13.1) + +`status` (truncated): `{"chain_id":"mainnet","protocol_version":84, +"latest_protocol_version":86,"version":{"version":"2.13.1","build":"unknown", +"rustc_version":"1.93.0"},"validator_account_id":null,"sync_info":{ +"latest_block_height":207365026,"latest_block_hash":"...", +"latest_block_time":"2026-07-17T...","earliest_block_height":207157933, +"syncing":false},...}` — testnet identical shape with `"chain_id":"testnet"`. + +`block {"finality":"optimistic"}` → `{"author":...,"header":{"height":..., +"hash":"","prev_hash":"","timestamp":1784299563227484539, +"timestamp_nanosec":"1784299563227484539",...},"chunks":[...]}`. The +optimistic head runs ~3 blocks ahead of `"final"`. `timestamp` is integer +nanoseconds (int64-boundary risk in some parsers; `timestamp_nanosec` is the +string twin). + +`gas_price [null]` → `{"gas_price":"100000000"}` (string). + +Errors carry a structured `name`/`cause` pair alongside the standard fields: + +```json +{"error":{"name":"HANDLER_ERROR","cause":{"name":"UNKNOWN_BLOCK","info":{}}, + "code":-32000,"message":"Server error","data":"DB Not Found Error: ..."}} +``` + +- Unknown method → `REQUEST_VALIDATION_ERROR`/`METHOD_NOT_FOUND`, `-32601`. +- Garbage-collected block (`block`/`chunk`) → `UNKNOWN_BLOCK` (`query` + returns the more explicit `GARBAGE_COLLECTED_BLOCK`). +- **Unknown transaction**: the node holds the request ~10s (server-side + poll loop, 10s default timeout) and returns HTTP **408** with + `cause.name: "TIMEOUT_ERROR"` — this is not a node-health signal and must + not be blindly retried in a tight loop. +- Syncing node → `NOT_SYNCED_YET` / `NO_SYNCED_BLOCKS` (retryable on another + upstream); user errors (`INVALID_TRANSACTION`, `PARSE_ERROR`, ...) are + deterministic. + +## Architecture + +### Factory and type plumbing + +- `upstream_factory.go`: new `case chains.Near` returning + `near_specific.NewNearChainSpecificObject(...)`. +- `chains.go`: `getMethodSpecName` gains `case Near: return "near"` + (currently resolves to `""`). `chains.Near` and the NEAR/NEAR_TESTNET/ + NEAR_BETANET constants, `chains.yaml` entries (`chain-id: mainnet|testnet| + betanet`, lags 40/20, 1s block time) and gRPC ChainRefs (1050/10064/10065) + all already exist — no registry work. + +### Head tracking (`near_specific`) + +Poll-based `RpcHead` (no subscriptions), dshackle parity: + +1. `GetLatestBlock`: `block {"finality":"optimistic"}` → height, + hash/prev_hash (base58 strings fed to `NewHashIdFromString` as-is). +2. `GetFinalizedBlock`: `block {"finality":"final"}` — implemented for + correctness, but `BlockProcessor()` returns nil in v1, so no separate + finalized-head poll loop runs (same simplification as every non-EVM + family; the optimistic/final gap is ~3 blocks / ~3s). Revisit only if + consumers need a distinct final head. + +### Chain validation + +`NearChainValidator` calls `status` and compares `chain_id` (case-insensitive) +against the configured `chain-id` (`mainnet`/`testnet`/`betanet` — NEAR +chain-ids in `chains.yaml` are network-name strings, not hex). Mismatch → +`FatalSettingError`; fetch error → `SettingsError`. This is exactly +dshackle's check; no genesis call needed (chain_id is unique per network, +unlike bitcoin where two forks both report `main`). + +### Health validation + +`NearHealthValidator` uses the same `status` payload: +- `sync_info.syncing == true` → Syncing; +- stale head guard: `latest_block_time` older than the chain's syncing-lag + worth of block time → Syncing (catches the frozen-head case `syncing:false` + does not); +- peers (when `validate-peers`): `network_info` → `num_active_peers == 0` → + Unavailable. + +### Labels and lower bounds + +- `client_version` label from `status.version.version` (e.g. `2.13.1`), + client type `neard` (the only production implementation). +- `NearLowerBoundDetector`: `status.sync_info.earliest_block_height` → + emitted as STATE and BLOCK bounds (dshackle parity). Our nodes are all + non-archival with a sliding ~5-epoch (~2.5 day) GC window, so the bound is + re-polled on a short period (3 min); no binary search needed — the node + reports its own boundary. + +### Method spec (`pkg/methods/specs/near.json`) + +The 15 methods dshackle serves, all `cacheable: false` in v1: + +| group | methods | notes | +|---|---|---| +| default | block, chunk, tx, EXPERIMENTAL_tx_status, EXPERIMENTAL_receipt, query, EXPERIMENTAL_changes, EXPERIMENTAL_changes_in_block, EXPERIMENTAL_protocol_config, gas_price, validators | object params (`{"finality":...}` / `{"block_id":...}`) | +| passthrough | status, network_info | node-local, never cache | +| broadcast | send_tx | broadcast to all upstreams (dshackle used BroadcastQuorum) | + +No translations, no bans: every upstream is a full neard and serves the whole +surface. Follow-up (not v1): cache tag-parsers for `block`/`chunk`/`tx` by +hash and `query` with explicit `block_id` via the existing jq `tag-parser` +mechanism. + +## Testing + +1. **Unit tests** (bitcoin-family parity): optimistic/final head parsing + (incl. the nanosecond timestamp), chain-id validation mainnet-vs-testnet + cross-check, syncing/stale-head/peers health states, version label, + earliest-block-height bounds, method-spec loading. +2. **Live comparison harness (laptop, before any infra change).** nodecore + runs locally against our production near mainnet + testnet nodes (reached + directly over VPN, plain HTTP, no auth). Corpus: every spec method against + node-direct vs nodecore — blocks by finality/height/hash, deep GC'd + heights (UNKNOWN_BLOCK passthrough), chunk by ids, a real tx and an + unknown tx (408/TIMEOUT_ERROR passthrough within tolerable latency), + query view_account/call_function, changes, protocol/genesis config, + gas_price, validators, unknown method, invalid params. `send_tx` only + with a deterministically invalid transaction on both networks (expected + error passthrough, nothing broadcast). +3. **Staged rollout** is deployment-side work, out of scope for this repo. + +## Out of scope / follow-ups + +- Cache tag-parsers and per-method cacheability for finalized data. +- Distinct finalized-head tracking (optimistic vs final) if a consumer needs + it. +- ripple, starknet, ton follow as separate family designs reusing this + template. From 025ae8acbd9f17deff00b546b7f655e88bbebdbc Mon Sep 17 00:00:00 2001 From: Artem Rootman <4586640+artemrootman@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:53:24 +0000 Subject: [PATCH 08/15] docs: mark near design approved --- docs/superpowers/specs/2026-07-17-near-support-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-07-17-near-support-design.md b/docs/superpowers/specs/2026-07-17-near-support-design.md index 5858e22f..d610e5d5 100644 --- a/docs/superpowers/specs/2026-07-17-near-support-design.md +++ b/docs/superpowers/specs/2026-07-17-near-support-design.md @@ -1,7 +1,7 @@ # NEAR family support — design Date: 2026-07-17 -Status: draft (pending approval) +Status: approved (pre-implementation) ## Goal From a2c2897568b87ccc4adf5a7d239cee18d9cf4cd4 Mon Sep 17 00:00:00 2001 From: Artem Rootman <4586640+artemrootman@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:57:20 +0000 Subject: [PATCH 09/15] feat(near): full family support - near_specific: optimistic-head poll (block finality=optimistic), final head on GetFinalizedBlock, base58 hashes verbatim; no subscriptions (nearcore has none). - near_validations: chain-id validation via status.chain_id (network-name strings, no genesis call needed) and health validation (syncing flag, stale-head guard on latest_block_time vs expected-block-time * syncing lag, optional network_info peer check). - near_labels: client neard + version from status.version.version. - near_bounds: sliding GC-window lower bounds from status.sync_info.earliest_block_height (STATE+BLOCK, 3 min period). - near method spec bundle: the 14-method dshackle-parity surface, all cacheable:false in v1, send_tx dispatched as broadcast. - factory case chains.Near + getMethodSpecName "near". --- .../specs/2026-07-17-near-support-design.md | 4 +- .../near_specific/near_chain_specific.go | 195 ++++++++++++++++++ .../labels/near_labels/near_detectors.go | 42 ++++ .../labels/near_labels/near_detectors_test.go | 80 +++++++ .../near_bounds/near_lower_bound.go | 139 +++++++++++++ .../near_bounds/near_lower_bound_test.go | 168 +++++++++++++++ internal/upstreams/upstream_factory.go | 9 + .../near_validations/near_chain_validator.go | 61 ++++++ .../near_validations/near_health_validator.go | 140 +++++++++++++ .../near_validations/near_validators_test.go | 117 +++++++++++ pkg/chains/chains.go | 2 + pkg/methods/methods_spec_test.go | 15 ++ pkg/methods/specs/near-json-rpc.json | 113 ++++++++++ pkg/methods/specs/near.json | 12 ++ 14 files changed, 1095 insertions(+), 2 deletions(-) create mode 100644 internal/upstreams/chains_specific/near_specific/near_chain_specific.go create mode 100644 internal/upstreams/labels/near_labels/near_detectors.go create mode 100644 internal/upstreams/labels/near_labels/near_detectors_test.go create mode 100644 internal/upstreams/lower_bounds/near_bounds/near_lower_bound.go create mode 100644 internal/upstreams/lower_bounds/near_bounds/near_lower_bound_test.go create mode 100644 internal/upstreams/validations/near_validations/near_chain_validator.go create mode 100644 internal/upstreams/validations/near_validations/near_health_validator.go create mode 100644 internal/upstreams/validations/near_validations/near_validators_test.go create mode 100644 pkg/methods/specs/near-json-rpc.json create mode 100644 pkg/methods/specs/near.json diff --git a/docs/superpowers/specs/2026-07-17-near-support-design.md b/docs/superpowers/specs/2026-07-17-near-support-design.md index d610e5d5..310cf0fb 100644 --- a/docs/superpowers/specs/2026-07-17-near-support-design.md +++ b/docs/superpowers/specs/2026-07-17-near-support-design.md @@ -14,7 +14,7 @@ dshackle. ## Scope - **In scope:** neard JSON-RPC 2.0 upstreams (plain HTTP, no auth, no WS — - nearcore has no subscriptions at all); the 15-method surface currently + nearcore has no subscriptions at all); the 14-method surface currently served by dshackle (see method table); optimistic head tracking; chain-id validation; syncing/peers health; client version labels; dynamic GC-window lower bounds. @@ -126,7 +126,7 @@ unlike bitcoin where two forks both report `main`). ### Method spec (`pkg/methods/specs/near.json`) -The 15 methods dshackle serves, all `cacheable: false` in v1: +The 14 methods dshackle serves, all `cacheable: false` in v1: | group | methods | notes | |---|---|---| diff --git a/internal/upstreams/chains_specific/near_specific/near_chain_specific.go b/internal/upstreams/chains_specific/near_specific/near_chain_specific.go new file mode 100644 index 00000000..55a44ed8 --- /dev/null +++ b/internal/upstreams/chains_specific/near_specific/near_chain_specific.go @@ -0,0 +1,195 @@ +package near_specific + +import ( + "context" + "fmt" + "time" + + "github.com/bytedance/sonic" + "github.com/drpcorg/nodecore/internal/protocol" + "github.com/drpcorg/nodecore/internal/upstreams/blocks" + "github.com/drpcorg/nodecore/internal/upstreams/caps" + "github.com/drpcorg/nodecore/internal/upstreams/chains_specific" + "github.com/drpcorg/nodecore/internal/upstreams/connectors" + "github.com/drpcorg/nodecore/internal/upstreams/labels" + "github.com/drpcorg/nodecore/internal/upstreams/labels/near_labels" + "github.com/drpcorg/nodecore/internal/upstreams/lower_bounds" + "github.com/drpcorg/nodecore/internal/upstreams/lower_bounds/near_bounds" + "github.com/drpcorg/nodecore/internal/upstreams/validations" + "github.com/drpcorg/nodecore/internal/upstreams/validations/near_validations" + "github.com/drpcorg/nodecore/pkg/blockchain" + "github.com/drpcorg/nodecore/pkg/chains" +) + +// errUnsupportedHeadSubscriptions is returned by SubscribeHeadRequest and +// ParseSubscriptionBlock: nearcore's JSON-RPC is HTTP request/response only, +// there is no subscription transport at all, so head tracking is poll-only. +var errUnsupportedHeadSubscriptions = fmt.Errorf("near: head subscriptions are not supported") + +type NearChainSpecificObject struct { + ctx context.Context + upstreamId string + connector connectors.ApiConnector + options *chains.Options + internalTimeout time.Duration + labelsDelay time.Duration + configuredChain *chains.ConfiguredChain +} + +func NewNearChainSpecificObject( + ctx context.Context, + configuredChain *chains.ConfiguredChain, + upstreamId string, + connector connectors.ApiConnector, + options *chains.Options, +) *NearChainSpecificObject { + return &NearChainSpecificObject{ + ctx: ctx, + upstreamId: upstreamId, + connector: connector, + options: options, + internalTimeout: options.InternalTimeout, + labelsDelay: options.ValidationInterval * 5, + configuredChain: configuredChain, + } +} + +func (n *NearChainSpecificObject) BlockProcessor() blocks.BlockProcessor { + return nil +} + +func (n *NearChainSpecificObject) LabelsProcessor() labels.LabelsProcessor { + labelsDetectors := []labels.LabelsDetector{ + labels.NewClientLabelDetectorHandler( + n.upstreamId, + n.connector, + near_labels.NewNearClientLabelsDetector(n.configuredChain.Chain), + n.internalTimeout, + ), + } + return labels.NewBaseLabelsProcessor(n.ctx, n.upstreamId, labelsDetectors, n.labelsDelay) +} + +func (n *NearChainSpecificObject) CapDetectors(input caps.DetectorInput) []caps.CapDetector { + return caps.DefaultCapDetectors(n.upstreamId, input.WsConnector) +} + +func (n *NearChainSpecificObject) LowerBoundProcessor() lower_bounds.LowerBoundProcessor { + detectors := []lower_bounds.LowerBoundDetector{ + near_bounds.NewNearLowerBoundDetector( + n.upstreamId, + n.configuredChain.Chain, + n.internalTimeout, + n.connector, + ), + } + return lower_bounds.NewBaseLowerBoundProcessor( + n.ctx, + n.upstreamId, + n.configuredChain.AverageRemoveSpeed(), + detectors, + ) +} + +func (n *NearChainSpecificObject) HealthValidators() []validations.Validator[protocol.AvailabilityStatus] { + if n.options != nil && *n.options.DisableHealthValidation { + return []validations.Validator[protocol.AvailabilityStatus]{} + } + validatePeers := n.options != nil && n.options.ValidatePeers != nil && *n.options.ValidatePeers + return []validations.Validator[protocol.AvailabilityStatus]{ + near_validations.NewNearHealthValidator( + n.upstreamId, n.connector, n.configuredChain, n.internalTimeout, validatePeers, + ), + } +} + +func (n *NearChainSpecificObject) SettingsValidators() []validations.Validator[validations.ValidationSettingResult] { + if n.configuredChain == nil || n.configuredChain.ChainId == "" { + return nil + } + if n.options != nil && *n.options.DisableChainValidation { + return []validations.Validator[validations.ValidationSettingResult]{} + } + return []validations.Validator[validations.ValidationSettingResult]{ + near_validations.NewNearChainValidator(n.upstreamId, n.connector, n.configuredChain, n.internalTimeout), + } +} + +// GetLatestBlock polls the optimistic head - the same head dshackle exposed +// for near, ~3 blocks ahead of "final". +func (n *NearChainSpecificObject) GetLatestBlock(ctx context.Context) (protocol.Block, error) { + return n.fetchBlockByFinality(ctx, "optimistic") +} + +// GetFinalizedBlock fetches the "final" head. It is not tracked in a separate +// poll loop (BlockProcessor is nil), but callers asking explicitly get the +// correct finalized block instead of an optimistic alias. +func (n *NearChainSpecificObject) GetFinalizedBlock(ctx context.Context) (protocol.Block, error) { + return n.fetchBlockByFinality(ctx, "final") +} + +func (n *NearChainSpecificObject) fetchBlockByFinality(ctx context.Context, finality string) (protocol.Block, error) { + request, err := protocol.NewInternalUpstreamJsonRpcRequest( + "block", + map[string]any{"finality": finality}, + n.configuredChain.Chain, + ) + if err != nil { + return protocol.ZeroBlock{}, err + } + response := n.connector.SendRequest(ctx, request) + if response.HasError() { + return protocol.ZeroBlock{}, response.GetError() + } + + block, err := n.ParseBlock(response.ResponseResult()) + if err != nil { + return protocol.ZeroBlock{}, fmt.Errorf("couldn't parse the near %s block: %w", finality, err) + } + return block, nil +} + +// ParseBlock expects the payload shape of the `block` method: +// {"author": ..., "header": {"height", "hash", "prev_hash", ...}, "chunks": [...]}. +// Hashes are base58 strings and are used verbatim as block identifiers. +func (n *NearChainSpecificObject) ParseBlock(blockBytes []byte) (protocol.Block, error) { + block := nearBlock{} + if err := sonic.Unmarshal(blockBytes, &block); err != nil { + return protocol.ZeroBlock{}, fmt.Errorf("couldn't parse the near block, reason - %s", err.Error()) + } + if block.Header.Hash == "" { + return protocol.ZeroBlock{}, fmt.Errorf("couldn't parse the near block, got '%s'", string(blockBytes)) + } + + parentHash := blockchain.EmptyHash + if block.Header.PrevHash != "" { + parentHash = blockchain.NewHashIdFromString(block.Header.PrevHash) + } + + return protocol.NewBlock( + block.Header.Height, + 0, + blockchain.NewHashIdFromString(block.Header.Hash), + parentHash, + ), nil +} + +func (n *NearChainSpecificObject) ParseSubscriptionBlock(_ []byte) (protocol.Block, error) { + return protocol.ZeroBlock{}, errUnsupportedHeadSubscriptions +} + +func (n *NearChainSpecificObject) SubscribeHeadRequest() (protocol.RequestHolder, error) { + return nil, errUnsupportedHeadSubscriptions +} + +type nearBlock struct { + Header nearBlockHeader `json:"header"` +} + +type nearBlockHeader struct { + Height uint64 `json:"height"` + Hash string `json:"hash"` + PrevHash string `json:"prev_hash"` +} + +var _ chains_specific.ChainSpecific = (*NearChainSpecificObject)(nil) diff --git a/internal/upstreams/labels/near_labels/near_detectors.go b/internal/upstreams/labels/near_labels/near_detectors.go new file mode 100644 index 00000000..c4a60153 --- /dev/null +++ b/internal/upstreams/labels/near_labels/near_detectors.go @@ -0,0 +1,42 @@ +package near_labels + +import ( + "fmt" + + "github.com/bytedance/sonic" + "github.com/drpcorg/nodecore/internal/protocol" + "github.com/drpcorg/nodecore/internal/upstreams/labels" + "github.com/drpcorg/nodecore/pkg/chains" +) + +// neard is the only production NEAR client, so the type is a constant +const nearClientType = "neard" + +type NearClientLabelsDetector struct { + chain chains.Chain +} + +func NewNearClientLabelsDetector(chain chains.Chain) *NearClientLabelsDetector { + return &NearClientLabelsDetector{chain: chain} +} + +func (n *NearClientLabelsDetector) NodeTypeRequest() (protocol.RequestHolder, error) { + return protocol.NewInternalUpstreamJsonRpcRequest("status", []any{}, n.chain) +} + +type nearStatusResponse struct { + Version struct { + Version string `json:"version"` + } `json:"version"` +} + +func (n *NearClientLabelsDetector) ClientVersionAndType(data []byte) (string, string, error) { + var status nearStatusResponse + if err := sonic.Unmarshal(data, &status); err != nil { + return "", "", fmt.Errorf("near status payload unparseable: %w", err) + } + // version looks like {"version":{"version":"2.13.1","build":"unknown","rustc_version":"1.93.0"},...} + return status.Version.Version, nearClientType, nil +} + +var _ labels.ClientLabelsDetector = (*NearClientLabelsDetector)(nil) diff --git a/internal/upstreams/labels/near_labels/near_detectors_test.go b/internal/upstreams/labels/near_labels/near_detectors_test.go new file mode 100644 index 00000000..93e0846b --- /dev/null +++ b/internal/upstreams/labels/near_labels/near_detectors_test.go @@ -0,0 +1,80 @@ +package near_labels_test + +import ( + "testing" + + "github.com/drpcorg/nodecore/internal/protocol" + "github.com/drpcorg/nodecore/internal/upstreams/labels/near_labels" + "github.com/drpcorg/nodecore/pkg/chains" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNearClientLabelsDetectorNodeTypeRequest(t *testing.T) { + detector := near_labels.NewNearClientLabelsDetector(chains.NEAR) + + request, err := detector.NodeTypeRequest() + require.NoError(t, err) + require.NotNil(t, request) + + assert.Equal(t, "status", request.Method()) + assert.Equal(t, protocol.JsonRpc, request.RequestType()) + + body, err := request.Body() + require.NoError(t, err) + + assert.JSONEq(t, `{"id":"1","jsonrpc":"2.0","method":"status","params":[]}`, string(body)) +} + +func TestNearClientLabelsDetectorClientVersionAndType(t *testing.T) { + tests := []struct { + name string + data []byte + expectedVersion string + expectedClientType string + expectErr bool + }{ + { + name: "parses neard version", + data: []byte(`{"version":{"version":"2.13.1","build":"unknown","rustc_version":"1.93.0"},"chain_id":"mainnet"}`), + expectedVersion: "2.13.1", + expectedClientType: "neard", + }, + { + name: "falls back on missing version", + data: []byte(`{"chain_id":"mainnet"}`), + expectedVersion: "", + expectedClientType: "neard", + }, + { + name: "falls back on empty version", + data: []byte(`{"version":{"version":"","build":"unknown"}}`), + expectedVersion: "", + expectedClientType: "neard", + }, + { + name: "fails on invalid json", + data: []byte(`{"version":`), + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + detector := near_labels.NewNearClientLabelsDetector(chains.NEAR) + + version, clientType, err := detector.ClientVersionAndType(tt.data) + + if tt.expectErr { + require.Error(t, err) + assert.Empty(t, version) + assert.Empty(t, clientType) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.expectedVersion, version) + assert.Equal(t, tt.expectedClientType, clientType) + }) + } +} diff --git a/internal/upstreams/lower_bounds/near_bounds/near_lower_bound.go b/internal/upstreams/lower_bounds/near_bounds/near_lower_bound.go new file mode 100644 index 00000000..869128ff --- /dev/null +++ b/internal/upstreams/lower_bounds/near_bounds/near_lower_bound.go @@ -0,0 +1,139 @@ +package near_bounds + +import ( + "context" + "errors" + "fmt" + "sync/atomic" + "time" + + "github.com/bytedance/sonic" + "github.com/drpcorg/nodecore/internal/protocol" + "github.com/drpcorg/nodecore/internal/upstreams/connectors" + "github.com/drpcorg/nodecore/internal/upstreams/lower_bounds" + "github.com/drpcorg/nodecore/pkg/chains" + "github.com/rs/zerolog/log" +) + +// Non-archival nodes garbage-collect a sliding ~5-epoch window, so the +// earliest available block moves at roughly one block per second; re-poll +// often to keep the bound close to the real GC boundary. +const nearPeriod = 3 * time.Minute + +var errNearNoEarliestHeight = errors.New("near node reported no earliest_block_height") + +var nearEmittedBoundTypes = []protocol.LowerBoundType{ + protocol.StateBound, + protocol.BlockBound, +} + +type NearLowerBoundDetector struct { + upstreamId string + connector connectors.ApiConnector + chain chains.Chain + internalTimeout time.Duration + + lastBound atomic.Int64 +} + +func NewNearLowerBoundDetector( + upstreamId string, + chain chains.Chain, + internalTimeout time.Duration, + connector connectors.ApiConnector, +) *NearLowerBoundDetector { + return &NearLowerBoundDetector{ + upstreamId: upstreamId, + connector: connector, + chain: chain, + internalTimeout: internalTimeout, + } +} + +func (n *NearLowerBoundDetector) DetectLowerBound(ctx context.Context) ([]protocol.LowerBoundData, error) { + status, err := n.fetchStatus(ctx) + if err != nil { + return n.fallback(fmt.Errorf("cannot fetch node status: %w", err)), nil + } + + bound := status.SyncInfo.EarliestBlockHeight + if bound <= 0 { + // Zero or absent means the node did not report its GC boundary, + // not that the full history is available. + return n.fallback(errNearNoEarliestHeight), nil + } + n.lastBound.Store(bound) + + return nearBounds(bound), nil +} + +func (n *NearLowerBoundDetector) SupportedTypes() []protocol.LowerBoundType { + return append(append([]protocol.LowerBoundType{}, nearEmittedBoundTypes...), protocol.UnknownBound) +} + +func (n *NearLowerBoundDetector) Period() time.Duration { + return nearPeriod +} + +// fallback decides what to publish when the calculation cannot complete. +// If a previous tick produced a bound, re-emit it so the router keeps using +// the last known good value. Otherwise emit UnknownBound=0 so consumers get +// an explicit "we don't know" signal instead of silence. +func (n *NearLowerBoundDetector) fallback(reason error) []protocol.LowerBoundData { + if cached := n.lastBound.Load(); cached > 0 { + log.Warn().Err(reason).Msgf( + "near upstream '%s' lower-bound calculation failed; retaining cached bound=%d", + n.upstreamId, cached, + ) + return nearBounds(cached) + } + log.Warn().Err(reason).Msgf( + "near upstream '%s' lower-bound calculation failed and no cache available; emitting UnknownBound", + n.upstreamId, + ) + return []protocol.LowerBoundData{ + protocol.NewLowerBoundDataNow(0, protocol.UnknownBound), + } +} + +func nearBounds(bound int64) []protocol.LowerBoundData { + bounds := make([]protocol.LowerBoundData, 0, len(nearEmittedBoundTypes)) + for _, bt := range nearEmittedBoundTypes { + bounds = append(bounds, protocol.NewLowerBoundDataNow(bound, bt)) + } + return bounds +} + +func (n *NearLowerBoundDetector) fetchStatus(ctx context.Context) (*nearStatus, error) { + ctx, cancel := context.WithTimeout(ctx, n.internalTimeout) + defer cancel() + + request, err := protocol.NewInternalUpstreamJsonRpcRequest("status", []any{}, n.chain) + if err != nil { + return nil, err + } + + response := n.connector.SendRequest(ctx, request) + if response.HasError() { + return nil, response.GetError() + } + raw := response.ResponseResult() + if len(raw) == 0 { + return nil, fmt.Errorf("near upstream '%s' status returned an empty body", n.upstreamId) + } + var status nearStatus + if err := sonic.Unmarshal(raw, &status); err != nil { + return nil, fmt.Errorf("near upstream '%s' status unparseable: %w", n.upstreamId, err) + } + return &status, nil +} + +type nearStatus struct { + SyncInfo nearSyncInfo `json:"sync_info"` +} + +type nearSyncInfo struct { + EarliestBlockHeight int64 `json:"earliest_block_height"` +} + +var _ lower_bounds.LowerBoundDetector = (*NearLowerBoundDetector)(nil) diff --git a/internal/upstreams/lower_bounds/near_bounds/near_lower_bound_test.go b/internal/upstreams/lower_bounds/near_bounds/near_lower_bound_test.go new file mode 100644 index 00000000..d33f876c --- /dev/null +++ b/internal/upstreams/lower_bounds/near_bounds/near_lower_bound_test.go @@ -0,0 +1,168 @@ +package near_bounds_test + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/drpcorg/nodecore/internal/protocol" + "github.com/drpcorg/nodecore/internal/upstreams/lower_bounds/near_bounds" + "github.com/drpcorg/nodecore/pkg/chains" + "github.com/drpcorg/nodecore/pkg/test_utils/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func nearStatusResponse(earliestBlockHeight int64) protocol.ResponseHolder { + body := fmt.Sprintf( + `{"jsonrpc":"2.0","id":"1","result":{"chain_id":"mainnet","sync_info":{"latest_block_height":207365026,"earliest_block_height":%d,"syncing":false}}}`, + earliestBlockHeight, + ) + return protocol.NewHttpUpstreamResponse("1", []byte(body), 200, protocol.JsonRpc) +} + +func nearStatusResponseWithoutEarliest() protocol.ResponseHolder { + body := `{"jsonrpc":"2.0","id":"1","result":{"chain_id":"mainnet","sync_info":{"latest_block_height":207365026,"syncing":false}}}` + return protocol.NewHttpUpstreamResponse("1", []byte(body), 200, protocol.JsonRpc) +} + +func matchNearStatusRequest() func(protocol.RequestHolder) bool { + return func(req protocol.RequestHolder) bool { + return req.Method() == "status" && req.RequestType() == protocol.JsonRpc + } +} + +func boundsByType(t *testing.T, result []protocol.LowerBoundData) map[protocol.LowerBoundType]int64 { + t.Helper() + got := make(map[protocol.LowerBoundType]int64, len(result)) + for _, b := range result { + got[b.Type] = b.Bound + } + return got +} + +func TestNearLowerBoundDetector_SupportedTypesAndPeriod(t *testing.T) { + connector := mocks.NewConnectorMock() + detector := near_bounds.NewNearLowerBoundDetector("id", chains.NEAR, time.Second, connector) + + assert.ElementsMatch(t, + []protocol.LowerBoundType{ + protocol.StateBound, + protocol.BlockBound, + protocol.UnknownBound, + }, + detector.SupportedTypes(), + ) + assert.Equal(t, 3*time.Minute, detector.Period()) +} + +func TestNearLowerBoundDetector_EarliestHeightReturnsStateAndBlockBounds(t *testing.T) { + connector := mocks.NewConnectorMock() + connector. + On("SendRequest", mock.Anything, mock.MatchedBy(matchNearStatusRequest())). + Return(nearStatusResponse(207157933)) + + detector := near_bounds.NewNearLowerBoundDetector("id", chains.NEAR, time.Second, connector) + + result, err := detector.DetectLowerBound(context.Background()) + require.NoError(t, err) + require.Len(t, result, 2) + got := boundsByType(t, result) + assert.Equal(t, int64(207157933), got[protocol.StateBound]) + assert.Equal(t, int64(207157933), got[protocol.BlockBound]) +} + +func TestNearLowerBoundDetector_ErrorWithCacheRetainsCachedBound(t *testing.T) { + connector := mocks.NewConnectorMock() + connector. + On("SendRequest", mock.Anything, mock.MatchedBy(matchNearStatusRequest())). + Return(nearStatusResponse(207157933)). + Once() + connector. + On("SendRequest", mock.Anything, mock.MatchedBy(matchNearStatusRequest())). + Return(protocol.NewHttpUpstreamResponseWithError(protocol.ServerError())) + + detector := near_bounds.NewNearLowerBoundDetector("id", chains.NEAR, time.Second, connector) + + result, err := detector.DetectLowerBound(context.Background()) + require.NoError(t, err) + require.Len(t, result, 2) + + result, err = detector.DetectLowerBound(context.Background()) + require.NoError(t, err) + require.Len(t, result, 2) + got := boundsByType(t, result) + assert.Equal(t, int64(207157933), got[protocol.StateBound]) + assert.Equal(t, int64(207157933), got[protocol.BlockBound]) +} + +func TestNearLowerBoundDetector_ErrorWithoutCacheEmitsUnknownBound(t *testing.T) { + connector := mocks.NewConnectorMock() + connector. + On("SendRequest", mock.Anything, mock.MatchedBy(matchNearStatusRequest())). + Return(protocol.NewHttpUpstreamResponseWithError(protocol.ServerError())) + + detector := near_bounds.NewNearLowerBoundDetector("id", chains.NEAR, time.Second, connector) + + result, err := detector.DetectLowerBound(context.Background()) + require.NoError(t, err) + require.Len(t, result, 1) + assert.Equal(t, protocol.UnknownBound, result[0].Type) + assert.Equal(t, int64(0), result[0].Bound) +} + +func TestNearLowerBoundDetector_ZeroEarliestHeightFallsBack(t *testing.T) { + connector := mocks.NewConnectorMock() + connector. + On("SendRequest", mock.Anything, mock.MatchedBy(matchNearStatusRequest())). + Return(nearStatusResponse(0)) + + detector := near_bounds.NewNearLowerBoundDetector("id", chains.NEAR, time.Second, connector) + + result, err := detector.DetectLowerBound(context.Background()) + require.NoError(t, err) + require.Len(t, result, 1) + assert.Equal(t, protocol.UnknownBound, result[0].Type) + assert.Equal(t, int64(0), result[0].Bound) +} + +func TestNearLowerBoundDetector_AbsentEarliestHeightFallsBack(t *testing.T) { + connector := mocks.NewConnectorMock() + connector. + On("SendRequest", mock.Anything, mock.MatchedBy(matchNearStatusRequest())). + Return(nearStatusResponseWithoutEarliest()) + + detector := near_bounds.NewNearLowerBoundDetector("id", chains.NEAR, time.Second, connector) + + result, err := detector.DetectLowerBound(context.Background()) + require.NoError(t, err) + require.Len(t, result, 1) + assert.Equal(t, protocol.UnknownBound, result[0].Type) + assert.Equal(t, int64(0), result[0].Bound) +} + +func TestNearLowerBoundDetector_ZeroEarliestHeightRetainsCachedBound(t *testing.T) { + connector := mocks.NewConnectorMock() + connector. + On("SendRequest", mock.Anything, mock.MatchedBy(matchNearStatusRequest())). + Return(nearStatusResponse(207157933)). + Once() + connector. + On("SendRequest", mock.Anything, mock.MatchedBy(matchNearStatusRequest())). + Return(nearStatusResponse(0)) + + detector := near_bounds.NewNearLowerBoundDetector("id", chains.NEAR, time.Second, connector) + + result, err := detector.DetectLowerBound(context.Background()) + require.NoError(t, err) + require.Len(t, result, 2) + + result, err = detector.DetectLowerBound(context.Background()) + require.NoError(t, err) + require.Len(t, result, 2) + got := boundsByType(t, result) + assert.Equal(t, int64(207157933), got[protocol.StateBound]) + assert.Equal(t, int64(207157933), got[protocol.BlockBound]) +} diff --git a/internal/upstreams/upstream_factory.go b/internal/upstreams/upstream_factory.go index aeac69bd..e7c46ab5 100644 --- a/internal/upstreams/upstream_factory.go +++ b/internal/upstreams/upstream_factory.go @@ -12,6 +12,7 @@ import ( "github.com/drpcorg/nodecore/internal/upstreams/chains_specific/beacon_specific" "github.com/drpcorg/nodecore/internal/upstreams/chains_specific/bitcoin_specific" "github.com/drpcorg/nodecore/internal/upstreams/chains_specific/evm_specific" + "github.com/drpcorg/nodecore/internal/upstreams/chains_specific/near_specific" "github.com/drpcorg/nodecore/internal/upstreams/chains_specific/solana_specific" "github.com/drpcorg/nodecore/internal/upstreams/chains_specific/tron_specific" "github.com/drpcorg/nodecore/pkg/methods" @@ -252,6 +253,14 @@ func getChainSpecific( upstreamConnectorsInfo.internalRequestConnector, conf.Options, ), nil + case chains.Near: + return near_specific.NewNearChainSpecificObject( + ctx, + configuredChain, + conf.Id, + upstreamConnectorsInfo.internalRequestConnector, + conf.Options, + ), nil case chains.Solana: return solana_specific.NewSolanaChainSpecificObject( ctx, diff --git a/internal/upstreams/validations/near_validations/near_chain_validator.go b/internal/upstreams/validations/near_validations/near_chain_validator.go new file mode 100644 index 00000000..3b1ce11e --- /dev/null +++ b/internal/upstreams/validations/near_validations/near_chain_validator.go @@ -0,0 +1,61 @@ +package near_validations + +import ( + "errors" + "strings" + "time" + + "github.com/drpcorg/nodecore/internal/upstreams/connectors" + "github.com/drpcorg/nodecore/internal/upstreams/validations" + "github.com/drpcorg/nodecore/pkg/chains" + "github.com/rs/zerolog/log" +) + +var errNearEmptyChainId = errors.New("near node returned empty chain_id") + +type NearChainValidator struct { + upstreamId string + connector connectors.ApiConnector + chain *chains.ConfiguredChain + internalTimeout time.Duration +} + +func NewNearChainValidator( + upstreamId string, + connector connectors.ApiConnector, + chain *chains.ConfiguredChain, + internalTimeout time.Duration, +) *NearChainValidator { + return &NearChainValidator{ + upstreamId: upstreamId, + connector: connector, + chain: chain, + internalTimeout: internalTimeout, + } +} + +func (n *NearChainValidator) Validate() validations.ValidationSettingResult { + status, err := fetchNearStatus(n.connector, n.chain.Chain, n.internalTimeout) + if err != nil { + log.Error().Err(err).Msgf("failed to fetch near status for upstream '%s'", n.upstreamId) + return validations.SettingsError + } + if status.ChainId == "" { + log.Error().Err(errNearEmptyChainId).Msgf("failed to validate the chain of near upstream '%s'", n.upstreamId) + return validations.SettingsError + } + // for near chains.yaml holds network-name chain-ids: mainnet/testnet/betanet + if strings.EqualFold(status.ChainId, n.chain.ChainId) { + return validations.Valid + } + log.Error().Msgf( + "'%s' expects chain_id '%s' but near upstream '%s' reports '%s'", + n.chain.Chain.String(), + n.chain.ChainId, + n.upstreamId, + status.ChainId, + ) + return validations.FatalSettingError +} + +var _ validations.SettingsValidator = (*NearChainValidator)(nil) diff --git a/internal/upstreams/validations/near_validations/near_health_validator.go b/internal/upstreams/validations/near_validations/near_health_validator.go new file mode 100644 index 00000000..27da0705 --- /dev/null +++ b/internal/upstreams/validations/near_validations/near_health_validator.go @@ -0,0 +1,140 @@ +package near_validations + +import ( + "context" + "errors" + "time" + + "github.com/bytedance/sonic" + "github.com/drpcorg/nodecore/internal/protocol" + "github.com/drpcorg/nodecore/internal/upstreams/connectors" + "github.com/drpcorg/nodecore/internal/upstreams/validations" + "github.com/drpcorg/nodecore/pkg/chains" + "github.com/rs/zerolog/log" +) + +var errNearNoPeers = errors.New("near node has no active peers") + +type NearHealthValidator struct { + upstreamId string + connector connectors.ApiConnector + chain *chains.ConfiguredChain + internalTimeout time.Duration + validatePeers bool +} + +func NewNearHealthValidator( + upstreamId string, + connector connectors.ApiConnector, + chain *chains.ConfiguredChain, + internalTimeout time.Duration, + validatePeers bool, +) *NearHealthValidator { + return &NearHealthValidator{ + upstreamId: upstreamId, + connector: connector, + chain: chain, + internalTimeout: internalTimeout, + validatePeers: validatePeers, + } +} + +func (n *NearHealthValidator) Validate() protocol.AvailabilityStatus { + status, err := fetchNearStatus(n.connector, n.chain.Chain, n.internalTimeout) + if err != nil { + log.Error().Err(err).Msgf("near upstream '%s' health validation failed", n.upstreamId) + return protocol.Unavailable + } + if status.SyncInfo.Syncing { + log.Warn().Msgf("near upstream '%s' is syncing", n.upstreamId) + return protocol.Syncing + } + // stale-head guard: syncing=false with a frozen head is still not serviceable + maxHeadAge := n.chain.Settings.ExpectedBlockTime * time.Duration(n.chain.Settings.Lags.Syncing) + if maxHeadAge > 0 { + if latestBlockTime, parseErr := time.Parse(time.RFC3339Nano, status.SyncInfo.LatestBlockTime); parseErr == nil { + if headAge := time.Since(latestBlockTime); headAge > maxHeadAge { + log.Warn().Msgf( + "near upstream '%s' head is stale, latest_block_time=%s age=%s", + n.upstreamId, + status.SyncInfo.LatestBlockTime, + headAge, + ) + return protocol.Syncing + } + } + } + if n.validatePeers { + peers, err := n.fetchActivePeers() + if err != nil { + log.Error().Err(err).Msgf("failed to fetch near network info for upstream '%s'", n.upstreamId) + return protocol.Unavailable + } + if peers == 0 { + log.Error().Err(errNearNoPeers).Msgf("near upstream '%s' has no active peers", n.upstreamId) + return protocol.Unavailable + } + } + return protocol.Available +} + +func (n *NearHealthValidator) fetchActivePeers() (uint64, error) { + ctx, cancel := context.WithTimeout(context.Background(), n.internalTimeout) + defer cancel() + + request, err := protocol.NewInternalUpstreamJsonRpcRequest("network_info", []any{}, n.chain.Chain) + if err != nil { + return 0, err + } + response := n.connector.SendRequest(ctx, request) + if response.HasError() { + return 0, response.GetError() + } + var info nearNetworkInfo + if err := sonic.Unmarshal(response.ResponseResult(), &info); err != nil { + return 0, err + } + return info.NumActivePeers, nil +} + +func fetchNearStatus(connector connectors.ApiConnector, chain chains.Chain, timeout time.Duration) (*NearStatus, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + request, err := protocol.NewInternalUpstreamJsonRpcRequest("status", []any{}, chain) + if err != nil { + return nil, err + } + response := connector.SendRequest(ctx, request) + if response.HasError() { + return nil, response.GetError() + } + var status NearStatus + if err := sonic.Unmarshal(response.ResponseResult(), &status); err != nil { + return nil, err + } + return &status, nil +} + +type NearStatus struct { + ChainId string `json:"chain_id"` + Version NearVersion `json:"version"` + SyncInfo NearSyncInfo `json:"sync_info"` +} + +type NearVersion struct { + Version string `json:"version"` +} + +type NearSyncInfo struct { + LatestBlockHeight uint64 `json:"latest_block_height"` + LatestBlockTime string `json:"latest_block_time"` + EarliestBlockHeight uint64 `json:"earliest_block_height"` + Syncing bool `json:"syncing"` +} + +type nearNetworkInfo struct { + NumActivePeers uint64 `json:"num_active_peers"` +} + +var _ validations.HealthValidator = (*NearHealthValidator)(nil) diff --git a/internal/upstreams/validations/near_validations/near_validators_test.go b/internal/upstreams/validations/near_validations/near_validators_test.go new file mode 100644 index 00000000..80b081e9 --- /dev/null +++ b/internal/upstreams/validations/near_validations/near_validators_test.go @@ -0,0 +1,117 @@ +package near_validations_test + +import ( + "fmt" + "testing" + "time" + + "github.com/drpcorg/nodecore/internal/protocol" + "github.com/drpcorg/nodecore/internal/upstreams/validations" + "github.com/drpcorg/nodecore/internal/upstreams/validations/near_validations" + "github.com/drpcorg/nodecore/pkg/chains" + "github.com/drpcorg/nodecore/pkg/test_utils/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func isStatus(r protocol.RequestHolder) bool { return r.Method() == "status" } +func isNetworkInfo(r protocol.RequestHolder) bool { return r.Method() == "network_info" } + +func statusBody(chainId string, latestBlockTime string, syncing bool) []byte { + return []byte(fmt.Sprintf( + `{"chain_id":"%s","version":{"version":"2.13.1"},"sync_info":{"latest_block_height":207365026,"latest_block_time":"%s","earliest_block_height":207157933,"syncing":%t}}`, + chainId, latestBlockTime, syncing, + )) +} + +func freshBlockTime() string { + return time.Now().Add(-time.Second).Format(time.RFC3339Nano) +} + +func TestNearChainValidatorValidOnMatch(t *testing.T) { + conn := mocks.NewConnectorMock() + conn.On("SendRequest", mock.Anything, mock.MatchedBy(isStatus)). + Return(protocol.NewSimpleHttpUpstreamResponse("1", statusBody("mainnet", freshBlockTime(), false), protocol.JsonRpc)) + v := near_validations.NewNearChainValidator("id", conn, chains.GetChain("near"), time.Second) + assert.Equal(t, validations.Valid, v.Validate()) +} + +func TestNearChainValidatorFatalOnMismatch(t *testing.T) { + conn := mocks.NewConnectorMock() + // a testnet node behind a config that says mainnet + conn.On("SendRequest", mock.Anything, mock.MatchedBy(isStatus)). + Return(protocol.NewSimpleHttpUpstreamResponse("1", statusBody("testnet", freshBlockTime(), false), protocol.JsonRpc)) + v := near_validations.NewNearChainValidator("id", conn, chains.GetChain("near"), time.Second) + assert.Equal(t, validations.FatalSettingError, v.Validate()) +} + +func TestNearChainValidatorRetriesOnFetchError(t *testing.T) { + conn := mocks.NewConnectorMock() + conn.On("SendRequest", mock.Anything, mock.MatchedBy(isStatus)). + Return(protocol.NewHttpUpstreamResponseWithError(protocol.ResponseErrorWithData(1, "boom", nil))) + v := near_validations.NewNearChainValidator("id", conn, chains.GetChain("near"), time.Second) + assert.Equal(t, validations.SettingsError, v.Validate()) +} + +func TestNearHealthAvailable(t *testing.T) { + conn := mocks.NewConnectorMock() + conn.On("SendRequest", mock.Anything, mock.MatchedBy(isStatus)). + Return(protocol.NewSimpleHttpUpstreamResponse("1", statusBody("mainnet", freshBlockTime(), false), protocol.JsonRpc)) + v := near_validations.NewNearHealthValidator("id", conn, chains.GetChain("near"), time.Second, false) + assert.Equal(t, protocol.Available, v.Validate()) +} + +func TestNearHealthSyncingOnSyncFlag(t *testing.T) { + conn := mocks.NewConnectorMock() + conn.On("SendRequest", mock.Anything, mock.MatchedBy(isStatus)). + Return(protocol.NewSimpleHttpUpstreamResponse("1", statusBody("mainnet", freshBlockTime(), true), protocol.JsonRpc)) + v := near_validations.NewNearHealthValidator("id", conn, chains.GetChain("near"), time.Second, false) + assert.Equal(t, protocol.Syncing, v.Validate()) +} + +func TestNearHealthSyncingOnStaleHead(t *testing.T) { + conn := mocks.NewConnectorMock() + // near's stale-head threshold is 1s * 40; the head is 5 minutes old + staleTime := time.Now().Add(-5 * time.Minute).Format(time.RFC3339Nano) + conn.On("SendRequest", mock.Anything, mock.MatchedBy(isStatus)). + Return(protocol.NewSimpleHttpUpstreamResponse("1", statusBody("mainnet", staleTime, false), protocol.JsonRpc)) + v := near_validations.NewNearHealthValidator("id", conn, chains.GetChain("near"), time.Second, false) + assert.Equal(t, protocol.Syncing, v.Validate()) +} + +func TestNearHealthUnavailableOnZeroPeers(t *testing.T) { + conn := mocks.NewConnectorMock() + conn.On("SendRequest", mock.Anything, mock.MatchedBy(isStatus)). + Return(protocol.NewSimpleHttpUpstreamResponse("1", statusBody("mainnet", freshBlockTime(), false), protocol.JsonRpc)) + conn.On("SendRequest", mock.Anything, mock.MatchedBy(isNetworkInfo)). + Return(protocol.NewSimpleHttpUpstreamResponse("1", []byte(`{"num_active_peers":0,"peer_max_count":40}`), protocol.JsonRpc)) + v := near_validations.NewNearHealthValidator("id", conn, chains.GetChain("near"), time.Second, true) + assert.Equal(t, protocol.Unavailable, v.Validate()) +} + +func TestNearHealthAvailableWithPeers(t *testing.T) { + conn := mocks.NewConnectorMock() + conn.On("SendRequest", mock.Anything, mock.MatchedBy(isStatus)). + Return(protocol.NewSimpleHttpUpstreamResponse("1", statusBody("mainnet", freshBlockTime(), false), protocol.JsonRpc)) + conn.On("SendRequest", mock.Anything, mock.MatchedBy(isNetworkInfo)). + Return(protocol.NewSimpleHttpUpstreamResponse("1", []byte(`{"num_active_peers":36,"peer_max_count":40}`), protocol.JsonRpc)) + v := near_validations.NewNearHealthValidator("id", conn, chains.GetChain("near"), time.Second, true) + assert.Equal(t, protocol.Available, v.Validate()) +} + +func TestNearHealthIgnoresPeersWhenDisabled(t *testing.T) { + conn := mocks.NewConnectorMock() + conn.On("SendRequest", mock.Anything, mock.MatchedBy(isStatus)). + Return(protocol.NewSimpleHttpUpstreamResponse("1", statusBody("mainnet", freshBlockTime(), false), protocol.JsonRpc)) + v := near_validations.NewNearHealthValidator("id", conn, chains.GetChain("near"), time.Second, false) + assert.Equal(t, protocol.Available, v.Validate()) + conn.AssertNotCalled(t, "SendRequest", mock.Anything, mock.MatchedBy(isNetworkInfo)) +} + +func TestNearHealthUnavailableOnError(t *testing.T) { + conn := mocks.NewConnectorMock() + conn.On("SendRequest", mock.Anything, mock.MatchedBy(isStatus)). + Return(protocol.NewHttpUpstreamResponseWithError(protocol.ResponseErrorWithData(1, "boom", nil))) + v := near_validations.NewNearHealthValidator("id", conn, chains.GetChain("near"), time.Second, false) + assert.Equal(t, protocol.Unavailable, v.Validate()) +} diff --git a/pkg/chains/chains.go b/pkg/chains/chains.go index fd486d8b..4e6dc53a 100644 --- a/pkg/chains/chains.go +++ b/pkg/chains/chains.go @@ -357,6 +357,8 @@ func getMethodSpecName(blockchainType BlockchainType, methodSpecName string) str return "aptos" case Bitcoin: return "bitcoin" + case Near: + return "near" } return "" diff --git a/pkg/methods/methods_spec_test.go b/pkg/methods/methods_spec_test.go index 8390dac4..7d619a3a 100644 --- a/pkg/methods/methods_spec_test.go +++ b/pkg/methods/methods_spec_test.go @@ -260,6 +260,21 @@ func TestBitcoinSpecLoads(t *testing.T) { assert.Equal(t, []string{"bc1qxyz"}, params) } +func TestNearSpecLoads(t *testing.T) { + err := specs.NewMethodSpecLoader().Load() + assert.NoError(t, err) + + spec := specs.GetSpecMethod("near", "block") + assert.NotNil(t, spec) + + spec = specs.GetSpecMethod("near", "send_tx") + assert.NotNil(t, spec) + assert.True(t, spec.IsBroadcastDispatch()) + + spec = specs.GetSpecMethod("near", "broadcast_tx_async") + assert.Nil(t, spec) +} + func TestAptosSpecMatchesNestedAccountAndTableRoutes(t *testing.T) { loader := specs.NewMethodSpecLoader() err := loader.Load() diff --git a/pkg/methods/specs/near-json-rpc.json b/pkg/methods/specs/near-json-rpc.json new file mode 100644 index 00000000..aae82e04 --- /dev/null +++ b/pkg/methods/specs/near-json-rpc.json @@ -0,0 +1,113 @@ +{ + "openrpc": "1.0.0", + "info": { + "title": "NEAR JSON-RPC methods", + "version": "1.0.0" + }, + "spec": { + "name": "near-json-rpc", + "api-connectors": ["json-rpc"], + "type": "plain" + }, + "methods": [ + { + "name": "block", + "params": [], + "settings": { + "cacheable": false + } + }, + { + "name": "chunk", + "params": [], + "settings": { + "cacheable": false + } + }, + { + "name": "tx", + "params": [], + "settings": { + "cacheable": false + } + }, + { + "name": "EXPERIMENTAL_tx_status", + "params": [], + "settings": { + "cacheable": false + } + }, + { + "name": "EXPERIMENTAL_receipt", + "params": [], + "settings": { + "cacheable": false + } + }, + { + "name": "query", + "params": [], + "settings": { + "cacheable": false + } + }, + { + "name": "EXPERIMENTAL_changes", + "params": [], + "settings": { + "cacheable": false + } + }, + { + "name": "EXPERIMENTAL_changes_in_block", + "params": [], + "settings": { + "cacheable": false + } + }, + { + "name": "EXPERIMENTAL_protocol_config", + "params": [], + "settings": { + "cacheable": false + } + }, + { + "name": "gas_price", + "params": [], + "settings": { + "cacheable": false + } + }, + { + "name": "validators", + "params": [], + "settings": { + "cacheable": false + } + }, + { + "name": "status", + "params": [], + "settings": { + "cacheable": false + } + }, + { + "name": "network_info", + "params": [], + "settings": { + "cacheable": false + } + }, + { + "name": "send_tx", + "params": [], + "settings": { + "cacheable": false, + "dispatch": "broadcast" + } + } + ] +} diff --git a/pkg/methods/specs/near.json b/pkg/methods/specs/near.json new file mode 100644 index 00000000..f4d012c7 --- /dev/null +++ b/pkg/methods/specs/near.json @@ -0,0 +1,12 @@ +{ + "openrpc": "1.0.0", + "info": { + "title": "NEAR methods", + "version": "1.0.0" + }, + "spec": { + "name": "near", + "type": "bundle" + }, + "spec-imports": ["near-json-rpc"] +} From 900357dd58b59bb83d2d4dfd2a0e6c99ec14941d Mon Sep 17 00:00:00 2001 From: Artem Rootman <4586640+artemrootman@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:03:54 +0000 Subject: [PATCH 10/15] docs: record live validation results for near family --- .../specs/2026-07-17-near-support-design.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/superpowers/specs/2026-07-17-near-support-design.md b/docs/superpowers/specs/2026-07-17-near-support-design.md index 310cf0fb..7aca9f50 100644 --- a/docs/superpowers/specs/2026-07-17-near-support-design.md +++ b/docs/superpowers/specs/2026-07-17-near-support-design.md @@ -155,6 +155,16 @@ mechanism. gas_price, validators, unknown method, invalid params. `send_tx` only with a deterministically invalid transaction on both networks (expected error passthrough, nothing broadcast). + **Executed 2026-07-17 — passed.** 42/42 checks identical across mainnet + and testnet, byte-identical payloads for all deterministic cases including + GC'd-block UNKNOWN_BLOCK, PARSE_ERROR and the ~10s unknown-tx + TIMEOUT_ERROR passthrough. Expected differences only: nodecore's own + -32601 text for unknown/absent-from-spec methods (local allowlist, + standard for all families), and HTTP transport status on the timeout case + (node replies 408, nodecore returns the identical JSON-RPC error with + 200 — nodecore-wide behavior). Lower bounds verified live: + STATE=BLOCK=the node's earliest_block_height. + 3. **Staged rollout** is deployment-side work, out of scope for this repo. ## Out of scope / follow-ups From be17a4dfa0c87d66c11e718cd9258f0d2862ce39 Mon Sep 17 00:00:00 2001 From: Artem Rootman <4586640+artemrootman@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:57:00 +0000 Subject: [PATCH 11/15] fix: remove merge conflict markers --- pkg/chains/chains.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/pkg/chains/chains.go b/pkg/chains/chains.go index 623f5489..4e6dc53a 100644 --- a/pkg/chains/chains.go +++ b/pkg/chains/chains.go @@ -357,11 +357,8 @@ func getMethodSpecName(blockchainType BlockchainType, methodSpecName string) str return "aptos" case Bitcoin: return "bitcoin" -<<<<<<< arootman/near-support case Near: return "near" -======= ->>>>>>> main } return "" From 136adaab4c9869024e79f5f7739296a93658f517 Mon Sep 17 00:00:00 2001 From: Artem Rootman <4586640+artemrootman@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:25:56 +0000 Subject: [PATCH 12/15] docs: near family docs (README, upstream-config, method-specs) + self-contained spec wording --- README.md | 4 ++-- docs/nodecore/05-upstream-config.md | 4 ++-- docs/nodecore/11-method-specs.md | 3 ++- docs/superpowers/specs/2026-07-17-near-support-design.md | 4 ++-- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index cd10ddc4..19d1332b 100644 --- a/README.md +++ b/README.md @@ -13,13 +13,13 @@ nodecore is **API/protocol-agnostic**: it is not tied to a single RPC shape. It ### Supported chains, methods & interfaces - **Interfaces** — `json-rpc` (over HTTP), `websocket`, and `rest` upstream connectors. The set available for a given chain is declared by that chain's [method spec](docs/nodecore/11-method-specs.md). -- **Chains** — every chain defined in [`chains.yaml`](https://github.com/drpcorg/public/blob/main/chains.yaml). Current chain families are EVM (Ethereum, Polygon, Optimism, Arbitrum, Base, BSC, and many others), Solana, Algorand, Aztec, Aptos, and the Ethereum/Gnosis Beacon Chain (consensus layer, REST-only). +- **Chains** — every chain defined in [`chains.yaml`](https://github.com/drpcorg/public/blob/main/chains.yaml). Current chain families are EVM (Ethereum, Polygon, Optimism, Arbitrum, Base, BSC, and many others), Solana, Algorand, Aztec, Aptos, NEAR, and the Ethereum/Gnosis Beacon Chain (consensus layer, REST-only). - **Methods** — per-chain RPC behavior is data-driven via [method specs](docs/nodecore/11-method-specs.md), covering standard EVM/Solana methods, subscriptions, and EVM filter methods. Methods unsupported by an upstream are automatically banned for it to avoid wasted requests. ## Key features - **Intelligent routing** — dynamically selects the most suitable upstream based on real-time performance metrics (latency, error rate, availability) for optimal speed, reliability, and fault-tolerance. See [Upstream config](docs/nodecore/05-upstream-config.md). -- **API/protocol-agnostic** — JSON-RPC, WebSocket, and REST interfaces across EVM, Solana, Algorand, Aztec, Aptos, and the Ethereum/Gnosis Beacon Chain, all driven by data-defined [method specs](docs/nodecore/11-method-specs.md). EVM filter methods (`eth_newFilter`, `eth_getFilterLogs`, etc.) are routed only to the upstream where the filter was created. +- **API/protocol-agnostic** — JSON-RPC, WebSocket, and REST interfaces across EVM, Solana, Algorand, Aztec, Aptos, NEAR, and the Ethereum/Gnosis Beacon Chain, all driven by data-defined [method specs](docs/nodecore/11-method-specs.md). EVM filter methods (`eth_newFilter`, `eth_getFilterLogs`, etc.) are routed only to the upstream where the filter was created. - **Subscriptions** — WebSocket subscriptions are aggregated so many identical client subscriptions share one upstream stream, with optional local synthesis of EVM topics (`newHeads`, `logs`, pending transactions). See [Subscriptions](docs/nodecore/13-subscriptions.md). - **Caching** — minimizes redundant traffic by caching frequent requests across in-memory/Redis/Postgres backends with configurable policies. See [Cache](docs/nodecore/04-cache.md). - **Failsafe mechanisms** — request hedging (duplicate slow requests to multiple upstreams) and configurable automatic retries. See [Upstream config](docs/nodecore/05-upstream-config.md). diff --git a/docs/nodecore/05-upstream-config.md b/docs/nodecore/05-upstream-config.md index 69320d8e..ecbfc143 100644 --- a/docs/nodecore/05-upstream-config.md +++ b/docs/nodecore/05-upstream-config.md @@ -326,8 +326,8 @@ The `chain-defaults` section defines per-chain baseline settings. `.optio * `disable-health-validation` - Disables only the health validators (per chain family). **_Default_**: `false` * `disable-lower-bounds-detection` - Disables the earliest-available-block detector. Mode-dependent default: `true` in `default` mode, `false` in `strict` mode * `disable-labels-detection` - Disables the EVM label detectors (client/version, archive, gas, flashblock, etc.). Mode-dependent default: `true` in `default` mode, `false` in `strict` mode - * `validate-syncing` - For EVM chains, calls `eth_syncing` periodically and marks the upstream unavailable when it is syncing. For beacon-chain upstreams it probes `GET /eth/v1/node/syncing` instead (marking the upstream `Syncing`, or `Unavailable` when its execution layer is offline). Mode-dependent default: `false` in `default` mode, `true` in `strict` mode - * `validate-peers` - For EVM chains, calls `net_peerCount` periodically and pairs with `min-peers`. For beacon-chain upstreams it probes `GET /eth/v1/node/peer_count`. Mode-dependent default: `false` in `default` mode, `true` in `strict` mode + * `validate-syncing` - For EVM chains, calls `eth_syncing` periodically and marks the upstream unavailable when it is syncing. For beacon-chain upstreams it probes `GET /eth/v1/node/syncing` instead (marking the upstream `Syncing`, or `Unavailable` when its execution layer is offline). For NEAR upstreams the `status` probe's `sync_info.syncing` and a stale-head guard on `latest_block_time` drive the same signal (always on as part of health validation). Mode-dependent default: `false` in `default` mode, `true` in `strict` mode + * `validate-peers` - For EVM chains, calls `net_peerCount` periodically and pairs with `min-peers`. For beacon-chain upstreams it probes `GET /eth/v1/node/peer_count`. NEAR upstreams probe `network_info` `num_active_peers`. Mode-dependent default: `false` in `default` mode, `true` in `strict` mode * `min-peers` - Minimum acceptable peer count when `validate-peers` is on. **_Default_**: `1` * `validate-call-limit` - For EVM chains, periodically probes the upstream's `eth_call` return-data limit and marks the upstream unhealthy when its observed limit is below `call-limit-size`. Mode-dependent default: `false` in `default` mode, `true` in `strict` mode * `call-limit-size` - Threshold (in bytes) of the smallest acceptable `eth_call` return-data limit. **_Default_**: `1000000` (1 MB) diff --git a/docs/nodecore/11-method-specs.md b/docs/nodecore/11-method-specs.md index ffb6b0c8..76079076 100644 --- a/docs/nodecore/11-method-specs.md +++ b/docs/nodecore/11-method-specs.md @@ -171,6 +171,7 @@ nodecore embeds the specs below (see [`pkg/methods/specs/`](../../pkg/methods/sp | `klaytn` | `klaytn-json-rpc`, `klaytn-websocket` | | `hyperliquid` | `hyperliquid-eth`, `hyperliquid-rest-additional` | | `tron` | `tron-json-rpc`, `tron-rest`, `tron-rest-solidity` | +| `near` | `near-json-rpc` | ### Plain specs @@ -179,7 +180,7 @@ Grouped by the transports they declare: | `api-connectors` | Specs | | --- | --- | | `json-rpc`, `websocket` | `arbitrum`, `cronos_zkevm`, `eth-json-rpc`, `fantom`, `filecoin`, `harmony_0`, `harmony_1`, `hyperliquid-eth`, `klaytn-json-rpc`, `linea`, `mantle`, `optimism`, `polygon`, `polygon_zkevm`, `rootstock`, `scroll`, `sei`, `solana-json-rpc`, `viction`, `zk` | -| `json-rpc` | `algorand`, `aztec`, `tron-json-rpc` | +| `json-rpc` | `algorand`, `aztec`, `near-json-rpc`, `tron-json-rpc` | | `websocket` | `eth-websocket`, `klaytn-websocket`, `solana-websocket` | | `rest` | `aptos`, `eth-beacon-chain`, `tron-rest` | | `rest-additional` | `hyperliquid-rest-additional`, `tron-rest-solidity` | diff --git a/docs/superpowers/specs/2026-07-17-near-support-design.md b/docs/superpowers/specs/2026-07-17-near-support-design.md index 7aca9f50..ae5df01d 100644 --- a/docs/superpowers/specs/2026-07-17-near-support-design.md +++ b/docs/superpowers/specs/2026-07-17-near-support-design.md @@ -29,8 +29,8 @@ dshackle. ## Approach -NEAR follows the **bitcoin family template** (JSON-RPC, poll-only head, -data-driven method spec): a `near_specific` chain-specific package plus +NEAR is modelled as a JSON-RPC blockchain family with a poll-only head and +a data-driven method spec: a `near_specific` chain-specific package plus `pkg/methods/specs/near.json`. It is strictly simpler than bitcoin — a single `status` call powers health, chain validation, labels and lower bounds; there is no esplora-style secondary connector and no method aliases to translate. From da2cef91ae88bc331443dbe76f9818e9b299e58c Mon Sep 17 00:00:00 2001 From: Artem Rootman <4586640+artemrootman@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:43:03 +0000 Subject: [PATCH 13/15] docs: add near rows to the validators table --- docs/nodecore/05-upstream-config.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/nodecore/05-upstream-config.md b/docs/nodecore/05-upstream-config.md index ecbfc143..b7838d31 100644 --- a/docs/nodecore/05-upstream-config.md +++ b/docs/nodecore/05-upstream-config.md @@ -637,6 +637,7 @@ Validators and label detectors run periodically (every `validation-interval`) ag | Chain id / `net_version` | EVM | `disable-chain-validation`, `disable-settings-validation`, `disable-validation` | Confirms the upstream is actually serving the configured chain. Fails at startup remove the upstream from the pool; runtime drift triggers re-removal | | Aztec chain validator | Aztec | `disable-chain-validation`, `disable-settings-validation`, `disable-validation` | Equivalent of chain-id check, using the Aztec node's chain-id endpoint | | Aptos chain validator | Aptos | `disable-chain-validation`, `disable-settings-validation`, `disable-validation` | Equivalent of chain-id check, using the `chain_id` from the Aptos ledger-info endpoint (`GET /v1`) | +| NEAR chain validator | NEAR | `disable-chain-validation`, `disable-settings-validation`, `disable-validation` | Equivalent of chain-id check, comparing the `status` probe's `chain_id` against the configured chain | | `eth_syncing` validator | EVM | `validate-syncing` (set to `false`) or `disable-settings-validation` | Marks the upstream as syncing/unavailable when the node reports it is not fully synced | | `net_peerCount` validator | EVM | `validate-peers` / `min-peers` or `disable-settings-validation` | Marks the upstream as unhealthy when peer count drops below `min-peers` | | `eth_call` return-data limit | EVM | `validate-call-limit` or `disable-settings-validation` | Probes the upstream's maximum `eth_call` return-data size and marks it unhealthy if it is below `call-limit-size` | @@ -648,10 +649,13 @@ Validators and label detectors run periodically (every `validation-interval`) ag | Health validator (Beacon) | Beacon Chain | `disable-health-validation` | Calls `GET /eth/v1/node/health` and marks the upstream available only on a 2xx response | | Syncing validator (Beacon) | Beacon Chain | `validate-syncing` (set to `false`) | Probes `GET /eth/v1/node/syncing`; marks the upstream `Syncing` when `is_syncing` is true and `Unavailable` when `el_offline` is true | | Peers validator (Beacon) | Beacon Chain | `validate-peers` / `min-peers` | Probes `GET /eth/v1/node/peer_count` and marks the upstream immature while connected peers are below `min-peers` | +| Health validator (NEAR) | NEAR | `disable-health-validation` | Calls the NEAR `status` RPC; marks the upstream `Syncing` when `sync_info.syncing` is true or `latest_block_time` is stale (stale-head guard). With `validate-peers` on, also probes `network_info` and marks the upstream `Unavailable` at zero `num_active_peers` | | Lower-bound detector | Solana, Algorand, Aztec, Aptos | `disable-lower-bounds-detection` | Determines the earliest available block / slot on the upstream so that queries against pruned ranges can be routed away | | Lower-bound detector (Beacon) | Beacon Chain | `disable-lower-bounds-detection` | Binary-searches the earliest retained block, state, epoch (attestation rewards), and blob-sidecar slots so requests against pruned ranges are routed away | +| Lower-bound detector (NEAR) | NEAR | `disable-lower-bounds-detection` | Reads `sync_info.earliest_block_height` from `status` and publishes it as the state and block lower bounds (a sliding GC window on non-archival nodes) | | Label detectors (EVM) | EVM | `disable-labels-detection` | Populates upstream labels - client name & version, archive vs. full, gas limit, flashblock support, high-latency-tx capability. Labels are exposed via the [gRPC API](12-grpc-server.md) so external consumers can target upstreams with specific capabilities | | Label detectors (Aptos) | Aptos | `disable-labels-detection` | Populates client name & version labels from the ledger-info endpoint (`GET /v1`) | | Client label detector (Beacon) | Beacon Chain | `disable-labels-detection` | Reads `GET /eth/v1/node/version` and publishes the consensus-client type and version labels (Lighthouse, Prysm, Teku, Nimbus, etc.) | +| Client label detector (NEAR) | NEAR | `disable-labels-detection` | Reads `version.version` from `status` and publishes the client (`neard`) and version labels | `disable-validation` is the master switch and overrides every per-validator flag. From 1f3fd480f8aa63ab1b1e3e4c08c266948a762b4c Mon Sep 17 00:00:00 2001 From: Artem Rootman <4586640+artemrootman@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:28:50 +0000 Subject: [PATCH 14/15] review: near - block processor for finality, split validators, simpler lower bound - rename EthLikeBlockProcessor -> BaseBlockProcessor and wire it for near: the final head is polled by the block processor (safe detection disabled) - CapDetectors returns nil: nearcore has no websocket transport - split NearHealthValidator into NearSyncingValidator (status) and NearPeersValidator (network_info), one method call per validator - lower bound detector simplified to dshackle parity: one status call with retries, no fallback/cached-bound logic (processor keeps last bound on error) - empty chain_id in status is now FatalSettingError - tests for NearChainSpecificObject --- internal/upstreams/blocks/block_processor.go | 60 ++++----- .../upstreams/blocks/block_processor_test.go | 20 +-- .../beacon_specific/beacon_chain_specific.go | 2 +- .../evm_specific/evm_chain_specific.go | 2 +- .../near_specific/near_chain_specific.go | 37 ++++-- .../near_specific/near_chain_specific_test.go | 124 ++++++++++++++++++ .../tron_specific/tron_rest_specific.go | 2 +- .../near_bounds/near_lower_bound.go | 70 ++++------ .../near_bounds/near_lower_bound_test.go | 59 ++------- internal/upstreams/upstream_factory.go | 1 + .../near_validations/near_chain_validator.go | 3 +- .../near_validations/near_health_validator.go | 56 ++------ .../near_validations/near_peers_validator.go | 76 +++++++++++ .../near_validations/near_validators_test.go | 58 ++++---- pkg/test_utils/test_helpers.go | 11 ++ 15 files changed, 366 insertions(+), 215 deletions(-) create mode 100644 internal/upstreams/chains_specific/near_specific/near_chain_specific_test.go create mode 100644 internal/upstreams/validations/near_validations/near_peers_validator.go diff --git a/internal/upstreams/blocks/block_processor.go b/internal/upstreams/blocks/block_processor.go index a1e61a0b..3c938145 100644 --- a/internal/upstreams/blocks/block_processor.go +++ b/internal/upstreams/blocks/block_processor.go @@ -40,31 +40,31 @@ type BlockEvent struct { BlockType protocol.BlockType } -type EthLikeBlockProcessor struct { - upstreamId string - connector connectors.ApiConnector - chainSpecific BlockChainSpecific - subManager *utils.SubscriptionManager[BlockEvent] - disableDetection atomic.Uint32 - disableSafeBlockDetection bool - disableFinalizedBlockDetection bool - manualBlockChan chan *BlockEvent - blocks map[protocol.BlockType]protocol.Block - lifecycle *utils.BaseLifecycle - internalTimeout time.Duration - pollInterval time.Duration -} - -func (b *EthLikeBlockProcessor) Running() bool { +type BaseBlockProcessor struct { + upstreamId string + connector connectors.ApiConnector + chainSpecific BlockChainSpecific + subManager *utils.SubscriptionManager[BlockEvent] + disableDetection atomic.Uint32 + disableSafeBlockDetection bool + disableFinalizedBlockDetection bool + manualBlockChan chan *BlockEvent + blocks map[protocol.BlockType]protocol.Block + lifecycle *utils.BaseLifecycle + internalTimeout time.Duration + pollInterval time.Duration +} + +func (b *BaseBlockProcessor) Running() bool { return b.lifecycle.Running() } -func (b *EthLikeBlockProcessor) Stop() { +func (b *BaseBlockProcessor) Stop() { log.Info().Msgf("stopping block processor of upstream '%s'", b.upstreamId) b.lifecycle.Stop() } -func NewEthLikeBlockProcessor( +func NewBaseBlockProcessor( ctx context.Context, upstreamId string, pollInterval, internalTimeout time.Duration, @@ -72,9 +72,9 @@ func NewEthLikeBlockProcessor( disableSafeBlockDetection bool, connector connectors.ApiConnector, chainSpecific BlockChainSpecific, -) *EthLikeBlockProcessor { +) *BaseBlockProcessor { name := fmt.Sprintf("%s_block_processor", upstreamId) - return &EthLikeBlockProcessor{ + return &BaseBlockProcessor{ upstreamId: upstreamId, connector: connector, chainSpecific: chainSpecific, @@ -89,15 +89,15 @@ func NewEthLikeBlockProcessor( } } -func (b *EthLikeBlockProcessor) UpdateBlock(blockData protocol.Block, blockType protocol.BlockType) { +func (b *BaseBlockProcessor) UpdateBlock(blockData protocol.Block, blockType protocol.BlockType) { b.manualBlockChan <- &BlockEvent{Block: blockData, BlockType: blockType} } -func (b *EthLikeBlockProcessor) Subscribe(name string) *utils.Subscription[BlockEvent] { +func (b *BaseBlockProcessor) Subscribe(name string) *utils.Subscription[BlockEvent] { return b.subManager.Subscribe(name) } -func (b *EthLikeBlockProcessor) Start() { +func (b *BaseBlockProcessor) Start() { b.lifecycle.Start(func(ctx context.Context) error { if !b.disableFinalizedBlockDetection { go b.pollLoop(ctx, protocol.FinalizedBlock) @@ -110,7 +110,7 @@ func (b *EthLikeBlockProcessor) Start() { }) } -func (b *EthLikeBlockProcessor) pollLoop(ctx context.Context, blockType protocol.BlockType) { +func (b *BaseBlockProcessor) pollLoop(ctx context.Context, blockType protocol.BlockType) { b.poll(blockType) ticker := time.NewTicker(b.pollInterval) @@ -126,7 +126,7 @@ func (b *EthLikeBlockProcessor) pollLoop(ctx context.Context, blockType protocol } } -func (b *EthLikeBlockProcessor) blockEventLoop(ctx context.Context) { +func (b *BaseBlockProcessor) blockEventLoop(ctx context.Context) { for { select { case <-ctx.Done(): @@ -141,7 +141,7 @@ func (b *EthLikeBlockProcessor) blockEventLoop(ctx context.Context) { } } -func (b *EthLikeBlockProcessor) poll(blockType protocol.BlockType) { +func (b *BaseBlockProcessor) poll(blockType protocol.BlockType) { if b.detectionDisabled(blockType) { return } @@ -166,11 +166,11 @@ func (b *EthLikeBlockProcessor) poll(blockType protocol.BlockType) { } } -func (b *EthLikeBlockProcessor) detectionDisabled(blockType protocol.BlockType) bool { +func (b *BaseBlockProcessor) detectionDisabled(blockType protocol.BlockType) bool { return b.disableDetection.Load()&blockTypeMask(blockType) != 0 } -func (b *EthLikeBlockProcessor) disableBlockDetection(blockType protocol.BlockType) { +func (b *BaseBlockProcessor) disableBlockDetection(blockType protocol.BlockType) { b.disableDetection.Or(blockTypeMask(blockType)) } @@ -178,7 +178,7 @@ func blockTypeMask(blockType protocol.BlockType) uint32 { return 1 << uint(blockType) } -func (b *EthLikeBlockProcessor) detectBlock(ctx context.Context, blockType protocol.BlockType) (protocol.Block, error) { +func (b *BaseBlockProcessor) detectBlock(ctx context.Context, blockType protocol.BlockType) (protocol.Block, error) { switch blockType { case protocol.FinalizedBlock: return b.chainSpecific.GetFinalizedBlock(ctx) @@ -194,4 +194,4 @@ func (b *EthLikeBlockProcessor) detectBlock(ctx context.Context, blockType proto } } -var _ BlockProcessor = (*EthLikeBlockProcessor)(nil) +var _ BlockProcessor = (*BaseBlockProcessor)(nil) diff --git a/internal/upstreams/blocks/block_processor_test.go b/internal/upstreams/blocks/block_processor_test.go index ac278297..6c1ddc9a 100644 --- a/internal/upstreams/blocks/block_processor_test.go +++ b/internal/upstreams/blocks/block_processor_test.go @@ -19,7 +19,7 @@ import ( "github.com/stretchr/testify/mock" ) -func TestEthLikeBlockProcessorGetFinalizedBlock(t *testing.T) { +func TestBaseBlockProcessorGetFinalizedBlock(t *testing.T) { upConfig := &config.Upstream{Id: "1", PollInterval: 1 * time.Second, Options: &chains.Options{InternalTimeout: 5 * time.Second}} ctx := context.Background() connector := mocks.NewConnectorMock() @@ -35,7 +35,7 @@ func TestEthLikeBlockProcessorGetFinalizedBlock(t *testing.T) { connector.On("SendRequest", mock.Anything, mock.Anything).Return(response) - processor := blocks.NewEthLikeBlockProcessor(ctx, upConfig.Id, upConfig.PollInterval, upConfig.Options.InternalTimeout, false, true, connector, test_utils.NewEvmChainSpecific(connector)) + processor := blocks.NewBaseBlockProcessor(ctx, upConfig.Id, upConfig.PollInterval, upConfig.Options.InternalTimeout, false, true, connector, test_utils.NewEvmChainSpecific(connector)) sub := processor.Subscribe("sub") go processor.Start() @@ -76,7 +76,7 @@ func TestEthLikeBlockProcessorGetFinalizedBlock(t *testing.T) { assert.Equal(t, expected, manualEvent) } -func TestEthLikeBlockProcessorDisableFinalizedBlock(t *testing.T) { +func TestBaseBlockProcessorDisableFinalizedBlock(t *testing.T) { upConfig := &config.Upstream{Id: "1", PollInterval: 1 * time.Second, Options: &chains.Options{InternalTimeout: 5 * time.Second}} ctx := context.Background() connector := mocks.NewConnectorMock() @@ -91,7 +91,7 @@ func TestEthLikeBlockProcessorDisableFinalizedBlock(t *testing.T) { connector.On("SendRequest", mock.Anything, mock.Anything).Return(response) - processor := blocks.NewEthLikeBlockProcessor(ctx, upConfig.Id, upConfig.PollInterval, upConfig.Options.InternalTimeout, false, true, connector, test_utils.NewEvmChainSpecific(connector)) + processor := blocks.NewBaseBlockProcessor(ctx, upConfig.Id, upConfig.PollInterval, upConfig.Options.InternalTimeout, false, true, connector, test_utils.NewEvmChainSpecific(connector)) sub := processor.Subscribe("sub") go processor.Start() @@ -106,14 +106,14 @@ func TestEthLikeBlockProcessorDisableFinalizedBlock(t *testing.T) { assert.False(t, ok) } -func TestEthLikeBlockProcessorPollsSafeBlockWhenSupported(t *testing.T) { +func TestBaseBlockProcessorPollsSafeBlockWhenSupported(t *testing.T) { disableSafe := false upConfig := &config.Upstream{Id: "1", PollInterval: time.Hour, Options: &chains.Options{InternalTimeout: 5 * time.Second, DisableSafeBlockDetection: &disableSafe}} chainSpecific := &blockProcessorChainSpecificStub{ blockProcessorChainSpecificNoSafeStub: blockProcessorChainSpecificNoSafeStub{finalized: protocol.NewBlockWithHeight(100)}, safe: protocol.NewBlockWithHeight(90), } - processor := blocks.NewEthLikeBlockProcessor(context.Background(), upConfig.Id, upConfig.PollInterval, upConfig.Options.InternalTimeout, false, *upConfig.Options.DisableSafeBlockDetection, mocks.NewConnectorMock(), chainSpecific) + processor := blocks.NewBaseBlockProcessor(context.Background(), upConfig.Id, upConfig.PollInterval, upConfig.Options.InternalTimeout, false, *upConfig.Options.DisableSafeBlockDetection, mocks.NewConnectorMock(), chainSpecific) sub := processor.Subscribe("sub") go processor.Start() @@ -133,11 +133,11 @@ func TestEthLikeBlockProcessorPollsSafeBlockWhenSupported(t *testing.T) { assert.Equal(t, uint64(90), seen[protocol.SafeBlock].Height) } -func TestEthLikeBlockProcessorDisablesSafeBlockWhenUnsupported(t *testing.T) { +func TestBaseBlockProcessorDisablesSafeBlockWhenUnsupported(t *testing.T) { disableSafe := false upConfig := &config.Upstream{Id: "1", PollInterval: time.Hour, Options: &chains.Options{InternalTimeout: 5 * time.Second, DisableSafeBlockDetection: &disableSafe}} chainSpecific := &blockProcessorChainSpecificNoSafeStub{finalized: protocol.NewBlockWithHeight(100)} - processor := blocks.NewEthLikeBlockProcessor(context.Background(), upConfig.Id, upConfig.PollInterval, upConfig.Options.InternalTimeout, false, *upConfig.Options.DisableSafeBlockDetection, mocks.NewConnectorMock(), chainSpecific) + processor := blocks.NewBaseBlockProcessor(context.Background(), upConfig.Id, upConfig.PollInterval, upConfig.Options.InternalTimeout, false, *upConfig.Options.DisableSafeBlockDetection, mocks.NewConnectorMock(), chainSpecific) sub := processor.Subscribe("sub") go processor.Start() @@ -205,14 +205,14 @@ func (b *blockProcessorChainSpecificStub) GetSafeBlock(context.Context) (protoco return b.safe, nil } -func TestEthLikeBlockProcessorSkipsSafeBlockWhenDisabled(t *testing.T) { +func TestBaseBlockProcessorSkipsSafeBlockWhenDisabled(t *testing.T) { disableSafe := true upConfig := &config.Upstream{Id: "1", PollInterval: time.Hour, Options: &chains.Options{InternalTimeout: 5 * time.Second, DisableSafeBlockDetection: &disableSafe}} chainSpecific := &blockProcessorChainSpecificStub{ blockProcessorChainSpecificNoSafeStub: blockProcessorChainSpecificNoSafeStub{finalized: protocol.NewBlockWithHeight(100)}, safe: protocol.NewBlockWithHeight(90), } - processor := blocks.NewEthLikeBlockProcessor(context.Background(), upConfig.Id, upConfig.PollInterval, upConfig.Options.InternalTimeout, false, *upConfig.Options.DisableSafeBlockDetection, mocks.NewConnectorMock(), chainSpecific) + processor := blocks.NewBaseBlockProcessor(context.Background(), upConfig.Id, upConfig.PollInterval, upConfig.Options.InternalTimeout, false, *upConfig.Options.DisableSafeBlockDetection, mocks.NewConnectorMock(), chainSpecific) sub := processor.Subscribe("sub") go processor.Start() diff --git a/internal/upstreams/chains_specific/beacon_specific/beacon_chain_specific.go b/internal/upstreams/chains_specific/beacon_specific/beacon_chain_specific.go index 80e9324e..5931c45e 100644 --- a/internal/upstreams/chains_specific/beacon_specific/beacon_chain_specific.go +++ b/internal/upstreams/chains_specific/beacon_specific/beacon_chain_specific.go @@ -64,7 +64,7 @@ func NewBeaconChainSpecificObject( // GetFinalizedBlock (GET /eth/v1/beacon/headers/finalized). Safe-block detection // is disabled - beacon has no "safe" head and doesn't implement GetSafeBlock. func (b *BeaconChainSpecificObject) BlockProcessor() blocks.BlockProcessor { - return blocks.NewEthLikeBlockProcessor( + return blocks.NewBaseBlockProcessor( b.ctx, b.upstreamId, b.pollInterval, diff --git a/internal/upstreams/chains_specific/evm_specific/evm_chain_specific.go b/internal/upstreams/chains_specific/evm_specific/evm_chain_specific.go index 4c9d1db9..5a2405eb 100644 --- a/internal/upstreams/chains_specific/evm_specific/evm_chain_specific.go +++ b/internal/upstreams/chains_specific/evm_specific/evm_chain_specific.go @@ -36,7 +36,7 @@ type EvmChainSpecificObject struct { } func (e *EvmChainSpecificObject) BlockProcessor() blocks.BlockProcessor { - return blocks.NewEthLikeBlockProcessor( + return blocks.NewBaseBlockProcessor( e.ctx, e.upstreamId, e.pollInterval, diff --git a/internal/upstreams/chains_specific/near_specific/near_chain_specific.go b/internal/upstreams/chains_specific/near_specific/near_chain_specific.go index 55a44ed8..25ac1b1e 100644 --- a/internal/upstreams/chains_specific/near_specific/near_chain_specific.go +++ b/internal/upstreams/chains_specific/near_specific/near_chain_specific.go @@ -31,6 +31,7 @@ type NearChainSpecificObject struct { upstreamId string connector connectors.ApiConnector options *chains.Options + pollInterval time.Duration internalTimeout time.Duration labelsDelay time.Duration configuredChain *chains.ConfiguredChain @@ -41,6 +42,7 @@ func NewNearChainSpecificObject( configuredChain *chains.ConfiguredChain, upstreamId string, connector connectors.ApiConnector, + pollInterval time.Duration, options *chains.Options, ) *NearChainSpecificObject { return &NearChainSpecificObject{ @@ -48,6 +50,7 @@ func NewNearChainSpecificObject( upstreamId: upstreamId, connector: connector, options: options, + pollInterval: pollInterval, internalTimeout: options.InternalTimeout, labelsDelay: options.ValidationInterval * 5, configuredChain: configuredChain, @@ -55,7 +58,16 @@ func NewNearChainSpecificObject( } func (n *NearChainSpecificObject) BlockProcessor() blocks.BlockProcessor { - return nil + return blocks.NewBaseBlockProcessor( + n.ctx, + n.upstreamId, + n.pollInterval, + n.internalTimeout, + n.options.FinalizedBlockDetectionDisabled(), + true, // near has no "safe" block concept, only optimistic/final + n.connector, + n, + ) } func (n *NearChainSpecificObject) LabelsProcessor() labels.LabelsProcessor { @@ -70,8 +82,9 @@ func (n *NearChainSpecificObject) LabelsProcessor() labels.LabelsProcessor { return labels.NewBaseLabelsProcessor(n.ctx, n.upstreamId, labelsDetectors, n.labelsDelay) } -func (n *NearChainSpecificObject) CapDetectors(input caps.DetectorInput) []caps.CapDetector { - return caps.DefaultCapDetectors(n.upstreamId, input.WsConnector) +func (n *NearChainSpecificObject) CapDetectors(_ caps.DetectorInput) []caps.CapDetector { + // nearcore has no websocket transport, so no ws-derived caps can ever be asserted + return nil } func (n *NearChainSpecificObject) LowerBoundProcessor() lower_bounds.LowerBoundProcessor { @@ -95,12 +108,17 @@ func (n *NearChainSpecificObject) HealthValidators() []validations.Validator[pro if n.options != nil && *n.options.DisableHealthValidation { return []validations.Validator[protocol.AvailabilityStatus]{} } - validatePeers := n.options != nil && n.options.ValidatePeers != nil && *n.options.ValidatePeers - return []validations.Validator[protocol.AvailabilityStatus]{ - near_validations.NewNearHealthValidator( - n.upstreamId, n.connector, n.configuredChain, n.internalTimeout, validatePeers, + validators := []validations.Validator[protocol.AvailabilityStatus]{ + near_validations.NewNearSyncingValidator( + n.upstreamId, n.connector, n.configuredChain, n.internalTimeout, ), } + if n.options != nil && n.options.ValidatePeers != nil && *n.options.ValidatePeers { + validators = append(validators, near_validations.NewNearPeersValidator( + n.upstreamId, n.connector, n.configuredChain, n.internalTimeout, + )) + } + return validators } func (n *NearChainSpecificObject) SettingsValidators() []validations.Validator[validations.ValidationSettingResult] { @@ -121,9 +139,8 @@ func (n *NearChainSpecificObject) GetLatestBlock(ctx context.Context) (protocol. return n.fetchBlockByFinality(ctx, "optimistic") } -// GetFinalizedBlock fetches the "final" head. It is not tracked in a separate -// poll loop (BlockProcessor is nil), but callers asking explicitly get the -// correct finalized block instead of an optimistic alias. +// GetFinalizedBlock fetches the "final" head; the block processor polls it +// alongside the optimistic head tracked by the head processor. func (n *NearChainSpecificObject) GetFinalizedBlock(ctx context.Context) (protocol.Block, error) { return n.fetchBlockByFinality(ctx, "final") } diff --git a/internal/upstreams/chains_specific/near_specific/near_chain_specific_test.go b/internal/upstreams/chains_specific/near_specific/near_chain_specific_test.go new file mode 100644 index 00000000..cdcd723b --- /dev/null +++ b/internal/upstreams/chains_specific/near_specific/near_chain_specific_test.go @@ -0,0 +1,124 @@ +package near_specific_test + +import ( + "context" + "strings" + "testing" + + "github.com/drpcorg/nodecore/internal/protocol" + "github.com/drpcorg/nodecore/internal/upstreams/caps" + "github.com/drpcorg/nodecore/pkg/blockchain" + "github.com/drpcorg/nodecore/pkg/test_utils" + "github.com/drpcorg/nodecore/pkg/test_utils/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestNearSubscribeHeadRequest(t *testing.T) { + req, err := test_utils.NewNearChainSpecific(context.Background(), nil).SubscribeHeadRequest() + assert.Nil(t, req) + assert.EqualError(t, err, "near: head subscriptions are not supported") +} + +func TestNearParseSubscriptionBlock(t *testing.T) { + block, err := test_utils.NewNearChainSpecific(context.Background(), nil).ParseSubscriptionBlock([]byte(`{}`)) + assert.True(t, block.IsFullEmpty()) + assert.EqualError(t, err, "near: head subscriptions are not supported") +} + +func TestNearParseBlock(t *testing.T) { + body := []byte(`{ + "author": "validator.near", + "header": {"height": 207365026, "hash": "9nEcHpjcsfjMwHzHYzDLZeLBEbeqbNRew7oXCSFvi2Wa", "prev_hash": "5qJoxdRBSDaZmGuLzjWfDGnWqCvHdRPuJqkyZv7QwXvJ"}, + "chunks": [] + }`) + + block, err := test_utils.NewNearChainSpecific(context.Background(), nil).ParseBlock(body) + require.NoError(t, err) + + expected := protocol.NewBlock( + 207365026, + 0, + blockchain.NewHashIdFromString("9nEcHpjcsfjMwHzHYzDLZeLBEbeqbNRew7oXCSFvi2Wa"), + blockchain.NewHashIdFromString("5qJoxdRBSDaZmGuLzjWfDGnWqCvHdRPuJqkyZv7QwXvJ"), + ) + assert.Equal(t, expected, block) +} + +func TestNearParseBlockNoPrevHash(t *testing.T) { + body := []byte(`{"header": {"height": 1, "hash": "9nEcHpjcsfjMwHzHYzDLZeLBEbeqbNRew7oXCSFvi2Wa"}}`) + + block, err := test_utils.NewNearChainSpecific(context.Background(), nil).ParseBlock(body) + require.NoError(t, err) + assert.Equal(t, blockchain.EmptyHash, block.ParentHash) +} + +func TestNearParseBlockInvalidJSON(t *testing.T) { + block, err := test_utils.NewNearChainSpecific(context.Background(), nil).ParseBlock([]byte(`not json`)) + assert.True(t, block.IsFullEmpty()) + assert.ErrorContains(t, err, "couldn't parse the near block") +} + +func TestNearParseBlockNoHash(t *testing.T) { + block, err := test_utils.NewNearChainSpecific(context.Background(), nil).ParseBlock([]byte(`{"header":{"height":1}}`)) + assert.True(t, block.IsFullEmpty()) + assert.ErrorContains(t, err, "couldn't parse the near block") +} + +func matchBlockRequestWithFinality(finality string) func(protocol.RequestHolder) bool { + return func(req protocol.RequestHolder) bool { + if req.Method() != "block" { + return false + } + body, err := req.Body() + if err != nil { + return false + } + return strings.Contains(string(body), `"finality":"`+finality+`"`) + } +} + +func TestNearGetLatestBlockPollsOptimistic(t *testing.T) { + connector := mocks.NewConnectorMock() + body := []byte(`{"header": {"height": 207365026, "hash": "9nEcHpjcsfjMwHzHYzDLZeLBEbeqbNRew7oXCSFvi2Wa", "prev_hash": "5qJoxdRBSDaZmGuLzjWfDGnWqCvHdRPuJqkyZv7QwXvJ"}}`) + connector.On("SendRequest", mock.Anything, mock.MatchedBy(matchBlockRequestWithFinality("optimistic"))). + Return(protocol.NewSimpleHttpUpstreamResponse("1", body, protocol.JsonRpc)) + + block, err := test_utils.NewNearChainSpecific(context.Background(), connector).GetLatestBlock(context.Background()) + require.NoError(t, err) + assert.Equal(t, uint64(207365026), block.Height) + connector.AssertExpectations(t) +} + +func TestNearGetFinalizedBlockPollsFinal(t *testing.T) { + connector := mocks.NewConnectorMock() + body := []byte(`{"header": {"height": 207365020, "hash": "5qJoxdRBSDaZmGuLzjWfDGnWqCvHdRPuJqkyZv7QwXvJ", "prev_hash": "9nEcHpjcsfjMwHzHYzDLZeLBEbeqbNRew7oXCSFvi2Wa"}}`) + connector.On("SendRequest", mock.Anything, mock.MatchedBy(matchBlockRequestWithFinality("final"))). + Return(protocol.NewSimpleHttpUpstreamResponse("1", body, protocol.JsonRpc)) + + block, err := test_utils.NewNearChainSpecific(context.Background(), connector).GetFinalizedBlock(context.Background()) + require.NoError(t, err) + assert.Equal(t, uint64(207365020), block.Height) + connector.AssertExpectations(t) +} + +func TestNearGetLatestBlockError(t *testing.T) { + connector := mocks.NewConnectorMock() + connector.On("SendRequest", mock.Anything, mock.Anything). + Return(protocol.NewHttpUpstreamResponseWithError(protocol.ResponseErrorWithData(1, "boom", nil))) + + block, err := test_utils.NewNearChainSpecific(context.Background(), connector).GetLatestBlock(context.Background()) + assert.True(t, block.IsFullEmpty()) + assert.Error(t, err) +} + +func TestNearBlockProcessorIsCreated(t *testing.T) { + processor := test_utils.NewNearChainSpecific(context.Background(), mocks.NewConnectorMock()).BlockProcessor() + assert.NotNil(t, processor) +} + +func TestNearCapDetectorsAreNil(t *testing.T) { + detectors := test_utils.NewNearChainSpecific(context.Background(), nil).CapDetectors(caps.DetectorInput{}) + assert.Nil(t, detectors) +} diff --git a/internal/upstreams/chains_specific/tron_specific/tron_rest_specific.go b/internal/upstreams/chains_specific/tron_specific/tron_rest_specific.go index c0c80b69..2ea48f7a 100644 --- a/internal/upstreams/chains_specific/tron_specific/tron_rest_specific.go +++ b/internal/upstreams/chains_specific/tron_specific/tron_rest_specific.go @@ -35,7 +35,7 @@ type TronRestSpecific struct { } func (t *TronRestSpecific) BlockProcessor() blocks.BlockProcessor { - return blocks.NewEthLikeBlockProcessor( + return blocks.NewBaseBlockProcessor( t.ctx, t.upstreamId, t.pollInterval, diff --git a/internal/upstreams/lower_bounds/near_bounds/near_lower_bound.go b/internal/upstreams/lower_bounds/near_bounds/near_lower_bound.go index 869128ff..bd280ada 100644 --- a/internal/upstreams/lower_bounds/near_bounds/near_lower_bound.go +++ b/internal/upstreams/lower_bounds/near_bounds/near_lower_bound.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "sync/atomic" "time" "github.com/bytedance/sonic" @@ -12,7 +11,8 @@ import ( "github.com/drpcorg/nodecore/internal/upstreams/connectors" "github.com/drpcorg/nodecore/internal/upstreams/lower_bounds" "github.com/drpcorg/nodecore/pkg/chains" - "github.com/rs/zerolog/log" + "github.com/failsafe-go/failsafe-go" + "github.com/failsafe-go/failsafe-go/retrypolicy" ) // Non-archival nodes garbage-collect a sliding ~5-epoch window, so the @@ -20,20 +20,22 @@ import ( // often to keep the bound close to the real GC boundary. const nearPeriod = 3 * time.Minute -var errNearNoEarliestHeight = errors.New("near node reported no earliest_block_height") +const ( + nearRetryAttempts = 3 + nearRetryDelay = 500 * time.Millisecond +) -var nearEmittedBoundTypes = []protocol.LowerBoundType{ - protocol.StateBound, - protocol.BlockBound, -} +var errNearNoEarliestHeight = errors.New("near node reported no earliest_block_height") +// NearLowerBoundDetector reads earliest_block_height from the `status` +// response - nearcore publishes its GC boundary directly, one RPC per +// refresh. On any error the detector returns (nil, err): the processor +// logs it, skips the tick, and the previously cached bound stays in place. type NearLowerBoundDetector struct { upstreamId string connector connectors.ApiConnector chain chains.Chain internalTimeout time.Duration - - lastBound atomic.Int64 } func NewNearLowerBoundDetector( @@ -51,59 +53,39 @@ func NewNearLowerBoundDetector( } func (n *NearLowerBoundDetector) DetectLowerBound(ctx context.Context) ([]protocol.LowerBoundData, error) { - status, err := n.fetchStatus(ctx) + retryPolicy := retrypolicy.NewBuilder[*nearStatus](). + WithMaxAttempts(nearRetryAttempts). + WithDelay(nearRetryDelay). + ReturnLastFailure(). + Build() + status, err := failsafe.With(retryPolicy).WithContext(ctx).Get(func() (*nearStatus, error) { + return n.fetchStatus(ctx) + }) if err != nil { - return n.fallback(fmt.Errorf("cannot fetch node status: %w", err)), nil + return nil, fmt.Errorf("cannot fetch near status for upstream '%s': %w", n.upstreamId, err) } bound := status.SyncInfo.EarliestBlockHeight if bound <= 0 { // Zero or absent means the node did not report its GC boundary, // not that the full history is available. - return n.fallback(errNearNoEarliestHeight), nil + return nil, errNearNoEarliestHeight } - n.lastBound.Store(bound) - return nearBounds(bound), nil + return []protocol.LowerBoundData{ + protocol.NewLowerBoundDataNow(bound, protocol.StateBound), + protocol.NewLowerBoundDataNow(bound, protocol.BlockBound), + }, nil } func (n *NearLowerBoundDetector) SupportedTypes() []protocol.LowerBoundType { - return append(append([]protocol.LowerBoundType{}, nearEmittedBoundTypes...), protocol.UnknownBound) + return []protocol.LowerBoundType{protocol.StateBound, protocol.BlockBound} } func (n *NearLowerBoundDetector) Period() time.Duration { return nearPeriod } -// fallback decides what to publish when the calculation cannot complete. -// If a previous tick produced a bound, re-emit it so the router keeps using -// the last known good value. Otherwise emit UnknownBound=0 so consumers get -// an explicit "we don't know" signal instead of silence. -func (n *NearLowerBoundDetector) fallback(reason error) []protocol.LowerBoundData { - if cached := n.lastBound.Load(); cached > 0 { - log.Warn().Err(reason).Msgf( - "near upstream '%s' lower-bound calculation failed; retaining cached bound=%d", - n.upstreamId, cached, - ) - return nearBounds(cached) - } - log.Warn().Err(reason).Msgf( - "near upstream '%s' lower-bound calculation failed and no cache available; emitting UnknownBound", - n.upstreamId, - ) - return []protocol.LowerBoundData{ - protocol.NewLowerBoundDataNow(0, protocol.UnknownBound), - } -} - -func nearBounds(bound int64) []protocol.LowerBoundData { - bounds := make([]protocol.LowerBoundData, 0, len(nearEmittedBoundTypes)) - for _, bt := range nearEmittedBoundTypes { - bounds = append(bounds, protocol.NewLowerBoundDataNow(bound, bt)) - } - return bounds -} - func (n *NearLowerBoundDetector) fetchStatus(ctx context.Context) (*nearStatus, error) { ctx, cancel := context.WithTimeout(ctx, n.internalTimeout) defer cancel() diff --git a/internal/upstreams/lower_bounds/near_bounds/near_lower_bound_test.go b/internal/upstreams/lower_bounds/near_bounds/near_lower_bound_test.go index d33f876c..0eca2d00 100644 --- a/internal/upstreams/lower_bounds/near_bounds/near_lower_bound_test.go +++ b/internal/upstreams/lower_bounds/near_bounds/near_lower_bound_test.go @@ -51,7 +51,6 @@ func TestNearLowerBoundDetector_SupportedTypesAndPeriod(t *testing.T) { []protocol.LowerBoundType{ protocol.StateBound, protocol.BlockBound, - protocol.UnknownBound, }, detector.SupportedTypes(), ) @@ -74,31 +73,27 @@ func TestNearLowerBoundDetector_EarliestHeightReturnsStateAndBlockBounds(t *test assert.Equal(t, int64(207157933), got[protocol.BlockBound]) } -func TestNearLowerBoundDetector_ErrorWithCacheRetainsCachedBound(t *testing.T) { +func TestNearLowerBoundDetector_RetriesTransientErrorThenSucceeds(t *testing.T) { connector := mocks.NewConnectorMock() connector. On("SendRequest", mock.Anything, mock.MatchedBy(matchNearStatusRequest())). - Return(nearStatusResponse(207157933)). + Return(protocol.NewHttpUpstreamResponseWithError(protocol.ServerError())). Once() connector. On("SendRequest", mock.Anything, mock.MatchedBy(matchNearStatusRequest())). - Return(protocol.NewHttpUpstreamResponseWithError(protocol.ServerError())) + Return(nearStatusResponse(207157933)) detector := near_bounds.NewNearLowerBoundDetector("id", chains.NEAR, time.Second, connector) result, err := detector.DetectLowerBound(context.Background()) require.NoError(t, err) require.Len(t, result, 2) - - result, err = detector.DetectLowerBound(context.Background()) - require.NoError(t, err) - require.Len(t, result, 2) got := boundsByType(t, result) assert.Equal(t, int64(207157933), got[protocol.StateBound]) assert.Equal(t, int64(207157933), got[protocol.BlockBound]) } -func TestNearLowerBoundDetector_ErrorWithoutCacheEmitsUnknownBound(t *testing.T) { +func TestNearLowerBoundDetector_ErrorReturnsError(t *testing.T) { connector := mocks.NewConnectorMock() connector. On("SendRequest", mock.Anything, mock.MatchedBy(matchNearStatusRequest())). @@ -107,13 +102,11 @@ func TestNearLowerBoundDetector_ErrorWithoutCacheEmitsUnknownBound(t *testing.T) detector := near_bounds.NewNearLowerBoundDetector("id", chains.NEAR, time.Second, connector) result, err := detector.DetectLowerBound(context.Background()) - require.NoError(t, err) - require.Len(t, result, 1) - assert.Equal(t, protocol.UnknownBound, result[0].Type) - assert.Equal(t, int64(0), result[0].Bound) + require.Error(t, err) + assert.Nil(t, result) } -func TestNearLowerBoundDetector_ZeroEarliestHeightFallsBack(t *testing.T) { +func TestNearLowerBoundDetector_ZeroEarliestHeightReturnsError(t *testing.T) { connector := mocks.NewConnectorMock() connector. On("SendRequest", mock.Anything, mock.MatchedBy(matchNearStatusRequest())). @@ -122,13 +115,11 @@ func TestNearLowerBoundDetector_ZeroEarliestHeightFallsBack(t *testing.T) { detector := near_bounds.NewNearLowerBoundDetector("id", chains.NEAR, time.Second, connector) result, err := detector.DetectLowerBound(context.Background()) - require.NoError(t, err) - require.Len(t, result, 1) - assert.Equal(t, protocol.UnknownBound, result[0].Type) - assert.Equal(t, int64(0), result[0].Bound) + require.ErrorContains(t, err, "no earliest_block_height") + assert.Nil(t, result) } -func TestNearLowerBoundDetector_AbsentEarliestHeightFallsBack(t *testing.T) { +func TestNearLowerBoundDetector_AbsentEarliestHeightReturnsError(t *testing.T) { connector := mocks.NewConnectorMock() connector. On("SendRequest", mock.Anything, mock.MatchedBy(matchNearStatusRequest())). @@ -137,32 +128,6 @@ func TestNearLowerBoundDetector_AbsentEarliestHeightFallsBack(t *testing.T) { detector := near_bounds.NewNearLowerBoundDetector("id", chains.NEAR, time.Second, connector) result, err := detector.DetectLowerBound(context.Background()) - require.NoError(t, err) - require.Len(t, result, 1) - assert.Equal(t, protocol.UnknownBound, result[0].Type) - assert.Equal(t, int64(0), result[0].Bound) -} - -func TestNearLowerBoundDetector_ZeroEarliestHeightRetainsCachedBound(t *testing.T) { - connector := mocks.NewConnectorMock() - connector. - On("SendRequest", mock.Anything, mock.MatchedBy(matchNearStatusRequest())). - Return(nearStatusResponse(207157933)). - Once() - connector. - On("SendRequest", mock.Anything, mock.MatchedBy(matchNearStatusRequest())). - Return(nearStatusResponse(0)) - - detector := near_bounds.NewNearLowerBoundDetector("id", chains.NEAR, time.Second, connector) - - result, err := detector.DetectLowerBound(context.Background()) - require.NoError(t, err) - require.Len(t, result, 2) - - result, err = detector.DetectLowerBound(context.Background()) - require.NoError(t, err) - require.Len(t, result, 2) - got := boundsByType(t, result) - assert.Equal(t, int64(207157933), got[protocol.StateBound]) - assert.Equal(t, int64(207157933), got[protocol.BlockBound]) + require.ErrorContains(t, err, "no earliest_block_height") + assert.Nil(t, result) } diff --git a/internal/upstreams/upstream_factory.go b/internal/upstreams/upstream_factory.go index b9144837..452e7905 100644 --- a/internal/upstreams/upstream_factory.go +++ b/internal/upstreams/upstream_factory.go @@ -259,6 +259,7 @@ func getChainSpecific( configuredChain, conf.Id, upstreamConnectorsInfo.internalRequestConnector, + conf.PollInterval, conf.Options, ), nil case chains.Solana: diff --git a/internal/upstreams/validations/near_validations/near_chain_validator.go b/internal/upstreams/validations/near_validations/near_chain_validator.go index 3b1ce11e..a6fec5b5 100644 --- a/internal/upstreams/validations/near_validations/near_chain_validator.go +++ b/internal/upstreams/validations/near_validations/near_chain_validator.go @@ -41,8 +41,9 @@ func (n *NearChainValidator) Validate() validations.ValidationSettingResult { return validations.SettingsError } if status.ChainId == "" { + // no chain_id means we can't tell what network the node is on - unusable as configured log.Error().Err(errNearEmptyChainId).Msgf("failed to validate the chain of near upstream '%s'", n.upstreamId) - return validations.SettingsError + return validations.FatalSettingError } // for near chains.yaml holds network-name chain-ids: mainnet/testnet/betanet if strings.EqualFold(status.ChainId, n.chain.ChainId) { diff --git a/internal/upstreams/validations/near_validations/near_health_validator.go b/internal/upstreams/validations/near_validations/near_health_validator.go index 27da0705..b3cd6f71 100644 --- a/internal/upstreams/validations/near_validations/near_health_validator.go +++ b/internal/upstreams/validations/near_validations/near_health_validator.go @@ -2,7 +2,6 @@ package near_validations import ( "context" - "errors" "time" "github.com/bytedance/sonic" @@ -13,36 +12,33 @@ import ( "github.com/rs/zerolog/log" ) -var errNearNoPeers = errors.New("near node has no active peers") - -type NearHealthValidator struct { +// NearSyncingValidator checks the node's own sync state via `status`: +// the explicit syncing flag plus a stale-head guard. +type NearSyncingValidator struct { upstreamId string connector connectors.ApiConnector chain *chains.ConfiguredChain internalTimeout time.Duration - validatePeers bool } -func NewNearHealthValidator( +func NewNearSyncingValidator( upstreamId string, connector connectors.ApiConnector, chain *chains.ConfiguredChain, internalTimeout time.Duration, - validatePeers bool, -) *NearHealthValidator { - return &NearHealthValidator{ +) *NearSyncingValidator { + return &NearSyncingValidator{ upstreamId: upstreamId, connector: connector, chain: chain, internalTimeout: internalTimeout, - validatePeers: validatePeers, } } -func (n *NearHealthValidator) Validate() protocol.AvailabilityStatus { +func (n *NearSyncingValidator) Validate() protocol.AvailabilityStatus { status, err := fetchNearStatus(n.connector, n.chain.Chain, n.internalTimeout) if err != nil { - log.Error().Err(err).Msgf("near upstream '%s' health validation failed", n.upstreamId) + log.Error().Err(err).Msgf("near upstream '%s' syncing validation failed", n.upstreamId) return protocol.Unavailable } if status.SyncInfo.Syncing { @@ -64,39 +60,9 @@ func (n *NearHealthValidator) Validate() protocol.AvailabilityStatus { } } } - if n.validatePeers { - peers, err := n.fetchActivePeers() - if err != nil { - log.Error().Err(err).Msgf("failed to fetch near network info for upstream '%s'", n.upstreamId) - return protocol.Unavailable - } - if peers == 0 { - log.Error().Err(errNearNoPeers).Msgf("near upstream '%s' has no active peers", n.upstreamId) - return protocol.Unavailable - } - } return protocol.Available } -func (n *NearHealthValidator) fetchActivePeers() (uint64, error) { - ctx, cancel := context.WithTimeout(context.Background(), n.internalTimeout) - defer cancel() - - request, err := protocol.NewInternalUpstreamJsonRpcRequest("network_info", []any{}, n.chain.Chain) - if err != nil { - return 0, err - } - response := n.connector.SendRequest(ctx, request) - if response.HasError() { - return 0, response.GetError() - } - var info nearNetworkInfo - if err := sonic.Unmarshal(response.ResponseResult(), &info); err != nil { - return 0, err - } - return info.NumActivePeers, nil -} - func fetchNearStatus(connector connectors.ApiConnector, chain chains.Chain, timeout time.Duration) (*NearStatus, error) { ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() @@ -133,8 +99,4 @@ type NearSyncInfo struct { Syncing bool `json:"syncing"` } -type nearNetworkInfo struct { - NumActivePeers uint64 `json:"num_active_peers"` -} - -var _ validations.HealthValidator = (*NearHealthValidator)(nil) +var _ validations.HealthValidator = (*NearSyncingValidator)(nil) diff --git a/internal/upstreams/validations/near_validations/near_peers_validator.go b/internal/upstreams/validations/near_validations/near_peers_validator.go new file mode 100644 index 00000000..216727b4 --- /dev/null +++ b/internal/upstreams/validations/near_validations/near_peers_validator.go @@ -0,0 +1,76 @@ +package near_validations + +import ( + "context" + "errors" + "time" + + "github.com/bytedance/sonic" + "github.com/drpcorg/nodecore/internal/protocol" + "github.com/drpcorg/nodecore/internal/upstreams/connectors" + "github.com/drpcorg/nodecore/internal/upstreams/validations" + "github.com/drpcorg/nodecore/pkg/chains" + "github.com/rs/zerolog/log" +) + +var errNearNoPeers = errors.New("near node has no active peers") + +// NearPeersValidator checks network connectivity via `network_info`. +type NearPeersValidator struct { + upstreamId string + connector connectors.ApiConnector + chain *chains.ConfiguredChain + internalTimeout time.Duration +} + +func NewNearPeersValidator( + upstreamId string, + connector connectors.ApiConnector, + chain *chains.ConfiguredChain, + internalTimeout time.Duration, +) *NearPeersValidator { + return &NearPeersValidator{ + upstreamId: upstreamId, + connector: connector, + chain: chain, + internalTimeout: internalTimeout, + } +} + +func (n *NearPeersValidator) Validate() protocol.AvailabilityStatus { + peers, err := n.fetchActivePeers() + if err != nil { + log.Error().Err(err).Msgf("failed to fetch near network info for upstream '%s'", n.upstreamId) + return protocol.Unavailable + } + if peers == 0 { + log.Error().Err(errNearNoPeers).Msgf("near upstream '%s' has no active peers", n.upstreamId) + return protocol.Unavailable + } + return protocol.Available +} + +func (n *NearPeersValidator) fetchActivePeers() (uint64, error) { + ctx, cancel := context.WithTimeout(context.Background(), n.internalTimeout) + defer cancel() + + request, err := protocol.NewInternalUpstreamJsonRpcRequest("network_info", []any{}, n.chain.Chain) + if err != nil { + return 0, err + } + response := n.connector.SendRequest(ctx, request) + if response.HasError() { + return 0, response.GetError() + } + var info nearNetworkInfo + if err := sonic.Unmarshal(response.ResponseResult(), &info); err != nil { + return 0, err + } + return info.NumActivePeers, nil +} + +type nearNetworkInfo struct { + NumActivePeers uint64 `json:"num_active_peers"` +} + +var _ validations.HealthValidator = (*NearPeersValidator)(nil) diff --git a/internal/upstreams/validations/near_validations/near_validators_test.go b/internal/upstreams/validations/near_validations/near_validators_test.go index 80b081e9..a6e7b996 100644 --- a/internal/upstreams/validations/near_validations/near_validators_test.go +++ b/internal/upstreams/validations/near_validations/near_validators_test.go @@ -45,6 +45,14 @@ func TestNearChainValidatorFatalOnMismatch(t *testing.T) { assert.Equal(t, validations.FatalSettingError, v.Validate()) } +func TestNearChainValidatorFatalOnEmptyChainId(t *testing.T) { + conn := mocks.NewConnectorMock() + conn.On("SendRequest", mock.Anything, mock.MatchedBy(isStatus)). + Return(protocol.NewSimpleHttpUpstreamResponse("1", statusBody("", freshBlockTime(), false), protocol.JsonRpc)) + v := near_validations.NewNearChainValidator("id", conn, chains.GetChain("near"), time.Second) + assert.Equal(t, validations.FatalSettingError, v.Validate()) +} + func TestNearChainValidatorRetriesOnFetchError(t *testing.T) { conn := mocks.NewConnectorMock() conn.On("SendRequest", mock.Anything, mock.MatchedBy(isStatus)). @@ -53,65 +61,69 @@ func TestNearChainValidatorRetriesOnFetchError(t *testing.T) { assert.Equal(t, validations.SettingsError, v.Validate()) } -func TestNearHealthAvailable(t *testing.T) { +func TestNearSyncingValidatorAvailable(t *testing.T) { conn := mocks.NewConnectorMock() conn.On("SendRequest", mock.Anything, mock.MatchedBy(isStatus)). Return(protocol.NewSimpleHttpUpstreamResponse("1", statusBody("mainnet", freshBlockTime(), false), protocol.JsonRpc)) - v := near_validations.NewNearHealthValidator("id", conn, chains.GetChain("near"), time.Second, false) + v := near_validations.NewNearSyncingValidator("id", conn, chains.GetChain("near"), time.Second) assert.Equal(t, protocol.Available, v.Validate()) } -func TestNearHealthSyncingOnSyncFlag(t *testing.T) { +func TestNearSyncingValidatorSyncingOnSyncFlag(t *testing.T) { conn := mocks.NewConnectorMock() conn.On("SendRequest", mock.Anything, mock.MatchedBy(isStatus)). Return(protocol.NewSimpleHttpUpstreamResponse("1", statusBody("mainnet", freshBlockTime(), true), protocol.JsonRpc)) - v := near_validations.NewNearHealthValidator("id", conn, chains.GetChain("near"), time.Second, false) + v := near_validations.NewNearSyncingValidator("id", conn, chains.GetChain("near"), time.Second) assert.Equal(t, protocol.Syncing, v.Validate()) } -func TestNearHealthSyncingOnStaleHead(t *testing.T) { +func TestNearSyncingValidatorSyncingOnStaleHead(t *testing.T) { conn := mocks.NewConnectorMock() // near's stale-head threshold is 1s * 40; the head is 5 minutes old staleTime := time.Now().Add(-5 * time.Minute).Format(time.RFC3339Nano) conn.On("SendRequest", mock.Anything, mock.MatchedBy(isStatus)). Return(protocol.NewSimpleHttpUpstreamResponse("1", statusBody("mainnet", staleTime, false), protocol.JsonRpc)) - v := near_validations.NewNearHealthValidator("id", conn, chains.GetChain("near"), time.Second, false) + v := near_validations.NewNearSyncingValidator("id", conn, chains.GetChain("near"), time.Second) assert.Equal(t, protocol.Syncing, v.Validate()) } -func TestNearHealthUnavailableOnZeroPeers(t *testing.T) { +func TestNearSyncingValidatorUnavailableOnError(t *testing.T) { conn := mocks.NewConnectorMock() conn.On("SendRequest", mock.Anything, mock.MatchedBy(isStatus)). - Return(protocol.NewSimpleHttpUpstreamResponse("1", statusBody("mainnet", freshBlockTime(), false), protocol.JsonRpc)) - conn.On("SendRequest", mock.Anything, mock.MatchedBy(isNetworkInfo)). - Return(protocol.NewSimpleHttpUpstreamResponse("1", []byte(`{"num_active_peers":0,"peer_max_count":40}`), protocol.JsonRpc)) - v := near_validations.NewNearHealthValidator("id", conn, chains.GetChain("near"), time.Second, true) + Return(protocol.NewHttpUpstreamResponseWithError(protocol.ResponseErrorWithData(1, "boom", nil))) + v := near_validations.NewNearSyncingValidator("id", conn, chains.GetChain("near"), time.Second) assert.Equal(t, protocol.Unavailable, v.Validate()) } -func TestNearHealthAvailableWithPeers(t *testing.T) { +func TestNearSyncingValidatorDoesNotCallNetworkInfo(t *testing.T) { conn := mocks.NewConnectorMock() conn.On("SendRequest", mock.Anything, mock.MatchedBy(isStatus)). Return(protocol.NewSimpleHttpUpstreamResponse("1", statusBody("mainnet", freshBlockTime(), false), protocol.JsonRpc)) - conn.On("SendRequest", mock.Anything, mock.MatchedBy(isNetworkInfo)). - Return(protocol.NewSimpleHttpUpstreamResponse("1", []byte(`{"num_active_peers":36,"peer_max_count":40}`), protocol.JsonRpc)) - v := near_validations.NewNearHealthValidator("id", conn, chains.GetChain("near"), time.Second, true) + v := near_validations.NewNearSyncingValidator("id", conn, chains.GetChain("near"), time.Second) assert.Equal(t, protocol.Available, v.Validate()) + conn.AssertNotCalled(t, "SendRequest", mock.Anything, mock.MatchedBy(isNetworkInfo)) } -func TestNearHealthIgnoresPeersWhenDisabled(t *testing.T) { +func TestNearPeersValidatorUnavailableOnZeroPeers(t *testing.T) { conn := mocks.NewConnectorMock() - conn.On("SendRequest", mock.Anything, mock.MatchedBy(isStatus)). - Return(protocol.NewSimpleHttpUpstreamResponse("1", statusBody("mainnet", freshBlockTime(), false), protocol.JsonRpc)) - v := near_validations.NewNearHealthValidator("id", conn, chains.GetChain("near"), time.Second, false) + conn.On("SendRequest", mock.Anything, mock.MatchedBy(isNetworkInfo)). + Return(protocol.NewSimpleHttpUpstreamResponse("1", []byte(`{"num_active_peers":0,"peer_max_count":40}`), protocol.JsonRpc)) + v := near_validations.NewNearPeersValidator("id", conn, chains.GetChain("near"), time.Second) + assert.Equal(t, protocol.Unavailable, v.Validate()) +} + +func TestNearPeersValidatorAvailableWithPeers(t *testing.T) { + conn := mocks.NewConnectorMock() + conn.On("SendRequest", mock.Anything, mock.MatchedBy(isNetworkInfo)). + Return(protocol.NewSimpleHttpUpstreamResponse("1", []byte(`{"num_active_peers":36,"peer_max_count":40}`), protocol.JsonRpc)) + v := near_validations.NewNearPeersValidator("id", conn, chains.GetChain("near"), time.Second) assert.Equal(t, protocol.Available, v.Validate()) - conn.AssertNotCalled(t, "SendRequest", mock.Anything, mock.MatchedBy(isNetworkInfo)) } -func TestNearHealthUnavailableOnError(t *testing.T) { +func TestNearPeersValidatorUnavailableOnError(t *testing.T) { conn := mocks.NewConnectorMock() - conn.On("SendRequest", mock.Anything, mock.MatchedBy(isStatus)). + conn.On("SendRequest", mock.Anything, mock.MatchedBy(isNetworkInfo)). Return(protocol.NewHttpUpstreamResponseWithError(protocol.ResponseErrorWithData(1, "boom", nil))) - v := near_validations.NewNearHealthValidator("id", conn, chains.GetChain("near"), time.Second, false) + v := near_validations.NewNearPeersValidator("id", conn, chains.GetChain("near"), time.Second) assert.Equal(t, protocol.Unavailable, v.Validate()) } diff --git a/pkg/test_utils/test_helpers.go b/pkg/test_utils/test_helpers.go index a91cf7e7..d361bc16 100644 --- a/pkg/test_utils/test_helpers.go +++ b/pkg/test_utils/test_helpers.go @@ -19,6 +19,7 @@ import ( "github.com/drpcorg/nodecore/internal/upstreams/chains_specific/beacon_specific" "github.com/drpcorg/nodecore/internal/upstreams/chains_specific/bitcoin_specific" "github.com/drpcorg/nodecore/internal/upstreams/chains_specific/evm_specific" + "github.com/drpcorg/nodecore/internal/upstreams/chains_specific/near_specific" specific "github.com/drpcorg/nodecore/internal/upstreams/chains_specific/solana_specific" "github.com/drpcorg/nodecore/internal/upstreams/connectors" "github.com/drpcorg/nodecore/internal/upstreams/event_processors" @@ -241,6 +242,16 @@ func NewAztecChainSpecific(ctx context.Context, connector connectors.ApiConnecto return aztec_specific.NewAztecChainSpecificObject(ctx, chains.GetChain("aztec-mainnet"), "id", options, connector) } +func NewNearChainSpecific(ctx context.Context, connector connectors.ApiConnector) *near_specific.NearChainSpecificObject { + options := &chains.Options{ + InternalTimeout: 5 * time.Second, + ValidationInterval: 10 * time.Second, + DisableChainValidation: new(false), + DisableHealthValidation: new(false), + } + return near_specific.NewNearChainSpecificObject(ctx, chains.GetChain("near"), "id", connector, time.Second, options) +} + func NewAlgorandChainSpecific(ctx context.Context, connector connectors.ApiConnector) *algorand_specific.AlgorandChainSpecificObject { options := &chains.Options{ InternalTimeout: 5 * time.Second, From 0bd96968b7bbd517703a09f6a78de7b2232ee353 Mon Sep 17 00:00:00 2001 From: Artem Rootman <4586640+artemrootman@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:49:46 +0000 Subject: [PATCH 15/15] review: near - status fetch on receiver, gate syncing validator by ValidateSyncing - fetchNearStatus moved onto an embedded nearStatusClient shared by the syncing and chain validators, no more param passing - NearSyncingValidator is added only when ValidateSyncing is on, matching EvmChainSpecificObject --- .../near_specific/near_chain_specific.go | 7 ++- .../near_validations/near_chain_validator.go | 14 ++--- .../near_validations/near_health_validator.go | 60 ++++++++++--------- 3 files changed, 41 insertions(+), 40 deletions(-) diff --git a/internal/upstreams/chains_specific/near_specific/near_chain_specific.go b/internal/upstreams/chains_specific/near_specific/near_chain_specific.go index 25ac1b1e..3bc275c3 100644 --- a/internal/upstreams/chains_specific/near_specific/near_chain_specific.go +++ b/internal/upstreams/chains_specific/near_specific/near_chain_specific.go @@ -108,10 +108,11 @@ func (n *NearChainSpecificObject) HealthValidators() []validations.Validator[pro if n.options != nil && *n.options.DisableHealthValidation { return []validations.Validator[protocol.AvailabilityStatus]{} } - validators := []validations.Validator[protocol.AvailabilityStatus]{ - near_validations.NewNearSyncingValidator( + validators := make([]validations.Validator[protocol.AvailabilityStatus], 0) + if n.options != nil && n.options.ValidateSyncing != nil && *n.options.ValidateSyncing { + validators = append(validators, near_validations.NewNearSyncingValidator( n.upstreamId, n.connector, n.configuredChain, n.internalTimeout, - ), + )) } if n.options != nil && n.options.ValidatePeers != nil && *n.options.ValidatePeers { validators = append(validators, near_validations.NewNearPeersValidator( diff --git a/internal/upstreams/validations/near_validations/near_chain_validator.go b/internal/upstreams/validations/near_validations/near_chain_validator.go index a6fec5b5..38e81577 100644 --- a/internal/upstreams/validations/near_validations/near_chain_validator.go +++ b/internal/upstreams/validations/near_validations/near_chain_validator.go @@ -14,10 +14,8 @@ import ( var errNearEmptyChainId = errors.New("near node returned empty chain_id") type NearChainValidator struct { - upstreamId string - connector connectors.ApiConnector - chain *chains.ConfiguredChain - internalTimeout time.Duration + nearStatusClient + upstreamId string } func NewNearChainValidator( @@ -27,15 +25,13 @@ func NewNearChainValidator( internalTimeout time.Duration, ) *NearChainValidator { return &NearChainValidator{ - upstreamId: upstreamId, - connector: connector, - chain: chain, - internalTimeout: internalTimeout, + nearStatusClient: nearStatusClient{connector: connector, chain: chain, internalTimeout: internalTimeout}, + upstreamId: upstreamId, } } func (n *NearChainValidator) Validate() validations.ValidationSettingResult { - status, err := fetchNearStatus(n.connector, n.chain.Chain, n.internalTimeout) + status, err := n.fetchNearStatus() if err != nil { log.Error().Err(err).Msgf("failed to fetch near status for upstream '%s'", n.upstreamId) return validations.SettingsError diff --git a/internal/upstreams/validations/near_validations/near_health_validator.go b/internal/upstreams/validations/near_validations/near_health_validator.go index b3cd6f71..0c8b34e3 100644 --- a/internal/upstreams/validations/near_validations/near_health_validator.go +++ b/internal/upstreams/validations/near_validations/near_health_validator.go @@ -12,15 +12,40 @@ import ( "github.com/rs/zerolog/log" ) -// NearSyncingValidator checks the node's own sync state via `status`: -// the explicit syncing flag plus a stale-head guard. -type NearSyncingValidator struct { - upstreamId string +// nearStatusClient holds what a `status` call needs; validators embed it and +// call fetchNearStatus without passing params around. +type nearStatusClient struct { connector connectors.ApiConnector chain *chains.ConfiguredChain internalTimeout time.Duration } +func (n *nearStatusClient) fetchNearStatus() (*NearStatus, error) { + ctx, cancel := context.WithTimeout(context.Background(), n.internalTimeout) + defer cancel() + + request, err := protocol.NewInternalUpstreamJsonRpcRequest("status", []any{}, n.chain.Chain) + if err != nil { + return nil, err + } + response := n.connector.SendRequest(ctx, request) + if response.HasError() { + return nil, response.GetError() + } + var status NearStatus + if err := sonic.Unmarshal(response.ResponseResult(), &status); err != nil { + return nil, err + } + return &status, nil +} + +// NearSyncingValidator checks the node's own sync state via `status`: +// the explicit syncing flag plus a stale-head guard. +type NearSyncingValidator struct { + nearStatusClient + upstreamId string +} + func NewNearSyncingValidator( upstreamId string, connector connectors.ApiConnector, @@ -28,15 +53,13 @@ func NewNearSyncingValidator( internalTimeout time.Duration, ) *NearSyncingValidator { return &NearSyncingValidator{ - upstreamId: upstreamId, - connector: connector, - chain: chain, - internalTimeout: internalTimeout, + nearStatusClient: nearStatusClient{connector: connector, chain: chain, internalTimeout: internalTimeout}, + upstreamId: upstreamId, } } func (n *NearSyncingValidator) Validate() protocol.AvailabilityStatus { - status, err := fetchNearStatus(n.connector, n.chain.Chain, n.internalTimeout) + status, err := n.fetchNearStatus() if err != nil { log.Error().Err(err).Msgf("near upstream '%s' syncing validation failed", n.upstreamId) return protocol.Unavailable @@ -63,25 +86,6 @@ func (n *NearSyncingValidator) Validate() protocol.AvailabilityStatus { return protocol.Available } -func fetchNearStatus(connector connectors.ApiConnector, chain chains.Chain, timeout time.Duration) (*NearStatus, error) { - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - request, err := protocol.NewInternalUpstreamJsonRpcRequest("status", []any{}, chain) - if err != nil { - return nil, err - } - response := connector.SendRequest(ctx, request) - if response.HasError() { - return nil, response.GetError() - } - var status NearStatus - if err := sonic.Unmarshal(response.ResponseResult(), &status); err != nil { - return nil, err - } - return &status, nil -} - type NearStatus struct { ChainId string `json:"chain_id"` Version NearVersion `json:"version"`