Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions lib/bsv/transaction.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ module Transaction
autoload :TransactionInput, 'bsv/transaction/transaction_input'
autoload :Sighash, 'bsv/transaction/sighash'
autoload :MerklePath, 'bsv/transaction/merkle_path'
autoload :ChainTracker, 'bsv/transaction/chain_tracker'
autoload :ChainTrackers, 'bsv/transaction/chain_trackers'
autoload :Beef, 'bsv/transaction/beef'
autoload :UnlockingScriptTemplate, 'bsv/transaction/unlocking_script_template'
autoload :P2PKH, 'bsv/transaction/p2pkh'
Expand Down
43 changes: 43 additions & 0 deletions lib/bsv/transaction/chain_tracker.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# frozen_string_literal: true

module BSV
module Transaction
# Base class for chain trackers that verify merkle roots against the blockchain.
#
# Chain trackers confirm that a given merkle root corresponds to a valid block
# at a specific height. This is essential for SPV verification — without it,
# merkle proofs cannot be validated against the actual blockchain.
#
# Subclasses must implement {#valid_root_for_height?} and {#current_height}.
#
# @example Implementing a custom chain tracker
# class MyTracker < BSV::Transaction::ChainTracker
# def valid_root_for_height?(root, height)
# # query your block header source
# end
#
# def current_height
# # return current chain tip height
# end
# end
class ChainTracker
# Verify that a merkle root is valid for the given block height.
#
# @param root [String] merkle root as a hex string
# @param height [Integer] block height
# @return [Boolean] true if the root matches the block at the given height
# @raise [NotImplementedError] if not overridden by a subclass
def valid_root_for_height?(_root, _height)
raise NotImplementedError, "#{self.class}#valid_root_for_height? must be implemented"
end

# Return the current blockchain height.
#
# @return [Integer] the height of the chain tip
# @raise [NotImplementedError] if not overridden by a subclass
def current_height
raise NotImplementedError, "#{self.class}#current_height must be implemented"
end
end
end
end
10 changes: 10 additions & 0 deletions lib/bsv/transaction/chain_trackers.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# frozen_string_literal: true

module BSV
module Transaction
# Namespace for chain tracker implementations.
module ChainTrackers
autoload :WhatsOnChain, 'bsv/transaction/chain_trackers/whats_on_chain'
end
end
end
95 changes: 95 additions & 0 deletions lib/bsv/transaction/chain_trackers/whats_on_chain.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# frozen_string_literal: true

require 'net/http'
require 'json'
require 'uri'

module BSV
module Transaction
module ChainTrackers
# Chain tracker that verifies merkle roots using the WhatsOnChain API.
#
# Queries the WoC block header endpoint to retrieve the merkle root for a
# given block height and compares it with the provided root.
#
# @example
# tracker = BSV::Transaction::ChainTrackers::WhatsOnChain.new
# tracker.valid_root_for_height?('abcd...', 800_000)
class WhatsOnChain < ChainTracker
BASE_URL = 'https://api.whatsonchain.com'

NETWORKS = {
main: 'main',
mainnet: 'main',
test: 'test',
testnet: 'test',
stn: 'stn'
}.freeze

# @param network [Symbol] :main, :mainnet, :test, :testnet, or :stn
# @param api_key [String, nil] optional WoC API key
# @param http_client [#request, nil] injectable HTTP client for testing
def initialize(network: :main, api_key: nil, http_client: nil)
super()
@network = NETWORKS.fetch(network) { raise ArgumentError, "unknown network: #{network}" }
@api_key = api_key
@http_client = http_client
end

# Verify that a merkle root is valid for the given block height.
#
# @param root [String] merkle root as a hex string
# @param height [Integer] block height
# @return [Boolean]
def valid_root_for_height?(root, height)
response = get("/v1/bsv/#{@network}/block/#{height}/header")
return false if response.nil?

data = JSON.parse(response.body)
data['merkleroot'].downcase == root.downcase
end

# Return the current blockchain height.
#
# @return [Integer]
def current_height
response = get("/v1/bsv/#{@network}/chain/info", not_found_returns_nil: false)
data = JSON.parse(response.body)
data['blocks']
end

private

# @param path [String] API path
# @param not_found_returns_nil [Boolean] if true, return nil on 404 instead of raising
# @return [Net::HTTPResponse, nil]
def get(path, not_found_returns_nil: true)
uri = URI("#{BASE_URL}#{path}")
request = Net::HTTP::Get.new(uri)
request['Authorization'] = @api_key if @api_key

response = execute(uri, request)
code = response.code.to_i

return nil if not_found_returns_nil && code == 404
return response if (200..299).cover?(code)

raise BSV::Network::ChainProviderError.new(
response.body || "HTTP #{code}",
status_code: code
)
end

def execute(uri, request)
if @http_client
@http_client.request(uri, request)
else
Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
http.request(request)
end
end
end
end
end
end
end
15 changes: 15 additions & 0 deletions lib/bsv/transaction/merkle_path.rb
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,21 @@ def compute_root_hex(txid_hex = nil)
compute_root(txid).reverse.unpack1('H*')
end

# --- Verification ---

# Verify that this merkle path is valid for a given transaction.
#
# Computes the merkle root from the path and txid, then checks it
# against the blockchain via the provided chain tracker.
#
# @param txid_hex [String] hex-encoded transaction ID (display order)
# @param chain_tracker [ChainTracker] chain tracker to verify the root against
# @return [Boolean] true if the computed root matches the block at this height
def verify(txid_hex, chain_tracker)
root_hex = compute_root_hex(txid_hex)
chain_tracker.valid_root_for_height?(root_hex, @block_height)
end

# --- Combine ---

# Merge another merkle path into this one.
Expand Down
47 changes: 47 additions & 0 deletions spec/bsv/transaction/chain_tracker_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# frozen_string_literal: true

RSpec.describe BSV::Transaction::ChainTracker do
subject(:tracker) { described_class.new }

describe '#valid_root_for_height?' do
it 'raises NotImplementedError' do
expect { tracker.valid_root_for_height?('abc', 100) }
.to raise_error(NotImplementedError, /valid_root_for_height\? must be implemented/)
end
end

describe '#current_height' do
it 'raises NotImplementedError' do
expect { tracker.current_height }
.to raise_error(NotImplementedError, /current_height must be implemented/)
end
end

context 'with a concrete subclass' do
let(:concrete_tracker_class) do
Class.new(described_class) do
def valid_root_for_height?(root, height)
root == 'valid_root' && height == 100
end

def current_height
800_000
end
end
end

let(:concrete) { concrete_tracker_class.new }

it 'returns true for matching root and height' do
expect(concrete.valid_root_for_height?('valid_root', 100)).to be true
end

it 'returns false for non-matching root' do
expect(concrete.valid_root_for_height?('bad_root', 100)).to be false
end

it 'returns current height' do
expect(concrete.current_height).to eq(800_000)
end
end
end
137 changes: 137 additions & 0 deletions spec/bsv/transaction/chain_trackers/whats_on_chain_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# frozen_string_literal: true

RSpec.describe BSV::Transaction::ChainTrackers::WhatsOnChain do
let(:http_client) { instance_double(Net::HTTP) }
let(:tracker) { described_class.new(http_client: http_client) }

let(:merkle_root) { '4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b' }

let(:header_json) do
{
'hash' => '000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f',
'confirmations' => 800_000,
'size' => 285,
'height' => 0,
'version' => 1,
'merkleroot' => merkle_root,
'tx' => [],
'time' => 1_231_006_505,
'nonce' => 2_083_236_893,
'bits' => '1d00ffff',
'previousblockhash' => '0000000000000000000000000000000000000000000000000000000000000000'
}.to_json
end

def mock_response(code, body)
instance_double(Net::HTTPResponse, code: code.to_s, body: body)
end

describe '#valid_root_for_height?' do
it 'returns true when root matches' do
allow(http_client).to receive(:request).and_return(mock_response(200, header_json))

expect(tracker.valid_root_for_height?(merkle_root, 0)).to be true
end

it 'returns true with case-insensitive comparison' do
allow(http_client).to receive(:request).and_return(mock_response(200, header_json))

expect(tracker.valid_root_for_height?(merkle_root.upcase, 0)).to be true
end

it 'returns false when root does not match' do
allow(http_client).to receive(:request).and_return(mock_response(200, header_json))

expect(tracker.valid_root_for_height?('0000' * 16, 0)).to be false
end

it 'returns false for 404 (block not found)' do
allow(http_client).to receive(:request).and_return(mock_response(404, 'Not Found'))

expect(tracker.valid_root_for_height?(merkle_root, 999_999_999)).to be false
end

it 'raises ChainProviderError for server errors' do
allow(http_client).to receive(:request).and_return(mock_response(500, 'Internal Server Error'))

expect { tracker.valid_root_for_height?(merkle_root, 0) }
.to raise_error(BSV::Network::ChainProviderError) { |e| expect(e.status_code).to eq(500) }
end

it 'sends request to correct URL for mainnet' do
allow(http_client).to receive(:request).and_return(mock_response(200, header_json))

tracker.valid_root_for_height?(merkle_root, 100)

expect(http_client).to have_received(:request) do |uri, _req|
expect(uri.to_s).to eq('https://api.whatsonchain.com/v1/bsv/main/block/100/header')
end
end

it 'sends API key in Authorization header when provided' do
keyed_tracker = described_class.new(api_key: 'my-key', http_client: http_client)
allow(http_client).to receive(:request).and_return(mock_response(200, header_json))

keyed_tracker.valid_root_for_height?(merkle_root, 0)

expect(http_client).to have_received(:request) do |_uri, req|
expect(req['Authorization']).to eq('my-key')
end
end
end

describe '#current_height' do
let(:chain_info_json) do
{ 'chain' => 'main', 'blocks' => 800_123, 'bestblockhash' => 'abc' }.to_json
end

it 'returns the current block height' do
allow(http_client).to receive(:request).and_return(mock_response(200, chain_info_json))

expect(tracker.current_height).to eq(800_123)
end

it 'raises ChainProviderError on failure' do
allow(http_client).to receive(:request).and_return(mock_response(503, 'Unavailable'))

expect { tracker.current_height }
.to raise_error(BSV::Network::ChainProviderError) { |e| expect(e.status_code).to eq(503) }
end
end

describe 'network configuration' do
it 'defaults to mainnet' do
allow(http_client).to receive(:request).and_return(mock_response(200, header_json))
tracker.valid_root_for_height?(merkle_root, 0)

expect(http_client).to have_received(:request) do |uri, _req|
expect(uri.path).to include('/bsv/main/')
end
end

it 'supports testnet' do
test_tracker = described_class.new(network: :test, http_client: http_client)
allow(http_client).to receive(:request).and_return(mock_response(200, header_json))
test_tracker.valid_root_for_height?(merkle_root, 0)

expect(http_client).to have_received(:request) do |uri, _req|
expect(uri.path).to include('/bsv/test/')
end
end

it 'supports stn' do
stn_tracker = described_class.new(network: :stn, http_client: http_client)
allow(http_client).to receive(:request).and_return(mock_response(200, header_json))
stn_tracker.valid_root_for_height?(merkle_root, 0)

expect(http_client).to have_received(:request) do |uri, _req|
expect(uri.path).to include('/bsv/stn/')
end
end

it 'raises ArgumentError for unknown network' do
expect { described_class.new(network: :invalid) }
.to raise_error(ArgumentError, /unknown network/)
end
end
end
Loading