diff --git a/.rubocop.yml b/.rubocop.yml index a19d6f7..dd77487 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -17,12 +17,14 @@ Naming/FileName: Exclude: - 'lib/bsv-sdk.rb' - 'lib/bsv-attest.rb' + - 'lib/bsv-x402.rb' -# Cryptographic code uses standard short names (r, s, x, y, n, m, bn, etc.) +# Cryptographic and protocol code uses standard short names (r, s, x, y, n, m, bn, v, etc.) Naming/MethodParameterName: AllowedNames: - r - s + - v - z - x - y @@ -41,7 +43,7 @@ Naming/PredicateMethod: - cast_bool - execute -# Cryptographic and script-parsing code is inherently complex; +# Cryptographic, script-parsing, and protocol object code is inherently complex; # splitting into tiny methods would harm readability and auditability. Metrics/AbcSize: Exclude: @@ -52,6 +54,8 @@ Metrics/AbcSize: - 'lib/bsv/wallet/**/*' - 'lib/bsv/attest.rb' - 'lib/bsv/attest/**/*' + - 'lib/bsv/x402.rb' + - 'lib/bsv/x402/**/*' - 'spec/**/*' Metrics/MethodLength: @@ -63,6 +67,8 @@ Metrics/MethodLength: - 'lib/bsv/wallet/**/*' - 'lib/bsv/attest.rb' - 'lib/bsv/attest/**/*' + - 'lib/bsv/x402.rb' + - 'lib/bsv/x402/**/*' - 'spec/**/*' Metrics/CyclomaticComplexity: @@ -74,6 +80,8 @@ Metrics/CyclomaticComplexity: - 'lib/bsv/wallet/**/*' - 'lib/bsv/attest.rb' - 'lib/bsv/attest/**/*' + - 'lib/bsv/x402.rb' + - 'lib/bsv/x402/**/*' - 'spec/**/*' Metrics/PerceivedComplexity: @@ -85,6 +93,8 @@ Metrics/PerceivedComplexity: - 'lib/bsv/wallet/**/*' - 'lib/bsv/attest.rb' - 'lib/bsv/attest/**/*' + - 'lib/bsv/x402.rb' + - 'lib/bsv/x402/**/*' - 'spec/**/*' Metrics/ModuleLength: @@ -101,12 +111,14 @@ Metrics/ClassLength: - 'lib/bsv/transaction/transaction.rb' - 'lib/bsv/transaction/merkle_path.rb' - 'lib/bsv/transaction/beef.rb' + - 'lib/bsv/x402/**/*' Metrics/ParameterLists: Exclude: - 'lib/bsv/primitives/**/*' - 'lib/bsv/script/**/*' - 'lib/bsv/transaction/**/*' + - 'lib/bsv/x402/**/*' # The interpreter condition stack uses :true/:false symbols intentionally # (matching the Go SDK pattern) to distinguish from boolean values. @@ -134,6 +146,7 @@ RSpec/MultipleExpectations: - 'spec/bsv/wallet/**/*' - 'spec/bsv/attest_spec.rb' - 'spec/bsv/attest/**/*' + - 'spec/x402/**/*' - 'spec/integration/**/*' - 'spec/conformance/**/*' @@ -142,6 +155,7 @@ RSpec/MultipleMemoizedHelpers: - 'spec/bsv/primitives/**/*' - 'spec/bsv/script/**/*' - 'spec/bsv/transaction/**/*' + - 'spec/x402/**/*' - 'spec/conformance/**/*' RSpec/ExampleLength: @@ -153,6 +167,7 @@ RSpec/ExampleLength: - 'spec/bsv/wallet/**/*' - 'spec/bsv/attest_spec.rb' - 'spec/bsv/attest/**/*' + - 'spec/x402/**/*' - 'spec/integration/**/*' - 'spec/conformance/**/*' @@ -170,6 +185,7 @@ RSpec/SpecFilePathFormat: - 'spec/bsv/primitives/shamir/**/*' - 'spec/bsv/transaction/benford_number/**/*' - 'spec/conformance/**/*' + - 'spec/x402/**/*' # Vector and conformance specs use string describes for cross-cutting test suites. RSpec/DescribeClass: diff --git a/Gemfile b/Gemfile index 1a81948..48049d8 100644 --- a/Gemfile +++ b/Gemfile @@ -4,6 +4,7 @@ source 'https://rubygems.org' gemspec name: 'bsv-sdk' gemspec name: 'bsv-attest' +gemspec name: 'bsv-x402' group :development, :test do gem 'rake' diff --git a/Rakefile b/Rakefile index 17b27e3..5b3c87f 100644 --- a/Rakefile +++ b/Rakefile @@ -2,6 +2,7 @@ Bundler::GemHelper.install_tasks(name: 'bsv-sdk') Bundler::GemHelper.install_tasks(name: 'bsv-attest') +Bundler::GemHelper.install_tasks(name: 'bsv-x402') require 'rspec/core/rake_task' RSpec::Core::RakeTask.new(:spec) diff --git a/bsv-x402.gemspec b/bsv-x402.gemspec new file mode 100644 index 0000000..26428fd --- /dev/null +++ b/bsv-x402.gemspec @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require_relative 'lib/bsv/x402/version' + +Gem::Specification.new do |spec| + spec.name = 'bsv-x402' + spec.version = BSV::X402::VERSION + spec.authors = ['Simon Bettison'] + + spec.summary = 'BSV x402 Payment Required protocol middleware' + spec.description = 'Rack middleware and client library implementing the x402 HTTP payment ' \ + 'protocol for BSV blockchain micropayments.' + spec.homepage = 'https://github.com/sgbett/bsv-ruby-sdk' + spec.license = 'LicenseRef-OpenBSV' + + spec.required_ruby_version = '>= 2.7' + + spec.metadata = { + 'homepage_uri' => spec.homepage, + 'source_code_uri' => spec.homepage, + 'changelog_uri' => "#{spec.homepage}/blob/master/CHANGELOG.md", + 'rubygems_mfa_required' => 'true' + } + + spec.files = Dir.glob('lib/bsv/x402{.rb,/**/*}') + %w[lib/bsv-x402.rb LICENCE] + spec.require_paths = ['lib'] + + spec.add_dependency 'base64' + spec.add_dependency 'bsv-sdk' + spec.add_dependency 'rack', '>= 2.0' +end diff --git a/examples/x402_client.rb b/examples/x402_client.rb new file mode 100644 index 0000000..d69ca5c --- /dev/null +++ b/examples/x402_client.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +# x402_client.rb +# +# Minimal client demonstrating the BSV::X402::Client auto-pay flow. +# +# This example is illustrative — it does not make real network requests. +# Substitute real UTXOs, a signing key, and an HTTP endpoint to use live. +# +# Usage: +# bundle exec ruby examples/x402_client.rb + +require 'bsv-x402' + +# --------------------------------------------------------------------------- +# 1. Client key material +# +# The client needs: +# - One or more funding UTXOs (to cover the payment amount plus miner fee) +# - A nonce unlocker: a callable that signs the nonce input issued by +# the server in its challenge. +# - Optionally, a change locking script to receive unspent funds back. +# --------------------------------------------------------------------------- +client_key = BSV::Primitives::PrivateKey.generate +client_script = BSV::Script::Script.p2pkh_lock(client_key.public_key.hash160) + +# The nonce UTXO locking script is server-controlled. The server issues a +# nonce locked to a key it owns, and the client must spend it. The +# nonce_unlocker callable receives the partially-built transaction and the +# input index, and must return the unlocking script for that input. +# +# When the server locks the nonce to the *client's* key (a common pattern), +# the client signs with P2PKH: +nonce_key = BSV::Primitives::PrivateKey.generate # matches nonce UTXO locking script +nonce_unlocker = lambda do |tx, idx| + BSV::Transaction::P2PKH.new(nonce_key).sign(tx, idx) +end + +# Funding UTXOs — each element is a plain Hash describing a spendable output. +# In production, fetch these from your wallet or UTXO index. +funding_utxos = [ + { + txid: 'ab' * 32, # transaction ID (hex, big-endian display order) + vout: 0, # output index + satoshis: 10_000, # value in satoshis + locking_script_hex: client_script.to_hex, + private_key: client_key # key to sign this input + } +] + +# Change — any surplus after payment + fee is returned here. +change_key = BSV::Primitives::PrivateKey.generate +change_script_hex = BSV::Script::Script.p2pkh_lock(change_key.public_key.hash160).to_hex + +# --------------------------------------------------------------------------- +# 2. Optional broadcaster +# +# Set a broadcaster if you want the payment transaction broadcast to the +# BSV network before the proof is submitted. The broadcaster must respond +# to #broadcast(transaction). +# +# When broadcaster is nil (the default), the transaction is embedded in +# the proof but not broadcast independently. The server receives the raw +# bytes via the proof and may broadcast it itself. +# --------------------------------------------------------------------------- +broadcaster = nil # replace with BSV::Network::ARC.new(...) or similar + +# --------------------------------------------------------------------------- +# 3. Construct the client +# --------------------------------------------------------------------------- +client = BSV::X402::Client.new( + funding_utxos: funding_utxos, + nonce_unlocker: nonce_unlocker, + change_locking_script_hex: change_script_hex, + broadcaster: broadcaster, + + # max_retries controls how many payment attempts the client makes if the + # server keeps returning 402 (e.g. stale challenge). Default is 1. + max_retries: 1, + + # auto_pay: false disables automatic payment — the client returns the raw + # 402 response to the caller instead of paying. + auto_pay: true +) + +# --------------------------------------------------------------------------- +# 4. Make a request — the client handles the 402 flow automatically. +# +# Flow: +# a. GET /api/premium +# b. Server returns 402 + X402-Challenge header +# c. Client decodes the challenge, builds the payment transaction, +# optionally broadcasts it, builds the proof, and retries. +# d. GET /api/premium with X402-Proof header +# e. Server verifies the proof, returns 200 (or another 402 on failure) +# --------------------------------------------------------------------------- +begin + status, _headers, body = client.get('http://localhost:9292/api/premium') + puts "Status: #{status}" + puts "Body: #{body}" +rescue BSV::X402::ChallengeExpiredError => e + # The server issued an already-expired challenge — retry the request. + puts "Challenge expired: #{e.message}" +rescue BSV::X402::InsufficientFundsError => e + # Not enough satoshis in the funding UTXOs to cover payment + fee. + puts "Insufficient funds: #{e.message}" +rescue BSV::X402::PaymentRetryExhaustedError => e + # The client tried max_retries times and the server kept returning 402. + puts "Max retries exceeded: #{e.message}" +end + +# --------------------------------------------------------------------------- +# 5. POST request with a body +# +# The request body is SHA-256 hashed and bound to the challenge, so the +# server can verify the client paid for exactly this request. +# --------------------------------------------------------------------------- +begin + payload = '{"city":"lisbon"}' + status, _headers, body = client.post( + 'http://localhost:9292/api/data', + body: payload, + headers: { 'content-type' => 'application/json' } + ) + puts "POST Status: #{status}" + puts "POST Body: #{body}" +rescue BSV::X402::Error => e + puts "x402 error: #{e.class}: #{e.message}" +end diff --git a/examples/x402_rack_app.rb b/examples/x402_rack_app.rb new file mode 100644 index 0000000..a72592e --- /dev/null +++ b/examples/x402_rack_app.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +# x402_rack_app.rb +# +# Minimal Rack application demonstrating BSV::X402::Middleware. +# +# This example is illustrative — it does not require a live BSV node or +# real UTXOs. Substitute your own nonce provider and payee key to deploy. +# +# Run with: +# gem install rack bsv-sdk +# bundle exec rackup examples/x402_rack_app.rb -p 9292 + +require 'bsv-x402' +require 'rack' +require 'json' + +# --------------------------------------------------------------------------- +# 1. Generate a payee key (server-side — holds the private key for the +# locking script that clients must pay). +# In production, load this from a keystore, not generated at startup. +# --------------------------------------------------------------------------- +payee_key = BSV::Primitives::PrivateKey.generate +payee_script_hex = BSV::Script::Script.p2pkh_lock(payee_key.public_key.hash160).to_hex + +# --------------------------------------------------------------------------- +# 2. Nonce provider — responsible for issuing fresh UTXOs that clients must +# spend in their payment transactions to prevent replay. +# +# StaticNonceProvider is provided for development/testing only. +# In production, use a database-backed provider that atomically reserves +# one UTXO per request and marks it spent after verification. +# --------------------------------------------------------------------------- +nonce_key = BSV::Primitives::PrivateKey.generate +nonce_utxo = BSV::X402::NonceUTXO.new( + txid: 'aa' * 32, # replace with a real on-chain txid + vout: 0, + satoshis: 10, + locking_script_hex: BSV::Script::Script.p2pkh_lock(nonce_key.public_key.hash160).to_hex +) +nonce_provider = BSV::X402::StaticNonceProvider.new(nonce_utxo) + +# --------------------------------------------------------------------------- +# 3. Global x402 configuration — applies to every protected route unless +# a per-route override is supplied in the middleware DSL block. +# --------------------------------------------------------------------------- +BSV::X402.configure do |config| + config.payee_locking_script_hex = payee_script_hex + config.amount_sats = 100 # default price per request (satoshis) + config.expires_in = 300 # challenge validity window (seconds) + config.nonce_provider = nonce_provider + config.bound_headers = [] # no extra headers bound by default + + # Optional: fire a callback after each successful payment. + config.on_payment_verified = lambda do |_env, result| + puts "[x402] payment verified — txid: #{result&.step || 'n/a'}" + end + + # Optional: verify mempool acceptance before responding (requires a node). + # config.settlement_verifier = ->(raw_tx_bytes) { MyNode.test_mempool_accept(raw_tx_bytes) } +end + +# --------------------------------------------------------------------------- +# 4. Inner Rack application — the content behind the paywall. +# --------------------------------------------------------------------------- +inner_app = lambda do |env| + request = Rack::Request.new(env) + body = JSON.generate( + path: request.path, + message: 'You have paid — here is the premium content.', + timestamp: Time.now.to_i + ) + [200, { 'content-type' => 'application/json' }, [body]] +end + +# --------------------------------------------------------------------------- +# 5. Wrap the inner app with x402 middleware. +# +# Routes registered with #protect are gated. All other paths pass through. +# Per-route :amount_sats overrides the global default. +# --------------------------------------------------------------------------- +app = BSV::X402::Middleware.new(inner_app) do |x402| + x402.protect '/api/premium', amount_sats: 100 # 100 satoshis + x402.protect '/api/data/*', amount_sats: 50 # 50 sats for any /api/data/ sub-path + x402.protect '/api/expensive', amount_sats: 1_000 # premium endpoint + # /health and all other paths are unprotected. +end + +# --------------------------------------------------------------------------- +# 6. Expose the Rack app (used by rackup / Rack::Handler). +# --------------------------------------------------------------------------- +run app diff --git a/lib/bsv-x402.rb b/lib/bsv-x402.rb new file mode 100644 index 0000000..af477bb --- /dev/null +++ b/lib/bsv-x402.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +require 'bsv-sdk' +require_relative 'bsv/x402' diff --git a/lib/bsv/x402.rb b/lib/bsv/x402.rb new file mode 100644 index 0000000..38589ee --- /dev/null +++ b/lib/bsv/x402.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module BSV + module X402 + autoload :Configuration, 'bsv/x402/configuration' + autoload :Challenge, 'bsv/x402/challenge' + autoload :CanonicalJSON, 'bsv/x402/canonical_json' + autoload :Encoding, 'bsv/x402/encoding' + autoload :Error, 'bsv/x402/errors' + autoload :EncodingError, 'bsv/x402/errors' + autoload :ValidationError, 'bsv/x402/errors' + autoload :InsufficientFundsError, 'bsv/x402/errors' + autoload :ChallengeExpiredError, 'bsv/x402/errors' + autoload :PaymentRetryExhaustedError, 'bsv/x402/errors' + autoload :NonceUTXO, 'bsv/x402/nonce_utxo' + autoload :Proof, 'bsv/x402/proof' + autoload :RequestBinding, 'bsv/x402/request_binding' + autoload :VerificationResult, 'bsv/x402/verification_result' + autoload :Verifier, 'bsv/x402/verifier' + autoload :TransactionBuilder, 'bsv/x402/transaction_builder' + autoload :ProofBuilder, 'bsv/x402/proof_builder' + autoload :NonceProvider, 'bsv/x402/nonce_provider' + autoload :StaticNonceProvider, 'bsv/x402/nonce_provider' + autoload :ChallengeStore, 'bsv/x402/challenge_store' + autoload :MemoryChallengeStore, 'bsv/x402/challenge_store' + autoload :RouteMap, 'bsv/x402/route_map' + autoload :ChallengeGenerator, 'bsv/x402/challenge_generator' + autoload :Middleware, 'bsv/x402/middleware' + autoload :Client, 'bsv/x402/client' + autoload :VERSION, 'bsv/x402/version' + + class << self + def configuration + @configuration ||= Configuration.new + end + + def configure + yield(configuration) + end + + def reset_configuration! + @configuration = Configuration.new + end + end + end +end diff --git a/lib/bsv/x402/canonical_json.rb b/lib/bsv/x402/canonical_json.rb new file mode 100644 index 0000000..1f7826c --- /dev/null +++ b/lib/bsv/x402/canonical_json.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require 'json' + +module BSV + module X402 + # Minimal RFC 8785 (JSON Canonicalisation Scheme) encoder. + # + # x402 challenge and proof objects contain only integers, booleans, strings, + # and nested hashes — no floats, no arrays at the top level. This + # implementation covers precisely those types. If a future spec version + # introduces floats, this must be revisited. + # + # Produces byte-for-byte identical output to any compliant RFC 8785 + # implementation for the same input, provided the input contains only the + # supported types. + module CanonicalJSON + # Encodes +value+ to a canonical JSON string per RFC 8785. + # + # Keys of all hashes are sorted in ascending byte order at every nesting + # level. Integers are emitted without decimal points. Strings are UTF-8 + # with minimal JSON escaping. Booleans and nil are emitted as their JSON + # literals. + # + # @param value [Hash, Integer, String, TrueClass, FalseClass, NilClass] + # @return [String] canonical JSON string (UTF-8, no trailing newline) + def self.encode(value) + case value + when Hash then encode_hash(value) + when Integer then value.to_s + when String then encode_string(value) + when TrueClass then 'true' + when FalseClass then 'false' + when NilClass then 'null' + else + raise EncodingError, "unsupported type for canonical JSON: #{value.class}" + end + end + + # ------------------------------------------------------------------ + # Private helpers + # ------------------------------------------------------------------ + + def self.encode_hash(hash) + # Normalise keys to strings so string and symbol keys sort identically. + normalised = hash.each_with_object({}) do |(k, v), acc| + acc[k.to_s] = v + end + + pairs = normalised.keys + .sort + .map { |k| "#{encode_string(k)}:#{encode(normalised[k])}" } + "{#{pairs.join(',')}}" + end + private_class_method :encode_hash + + # Produces a JSON-safe quoted string. Ruby's JSON library handles all + # required escape sequences (control characters, \", \\) correctly. + def self.encode_string(str) + JSON.generate(str) + end + private_class_method :encode_string + end + end +end diff --git a/lib/bsv/x402/challenge.rb b/lib/bsv/x402/challenge.rb new file mode 100644 index 0000000..3a989d9 --- /dev/null +++ b/lib/bsv/x402/challenge.rb @@ -0,0 +1,220 @@ +# frozen_string_literal: true + +require 'json' + +module BSV + module X402 + # Immutable value object representing an x402 payment challenge (spec §4). + # + # A challenge is issued by a server as an HTTP 402 response header + # (+X402-Challenge+). The client reads it, constructs a payment transaction, + # and returns the proof in the follow-up request. + # + # Construction validates all required fields. Use +Challenge.from_header+ or + # +Challenge.from_json+ for deserialisation; use +#to_header+ / +#to_json+ + # for serialisation. + # + # The +scheme+ field is validated against +bsv-tx-v1+ for production use. + # The test-vector conformance suite passes +scheme: 'x402-bsv'+ explicitly + # and uses the +skip_scheme_validation: true+ escape hatch. + class Challenge + # Normative scheme identifier for BSV x402 v1.0. + SCHEME = 'bsv-tx-v1' + + # Protocol version supported by this implementation. + SUPPORTED_VERSION = 1 + + # @return [Integer] protocol version (must be 1) + attr_reader :v + + # @return [String] scheme identifier + attr_reader :scheme + + # @return [String] domain the challenge was issued for + attr_reader :domain + + # @return [String] HTTP method bound to this challenge + attr_reader :method + + # @return [String] URL path bound to this challenge + attr_reader :path + + # @return [String] URL query string bound to this challenge + attr_reader :query + + # @return [String] SHA-256 hex digest of the bound request headers + attr_reader :req_headers_sha256 + + # @return [String] SHA-256 hex digest of the bound request body + attr_reader :req_body_sha256 + + # @return [Integer] required payment amount in satoshis + attr_reader :amount_sats + + # @return [String] hex-encoded locking script for the payment output + attr_reader :payee_locking_script_hex + + # @return [BSV::X402::NonceUTXO] nonce UTXO that must be spent in the payment + attr_reader :nonce_utxo + + # @return [Integer] Unix timestamp at which the challenge expires + attr_reader :expires_at + + # @return [Boolean] whether the server requires mempool acceptance before responding + attr_reader :require_mempool_accept + + def initialize( + v:, scheme:, domain:, method:, path:, query:, + req_headers_sha256:, req_body_sha256:, + amount_sats:, payee_locking_script_hex:, + nonce_utxo:, expires_at:, require_mempool_accept:, + skip_scheme_validation: false + ) + @v = v + @scheme = scheme + @domain = domain + @method = method + @path = path + @query = query + @req_headers_sha256 = req_headers_sha256 + @req_body_sha256 = req_body_sha256 + @amount_sats = amount_sats + @payee_locking_script_hex = payee_locking_script_hex + @nonce_utxo = nonce_utxo + @expires_at = expires_at + @require_mempool_accept = require_mempool_accept + + validate!(skip_scheme_validation: skip_scheme_validation) + freeze + end + + # Parses a +Challenge+ from a JSON string. + # + # @param json_string [String] + # @param skip_scheme_validation [Boolean] bypass scheme check (test vectors only) + # @return [Challenge] + # @raise [BSV::X402::EncodingError] if the JSON is malformed + # @raise [BSV::X402::ValidationError] if required fields are missing or invalid + def self.from_json(json_string, skip_scheme_validation: false) + hash = JSON.parse(json_string) + from_hash(hash, skip_scheme_validation: skip_scheme_validation) + rescue JSON::ParserError => e + raise EncodingError, "invalid JSON in challenge: #{e.message}" + end + + # Decodes a +Challenge+ from a base64url-encoded header value. + # + # @param base64url_string [String] value of the +X402-Challenge+ header + # @param skip_scheme_validation [Boolean] bypass scheme check (test vectors only) + # @return [Challenge] + # @raise [BSV::X402::EncodingError] if decoding or JSON parsing fails + # @raise [BSV::X402::ValidationError] if required fields are missing or invalid + def self.from_header(base64url_string, skip_scheme_validation: false) + json_bytes = Encoding.base64url_decode(base64url_string) + from_json(json_bytes, skip_scheme_validation: skip_scheme_validation) + end + + # Returns the canonical JSON representation (RFC 8785) of this challenge. + # + # @return [String] + def to_json(*_args) + CanonicalJSON.encode(to_hash) + end + + # Returns the base64url encoding of the canonical JSON, suitable for use + # as the +X402-Challenge+ HTTP header value. + # + # @return [String] + def to_header + Encoding.base64url_encode(to_json) + end + + # Returns the SHA-256 hex digest of the canonical JSON bytes. + # This is the value the client stores in +proof.challenge_sha256+. + # + # @return [String] lowercase hex string + def sha256 + BSV::Primitives::Digest.sha256(to_json).unpack1('H*') + end + + # Returns +true+ if the challenge has expired. + # + # @param now [Integer] current Unix timestamp (defaults to +Time.now.to_i+) + # @return [Boolean] + def expired?(now = Time.now.to_i) + now > expires_at + end + + # Returns a plain hash with string keys, matching the canonical field order. + # + # @return [Hash] + def to_hash + { + 'amount_sats' => amount_sats, + 'domain' => domain, + 'expires_at' => expires_at, + 'method' => method, + 'nonce_utxo' => nonce_utxo.to_hash, + 'path' => path, + 'payee_locking_script_hex' => payee_locking_script_hex, + 'query' => query, + 'req_body_sha256' => req_body_sha256, + 'req_headers_sha256' => req_headers_sha256, + 'require_mempool_accept' => require_mempool_accept, + 'scheme' => scheme, + 'v' => v + } + end + + private + + def validate!(skip_scheme_validation: false) + required_string_fields.each do |field| + value = send(field) + raise ValidationError, "challenge missing required field: #{field}" if value.nil? || value.to_s.empty? + end + + raise ValidationError, "challenge.v must be #{SUPPORTED_VERSION}" unless v == SUPPORTED_VERSION + raise ValidationError, "challenge.scheme must be '#{SCHEME}'" unless skip_scheme_validation || scheme == SCHEME + raise ValidationError, 'challenge.amount_sats must be a positive integer' unless amount_sats.is_a?(Integer) && amount_sats.positive? + raise ValidationError, 'challenge.expires_at must be an integer' unless expires_at.is_a?(Integer) + raise ValidationError, 'challenge.nonce_utxo must be a NonceUTXO' unless nonce_utxo.is_a?(NonceUTXO) + end + + def required_string_fields + %i[scheme domain method path req_headers_sha256 req_body_sha256 payee_locking_script_hex] + end + + def self.from_hash(hash, skip_scheme_validation: false) + nonce_hash = fetch(hash, 'nonce_utxo') + raise ValidationError, 'challenge missing required field: nonce_utxo' unless nonce_hash.is_a?(Hash) + + new( + v: fetch(hash, 'v'), + scheme: fetch(hash, 'scheme'), + domain: fetch(hash, 'domain'), + method: fetch(hash, 'method'), + path: fetch(hash, 'path'), + query: fetch(hash, 'query'), + req_headers_sha256: fetch(hash, 'req_headers_sha256'), + req_body_sha256: fetch(hash, 'req_body_sha256'), + amount_sats: fetch(hash, 'amount_sats'), + payee_locking_script_hex: fetch(hash, 'payee_locking_script_hex'), + nonce_utxo: NonceUTXO.from_hash(nonce_hash), + expires_at: fetch(hash, 'expires_at'), + require_mempool_accept: fetch(hash, 'require_mempool_accept'), + skip_scheme_validation: skip_scheme_validation + ) + end + private_class_method :from_hash + + def self.fetch(hash, key) + return hash[key] if hash.key?(key) + return hash[key.to_sym] if hash.key?(key.to_sym) + + raise ValidationError, "challenge missing required field: #{key}" + end + private_class_method :fetch + end + end +end diff --git a/lib/bsv/x402/challenge_generator.rb b/lib/bsv/x402/challenge_generator.rb new file mode 100644 index 0000000..f0c5f46 --- /dev/null +++ b/lib/bsv/x402/challenge_generator.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +require 'openssl' +require 'rack' + +module BSV + module X402 + # Builds a {Challenge} from a Rack request environment and configuration. + # + # The generator: + # 1. Extracts the HTTP method, path, query string from the Rack env. + # 2. Computes the request-binding hashes for the configured bound headers + # and the request body (streaming SHA-256 to avoid buffering large bodies). + # 3. Calls +config.nonce_provider.reserve_nonce+ to obtain a fresh nonce + # UTXO (invariant N-3: the provider is responsible for atomic reservation). + # 4. Constructs and returns a frozen {Challenge}. + # + # The Rack body (+rack.input+) is read, hashed, then rewound so that + # downstream middleware and the application can still read it. + module ChallengeGenerator + # Generates a {Challenge} from a Rack env hash and configuration. + # + # @param env [Hash] Rack environment hash + # @param config [Configuration] effective configuration (may be per-route merged) + # @param route_options [Hash] per-route option overrides (e.g. +:amount_sats+) + # @return [Challenge] + # @raise [BSV::X402::ValidationError] if configuration is incomplete + def self.generate(env:, config:, route_options: {}) + request = Rack::Request.new(env) + + http_method = env['REQUEST_METHOD'].to_s + path = env['PATH_INFO'].to_s + query = (env['QUERY_STRING'] || '').to_s + + headers_hash = compute_headers_hash(env, config.bound_headers) + body_hash = compute_body_hash(request) + + nonce_utxo = reserve_nonce(config) + amount = route_options.fetch(:amount_sats, config.amount_sats) + domain = extract_domain(env) + expires_at = Time.now.to_i + config.expires_in + + Challenge.new( + v: Challenge::SUPPORTED_VERSION, + scheme: Challenge::SCHEME, + domain: domain, + method: http_method, + path: path, + query: query, + req_headers_sha256: headers_hash, + req_body_sha256: body_hash, + amount_sats: amount, + payee_locking_script_hex: config.payee_locking_script_hex, + nonce_utxo: nonce_utxo, + expires_at: expires_at, + require_mempool_accept: false + ) + end + + # ------------------------------------------------------------------ + # Private helpers + # ------------------------------------------------------------------ + + # Extracts the host (domain) from the Rack env. + # + # Uses +HTTP_HOST+ if present, falling back to +SERVER_NAME+. + # + # @param env [Hash] + # @return [String] + def self.extract_domain(env) + (env['HTTP_HOST'] || env['SERVER_NAME'] || '').to_s + end + private_class_method :extract_domain + + # Computes the SHA-256 hex digest of the bound request headers. + # + # Only the headers listed in +bound_header_names+ are included. + # Rack stores HTTP headers as +HTTP_HEADER_NAME+ (uppercase, underscores). + # We normalise to lowercase hyphenated names before hashing. + # + # @param env [Hash] Rack environment + # @param bound_header_names [Array] header names to bind (e.g. 'content-type') + # @return [String] lowercase hex SHA-256 digest + def self.compute_headers_hash(env, bound_header_names) + selected = {} + Array(bound_header_names).each do |name| + rack_key = "HTTP_#{name.upcase.tr('-', '_')}" + value = env[rack_key] + selected[name.downcase] = value.to_s if value + end + + RequestBinding.compute_headers_hash(selected) + end + private_class_method :compute_headers_hash + + # Computes the SHA-256 hex digest of the request body. + # + # Reads from +rack.input+, hashes the bytes, then rewinds the IO so + # downstream middleware and the application can still read the body. + # Handles non-rewindable bodies gracefully by catching the error. + # + # @param request [Rack::Request] + # @return [String] lowercase hex SHA-256 digest + def self.compute_body_hash(request) + input = request.env['rack.input'] + return RequestBinding.compute_body_hash(nil) unless input + + digest = OpenSSL::Digest.new('SHA256') + buf = String.new + digest.update(buf) while input.read(16_384, buf) && !buf.empty? + + begin + input.rewind + rescue IOError + # Non-rewindable input (e.g. pipe) — body has been consumed but we + # cannot restore it. Downstream reads will return empty; this is an + # acceptable trade-off for streaming bodies in a payment gateway. + end + + digest.hexdigest + end + private_class_method :compute_body_hash + + # Obtains a fresh nonce UTXO from the configured provider. + # + # Accepts a +NonceUTXO+ directly (already-constructed value object) or + # any object responding to +reserve_nonce+ (returns a +NonceUTXO+ or + # a compatible hash). + # + # @param config [Configuration] + # @return [NonceUTXO] + # @raise [BSV::X402::ValidationError] if no nonce provider is configured + def self.reserve_nonce(config) + provider = config.nonce_provider + raise ValidationError, 'no nonce_provider configured' unless provider + + result = provider.reserve_nonce + return result if result.is_a?(NonceUTXO) + + NonceUTXO.from_hash(result) + end + private_class_method :reserve_nonce + end + end +end diff --git a/lib/bsv/x402/challenge_store.rb b/lib/bsv/x402/challenge_store.rb new file mode 100644 index 0000000..39d4387 --- /dev/null +++ b/lib/bsv/x402/challenge_store.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +module BSV + module X402 + # Duck-type interface for challenge storage backends. + # + # A challenge store persists issued {Challenge} objects so they can be + # retrieved during proof verification. The key used for storage and retrieval + # is the challenge SHA-256 hex digest (+challenge.sha256+), which the client + # copies into +proof.challenge_sha256+. + # + # Implementations MUST ensure that: + # - +store+ is called at most once per SHA-256 key (challenges are immutable) + # - +fetch+ returns +nil+ for unknown keys rather than raising + # - Thread safety is the responsibility of the implementation + # + # Include this module to document intent; the default +store+/+fetch+ raise + # +NotImplementedError+ if not overridden. + module ChallengeStore + # Persists a challenge. + # + # @param sha256 [String] hex SHA-256 digest of the canonical challenge JSON + # @param challenge [Challenge] + def store(sha256, challenge) + raise NotImplementedError, "#{self.class}#store is not implemented" + end + + # Retrieves a previously stored challenge. + # + # @param sha256 [String] hex SHA-256 digest + # @return [Challenge, nil] the stored challenge, or nil if not found + def fetch(sha256) + raise NotImplementedError, "#{self.class}#fetch is not implemented" + end + end + + # Thread-safe in-memory challenge store. + # + # Suitable for single-process deployments and testing. For multi-process or + # distributed deployments, replace with a Redis- or database-backed store. + # + # Challenges are not automatically expired from the store; they remain until + # the process restarts. For long-running processes, consider a store with TTL + # support to prevent unbounded memory growth. + class MemoryChallengeStore + include ChallengeStore + + def initialize + @store = {} + @mutex = Mutex.new + end + + # Stores a challenge keyed by its SHA-256 digest. + # + # @param sha256 [String] + # @param challenge [Challenge] + def store(sha256, challenge) + @mutex.synchronize { @store[sha256] = challenge } + end + + # Retrieves a challenge by its SHA-256 digest. + # + # @param sha256 [String] + # @return [Challenge, nil] + def fetch(sha256) + @mutex.synchronize { @store[sha256] } + end + + # Returns the number of stored challenges. + # + # @return [Integer] + def size + @mutex.synchronize { @store.size } + end + + # Removes all stored challenges. Useful for testing. + def clear + @mutex.synchronize { @store.clear } + end + end + end +end diff --git a/lib/bsv/x402/client.rb b/lib/bsv/x402/client.rb new file mode 100644 index 0000000..b3778df --- /dev/null +++ b/lib/bsv/x402/client.rb @@ -0,0 +1,222 @@ +# frozen_string_literal: true + +require 'net/http' +require 'uri' + +module BSV + module X402 + # HTTP client wrapper that automatically handles x402 Payment Required + # responses. + # + # When a request returns HTTP 402, the client: + # 1. Decodes the +X402-Challenge+ header to obtain the payment challenge. + # 2. Builds a payment transaction using {TransactionBuilder}. + # 3. Broadcasts the transaction via the configured broadcaster (if any). + # 4. Builds a {Proof} and encodes it as the +X402-Proof+ header. + # 5. Retries the original request with the proof attached. + # 6. Returns the response from the retry. + # + # == HTTP client injection + # + # The client is HTTP-library agnostic. By default it uses +Net::HTTP+. A + # custom callable may be injected via the +http_client:+ parameter: + # + # my_http = ->(method, url, headers, body) { [status, headers, body] } + # client = BSV::X402::Client.new(http_client: my_http, ...) + # + # == Funding UTXOs + # + # Each element in +funding_utxos+ must be a hash with: + # { txid:, vout:, satoshis:, locking_script_hex:, private_key: } + # + # == Nonce unlocker + # + # The nonce UTXO is server-issued and must be spent in the payment + # transaction. The +nonce_unlocker+ parameter is a callable that returns + # the unlocking script for the nonce input: + # + # nonce_unlocker = ->(tx, idx) { BSV::Transaction::P2PKH.new(key).sign(tx, idx) } + # + # == Configuration + # + # The broadcaster is read from +BSV::X402.configuration.broadcaster+ unless + # overridden in the constructor. + class Client + # Default maximum number of payment attempts per request. + DEFAULT_MAX_RETRIES = 1 + + # @return [Integer] configured maximum retry attempts + attr_reader :max_retries + + # @return [Boolean] whether auto-pay is enabled + attr_reader :auto_pay + + # @param funding_utxos [Array] UTXOs for payment funding + # @param nonce_unlocker [#call] callable that unlocks the nonce UTXO input + # @param change_locking_script_hex [String, nil] change output script + # @param broadcaster [#broadcast, nil] broadcaster to use (overrides config) + # @param http_client [#call, nil] injectable HTTP callable; defaults to Net::HTTP + # @param max_retries [Integer] maximum payment retries (default: 1) + # @param auto_pay [Boolean] whether to auto-pay 402 responses (default: true) + def initialize( + funding_utxos:, + nonce_unlocker:, + change_locking_script_hex: nil, + broadcaster: :use_config, + http_client: nil, + max_retries: DEFAULT_MAX_RETRIES, + auto_pay: true + ) + @funding_utxos = funding_utxos + @nonce_unlocker = nonce_unlocker + @change_locking_script_hex = change_locking_script_hex + @broadcaster = broadcaster == :use_config ? BSV::X402.configuration.broadcaster : broadcaster + @http_client = http_client || method(:default_http_request) + @max_retries = max_retries + @auto_pay = auto_pay + end + + # Make a GET request, automatically handling 402 responses. + # + # @param url [String] the URL to request + # @param headers [Hash] additional request headers + # @return [Array] +[status, headers, body]+ + def get(url, headers: {}) + request('GET', url, headers: headers) + end + + # Make a POST request, automatically handling 402 responses. + # + # @param url [String] the URL to request + # @param body [String, nil] request body + # @param headers [Hash] additional request headers + # @return [Array] +[status, headers, body]+ + def post(url, body: nil, headers: {}) + request('POST', url, headers: headers, body: body) + end + + # Make an HTTP request, automatically handling 402 Payment Required. + # + # @param method [String] HTTP method (e.g. 'GET', 'POST') + # @param url [String] full URL + # @param headers [Hash] request headers + # @param body [String, nil] request body + # @return [Array] +[status, headers, body]+ from the final response + # @raise [ChallengeExpiredError] if the challenge has already expired + # @raise [InsufficientFundsError] if funding UTXOs cannot cover the payment + def request(method, url, headers: {}, body: nil) + status, resp_headers, resp_body = @http_client.call(method, url, headers, body) + + return [status, resp_headers, resp_body] unless status == 402 + return [status, resp_headers, resp_body] unless @auto_pay + + handle_payment(method, url, headers, body, resp_headers, attempts: 0) + end + + private + + # Handle a 402 response by building a payment and retrying. + # + # @param method [String] + # @param url [String] + # @param req_headers [Hash] original request headers + # @param body [String, nil] original request body + # @param resp_headers [Hash] headers from the 402 response + # @param attempts [Integer] number of payment attempts so far + # @return [Array] +[status, headers, body]+ + def handle_payment(method, url, req_headers, body, resp_headers, attempts:) + raise PaymentRetryExhaustedError, "max retries (#{@max_retries}) exceeded" if attempts >= @max_retries + + challenge_header = header_value(resp_headers, 'x402-challenge') + raise ValidationError, 'server returned 402 without X402-Challenge header' if challenge_header.nil? + + challenge = Challenge.from_header(challenge_header) + + # Build the request-binding fields for the proof. + request_info = build_request_info(method, url, req_headers, body) + + # Build and broadcast the payment transaction. + tx = TransactionBuilder.build( + challenge: challenge, + funding_utxos: @funding_utxos, + nonce_unlocker: @nonce_unlocker, + change_locking_script_hex: @change_locking_script_hex + ) + + @broadcaster&.broadcast(tx) + + # Build the proof and encode it into the retry request headers. + proof = ProofBuilder.build( + challenge: challenge, + transaction: tx, + request: request_info + ) + + retry_headers = req_headers.merge('X402-Proof' => proof.to_header) + status, resp_headers2, resp_body2 = @http_client.call(method, url, retry_headers, body) + + return [status, resp_headers2, resp_body2] unless status == 402 + + # Another 402 — recurse with incremented attempt count. + handle_payment(method, url, retry_headers, body, resp_headers2, attempts: attempts + 1) + end + + # Build the request-binding hash for use in {ProofBuilder}. + # + # Computes the canonical hashes of the request headers and body that were + # sent with the original request. + # + # @param method [String] + # @param url [String] + # @param headers [Hash] + # @param body [String, nil] + # @return [Hash] + def build_request_info(method, url, headers, body) + uri = URI.parse(url) + { + method: method.upcase, + path: uri.path, + query: uri.query.to_s, + req_headers_sha256: RequestBinding.compute_headers_hash(headers), + req_body_sha256: RequestBinding.compute_body_hash(body) + } + end + + # Retrieve a header value case-insensitively from a response header hash. + # + # @param headers [Hash] response headers + # @param name [String] lowercase header name to look up + # @return [String, nil] the header value, or nil if not present + def header_value(headers, name) + return nil if headers.nil? + + headers.each do |key, value| + return value if key.to_s.downcase == name + end + nil + end + + # Default HTTP request implementation using Ruby's +Net::HTTP+. + # + # @param method [String] HTTP method + # @param url [String] full URL + # @param headers [Hash] request headers + # @param body [String, nil] request body + # @return [Array] +[Integer, Hash, String]+ status, headers, body + def default_http_request(method, url, headers, body) + uri = URI.parse(url) + + Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http| + req_class = Net::HTTP.const_get(method.capitalize) + req = req_class.new(uri.request_uri) + headers.each { |k, v| req[k] = v } + req.body = body if body + + response = http.request(req) + resp_headers = response.each_header.to_h + [response.code.to_i, resp_headers, response.body] + end + end + end + end +end diff --git a/lib/bsv/x402/configuration.rb b/lib/bsv/x402/configuration.rb new file mode 100644 index 0000000..f254f45 --- /dev/null +++ b/lib/bsv/x402/configuration.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module BSV + module X402 + # Configuration for the BSV x402 payment protocol middleware. + # + # Fields: + # - +payee_locking_script_hex+ — hex-encoded locking script for the payment output + # - +amount_sats+ — default payment amount in satoshis + # - +nonce_provider+ — pluggable UTXO nonce provider (must respond to +reserve_nonce+) + # - +broadcaster+ — pluggable broadcaster (must respond to +broadcast+) + # - +expires_in+ — challenge expiry window in seconds + # - +bound_headers+ — array of header names to include in request binding + # - +on_payment_verified+ — optional callable invoked after successful verification + # receives the Rack env and the VerificationResult + # - +settlement_verifier+ — optional callable for mempool acceptance check + # receives raw transaction bytes, returns truthy value + # - +challenge_store+ — object responding to +store(sha256, challenge)+ and + # +fetch(sha256)+ for persisting issued challenges. + # Defaults to a new +MemoryChallengeStore+. + class Configuration + attr_accessor :payee_locking_script_hex, + :amount_sats, + :nonce_provider, + :broadcaster, + :expires_in, + :bound_headers, + :on_payment_verified, + :settlement_verifier, + :challenge_store + + def initialize + @payee_locking_script_hex = nil + @amount_sats = 100 + @nonce_provider = nil + @broadcaster = nil + @expires_in = 300 + @bound_headers = [] + @on_payment_verified = nil + @settlement_verifier = nil + @challenge_store = MemoryChallengeStore.new + end + end + end +end diff --git a/lib/bsv/x402/encoding.rb b/lib/bsv/x402/encoding.rb new file mode 100644 index 0000000..cfe1bf0 --- /dev/null +++ b/lib/bsv/x402/encoding.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require 'base64' + +module BSV + module X402 + # Encoding utilities for the x402 protocol. + # + # Provides URL-safe base64 (RFC 4648 §5) without padding, used for + # X402-Challenge and X402-Proof headers, and standard base64 for + # +rawtx_b64+ (transaction bytes in proof payment). + module Encoding + # Maximum permitted header size (64 KiB). Headers larger than this are + # rejected to prevent denial-of-service via oversized inputs. + MAX_HEADER_BYTES = 65_536 + + # Encodes +data+ (String of bytes) to URL-safe base64 without padding. + # + # @param data [String] binary string + # @return [String] base64url-encoded string + def self.base64url_encode(data) + Base64.urlsafe_encode64(data, padding: false) + end + + # Decodes a URL-safe base64 string (with or without padding). + # + # @param str [String] + # @return [String] decoded binary string + # @raise [BSV::X402::EncodingError] if +str+ exceeds 64 KiB or is malformed + def self.base64url_decode(str) + raise EncodingError, 'header exceeds 64 KiB size limit' if str.bytesize > MAX_HEADER_BYTES + + # Ruby's strict decoder rejects padding errors; add padding if absent. + padded = add_base64_padding(str) + Base64.urlsafe_decode64(padded) + rescue ArgumentError => e + raise EncodingError, "invalid base64url encoding: #{e.message}" + end + + # Encodes +data+ to standard base64 (used for +rawtx_b64+). + # + # @param data [String] binary string + # @return [String] base64-encoded string (with padding) + def self.base64_encode(data) + Base64.strict_encode64(data) + end + + # Decodes a standard base64 string. + # + # @param str [String] + # @return [String] decoded binary string + # @raise [BSV::X402::EncodingError] if +str+ is malformed + def self.base64_decode(str) + Base64.strict_decode64(str) + rescue ArgumentError => e + raise EncodingError, "invalid base64 encoding: #{e.message}" + end + + # ------------------------------------------------------------------ + # Private helpers + # ------------------------------------------------------------------ + + # Adds the correct number of '=' padding characters so Ruby's decoder + # accepts strings that were encoded without padding. + def self.add_base64_padding(str) + remainder = str.length % 4 + return str if remainder.zero? + + str + ('=' * (4 - remainder)) + end + private_class_method :add_base64_padding + end + end +end diff --git a/lib/bsv/x402/errors.rb b/lib/bsv/x402/errors.rb new file mode 100644 index 0000000..3f97979 --- /dev/null +++ b/lib/bsv/x402/errors.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module BSV + module X402 + # Base error class for all BSV x402 errors. + class Error < StandardError; end + + # Raised when base64url or JSON decoding fails. + class EncodingError < Error; end + + # Raised when a protocol object fails field-level validation. + class ValidationError < Error; end + + # Raised when the client's UTXOs cannot cover the required payment plus fee. + class InsufficientFundsError < Error; end + + # Raised when the challenge has already expired before the client can pay. + class ChallengeExpiredError < Error; end + + # Raised when the client has exhausted its configured payment retry budget. + class PaymentRetryExhaustedError < Error; end + end +end diff --git a/lib/bsv/x402/middleware.rb b/lib/bsv/x402/middleware.rb new file mode 100644 index 0000000..6df2126 --- /dev/null +++ b/lib/bsv/x402/middleware.rb @@ -0,0 +1,213 @@ +# frozen_string_literal: true + +require 'json' +require 'rack' + +module BSV + module X402 + # Rack middleware that enforces x402 payment requirements on protected routes. + # + # == Usage + # + # use BSV::X402::Middleware do |x402| + # x402.protect '/api/v1/premium', amount_sats: 100 + # x402.protect '/api/v1/data/*', amount_sats: 50 + # end + # + # Or with a proc: + # + # use BSV::X402::Middleware, + # protect: ->(env) { env['PATH_INFO'].start_with?('/api/premium') } + # + # == Request flow + # + # 1. If the route is unprotected, pass the request through to the inner app. + # 2. If the +X402-Proof+ header is absent, generate a {Challenge}, persist it + # in the challenge store, and return 402 with the +X402-Challenge+ header. + # 3. If the header is present but malformed, return 400. + # 4. Look up the original challenge from the store using the proof's + # +challenge_sha256+ field. If not found, return 402 (fresh challenge). + # 5. Verify the proof against the stored challenge. + # 6. On success, pass through to the inner app. + # 7. On verification failure, return the appropriate status (400 or 402). + # + # == Invariants + # + # - The middleware MUST NOT hold private keys or sign transactions (A-2). + # - Route matching is first-match-wins. + # - Exactly one +X402-Challenge+ header is set in 402 responses. + # - Challenges are persisted in +config.challenge_store+ (defaults to an + # in-process {MemoryChallengeStore}). Replace with a distributed store for + # multi-process deployments. + # + # == Configuration + # + # Global configuration is read from +BSV::X402.configuration+. Individual + # routes may override +:amount_sats+ and +:bound_headers+. + # + # The optional +:on_payment_verified+ callback (a callable) is invoked after + # successful verification, before the request is passed to the inner app. + # It receives the Rack env and the {VerificationResult}. + class Middleware + # @param app [#call] the inner Rack application + # @param options [Hash] middleware options + # @option options [#call] :protect proc-based protection (receives Rack env, returns Boolean) + # @option options [Configuration] :config overrides the global +BSV::X402.configuration+ + # @option options [#call] :on_payment_verified callback fired on successful verification + # @yield [RouteMap] optional DSL block for registering protected routes + def initialize(app, options = {}) + @app = app + @config = options.fetch(:config, BSV::X402.configuration) + @protect_proc = options[:protect] + @route_map = RouteMap.new + @on_verified = options[:on_payment_verified] || @config.on_payment_verified + + yield(@route_map) if block_given? + end + + # Entry point for the Rack call chain. + # + # @param env [Hash] Rack environment + # @return [Array(Integer, Hash, #each)] Rack response triple + def call(env) + return @app.call(env) unless protected?(env) + + proof_header = env['HTTP_X402_PROOF'] + + return issue_challenge(env) unless proof_header + + handle_proof(env, proof_header) + end + + private + + # Returns +true+ if the request should be gated by x402. + # + # Checks the proc-based guard first (if configured), then the route map. + # + # @param env [Hash] + # @return [Boolean] + def protected?(env) + return @protect_proc.call(env) if @protect_proc + + @route_map.protected?(env['PATH_INFO'].to_s) + end + + # Generates a fresh challenge, stores it, and returns a 402 response. + # + # @param env [Hash] + # @return [Array] + def issue_challenge(env) + route_options = route_options_for(env) + challenge = ChallengeGenerator.generate(env: env, config: @config, route_options: route_options) + + @config.challenge_store.store(challenge.sha256, challenge) + + header_value = challenge.to_header + + body = JSON.generate( + 'error' => 'Payment Required', + 'scheme' => challenge.scheme, + 'amount_sats' => challenge.amount_sats + ) + + [402, { + 'content-type' => 'application/json', + 'x402-challenge' => header_value + }, [body]] + rescue ValidationError => e + error_response(500, "challenge generation failed: #{e.message}") + end + + # Parses and verifies the proof header, then passes through or rejects. + # + # @param env [Hash] + # @param proof_header [String] raw value of the +X402-Proof+ header + # @return [Array] + def handle_proof(env, proof_header) + proof = Proof.from_header(proof_header) + challenge = lookup_challenge(proof.challenge_sha256) + + return issue_challenge(env) unless challenge + + request_info = build_request_info(env) + result = Verifier.verify( + challenge: challenge, + proof: proof, + request: request_info, + settlement_verifier: @config.settlement_verifier + ) + + if result.success? + @on_verified&.call(env, result) + @app.call(env) + else + verification_failure_response(result) + end + rescue EncodingError => e + error_response(400, "invalid X402-Proof header: #{e.message}") + rescue ValidationError => e + error_response(400, "invalid proof structure: #{e.message}") + end + + # Looks up a previously issued challenge from the store. + # + # Returns +nil+ if the challenge is not found (unknown or expired from store). + # + # @param sha256 [String] hex SHA-256 digest from the proof + # @return [Challenge, nil] + def lookup_challenge(sha256) + @config.challenge_store.fetch(sha256) + end + + # Builds the normalised request info hash expected by {Verifier}. + # + # @param env [Hash] + # @return [Hash] + def build_request_info(env) + request = Rack::Request.new(env) + + headers_hash = ChallengeGenerator.send(:compute_headers_hash, env, @config.bound_headers) + body_hash = ChallengeGenerator.send(:compute_body_hash, request) + + { + method: env['REQUEST_METHOD'].to_s, + path: env['PATH_INFO'].to_s, + query: (env['QUERY_STRING'] || '').to_s, + headers_sha256: headers_hash, + body_sha256: body_hash + } + end + + # Returns per-route option overrides for the matched route, or an empty hash. + # + # @param env [Hash] + # @return [Hash] + def route_options_for(env) + return {} if @protect_proc + + route = @route_map.match(env['PATH_INFO'].to_s) + route ? route.options : {} + end + + # Returns a 400/402 JSON response describing the verification failure. + # + # @param result [VerificationResult] + # @return [Array] + def verification_failure_response(result) + body = JSON.generate('error' => result.reason, 'step' => result.step) + [result.http_status, { 'content-type' => 'application/json' }, [body]] + end + + # Returns a plain JSON error response. + # + # @param status [Integer] HTTP status code + # @param message [String] + # @return [Array] + def error_response(status, message) + body = JSON.generate({ 'error' => message }) + [status, { 'content-type' => 'application/json' }, [body]] + end + end + end +end diff --git a/lib/bsv/x402/nonce_provider.rb b/lib/bsv/x402/nonce_provider.rb new file mode 100644 index 0000000..50d69b1 --- /dev/null +++ b/lib/bsv/x402/nonce_provider.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module BSV + module X402 + # Duck-type interface for nonce UTXO providers. + # + # Any object that responds to +reserve_nonce+ satisfies this interface. + # Implementations are responsible for atomically reserving a fresh nonce + # UTXO so that it cannot be issued to two clients simultaneously + # (invariant N-3). + # + # The returned hash must contain the following string or symbol keys: + # - +txid+ — transaction ID of the nonce UTXO + # - +vout+ — output index + # - +satoshis+ — value in satoshis (must be > 0) + # - +locking_script_hex+ — hex-encoded locking script + # + # Include this module in a concrete provider class to document intent. + # The module provides a default +reserve_nonce+ that raises +NotImplementedError+. + module NonceProvider + # Atomically reserves and returns a fresh nonce UTXO. + # + # @return [Hash] hash with keys: txid, vout, satoshis, locking_script_hex + # @raise [NotImplementedError] if not overridden by the including class + def reserve_nonce + raise NotImplementedError, "#{self.class}#reserve_nonce is not implemented" + end + end + + # A simple nonce provider that always returns a fixed +NonceUTXO+. + # + # Intended for testing and development. Not suitable for production use + # because the same nonce UTXO would be issued to every requester, + # violating invariant N-3 (atomic reservation). + class StaticNonceProvider + include NonceProvider + + # @param nonce_utxo [NonceUTXO] the fixed nonce UTXO to return + def initialize(nonce_utxo) + @nonce_utxo = nonce_utxo + end + + # Returns the fixed nonce UTXO on every call. + # + # @return [NonceUTXO] + def reserve_nonce + @nonce_utxo + end + end + end +end diff --git a/lib/bsv/x402/nonce_utxo.rb b/lib/bsv/x402/nonce_utxo.rb new file mode 100644 index 0000000..9079eb1 --- /dev/null +++ b/lib/bsv/x402/nonce_utxo.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +module BSV + module X402 + # Immutable value object representing the nonce UTXO embedded in a challenge. + # + # The nonce UTXO is a server-issued UTXO (invariant N-2) that the client must + # spend in the payment transaction to prove freshness and prevent replay. + # + # Invariant N-1: +satoshis+ must be greater than zero. + class NonceUTXO + # @return [String] transaction ID of the nonce UTXO + attr_reader :txid + + # @return [Integer] output index of the nonce UTXO + attr_reader :vout + + # @return [Integer] value of the nonce UTXO in satoshis (must be > 0) + attr_reader :satoshis + + # @return [String] hex-encoded locking script of the nonce UTXO + attr_reader :locking_script_hex + + # Constructs a +NonceUTXO+ from explicit field values. + # + # @raise [BSV::X402::ValidationError] if +satoshis+ is not positive + def initialize(txid:, vout:, satoshis:, locking_script_hex:) + @txid = txid + @vout = vout + @satoshis = satoshis + @locking_script_hex = locking_script_hex + + validate! + freeze + end + + # Constructs a +NonceUTXO+ from a parsed JSON hash (string or symbol keys). + # + # @param hash [Hash] + # @return [NonceUTXO] + def self.from_hash(hash) + new( + txid: fetch(hash, 'txid'), + vout: fetch(hash, 'vout'), + satoshis: fetch(hash, 'satoshis'), + locking_script_hex: fetch(hash, 'locking_script_hex') + ) + end + + # Returns a plain hash suitable for JSON serialisation. + # + # @return [Hash] + def to_hash + { + 'locking_script_hex' => locking_script_hex, + 'satoshis' => satoshis, + 'txid' => txid, + 'vout' => vout + } + end + + private + + def validate! + raise ValidationError, 'nonce_utxo.satoshis must be greater than zero' unless satoshis.is_a?(Integer) && satoshis.positive? + end + + def self.fetch(hash, key) + return hash[key] if hash.key?(key) + return hash[key.to_sym] if hash.key?(key.to_sym) + + raise ValidationError, "nonce_utxo missing required field: #{key}" + end + private_class_method :fetch + end + end +end diff --git a/lib/bsv/x402/proof.rb b/lib/bsv/x402/proof.rb new file mode 100644 index 0000000..061c1f2 --- /dev/null +++ b/lib/bsv/x402/proof.rb @@ -0,0 +1,179 @@ +# frozen_string_literal: true + +require 'json' + +module BSV + module X402 + # Immutable value object representing an x402 payment proof (spec §5). + # + # A proof is submitted by the client in the +X402-Proof+ header of a + # follow-up request. It binds the payment transaction to the original + # challenge and the specific request being made. + # + # The nested +request+ sub-object mirrors the request-binding fields + # from the challenge so the verifier can confirm they have not changed. + # The nested +payment+ sub-object carries the transaction ID and raw + # transaction bytes. + class Proof + # Normative scheme identifier for BSV x402 v1.0. + SCHEME = 'bsv-tx-v1' + + # Protocol version supported by this implementation. + SUPPORTED_VERSION = 1 + + # @return [Integer] protocol version (must be 1) + attr_reader :v + + # @return [String] scheme identifier + attr_reader :scheme + + # @return [String] SHA-256 hex digest of the canonical challenge JSON + attr_reader :challenge_sha256 + + # @return [Hash] request-binding sub-object + # Keys: +method+, +path+, +query+, +req_headers_sha256+, +req_body_sha256+ + attr_reader :request + + # @return [Hash] payment sub-object + # Keys: +txid+, +rawtx_b64+ + attr_reader :payment + + def initialize(v:, scheme:, challenge_sha256:, request:, payment:) + @v = v + @scheme = scheme + @challenge_sha256 = challenge_sha256 + @request = request.freeze + @payment = payment.freeze + + validate! + freeze + end + + # Parses a +Proof+ from a JSON string. + # + # @param json_string [String] + # @return [Proof] + # @raise [BSV::X402::EncodingError] if the JSON is malformed + # @raise [BSV::X402::ValidationError] if required fields are missing or invalid + def self.from_json(json_string) + hash = JSON.parse(json_string) + from_hash(hash) + rescue JSON::ParserError => e + raise EncodingError, "invalid JSON in proof: #{e.message}" + end + + # Decodes a +Proof+ from a base64url-encoded header value. + # + # @param base64url_string [String] value of the +X402-Proof+ header + # @return [Proof] + # @raise [BSV::X402::EncodingError] if decoding or JSON parsing fails + # @raise [BSV::X402::ValidationError] if required fields are missing or invalid + def self.from_header(base64url_string) + json_bytes = Encoding.base64url_decode(base64url_string) + from_json(json_bytes) + end + + # Returns the canonical JSON representation (RFC 8785) of this proof. + # + # @return [String] + def to_json(*_args) + CanonicalJSON.encode(to_hash) + end + + # Returns the base64url encoding of the canonical JSON, suitable for use + # as the +X402-Proof+ HTTP header value. + # + # @return [String] + def to_header + Encoding.base64url_encode(to_json) + end + + # Decodes the raw transaction bytes from +payment['rawtx_b64']+. + # The transaction is encoded as standard base64 (not base64url). + # + # @return [String] binary string of transaction bytes + # @raise [BSV::X402::EncodingError] if the base64 is malformed + def decoded_tx + Encoding.base64_decode(payment['rawtx_b64']) + end + + # Verifies that +payment['txid']+ matches the double-SHA-256 of the raw + # transaction bytes in reversed byte order (standard Bitcoin txid format). + # + # @return [Boolean] + def valid_txid? + raw = decoded_tx + hash = BSV::Primitives::Digest.sha256d(raw) + # Bitcoin txid is the double-SHA-256 hash with bytes reversed. + computed_txid = hash.reverse.unpack1('H*') + computed_txid == payment['txid'] + end + + # Returns a plain hash with string keys for JSON serialisation. + # + # @return [Hash] + def to_hash + { + 'challenge_sha256' => challenge_sha256, + 'payment' => payment, + 'request' => request, + 'scheme' => scheme, + 'v' => v + } + end + + private + + def validate! + raise ValidationError, "proof.v must be #{SUPPORTED_VERSION}" unless v == SUPPORTED_VERSION + raise ValidationError, "proof.scheme must be '#{SCHEME}'" unless scheme == SCHEME + raise ValidationError, 'proof missing required field: challenge_sha256' if challenge_sha256.nil? || challenge_sha256.empty? + + validate_request! + validate_payment! + end + + def validate_request! + raise ValidationError, 'proof missing required field: request' unless request.is_a?(Hash) + + %w[method path query req_headers_sha256 req_body_sha256].each do |field| + value = request[field] || request[field.to_sym] + raise ValidationError, "proof.request missing required field: #{field}" if value.nil? + end + end + + def validate_payment! + raise ValidationError, 'proof missing required field: payment' unless payment.is_a?(Hash) + + %w[txid rawtx_b64].each do |field| + value = payment[field] || payment[field.to_sym] + raise ValidationError, "proof.payment missing required field: #{field}" if value.nil? || value.to_s.empty? + end + end + + def self.from_hash(hash) + request_hash = fetch(hash, 'request') + payment_hash = fetch(hash, 'payment') + + normalise = ->(h) { h.transform_keys(&:to_s) } + + new( + v: fetch(hash, 'v'), + scheme: fetch(hash, 'scheme'), + challenge_sha256: fetch(hash, 'challenge_sha256'), + request: normalise.call(request_hash), + payment: normalise.call(payment_hash) + ) + end + private_class_method :from_hash + + def self.fetch(hash, key) + return hash[key] if hash.key?(key) + return hash[key.to_sym] if hash.key?(key.to_sym) + + raise ValidationError, "proof missing required field: #{key}" + end + private_class_method :fetch + end + end +end diff --git a/lib/bsv/x402/proof_builder.rb b/lib/bsv/x402/proof_builder.rb new file mode 100644 index 0000000..cefe9e6 --- /dev/null +++ b/lib/bsv/x402/proof_builder.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +module BSV + module X402 + # Builds a {Proof} from a challenge and a completed payment transaction. + # + # The proof binds the payment to the original challenge and the specific + # HTTP request that triggered the 402 response, preventing replay of the + # same proof against a different request. + # + # Usage: + # + # proof = BSV::X402::ProofBuilder.build( + # challenge: challenge, + # transaction: tx, + # request: { + # method: 'GET', + # path: '/api/resource', + # query: 'page=1', + # req_headers_sha256: headers_hash, + # req_body_sha256: body_hash + # } + # ) + # header_value = proof.to_header # => base64url string for X402-Proof + class ProofBuilder + # Build a {Proof} from a challenge and payment transaction. + # + # The +request+ hash keys may be symbols or strings. It must contain: + # +:method+ / +'method'+ + # +:path+ / +'path'+ + # +:query+ / +'query'+ + # +:req_headers_sha256+ / +'req_headers_sha256'+ + # +:req_body_sha256+ / +'req_body_sha256'+ + # + # @param challenge [Challenge] the original challenge + # @param transaction [BSV::Transaction::Transaction] the signed payment tx + # @param request [Hash] the HTTP request being made + # @return [Proof] the proof ready to be sent as the +X402-Proof+ header + def self.build(challenge:, transaction:, request:) + raw_bytes = transaction.to_binary + txid_hex = transaction.txid_hex + rawtx_b64 = Encoding.base64_encode(raw_bytes) + + # Normalise request keys to strings for the proof sub-object. + req = normalise_request(request) + + Proof.new( + v: Proof::SUPPORTED_VERSION, + scheme: Proof::SCHEME, + challenge_sha256: challenge.sha256, + request: { + 'method' => req[:method], + 'path' => req[:path], + 'query' => req[:query], + 'req_headers_sha256' => req[:req_headers_sha256], + 'req_body_sha256' => req[:req_body_sha256] + }, + payment: { + 'txid' => txid_hex, + 'rawtx_b64' => rawtx_b64 + } + ) + end + + # ------------------------------------------------------------------ + # Private helpers + # ------------------------------------------------------------------ + + # Normalise request hash to symbol keys, accepting either string or symbol + # keys from the caller. + # + # @param req [Hash] + # @return [Hash] symbol-keyed hash + def self.normalise_request(req) + { + method: (req[:method] || req['method']).to_s, + path: (req[:path] || req['path']).to_s, + query: (req[:query] || req['query']).to_s, + req_headers_sha256: (req[:req_headers_sha256] || req['req_headers_sha256']).to_s, + req_body_sha256: (req[:req_body_sha256] || req['req_body_sha256']).to_s + } + end + private_class_method :normalise_request + end + end +end diff --git a/lib/bsv/x402/request_binding.rb b/lib/bsv/x402/request_binding.rb new file mode 100644 index 0000000..253336f --- /dev/null +++ b/lib/bsv/x402/request_binding.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module BSV + module X402 + # Utilities for computing the request-binding hashes embedded in challenges + # and proofs. + # + # The request-binding mechanism ties a challenge to a specific HTTP request + # so that a proof cannot be replayed against a different request or path. + module RequestBinding + # Computes the SHA-256 hex digest for a set of HTTP headers. + # + # The canonical header string is built by: + # 1. Lowercasing all header names + # 2. Trimming leading/trailing whitespace from values + # 3. Sorting headers by name in ascending byte order + # 4. Concatenating as +name:value\n+ for each header + # + # This matches the binding format specified in the x402 implementation guide. + # + # @param headers [Hash] header name → value pairs (string or symbol keys) + # @return [String] lowercase hex SHA-256 digest + def self.compute_headers_hash(headers) + normalised = headers.map { |(name, value)| "#{name.to_s.downcase}:#{value.to_s.strip}" } + canonical = normalised.sort.map { |line| "#{line}\n" }.join + + BSV::Primitives::Digest.sha256(canonical).unpack1('H*') + end + + # Computes the SHA-256 hex digest of the request body bytes. + # + # An empty or nil body hashes to the SHA-256 of the empty string: + # +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855+ + # + # @param body_bytes [String, nil] raw request body bytes + # @return [String] lowercase hex SHA-256 digest + def self.compute_body_hash(body_bytes) + BSV::Primitives::Digest.sha256(body_bytes.to_s).unpack1('H*') + end + end + end +end diff --git a/lib/bsv/x402/route_map.rb b/lib/bsv/x402/route_map.rb new file mode 100644 index 0000000..69dfbf5 --- /dev/null +++ b/lib/bsv/x402/route_map.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module BSV + module X402 + # Stores the set of protected route patterns and their per-route options. + # + # Routes are matched in registration order; the first matching pattern wins. + # Patterns may be: + # - an exact string (e.g. +'/api/v1/resource'+) + # - a glob string containing +*+ (e.g. +'/api/v1/*'+) + # - a +Regexp+ + # + # Glob wildcards match a single path segment (everything up to the next +/+ + # or the end of the string). Use a +Regexp+ for more complex matching. + # + # Per-route options override the global configuration: + # - +:amount_sats+ — payment amount for this route + # - +:bound_headers+ — header names to include in the request binding + class RouteMap + # Represents a single protected route entry. + Route = Struct.new(:pattern, :options) + + def initialize + @routes = [] + end + + # Registers a protected route pattern with optional overrides. + # + # @param pattern [String, Regexp] path pattern to protect + # @param options [Hash] per-route overrides (+:amount_sats+, +:bound_headers+) + def protect(pattern, options = {}) + @routes << Route.new(compile(pattern), options.freeze) + end + + # Returns +true+ if any registered pattern matches +path+. + # + # @param path [String] the request path (e.g. +PATH_INFO+ from Rack env) + # @return [Boolean] + def protected?(path) + !match(path).nil? + end + + # Returns the first route whose pattern matches +path+, or +nil+. + # + # @param path [String] + # @return [Route, nil] + def match(path) + @routes.find { |route| route.pattern.match?(path) } + end + + # Returns +true+ if no routes have been registered. + # + # @return [Boolean] + def empty? + @routes.empty? + end + + private + + # Compiles a string pattern or regexp into a +Regexp+. + # + # String patterns are treated as globs: +*+ matches any sequence of + # non-slash characters. All other regex metacharacters are escaped. + # + # @param pattern [String, Regexp] + # @return [Regexp] + def compile(pattern) + return pattern if pattern.is_a?(Regexp) + + # Escape everything, then substitute the glob wildcard back. + escaped = Regexp.escape(pattern.to_s) + # Replace the escaped asterisk with a non-slash wildcard. + regex_src = escaped.gsub('\*', '[^/]*') + Regexp.new("\\A#{regex_src}\\z") + end + end + end +end diff --git a/lib/bsv/x402/transaction_builder.rb b/lib/bsv/x402/transaction_builder.rb new file mode 100644 index 0000000..7951f05 --- /dev/null +++ b/lib/bsv/x402/transaction_builder.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +module BSV + module X402 + # Builds a payment transaction that satisfies an x402 challenge. + # + # The builder is intentionally stateless — all configuration is passed + # explicitly so that the same builder class can be used across different + # payment contexts without shared mutable state. + # + # == Nonce UTXO + # + # The nonce UTXO is server-issued (invariant N-2). The client must spend it + # to prove the payment is fresh and to prevent replay. Because the locking + # script is determined by the server, the caller supplies a +nonce_unlocker+ + # callable that is responsible for generating the unlocking script for that + # specific input. + # + # The default assumption is that the nonce UTXO is P2PKH-locked to the + # client's key. To use a custom script, supply a lambda: + # + # nonce_unlocker = ->(tx, idx) { MyCustomTemplate.new.sign(tx, idx) } + # + # == Funding UTXOs + # + # Additional UTXOs from the client's wallet cover the payment amount and + # miner fee. Each funding UTXO hash must include: + # + # { txid:, vout:, satoshis:, locking_script_hex:, private_key: } + # + # Funding inputs are signed with P2PKH using the +private_key+ on each UTXO. + # + # == Change + # + # If the total input value exceeds the payment amount plus fee, change is + # returned to +change_locking_script_hex+. If that parameter is nil, or if + # the change falls below the dust threshold (1 satoshi), the change output + # is omitted and the surplus becomes additional fee. + # + # == Dust threshold + # + # Change below 1 satoshi is omitted. The built-in +Transaction#fee+ method + # handles removal of change outputs that don't cover themselves. + class TransactionBuilder + # Minimum satoshi value for a change output. Anything below this is + # absorbed into the miner fee. + DUST_THRESHOLD = 1 + + # Fee rate in satoshis per kilobyte used when no fee model is provided. + # Matches the default in +BSV::Transaction::FeeModels::SatoshisPerKilobyte+. + DEFAULT_FEE_RATE_SAT_PER_KB = 50 + + # Build a payment transaction that satisfies +challenge+. + # + # @param challenge [Challenge] the x402 challenge to satisfy + # @param funding_utxos [Array] UTXOs to cover payment and fee + # Each: { txid:, vout:, satoshis:, locking_script_hex:, private_key: } + # @param nonce_unlocker [#call] callable that returns a +Script::Script+ + # unlocking the nonce input. Receives +(tx, input_index)+. + # @param change_locking_script_hex [String, nil] locking script for change + # @return [BSV::Transaction::Transaction] the signed transaction + # @raise [InsufficientFundsError] when UTXOs cannot cover payment + fee + # @raise [ChallengeExpiredError] when the challenge has already expired + def self.build(challenge:, funding_utxos:, nonce_unlocker:, change_locking_script_hex: nil) + raise ChallengeExpiredError, 'challenge has expired' if challenge.expired? + + tx = BSV::Transaction::Transaction.new + + # Add nonce UTXO as the first input. The nonce_unlocker will be called + # during sign_all via the unlocking_script_template mechanism. + nonce = challenge.nonce_utxo + nonce_input = build_nonce_input(nonce, nonce_unlocker) + tx.add_input(nonce_input) + + # Add client funding inputs. + funding_utxos.each do |utxo| + tx.add_input(build_funding_input(utxo)) + end + + # Payment output — the payee script and required amount from the challenge. + payment_script = BSV::Script::Script.from_hex(challenge.payee_locking_script_hex) + tx.add_output( + BSV::Transaction::TransactionOutput.new( + satoshis: challenge.amount_sats, + locking_script: payment_script + ) + ) + + # Optional change output, flagged so +Transaction#fee+ can adjust it. + if change_locking_script_hex + change_script = BSV::Script::Script.from_hex(change_locking_script_hex) + tx.add_output( + BSV::Transaction::TransactionOutput.new( + satoshis: 0, + locking_script: change_script, + change: true + ) + ) + end + + # Calculate and apply fee; this also distributes change or removes the + # change output if insufficient funds remain. + total_input = nonce.satoshis + funding_utxos.sum { |u| u[:satoshis] } + raise InsufficientFundsError, 'funding UTXOs insufficient to cover payment amount' if total_input < challenge.amount_sats + + tx.fee + + # After fee computation, check that we have not over-spent. + non_change_outputs = tx.outputs.reject(&:change) + required = non_change_outputs.sum(&:satoshis) + raise InsufficientFundsError, 'funding UTXOs insufficient to cover payment plus fee' if total_input < required + + # Sign nonce input via its template, then sign funding inputs with P2PKH. + tx.sign_all + + funding_utxos.each_with_index do |utxo, idx| + # Funding inputs start at index 1 (after the nonce input at index 0). + tx.sign(idx + 1, utxo[:private_key]) + end + + tx + end + + # ------------------------------------------------------------------ + # Private helpers + # ------------------------------------------------------------------ + + # Build the nonce input with an unlocking script template that delegates + # to the caller-supplied +nonce_unlocker+ callable. + # + # @param nonce [NonceUTXO] + # @param nonce_unlocker [#call] + # @return [BSV::Transaction::TransactionInput] + def self.build_nonce_input(nonce, nonce_unlocker) + input = BSV::Transaction::TransactionInput.new( + prev_tx_id: BSV::Transaction::TransactionInput.txid_from_hex(nonce.txid), + prev_tx_out_index: nonce.vout + ) + input.source_satoshis = nonce.satoshis + input.source_locking_script = BSV::Script::Script.from_hex(nonce.locking_script_hex) + input.unlocking_script_template = CallableUnlockingTemplate.new(nonce_unlocker) + input + end + private_class_method :build_nonce_input + + # Build a funding input from a client UTXO hash. + # + # @param utxo [Hash] { txid:, vout:, satoshis:, locking_script_hex:, private_key: } + # @return [BSV::Transaction::TransactionInput] + def self.build_funding_input(utxo) + input = BSV::Transaction::TransactionInput.new( + prev_tx_id: BSV::Transaction::TransactionInput.txid_from_hex(utxo[:txid]), + prev_tx_out_index: utxo[:vout] + ) + input.source_satoshis = utxo[:satoshis] + input.source_locking_script = BSV::Script::Script.from_hex(utxo[:locking_script_hex]) + input + end + private_class_method :build_funding_input + + # Minimal +UnlockingScriptTemplate+ wrapper around an arbitrary callable. + # + # Allows any +#call(tx, input_index)+ lambda or object to participate in + # the deferred +sign_all+ signing flow without requiring a named subclass + # of +P2PKH+. + class CallableUnlockingTemplate < BSV::Transaction::UnlockingScriptTemplate + # @param callable [#call] must respond to +call(tx, input_index)+ + def initialize(callable) + super() + @callable = callable + end + + # @param tx [BSV::Transaction::Transaction] + # @param input_index [Integer] + # @return [BSV::Script::Script] + def sign(tx, input_index) + @callable.call(tx, input_index) + end + + # @return [Integer] conservative upper bound for fee estimation + def estimated_length(_tx, _input_index) + BSV::Transaction::P2PKH::ESTIMATED_SCRIPT_LENGTH + end + end + end + end +end diff --git a/lib/bsv/x402/verification_result.rb b/lib/bsv/x402/verification_result.rb new file mode 100644 index 0000000..80904f5 --- /dev/null +++ b/lib/bsv/x402/verification_result.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +module BSV + module X402 + # The result of a verification procedure run by {Verifier}. + # + # On success, {#step} and {#reason} are nil and {#success?} returns true. + # On failure, {#step} holds the step number that failed (1–9) and {#reason} + # holds a human-readable explanation. + # + # HTTP status codes follow spec §9: + # - Steps 1, 2, 4, 6 → 400 Bad Request (client sent a malformed proof) + # - Steps 3, 5, 7, 8, 9 → 402 Payment Required (valid proof but payment problem) + class VerificationResult + # Steps that map to HTTP 400 Bad Request. + BAD_REQUEST_STEPS = [1, 2, 4, 6].freeze + + # @return [Integer, nil] the step number that failed, or nil on success + attr_reader :step + + # @return [String, nil] human-readable failure reason, or nil on success + attr_reader :reason + + # @param step [Integer, nil] nil for success + # @param reason [String, nil] nil for success + def initialize(step: nil, reason: nil) + @step = step + @reason = reason + freeze + end + + # Returns a successful result. + # + # @return [VerificationResult] + def self.success + new + end + + # Returns a failure result for the given step. + # + # @param step [Integer] the step number that failed (1–9) + # @param reason [String] human-readable failure reason + # @return [VerificationResult] + def self.failure(step:, reason:) + new(step: step, reason: reason) + end + + # @return [Boolean] true if verification passed all steps + def success? + @step.nil? + end + + # @return [Boolean] true if any step failed + def failure? + !success? + end + + # Returns the appropriate HTTP status code for this result. + # + # Success → 200 (caller decides the final passthrough status). + # Failure at steps 1, 2, 4, 6 → 400 Bad Request. + # Failure at steps 3, 5, 7, 8, 9 → 402 Payment Required. + # + # @return [Integer] HTTP status code + def http_status + return 200 if success? + + BAD_REQUEST_STEPS.include?(@step) ? 400 : 402 + end + end + end +end diff --git a/lib/bsv/x402/verifier.rb b/lib/bsv/x402/verifier.rb new file mode 100644 index 0000000..9dd2d76 --- /dev/null +++ b/lib/bsv/x402/verifier.rb @@ -0,0 +1,315 @@ +# frozen_string_literal: true + +module BSV + module X402 + # Implements the ordered 9-step verification procedure from spec §7. + # + # Each step MUST execute in order (invariant V-1). The first failing step + # causes an immediate return with a {VerificationResult} describing the + # failure. No subsequent steps are executed after a failure. + # + # Usage: + # + # result = BSV::X402::Verifier.verify( + # challenge: challenge, + # proof: proof, + # request: { method: 'GET', path: '/pay', query: '', headers_sha256: '...', body_sha256: '...' }, + # ) + # result.success? # => true / false + # result.step # => nil or 1..9 + # result.reason # => nil or String + # + # The +request+ hash keys may be symbols or strings: + # +:method+ / +'method'+, +:path+ / +'path'+, +:query+ / +'query'+, + # +:headers_sha256+ / +'headers_sha256'+, +:body_sha256+ / +'body_sha256'+ + # + # The +settlement_verifier+ is an optional callable (responds to +#call+). + # It receives the raw transaction bytes and must return a truthy value for + # mempool acceptance. It is only invoked when +challenge.require_mempool_accept+ + # is true. + class Verifier + # Runs the full 9-step verification procedure. + # + # @param challenge [Challenge] the original challenge + # @param proof [Proof] the proof to verify + # @param request [Hash] the actual inbound HTTP request info: + # { method:, path:, query:, headers_sha256:, body_sha256: } + # @param settlement_verifier [#call, nil] optional callable for mempool check + # @return [VerificationResult] success or failure with step number and reason + def self.verify(challenge:, proof:, request:, settlement_verifier: nil) + new(challenge, proof, request, settlement_verifier).verify + end + + # @param challenge [Challenge] + # @param proof [Proof] + # @param request [Hash] + # @param settlement_verifier [#call, nil] + def initialize(challenge, proof, request, settlement_verifier) + @challenge = challenge + @proof = proof + @request = request + @settlement_verifier = settlement_verifier + end + + # Executes all 9 steps in order and returns the first failure or success. + # + # @return [VerificationResult] + def verify + result = step1_validate_proof_structure + return result if result.failure? + + result = step2_validate_version_and_scheme + return result if result.failure? + + result = step3_verify_challenge_sha256 + return result if result.failure? + + result = step4_validate_request_binding + return result if result.failure? + + result = step5_check_expiry + return result if result.failure? + + result, tx = step6_decode_and_verify_transaction + return result if result.failure? + + result = step7_verify_nonce_spend(tx) + return result if result.failure? + + result = step8_verify_payment_output(tx) + return result if result.failure? + + step9_verify_mempool_acceptance(tx) + end + + private + + # Step 1: Verify the proof is structurally present and complete. + # + # Proof parsing (via +Proof.from_header+) has already been done by the + # caller, but a nil proof is still a valid failure here. The Proof + # constructor already validates required fields, so a non-nil Proof + # instance is structurally valid by construction. + # + # Failure → HTTP 400 + def step1_validate_proof_structure + return fail(1, 'proof is nil') if @proof.nil? + + ok + end + + # Step 2: Validate protocol version and scheme identifier. + # + # The proof must carry v=1 and scheme='bsv-tx-v1'. These are already + # enforced by +Proof.new+, so this step is a defence-in-depth guard + # for proofs constructed outside the normal path. + # + # Failure → HTTP 400 + def step2_validate_version_and_scheme + unless @proof.v == BSV::X402::Proof::SUPPORTED_VERSION + return fail(2, "proof.v must be #{BSV::X402::Proof::SUPPORTED_VERSION}, got #{@proof.v}") + end + + return fail(2, "proof.scheme must be '#{BSV::X402::Proof::SCHEME}', got '#{@proof.scheme}'") unless @proof.scheme == BSV::X402::Proof::SCHEME + + ok + end + + # Step 3: Recompute the challenge SHA-256 and compare to the proof's value. + # + # The proof's +challenge_sha256+ must exactly match the SHA-256 of the + # canonical JSON of the challenge that was issued. A mismatch means the + # client has a different challenge than the server, so the server must + # issue a fresh challenge. + # + # Failure → HTTP 402 + def step3_verify_challenge_sha256 + expected = @challenge.sha256 + actual = @proof.challenge_sha256 + + return fail(3, 'proof.challenge_sha256 does not match the issued challenge') unless actual == expected + + ok + end + + # Step 4: Validate that the proof's request-binding fields match both + # the challenge and the actual inbound HTTP request. + # + # The binding prevents a proof from being replayed against a different + # request. We check: + # - method, path, query match both the challenge and the actual request + # - req_headers_sha256 matches the challenge + # - req_body_sha256 matches the challenge + # - The actual request's headers/body hashes match the challenge values + # + # Failure → HTTP 400 + def step4_validate_request_binding + proof_req = @proof.request + chal = @challenge + actual = normalise_request(@request) + + if proof_req['method'] != chal.method + return fail(4, "proof.request.method '#{proof_req['method']}' does not match challenge method '#{chal.method}'") + end + + if actual[:method] != chal.method + return fail(4, "actual request method '#{actual[:method]}' does not match challenge method '#{chal.method}'") + end + + return fail(4, "proof.request.path '#{proof_req['path']}' does not match challenge path '#{chal.path}'") if proof_req['path'] != chal.path + + return fail(4, "actual request path '#{actual[:path]}' does not match challenge path '#{chal.path}'") if actual[:path] != chal.path + + return fail(4, 'proof.request.query does not match challenge query') if proof_req['query'] != chal.query + + return fail(4, 'actual request query does not match challenge query') if actual[:query] != chal.query + + return fail(4, 'proof.request.req_headers_sha256 does not match challenge') if proof_req['req_headers_sha256'] != chal.req_headers_sha256 + + return fail(4, 'actual request headers hash does not match challenge') if actual[:headers_sha256] != chal.req_headers_sha256 + + return fail(4, 'proof.request.req_body_sha256 does not match challenge') if proof_req['req_body_sha256'] != chal.req_body_sha256 + + return fail(4, 'actual request body hash does not match challenge') if actual[:body_sha256] != chal.req_body_sha256 + + ok + end + + # Step 5: Check that the challenge has not expired. + # + # Uses +Challenge#expired?+ with the current server time. No clock-skew + # tolerance is applied (spec defines none). + # + # Failure → HTTP 402 + def step5_check_expiry + return fail(5, 'challenge has expired') if @challenge.expired? + + ok + end + + # Step 6: Decode the raw transaction and verify its txid. + # + # Decodes +proof.payment.rawtx_b64+ (standard base64, not base64url) and + # parses it as a BSV transaction. Then confirms that +proof.payment.txid+ + # matches the double-SHA-256 of the decoded bytes in reversed byte order + # (Bitcoin txid convention). + # + # Returns +[result, tx]+ — the transaction is nil on failure so callers + # can pattern-match on result.failure? before using tx. + # + # Failure → HTTP 400 + def step6_decode_and_verify_transaction + raw_bytes = @proof.decoded_tx + tx = BSV::Transaction::Transaction.from_binary(raw_bytes) + + return [fail(6, 'proof.payment.txid does not match double-SHA-256 of the raw transaction'), nil] unless @proof.valid_txid? + + [ok, tx] + rescue BSV::X402::EncodingError => e + [fail(6, "failed to decode rawtx_b64: #{e.message}"), nil] + rescue ArgumentError => e + [fail(6, "failed to parse transaction: #{e.message}"), nil] + end + + # Step 7: Verify that the transaction spends the nonce UTXO. + # + # The transaction must have at least one input whose outpoint matches the + # challenge's nonce UTXO (same txid in display hex order and same vout). + # Any input position is acceptable — we do not require the nonce at index 0. + # + # Failure → HTTP 402 + def step7_verify_nonce_spend(tx) + nonce = @challenge.nonce_utxo + # TransactionInput stores prev_tx_id in internal (wire) byte order. + # txid_hex reverses it to display order for comparison. + found = tx.inputs.any? do |input| + input.txid_hex == nonce.txid && input.prev_tx_out_index == nonce.vout + end + + return fail(7, "transaction does not spend nonce UTXO #{nonce.txid}:#{nonce.vout}") unless found + + ok + end + + # Step 8: Verify that the transaction has a payment output satisfying both + # the locking script and minimum amount requirements. + # + # A single output must have: + # - locking script hex == challenge.payee_locking_script_hex + # - satoshis >= challenge.amount_sats + # + # We do NOT sum across multiple matching outputs (spec §6.8 ambiguity → + # reject). An output matching the script but below the required amount + # is a separate, explicit rejection. + # + # Failure → HTTP 402 + def step8_verify_payment_output(tx) + required_script = @challenge.payee_locking_script_hex + required_sats = @challenge.amount_sats + + script_match = tx.outputs.find { |o| o.locking_script.to_hex == required_script } + + return fail(8, "transaction has no output with payee locking script #{required_script}") unless script_match + + return fail(8, "payment output has #{script_match.satoshis} satoshis, need >= #{required_sats}") if script_match.satoshis < required_sats + + ok + end + + # Step 9: Verify mempool acceptance (conditional). + # + # Only executed if +challenge.require_mempool_accept+ is true AND a + # +settlement_verifier+ callable was provided. If either condition is absent + # the step is skipped and success is returned. + # + # The +settlement_verifier+ is called with the raw transaction bytes. It + # must return a truthy value for acceptance. + # + # Failure → HTTP 402 + def step9_verify_mempool_acceptance(_tx) + return ok unless @challenge.require_mempool_accept + return ok unless @settlement_verifier + + raw_bytes = @proof.decoded_tx + accepted = @settlement_verifier.call(raw_bytes) + + return fail(9, 'transaction was not accepted by the mempool') unless accepted + + ok + end + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + # Normalises the caller-supplied request hash to a consistent symbol-keyed form. + # + # @param req [Hash] request hash (symbol or string keys) + # @return [Hash] symbol-keyed hash with +:method+, +:path+, +:query+, + # +:headers_sha256+, +:body_sha256+ + def normalise_request(req) + { + method: (req[:method] || req['method']).to_s, + path: (req[:path] || req['path']).to_s, + query: (req[:query] || req['query']).to_s, + headers_sha256: (req[:headers_sha256] || req['headers_sha256']).to_s, + body_sha256: (req[:body_sha256] || req['body_sha256']).to_s + } + end + + # Returns a successful VerificationResult. + def ok + VerificationResult.success + end + + # Returns a failure VerificationResult for the given step and reason. + # + # @param step [Integer] the step number (1–9) + # @param reason [String] human-readable failure reason + # @return [VerificationResult] + def fail(step, reason) + VerificationResult.failure(step: step, reason: reason) + end + end + end +end diff --git a/lib/bsv/x402/version.rb b/lib/bsv/x402/version.rb new file mode 100644 index 0000000..754ba11 --- /dev/null +++ b/lib/bsv/x402/version.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module BSV + module X402 + VERSION = '0.1.0' + end +end diff --git a/spec/x402/canonical_json_spec.rb b/spec/x402/canonical_json_spec.rb new file mode 100644 index 0000000..8b69829 --- /dev/null +++ b/spec/x402/canonical_json_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require_relative 'spec_helper' + +RSpec.describe 'BSV::X402::CanonicalJSON' do + subject(:encode) { BSV::X402::CanonicalJSON.method(:encode) } + + describe 'key ordering' do + it 'sorts keys in ascending byte order' do + result = BSV::X402::CanonicalJSON.encode('z' => 1, 'a' => 2) + expect(result).to eq('{"a":2,"z":1}') + end + + it 'sorts nested object keys independently' do + result = BSV::X402::CanonicalJSON.encode('b' => { 'z' => 9, 'a' => 1 }, 'a' => 2) + expect(result).to eq('{"a":2,"b":{"a":1,"z":9}}') + end + + it 'sorts by byte value, not locale (uppercase before lowercase in ASCII)' do + # 'Z' (90) < 'a' (97) in ASCII byte order + result = BSV::X402::CanonicalJSON.encode('a' => 1, 'Z' => 2) + expect(result).to eq('{"Z":2,"a":1}') + end + end + + describe 'integer encoding' do + it 'emits integers without decimal points' do + expect(BSV::X402::CanonicalJSON.encode('n' => 42)).to eq('{"n":42}') + end + + it 'emits zero correctly' do + expect(BSV::X402::CanonicalJSON.encode('n' => 0)).to eq('{"n":0}') + end + + it 'emits negative integers correctly' do + expect(BSV::X402::CanonicalJSON.encode('n' => -7)).to eq('{"n":-7}') + end + end + + describe 'boolean encoding' do + it 'emits true as the JSON literal true' do + expect(BSV::X402::CanonicalJSON.encode('b' => true)).to eq('{"b":true}') + end + + it 'emits false as the JSON literal false' do + expect(BSV::X402::CanonicalJSON.encode('b' => false)).to eq('{"b":false}') + end + end + + describe 'null encoding' do + it 'emits nil as null' do + expect(BSV::X402::CanonicalJSON.encode('x' => nil)).to eq('{"x":null}') + end + end + + describe 'string encoding' do + it 'encodes plain ASCII strings' do + expect(BSV::X402::CanonicalJSON.encode('s' => 'hello')).to eq('{"s":"hello"}') + end + + it 'escapes double-quote characters' do + expect(BSV::X402::CanonicalJSON.encode('s' => 'say "hi"')).to eq('{"s":"say \\"hi\\""}') + end + + it 'escapes backslash characters' do + expect(BSV::X402::CanonicalJSON.encode('s' => 'back\\slash')).to eq('{"s":"back\\\\slash"}') + end + + it 'passes UTF-8 strings through unchanged' do + # RFC 8785 does not escape printable Unicode — café passes through as-is. + # "\u00E9" is é in Ruby double-quoted strings. + expect(BSV::X402::CanonicalJSON.encode('s' => "caf\u00E9")).to eq("{\"s\":\"caf\u00E9\"}") + end + end + + describe 'no whitespace' do + it 'emits no spaces or newlines' do + result = BSV::X402::CanonicalJSON.encode('a' => 1, 'b' => 'two', 'c' => { 'd' => 3 }) + expect(result).not_to match(/\s/) + end + end + + describe 'symbol keys' do + it 'treats symbol keys the same as string keys' do + result = BSV::X402::CanonicalJSON.encode(z: 1, a: 2) + expect(result).to eq('{"a":2,"z":1}') + end + end + + describe 'unsupported types' do + it 'raises EncodingError for arrays' do + expect { BSV::X402::CanonicalJSON.encode([1, 2, 3]) } + .to raise_error(BSV::X402::EncodingError, /unsupported type/) + end + + it 'raises EncodingError for floats' do + expect { BSV::X402::CanonicalJSON.encode('f' => 1.5) } + .to raise_error(BSV::X402::EncodingError, /unsupported type/) + end + end +end diff --git a/spec/x402/challenge_generator_spec.rb b/spec/x402/challenge_generator_spec.rb new file mode 100644 index 0000000..ff0771a --- /dev/null +++ b/spec/x402/challenge_generator_spec.rb @@ -0,0 +1,225 @@ +# frozen_string_literal: true + +require_relative 'spec_helper' +require 'rack' + +RSpec.describe BSV::X402::ChallengeGenerator do + let(:nonce_utxo) do + BSV::X402::NonceUTXO.new( + txid: 'cc' * 32, + vout: 0, + satoshis: 1, + locking_script_hex: "76a914#{'bb' * 20}88ac" + ) + end + + let(:nonce_provider) { BSV::X402::StaticNonceProvider.new(nonce_utxo) } + + let(:payee_script_hex) { "76a914#{'11' * 20}88ac" } + + let(:config) do + cfg = BSV::X402::Configuration.new + cfg.payee_locking_script_hex = payee_script_hex + cfg.amount_sats = 200 + cfg.nonce_provider = nonce_provider + cfg.expires_in = 300 + cfg.bound_headers = [] + cfg + end + + def rack_env(method: 'GET', path: '/api/data', query: '', host: 'example.com', body: '') + Rack::MockRequest.env_for( + "http://#{host}#{path}#{"?#{query}" unless query.empty?}", + method: method, + input: body + ) + end + + describe '.generate' do + context 'with a simple GET request' do + it 'returns a Challenge' do + env = rack_env + challenge = described_class.generate(env: env, config: config) + expect(challenge).to be_a(BSV::X402::Challenge) + end + + it 'sets method from REQUEST_METHOD' do + env = rack_env(method: 'GET') + challenge = described_class.generate(env: env, config: config) + expect(challenge.method).to eq('GET') + end + + it 'sets path from PATH_INFO' do + env = rack_env(path: '/api/premium') + challenge = described_class.generate(env: env, config: config) + expect(challenge.path).to eq('/api/premium') + end + + it 'sets query from QUERY_STRING' do + env = rack_env(query: 'page=2&sort=asc') + challenge = described_class.generate(env: env, config: config) + expect(challenge.query).to eq('page=2&sort=asc') + end + + it 'sets domain from HTTP_HOST' do + env = rack_env(host: 'payments.example.com') + challenge = described_class.generate(env: env, config: config) + expect(challenge.domain).to eq('payments.example.com') + end + + it 'sets nonce_utxo from the provider' do + env = rack_env + challenge = described_class.generate(env: env, config: config) + expect(challenge.nonce_utxo).to eq(nonce_utxo) + end + + it 'sets amount_sats from config' do + env = rack_env + challenge = described_class.generate(env: env, config: config) + expect(challenge.amount_sats).to eq(200) + end + + it 'sets payee_locking_script_hex from config' do + env = rack_env + challenge = described_class.generate(env: env, config: config) + expect(challenge.payee_locking_script_hex).to eq(payee_script_hex) + end + + it 'sets scheme to bsv-tx-v1' do + env = rack_env + challenge = described_class.generate(env: env, config: config) + expect(challenge.scheme).to eq('bsv-tx-v1') + end + + it 'sets expires_at to approximately now + expires_in' do + env = rack_env + before = Time.now.to_i + challenge = described_class.generate(env: env, config: config) + after = Time.now.to_i + + expect(challenge.expires_at).to be_between(before + 300, after + 300) + end + end + + context 'with a POST request and body' do + it 'computes the body SHA-256 hash correctly' do + body = 'hello world' + env = rack_env(method: 'POST', path: '/api/pay', body: body) + challenge = described_class.generate(env: env, config: config) + + expected = OpenSSL::Digest::SHA256.hexdigest(body) + expect(challenge.req_body_sha256).to eq(expected) + end + + it 'sets method to POST' do + env = rack_env(method: 'POST', body: 'data') + challenge = described_class.generate(env: env, config: config) + expect(challenge.method).to eq('POST') + end + end + + context 'with an empty body' do + it 'produces the SHA-256 of the empty string' do + env = rack_env + challenge = described_class.generate(env: env, config: config) + empty_sha256 = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' + expect(challenge.req_body_sha256).to eq(empty_sha256) + end + end + + context 'with a nil QUERY_STRING' do + it 'treats the query as an empty string' do + env = rack_env + env.delete('QUERY_STRING') + challenge = described_class.generate(env: env, config: config) + expect(challenge.query).to eq('') + end + end + + context 'with bound headers' do + it 'includes only the configured headers in the hash' do + config.bound_headers = ['content-type'] + env = rack_env + env['HTTP_CONTENT_TYPE'] = 'application/json' + env['HTTP_X_CUSTOM'] = 'should-be-ignored' + + challenge_with = described_class.generate(env: env, config: config) + + config_no_headers = BSV::X402::Configuration.new + config_no_headers.payee_locking_script_hex = payee_script_hex + config_no_headers.nonce_provider = nonce_provider + config_no_headers.bound_headers = [] + challenge_without = described_class.generate(env: env, config: config_no_headers) + + # The hashes must differ because content-type is included in one but not the other. + expect(challenge_with.req_headers_sha256).not_to eq(challenge_without.req_headers_sha256) + end + + it 'produces a deterministic hash for the same headers' do + config.bound_headers = ['content-type'] + env1 = rack_env + env1['HTTP_CONTENT_TYPE'] = 'application/json' + env2 = rack_env + env2['HTTP_CONTENT_TYPE'] = 'application/json' + + c1 = described_class.generate(env: env1, config: config) + c2 = described_class.generate(env: env2, config: config) + expect(c1.req_headers_sha256).to eq(c2.req_headers_sha256) + end + end + + context 'with per-route amount_sats override' do + it 'uses the route option instead of the config default' do + env = rack_env + challenge = described_class.generate(env: env, config: config, route_options: { amount_sats: 999 }) + expect(challenge.amount_sats).to eq(999) + end + end + + context 'when nonce_provider is nil' do + it 'raises ValidationError' do + config.nonce_provider = nil + env = rack_env + expect { described_class.generate(env: env, config: config) } + .to raise_error(BSV::X402::ValidationError, /nonce_provider/) + end + end + + context 'when nonce_provider returns a hash' do + it 'wraps the hash in a NonceUTXO' do + hash_provider = instance_double(BSV::X402::StaticNonceProvider) + allow(hash_provider).to receive(:reserve_nonce).and_return( + 'txid' => 'dd' * 32, 'vout' => 2, 'satoshis' => 10, + 'locking_script_hex' => "76a914#{'cc' * 20}88ac" + ) + config.nonce_provider = hash_provider + + env = rack_env + challenge = described_class.generate(env: env, config: config) + expect(challenge.nonce_utxo).to be_a(BSV::X402::NonceUTXO) + expect(challenge.nonce_utxo.txid).to eq('dd' * 32) + end + end + + context 'when nonce_provider returns a NonceUTXO directly' do + it 'uses it as-is without double-wrapping' do + env = rack_env + challenge = described_class.generate(env: env, config: config) + expect(challenge.nonce_utxo).to be_a(BSV::X402::NonceUTXO) + end + end + + context 'when verifying reserve_nonce call count' do + it 'calls reserve_nonce exactly once per generate call' do + provider = instance_spy(BSV::X402::StaticNonceProvider) + allow(provider).to receive(:reserve_nonce).and_return(nonce_utxo) + config.nonce_provider = provider + + env = rack_env + described_class.generate(env: env, config: config) + + expect(provider).to have_received(:reserve_nonce).once + end + end + end +end diff --git a/spec/x402/challenge_spec.rb b/spec/x402/challenge_spec.rb new file mode 100644 index 0000000..a4d7c05 --- /dev/null +++ b/spec/x402/challenge_spec.rb @@ -0,0 +1,213 @@ +# frozen_string_literal: true + +require_relative 'spec_helper' + +# Test vector data defined outside describe block to avoid Lint/ConstantDefinitionInBlock. +VECTOR_NONCE_UTXO = BSV::X402::NonceUTXO.new( + txid: '4f8b42a7c1d7c0e0a3e3f7b8d4c5a6b7c8d9e0f112233445566778899aabbcc', + vout: 0, + satoshis: 1, + locking_script_hex: '76a91400112233445566778899aabbccddeeff0011223388ac' +).freeze + +VECTOR_CHALLENGE_ATTRS = { + v: 1, + scheme: 'x402-bsv', + domain: 'api.example.com', + method: 'GET', + path: '/v1/weather', + query: 'city=lisbon', + req_headers_sha256: '5b2d5c2d9c7e1a2e3f0b4f6d7c8a9e0f1a2b3c4d5e6f7081920a1b2c3d4e5f60', + req_body_sha256: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', + amount_sats: 50, + payee_locking_script_hex: '76a91489abcdefabbaabbaabbaabbaabbaabbaabba88ac', + nonce_utxo: VECTOR_NONCE_UTXO, + expires_at: 1_700_000_000, + require_mempool_accept: true, + skip_scheme_validation: true +}.freeze + +RSpec.describe BSV::X402::Challenge do + let(:nonce_utxo) do + BSV::X402::NonceUTXO.new( + txid: 'deadbeef' * 8, + vout: 0, + satoshis: 1, + locking_script_hex: "76a914#{'ab' * 20}88ac" + ) + end + + let(:valid_attrs) do + { + v: 1, + scheme: 'bsv-tx-v1', + domain: 'example.com', + method: 'GET', + path: '/api/data', + query: '', + req_headers_sha256: 'a' * 64, + req_body_sha256: 'b' * 64, + amount_sats: 100, + payee_locking_script_hex: "76a914#{'cc' * 20}88ac", + nonce_utxo: nonce_utxo, + expires_at: 9_999_999_999, + require_mempool_accept: false + } + end + + describe '.new' do + it 'constructs with valid attributes' do + challenge = described_class.new(**valid_attrs) + expect(challenge.v).to eq(1) + expect(challenge.scheme).to eq('bsv-tx-v1') + expect(challenge.domain).to eq('example.com') + expect(challenge.amount_sats).to eq(100) + end + + it 'is frozen after construction' do + expect(described_class.new(**valid_attrs)).to be_frozen + end + + it 'raises ValidationError when v is not 1' do + expect { described_class.new(**valid_attrs, v: 2) } + .to raise_error(BSV::X402::ValidationError, /v must be 1/) + end + + it 'raises ValidationError when scheme is not bsv-tx-v1' do + expect { described_class.new(**valid_attrs, scheme: 'other') } + .to raise_error(BSV::X402::ValidationError, /scheme must be/) + end + + it 'allows non-standard scheme when skip_scheme_validation is true' do + expect do + described_class.new(**valid_attrs, scheme: 'x402-bsv', skip_scheme_validation: true) + end.not_to raise_error + end + + it 'raises ValidationError when amount_sats is zero' do + expect { described_class.new(**valid_attrs, amount_sats: 0) } + .to raise_error(BSV::X402::ValidationError, /amount_sats must be a positive integer/) + end + + it 'raises ValidationError when a required string field is missing' do + expect { described_class.new(**valid_attrs, domain: nil) } + .to raise_error(BSV::X402::ValidationError, /domain/) + end + + it 'raises ValidationError when nonce_utxo is not a NonceUTXO' do + expect { described_class.new(**valid_attrs, nonce_utxo: {}) } + .to raise_error(BSV::X402::ValidationError, /nonce_utxo/) + end + end + + describe '.from_json' do + it 'parses a valid JSON string' do + json = described_class.new(**valid_attrs).to_json + challenge = described_class.from_json(json) + expect(challenge.domain).to eq('example.com') + expect(challenge.amount_sats).to eq(100) + end + + it 'raises EncodingError for malformed JSON' do + expect { described_class.from_json('{not valid json}') } + .to raise_error(BSV::X402::EncodingError, /invalid JSON/) + end + + it 'raises ValidationError when nonce_utxo is missing from JSON' do + hash = described_class.new(**valid_attrs).to_hash + hash.delete('nonce_utxo') + expect { described_class.from_json(JSON.generate(hash)) } + .to raise_error(BSV::X402::ValidationError, /nonce_utxo/) + end + end + + describe '.from_header / #to_header' do + it 'round-trips through base64url header encoding' do + original = described_class.new(**valid_attrs) + header = original.to_header + restored = described_class.from_header(header) + + expect(restored.domain).to eq(original.domain) + expect(restored.amount_sats).to eq(original.amount_sats) + expect(restored.nonce_utxo.txid).to eq(original.nonce_utxo.txid) + expect(restored.require_mempool_accept).to eq(original.require_mempool_accept) + end + + it 'raises EncodingError for invalid base64url input' do + expect { described_class.from_header('not!valid!base64url') } + .to raise_error(BSV::X402::EncodingError) + end + + it 'raises EncodingError for valid base64url but invalid JSON' do + bad_json = BSV::X402::Encoding.base64url_encode('{broken json') + expect { described_class.from_header(bad_json) } + .to raise_error(BSV::X402::EncodingError, /invalid JSON/) + end + end + + describe '#to_json' do + it 'produces canonical JSON with sorted keys' do + challenge = described_class.new(**valid_attrs) + json = challenge.to_json + keys = JSON.parse(json).keys + expect(keys).to eq(keys.sort) + end + + it 'includes nonce_utxo with sorted keys' do + challenge = described_class.new(**valid_attrs) + nonce_keys = JSON.parse(challenge.to_json)['nonce_utxo'].keys + expect(nonce_keys).to eq(nonce_keys.sort) + end + end + + describe '#sha256' do + it 'returns a lowercase hex string of length 64' do + challenge = described_class.new(**valid_attrs) + expect(challenge.sha256).to match(/\A[0-9a-f]{64}\z/) + end + + it 'returns a different hash when a field changes' do + a = described_class.new(**valid_attrs) + b = described_class.new(**valid_attrs, amount_sats: 200) + expect(a.sha256).not_to eq(b.sha256) + end + end + + describe '#expired?' do + it 'returns false when expires_at equals now' do + now = 1_700_000_000 + challenge = described_class.new(**valid_attrs, expires_at: now) + expect(challenge.expired?(now)).to be(false) + end + + it 'returns true when expires_at is one second before now' do + now = 1_700_000_001 + challenge = described_class.new(**valid_attrs, expires_at: now - 1) + expect(challenge.expired?(now)).to be(true) + end + + it 'returns false when expires_at is in the future' do + challenge = described_class.new(**valid_attrs, expires_at: 9_999_999_999) + expect(challenge.expired?).to be(false) + end + end + + describe 'test vector conformance' do + it 'computes the correct sha256 for challenge-vector-001' do + challenge = described_class.new(**VECTOR_CHALLENGE_ATTRS) + expect(challenge.sha256).to eq('e1c2b034b378048b8a7299137f9ffcabfe0fc6a06018f15dd05b553858237aa9') + end + + it 'produces the expected canonical JSON for challenge-vector-001' do + challenge = described_class.new(**VECTOR_CHALLENGE_ATTRS) + expected = '{"amount_sats":50,"domain":"api.example.com","expires_at":1700000000,' \ + '"method":"GET","nonce_utxo":{"locking_script_hex":"76a91400112233445566778899aabbccddeeff0011223388ac",' \ + '"satoshis":1,"txid":"4f8b42a7c1d7c0e0a3e3f7b8d4c5a6b7c8d9e0f112233445566778899aabbcc","vout":0},' \ + '"path":"/v1/weather","payee_locking_script_hex":"76a91489abcdefabbaabbaabbaabbaabbaabbaabba88ac",' \ + '"query":"city=lisbon","req_body_sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",' \ + '"req_headers_sha256":"5b2d5c2d9c7e1a2e3f0b4f6d7c8a9e0f1a2b3c4d5e6f7081920a1b2c3d4e5f60",' \ + '"require_mempool_accept":true,"scheme":"x402-bsv","v":1}' + expect(challenge.to_json).to eq(expected) + end + end +end diff --git a/spec/x402/client_spec.rb b/spec/x402/client_spec.rb new file mode 100644 index 0000000..87fe622 --- /dev/null +++ b/spec/x402/client_spec.rb @@ -0,0 +1,379 @@ +# frozen_string_literal: true + +require_relative 'spec_helper' + +RSpec.describe 'BSV::X402::Client' do + # ------------------------------------------------------------------- + # Shared fixtures + # ------------------------------------------------------------------- + + # Nonce UTXO — locked to a throwaway key. + let(:nonce_key) { BSV::Primitives::PrivateKey.generate } + let(:nonce_script) { BSV::Script::Script.p2pkh_lock(nonce_key.public_key.hash160) } + + let(:nonce_utxo) do + BSV::X402::NonceUTXO.new( + txid: 'aa' * 32, + vout: 0, + satoshis: 1, + locking_script_hex: nonce_script.to_hex + ) + end + + # Payee script — a simple P2PKH. + let(:payee_key) { BSV::Primitives::PrivateKey.generate } + let(:payee_script_hex) { BSV::Script::Script.p2pkh_lock(payee_key.public_key.hash160).to_hex } + let(:amount_sats) { 200 } + + # Funding UTXO — has enough to cover the payment plus fee. + let(:funding_key) { BSV::Primitives::PrivateKey.generate } + let(:funding_lock) { BSV::Script::Script.p2pkh_lock(funding_key.public_key.hash160) } + let(:funding_utxo) do + { + txid: 'bb' * 32, + vout: 0, + satoshis: 10_000, + locking_script_hex: funding_lock.to_hex, + private_key: funding_key + } + end + + # Change script. + let(:change_key) { BSV::Primitives::PrivateKey.generate } + let(:change_script_hex) { BSV::Script::Script.p2pkh_lock(change_key.public_key.hash160).to_hex } + + # Nonce unlocker: signs with the nonce key. + let(:nonce_unlocker) do + p2pkh = BSV::Transaction::P2PKH.new(nonce_key) + ->(tx, idx) { p2pkh.sign(tx, idx) } + end + + # A valid challenge. + let(:challenge) do + BSV::X402::Challenge.new( + v: 1, + scheme: 'bsv-tx-v1', + domain: 'example.com', + method: 'GET', + path: '/resource', + query: '', + req_headers_sha256: BSV::X402::RequestBinding.compute_headers_hash({}), + req_body_sha256: BSV::X402::RequestBinding.compute_body_hash(nil), + amount_sats: amount_sats, + payee_locking_script_hex: payee_script_hex, + nonce_utxo: nonce_utxo, + expires_at: Time.now.to_i + 3600, + require_mempool_accept: false + ) + end + + let(:challenge_header) { challenge.to_header } + + # HTTP client double: returns [200, {}, 'ok'] by default. + let(:http_responses) { [[200, {}, 'ok']] } + let(:http_calls) { [] } + let(:http_client) do + responses = http_responses.dup + lambda do |method, url, headers, body| + http_calls << { method: method, url: url, headers: headers, body: body } + responses.shift || [200, {}, 'ok'] + end + end + + # The client under test. + let(:client) do + BSV::X402::Client.new( + funding_utxos: [funding_utxo], + nonce_unlocker: nonce_unlocker, + change_locking_script_hex: change_script_hex, + broadcaster: nil, + http_client: http_client + ) + end + + # ------------------------------------------------------------------- + # Pass-through behaviour + # ------------------------------------------------------------------- + + describe 'non-402 response' do + it 'returns the response unchanged for a 200' do + status, _headers, body = client.request('GET', 'http://example.com/resource') + expect(status).to eq(200) + expect(body).to eq('ok') + end + + it 'returns the response unchanged for a 404' do + let_http_responses [[404, {}, 'not found']] + status, _headers, body = client.request('GET', 'http://example.com/missing') + expect(status).to eq(404) + expect(body).to eq('not found') + end + + it 'makes exactly one HTTP call for a non-402 response' do + client.request('GET', 'http://example.com/resource') + expect(http_calls.length).to eq(1) + end + end + + # ------------------------------------------------------------------- + # 402 payment flow + # ------------------------------------------------------------------- + + describe '402 auto-pay flow' do + let(:http_responses) do + [ + [402, { 'x402-challenge' => challenge_header }, ''], + [200, {}, 'paid content'] + ] + end + + it 'retries the request after building a proof' do + client.request('GET', 'http://example.com/resource') + expect(http_calls.length).to eq(2) + end + + it 'includes the X402-Proof header on the retry request' do + client.request('GET', 'http://example.com/resource') + retry_headers = http_calls[1][:headers] + expect(retry_headers.key?('X402-Proof')).to be(true) + end + + it 'returns the final response after successful payment' do + status, _headers, body = client.request('GET', 'http://example.com/resource') + expect(status).to eq(200) + expect(body).to eq('paid content') + end + + it 'sends the same method and URL on retry' do + client.request('GET', 'http://example.com/resource') + expect(http_calls[1][:method]).to eq('GET') + expect(http_calls[1][:url]).to eq('http://example.com/resource') + end + + it 'encodes a valid proof in the X402-Proof header' do + client.request('GET', 'http://example.com/resource') + proof_header = http_calls[1][:headers]['X402-Proof'] + proof = BSV::X402::Proof.from_header(proof_header) + expect(proof.challenge_sha256).to eq(challenge.sha256) + end + end + + # ------------------------------------------------------------------- + # Broadcaster integration + # ------------------------------------------------------------------- + + describe 'broadcaster' do + let(:broadcaster) { double('broadcaster') } # rubocop:disable RSpec/VerifiedDoubles + + let(:http_responses) do + [ + [402, { 'x402-challenge' => challenge_header }, ''], + [200, {}, 'paid'] + ] + end + + let(:client_with_broadcaster) do + BSV::X402::Client.new( + funding_utxos: [funding_utxo], + nonce_unlocker: nonce_unlocker, + change_locking_script_hex: change_script_hex, + broadcaster: broadcaster, + http_client: http_client + ) + end + + it 'calls broadcast on the broadcaster when configured' do + allow(broadcaster).to receive(:broadcast) + client_with_broadcaster.request('GET', 'http://example.com/resource') + expect(broadcaster).to have_received(:broadcast).once + end + + it 'passes a Transaction to the broadcaster' do + received_tx = nil + allow(broadcaster).to receive(:broadcast) { |tx| received_tx = tx } + client_with_broadcaster.request('GET', 'http://example.com/resource') + expect(received_tx).to be_a(BSV::Transaction::Transaction) + end + + it 'does not call broadcast when no broadcaster is configured' do + # client has broadcaster: nil — just verify no error is raised. + expect do + client.request('GET', 'http://example.com/resource') + end.not_to raise_error + end + end + + # ------------------------------------------------------------------- + # auto_pay: false + # ------------------------------------------------------------------- + + describe 'auto_pay: false' do + let(:http_responses) do + [[402, { 'x402-challenge' => challenge_header }, '']] + end + + let(:client_no_autopay) do + BSV::X402::Client.new( + funding_utxos: [funding_utxo], + nonce_unlocker: nonce_unlocker, + http_client: http_client, + auto_pay: false + ) + end + + it 'returns the 402 response without auto-paying' do + status, _headers, _body = client_no_autopay.request('GET', 'http://example.com/resource') + expect(status).to eq(402) + end + + it 'makes only one HTTP call' do + client_no_autopay.request('GET', 'http://example.com/resource') + expect(http_calls.length).to eq(1) + end + end + + # ------------------------------------------------------------------- + # max_retries + # ------------------------------------------------------------------- + + describe 'max_retries' do + # Server always returns 402. + let(:http_responses) do + Array.new(10, [402, { 'x402-challenge' => challenge_header }, '']) + end + + let(:client_with_retries) do + BSV::X402::Client.new( + funding_utxos: [funding_utxo], + nonce_unlocker: nonce_unlocker, + http_client: http_client, + max_retries: 2 + ) + end + + it 'raises PaymentRetryExhaustedError when max_retries is exceeded' do + expect do + client_with_retries.request('GET', 'http://example.com/resource') + end.to raise_error(BSV::X402::PaymentRetryExhaustedError) + end + end + + # ------------------------------------------------------------------- + # POST with body + # ------------------------------------------------------------------- + + describe 'POST request with body' do + let(:post_body) { '{"amount":42}' } + + let(:post_challenge) do + body_hash = BSV::X402::RequestBinding.compute_body_hash(post_body) + BSV::X402::Challenge.new( + v: 1, + scheme: 'bsv-tx-v1', + domain: 'example.com', + method: 'POST', + path: '/pay', + query: '', + req_headers_sha256: BSV::X402::RequestBinding.compute_headers_hash({}), + req_body_sha256: body_hash, + amount_sats: amount_sats, + payee_locking_script_hex: payee_script_hex, + nonce_utxo: nonce_utxo, + expires_at: Time.now.to_i + 3600, + require_mempool_accept: false + ) + end + + let(:http_responses) do + [ + [402, { 'x402-challenge' => post_challenge.to_header }, ''], + [200, {}, 'post paid'] + ] + end + + it 'includes the body hash in the proof request binding' do + client.post('http://example.com/pay', body: post_body) + proof = BSV::X402::Proof.from_header(http_calls[1][:headers]['X402-Proof']) + expected_hash = BSV::X402::RequestBinding.compute_body_hash(post_body) + expect(proof.request['req_body_sha256']).to eq(expected_hash) + end + end + + # ------------------------------------------------------------------- + # Convenience methods + # ------------------------------------------------------------------- + + describe '#get' do + it 'delegates to #request with GET method' do + client.get('http://example.com/resource') + expect(http_calls.first[:method]).to eq('GET') + end + end + + describe '#post' do + let(:http_responses) { [[200, {}, 'ok']] } + + it 'delegates to #request with POST method' do + client.post('http://example.com/resource', body: 'data') + expect(http_calls.first[:method]).to eq('POST') + end + + it 'forwards the body to the HTTP client' do + client.post('http://example.com/resource', body: 'payload') + expect(http_calls.first[:body]).to eq('payload') + end + end + + # ------------------------------------------------------------------- + # Error cases + # ------------------------------------------------------------------- + + describe 'insufficient funds' do + let(:poor_utxo) do + { + txid: 'cc' * 32, + vout: 0, + satoshis: 1, + locking_script_hex: funding_lock.to_hex, + private_key: funding_key + } + end + + let(:http_responses) do + [[402, { 'x402-challenge' => challenge_header }, '']] + end + + let(:poor_client) do + BSV::X402::Client.new( + funding_utxos: [poor_utxo], + nonce_unlocker: nonce_unlocker, + http_client: http_client + ) + end + + it 'raises InsufficientFundsError when UTXOs cannot cover the payment' do + expect do + poor_client.request('GET', 'http://example.com/resource') + end.to raise_error(BSV::X402::InsufficientFundsError) + end + end + + describe 'missing X402-Challenge header' do + let(:http_responses) { [[402, {}, '']] } + + it 'raises ValidationError when X402-Challenge header is absent' do + expect do + client.request('GET', 'http://example.com/resource') + end.to raise_error(BSV::X402::ValidationError, /X402-Challenge/) + end + end + + # ------------------------------------------------------------------- + # Private helpers + # ------------------------------------------------------------------- + + # Mutates http_responses for tests that need a different first response. + def let_http_responses(responses) + http_responses.replace(responses) + end +end diff --git a/spec/x402/configuration_spec.rb b/spec/x402/configuration_spec.rb new file mode 100644 index 0000000..d7ab7db --- /dev/null +++ b/spec/x402/configuration_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require_relative 'spec_helper' + +RSpec.describe BSV::X402::Configuration do + subject(:config) { described_class.new } + + describe 'defaults' do + it 'defaults payee_locking_script_hex to nil' do + expect(config.payee_locking_script_hex).to be_nil + end + + it 'defaults amount_sats to 100' do + expect(config.amount_sats).to eq(100) + end + + it 'defaults nonce_provider to nil' do + expect(config.nonce_provider).to be_nil + end + + it 'defaults broadcaster to nil' do + expect(config.broadcaster).to be_nil + end + + it 'defaults expires_in to 300' do + expect(config.expires_in).to eq(300) + end + + it 'defaults bound_headers to an empty array' do + expect(config.bound_headers).to eq([]) + end + end + + describe 'attribute assignment' do + it 'accepts a payee_locking_script_hex' do + config.payee_locking_script_hex = '76a914aabbcc' \ + '00000000000000000000000000000088ac' + expect(config.payee_locking_script_hex).to start_with('76a914') + end + + it 'accepts an amount_sats value' do + config.amount_sats = 1000 + expect(config.amount_sats).to eq(1000) + end + + it 'accepts a nonce_provider' do + provider = Object.new + config.nonce_provider = provider + expect(config.nonce_provider).to equal(provider) + end + + it 'accepts a broadcaster' do + broadcaster = Object.new + config.broadcaster = broadcaster + expect(config.broadcaster).to equal(broadcaster) + end + + it 'accepts an expires_in value' do + config.expires_in = 600 + expect(config.expires_in).to eq(600) + end + + it 'accepts a bound_headers array' do + config.bound_headers = %w[x-api-version content-type] + expect(config.bound_headers).to eq(%w[x-api-version content-type]) + end + end +end diff --git a/spec/x402/conformance_spec.rb b/spec/x402/conformance_spec.rb new file mode 100644 index 0000000..fbb6207 --- /dev/null +++ b/spec/x402/conformance_spec.rb @@ -0,0 +1,198 @@ +# frozen_string_literal: true + +require_relative 'spec_helper' + +# Test vector: challenge-vector-001 +# Source: https://github.com/sgbett/merkleworks-x402-spec/blob/main/test-vectors/challenge-vector-001.md +# +# These constants are defined at file scope to avoid Lint/ConstantDefinitionInBlock. +CONFORMANCE_NONCE_UTXO = BSV::X402::NonceUTXO.new( + txid: '4f8b42a7c1d7c0e0a3e3f7b8d4c5a6b7c8d9e0f112233445566778899aabbcc', + vout: 0, + satoshis: 1, + locking_script_hex: '76a91400112233445566778899aabbccddeeff0011223388ac' +).freeze + +CONFORMANCE_CHALLENGE_ATTRS = { + v: 1, + scheme: 'x402-bsv', + domain: 'api.example.com', + method: 'GET', + path: '/v1/weather', + query: 'city=lisbon', + req_headers_sha256: '5b2d5c2d9c7e1a2e3f0b4f6d7c8a9e0f1a2b3c4d5e6f7081920a1b2c3d4e5f60', + req_body_sha256: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', + amount_sats: 50, + payee_locking_script_hex: '76a91489abcdefabbaabbaabbaabbaabbaabbaabba88ac', + nonce_utxo: CONFORMANCE_NONCE_UTXO, + expires_at: 1_700_000_000, + require_mempool_accept: true, + skip_scheme_validation: true +}.freeze + +# Expected canonical JSON for challenge-vector-001. +CONFORMANCE_CANONICAL_JSON = + '{"amount_sats":50,"domain":"api.example.com","expires_at":1700000000,' \ + '"method":"GET","nonce_utxo":{"locking_script_hex":"76a91400112233445566778899aabbccddeeff0011223388ac",' \ + '"satoshis":1,"txid":"4f8b42a7c1d7c0e0a3e3f7b8d4c5a6b7c8d9e0f112233445566778899aabbcc","vout":0},' \ + '"path":"/v1/weather","payee_locking_script_hex":"76a91489abcdefabbaabbaabbaabbaabbaabbaabba88ac",' \ + '"query":"city=lisbon","req_body_sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",' \ + '"req_headers_sha256":"5b2d5c2d9c7e1a2e3f0b4f6d7c8a9e0f1a2b3c4d5e6f7081920a1b2c3d4e5f60",' \ + '"require_mempool_accept":true,"scheme":"x402-bsv","v":1}' + +# Expected challenge_sha256 for challenge-vector-001 (spec §test-vectors). +CONFORMANCE_EXPECTED_SHA256 = 'e1c2b034b378048b8a7299137f9ffcabfe0fc6a06018f15dd05b553858237aa9' + +# rubocop:disable RSpec/DescribeClass +RSpec.describe 'x402 conformance: challenge-vector-001' do + subject(:challenge) { BSV::X402::Challenge.new(**CONFORMANCE_CHALLENGE_ATTRS) } + + # ----------------------------------------------------------------------- + # Canonical JSON output + # ----------------------------------------------------------------------- + + describe 'canonical JSON serialisation' do + it 'produces the exact canonical JSON bytes specified by the test vector' do + expect(challenge.to_json).to eq(CONFORMANCE_CANONICAL_JSON) + end + + it 'has all top-level keys in ascending lexicographic order' do + keys = JSON.parse(challenge.to_json).keys + expect(keys).to eq(keys.sort) + end + + it 'has all nonce_utxo keys in ascending lexicographic order' do + nonce_keys = JSON.parse(challenge.to_json)['nonce_utxo'].keys + expect(nonce_keys).to eq(nonce_keys.sort) + end + + it 'serialises integers as JSON numbers (not strings)' do + parsed = JSON.parse(challenge.to_json) + expect(parsed['amount_sats']).to be_a(Integer) + expect(parsed['expires_at']).to be_a(Integer) + expect(parsed['v']).to be_a(Integer) + expect(parsed['nonce_utxo']['satoshis']).to be_a(Integer) + expect(parsed['nonce_utxo']['vout']).to be_a(Integer) + end + + it 'serialises booleans as JSON booleans (not strings)' do + parsed = JSON.parse(challenge.to_json) + # require_mempool_accept is set to true in the vector, so it must parse as TrueClass. + expect(parsed['require_mempool_accept']).to be(true).or be(false) + end + + it 'serialises string fields without unnecessary escaping' do + json = challenge.to_json + # No spurious backslash escapes in plain ASCII field values. + expect(json).not_to include('\/') + end + end + + # ----------------------------------------------------------------------- + # SHA-256 hash + # ----------------------------------------------------------------------- + + describe '#sha256' do + it 'matches the expected hash from the spec test vector' do + expect(challenge.sha256).to eq(CONFORMANCE_EXPECTED_SHA256) + end + + it 'returns a 64-character lowercase hex string' do + expect(challenge.sha256).to match(/\A[0-9a-f]{64}\z/) + end + + it 'is deterministic — calling sha256 twice returns the same value' do + first_call = challenge.sha256 + second_call = challenge.sha256 + expect(first_call).to eq(second_call) + end + + it 'differs when any field is changed' do + mutated = BSV::X402::Challenge.new(**CONFORMANCE_CHALLENGE_ATTRS, amount_sats: 51) + expect(mutated.sha256).not_to eq(CONFORMANCE_EXPECTED_SHA256) + end + end + + # ----------------------------------------------------------------------- + # Round-trip through header encoding + # ----------------------------------------------------------------------- + + describe 'header round-trip' do + it 'survives base64url encode → decode without data loss' do + header = challenge.to_header + restored = BSV::X402::Challenge.from_header(header, skip_scheme_validation: true) + + expect(restored.sha256).to eq(challenge.sha256) + expect(restored.to_json).to eq(challenge.to_json) + end + + it 'produces a header string containing only URL-safe characters' do + header = challenge.to_header + expect(header).to match(/\A[A-Za-z0-9\-_]+\z/) + end + end + + # ----------------------------------------------------------------------- + # Field-level assertions + # ----------------------------------------------------------------------- + + describe 'field values' do + it 'has v = 1' do + expect(challenge.v).to eq(1) + end + + it 'has scheme x402-bsv' do + expect(challenge.scheme).to eq('x402-bsv') + end + + it 'has amount_sats 50' do + expect(challenge.amount_sats).to eq(50) + end + + it 'has expires_at 1700000000' do + expect(challenge.expires_at).to eq(1_700_000_000) + end + + it 'has require_mempool_accept true' do + expect(challenge.require_mempool_accept).to be(true) + end + + it 'has the correct nonce txid' do + expect(challenge.nonce_utxo.txid).to eq('4f8b42a7c1d7c0e0a3e3f7b8d4c5a6b7c8d9e0f112233445566778899aabbcc') + end + + it 'has nonce vout 0' do + expect(challenge.nonce_utxo.vout).to eq(0) + end + + it 'has nonce satoshis 1' do + expect(challenge.nonce_utxo.satoshis).to eq(1) + end + end + + # ----------------------------------------------------------------------- + # CanonicalJSON properties + # ----------------------------------------------------------------------- + + describe 'BSV::X402::CanonicalJSON' do + it 'encodes a nested hash with recursively sorted keys' do + input = { 'z' => 1, 'a' => { 'z' => 2, 'a' => 3 } } + output = BSV::X402::CanonicalJSON.encode(input) + parsed = JSON.parse(output) + expect(parsed.keys).to eq(%w[a z]) + expect(parsed['a'].keys).to eq(%w[a z]) + end + + it 'encodes a string with a forward slash without escaping it' do + output = BSV::X402::CanonicalJSON.encode({ 'path' => '/v1/weather' }) + expect(output).to include('"/v1/weather"') + end + + it 'produces byte-identical output to the known vector JSON' do + # Reconstruct from the challenge hash and verify byte equality. + json = BSV::X402::CanonicalJSON.encode(challenge.to_hash) + expect(json.bytes).to eq(CONFORMANCE_CANONICAL_JSON.bytes) + end + end +end +# rubocop:enable RSpec/DescribeClass diff --git a/spec/x402/encoding_spec.rb b/spec/x402/encoding_spec.rb new file mode 100644 index 0000000..eeae1bd --- /dev/null +++ b/spec/x402/encoding_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require_relative 'spec_helper' + +RSpec.describe 'BSV::X402::Encoding' do + describe '.base64url_encode / .base64url_decode' do + it 'round-trips arbitrary bytes' do + data = (0..255).map(&:chr).join + expect(BSV::X402::Encoding.base64url_decode(BSV::X402::Encoding.base64url_encode(data))).to eq(data) + end + + it 'encodes the empty string to an empty string' do + expect(BSV::X402::Encoding.base64url_encode('')).to eq('') + end + + it 'decodes an empty string to empty bytes' do + expect(BSV::X402::Encoding.base64url_decode('')).to eq('') + end + + it 'omits padding characters from encoded output' do + encoded = BSV::X402::Encoding.base64url_encode('a') + expect(encoded).not_to include('=') + end + + it 'uses URL-safe characters (- and _ instead of + and /)' do + # Byte 0xFB (11111011) encodes to a character containing + in standard base64, + # URL-safe replaces it with - + data = "\xFB\xFF" + encoded = BSV::X402::Encoding.base64url_encode(data) + expect(encoded).not_to include('+') + expect(encoded).not_to include('/') + end + + it 'decodes strings that have padding' do + padded = 'eyJhIjoxfQ==' + without_padding = 'eyJhIjoxfQ' + expect(BSV::X402::Encoding.base64url_decode(padded)).to eq(BSV::X402::Encoding.base64url_decode(without_padding)) + end + + it 'matches a known encoding vector: {"a":1}' do + # base64url of '{"a":1}' is 'eyJhIjoxfQ' + expect(BSV::X402::Encoding.base64url_encode('{"a":1}')).to eq('eyJhIjoxfQ') + end + + it 'raises EncodingError for malformed base64url' do + expect { BSV::X402::Encoding.base64url_decode('not!base64url') } + .to raise_error(BSV::X402::EncodingError) + end + + it 'raises EncodingError when the string exceeds 64 KiB' do + oversized = 'A' * 65_537 + expect { BSV::X402::Encoding.base64url_decode(oversized) } + .to raise_error(BSV::X402::EncodingError, /64 KiB/) + end + end + + describe '.base64_encode / .base64_decode' do + it 'round-trips arbitrary bytes' do + data = 'raw transaction bytes \x00\xFF' + expect(BSV::X402::Encoding.base64_decode(BSV::X402::Encoding.base64_encode(data))).to eq(data) + end + + it 'includes padding in standard base64 output' do + # 'a' encodes to 'YQ==' in standard base64 + expect(BSV::X402::Encoding.base64_encode('a')).to eq('YQ==') + end + + it 'raises EncodingError for malformed standard base64' do + expect { BSV::X402::Encoding.base64_decode('not!!!valid') } + .to raise_error(BSV::X402::EncodingError) + end + end +end diff --git a/spec/x402/integration_spec.rb b/spec/x402/integration_spec.rb new file mode 100644 index 0000000..1327c91 --- /dev/null +++ b/spec/x402/integration_spec.rb @@ -0,0 +1,741 @@ +# frozen_string_literal: true + +require_relative 'spec_helper' +require 'rack' +require 'rack/mock' + +# rubocop:disable RSpec/DescribeClass +RSpec.describe 'BSV::X402 integration: full request/response cycle' do + # ----------------------------------------------------------------------- + # Shared key material and scripts + # ----------------------------------------------------------------------- + + let(:payee_key) { BSV::Primitives::PrivateKey.generate } + let(:nonce_key) { BSV::Primitives::PrivateKey.generate } + let(:client_key) { BSV::Primitives::PrivateKey.generate } + let(:change_key) { BSV::Primitives::PrivateKey.generate } + + # Locking scripts (P2PKH). + let(:payee_script) { BSV::Script::Script.p2pkh_lock(payee_key.public_key.hash160) } + let(:nonce_script) { BSV::Script::Script.p2pkh_lock(nonce_key.public_key.hash160) } + let(:client_script) { BSV::Script::Script.p2pkh_lock(client_key.public_key.hash160) } + let(:change_script) { BSV::Script::Script.p2pkh_lock(change_key.public_key.hash160) } + + # ----------------------------------------------------------------------- + # Nonce UTXO — locked to the nonce key, funded enough to pay the fee + # ----------------------------------------------------------------------- + + let(:nonce_utxo) do + BSV::X402::NonceUTXO.new( + txid: 'ab' * 32, + vout: 0, + satoshis: 10, + locking_script_hex: nonce_script.to_hex + ) + end + + # Callable that signs the nonce input with the nonce key. + let(:nonce_unlocker) do + p2pkh = BSV::Transaction::P2PKH.new(nonce_key) + ->(tx, idx) { p2pkh.sign(tx, idx) } + end + + # ----------------------------------------------------------------------- + # Client funding UTXOs + # ----------------------------------------------------------------------- + + let(:amount_sats) { 200 } + + let(:funding_utxo) do + { + txid: 'cd' * 32, + vout: 0, + satoshis: 10_000, + locking_script_hex: client_script.to_hex, + private_key: client_key + } + end + + # ----------------------------------------------------------------------- + # Server configuration + # ----------------------------------------------------------------------- + + let(:nonce_provider) { BSV::X402::StaticNonceProvider.new(nonce_utxo) } + + let(:config) do + cfg = BSV::X402::Configuration.new + cfg.payee_locking_script_hex = payee_script.to_hex + cfg.amount_sats = amount_sats + cfg.nonce_provider = nonce_provider + cfg.expires_in = 300 + cfg.bound_headers = [] + cfg + end + + # ----------------------------------------------------------------------- + # Inner Rack app — returns 200 with a JSON body + # ----------------------------------------------------------------------- + + let(:inner_app) do + ->(_env) { [200, { 'content-type' => 'application/json' }, ['{"data":"premium content"}']] } + end + + # Build a middleware stack with the given DSL block. + def build_middleware(&block) + BSV::X402::Middleware.new(inner_app, config: config, &block) + end + + # ----------------------------------------------------------------------- + # E2E — GET: client → 402 → pay → 200 + # ----------------------------------------------------------------------- + + describe 'GET flow: client receives 402, pays, and receives 200' do + let(:middleware) do + build_middleware do |x402| + x402.protect '/api/premium', amount_sats: amount_sats + end + end + + it 'completes the full challenge → proof → 200 cycle' do + # ---- Step 1: initial request (no proof) → 402 ---------------------- + first_env = Rack::MockRequest.env_for( + 'http://example.com/api/premium', + method: 'GET' + ) + first_status, first_headers, = middleware.call(first_env) + + expect(first_status).to eq(402) + expect(first_headers['x402-challenge']).not_to be_nil + + # ---- Step 2: parse the challenge ----------------------------------- + challenge = BSV::X402::Challenge.from_header(first_headers['x402-challenge']) + expect(challenge.amount_sats).to eq(amount_sats) + expect(challenge.payee_locking_script_hex).to eq(payee_script.to_hex) + + # ---- Step 3: build the payment transaction via TransactionBuilder -- + tx = BSV::X402::TransactionBuilder.build( + challenge: challenge, + funding_utxos: [funding_utxo], + nonce_unlocker: nonce_unlocker, + change_locking_script_hex: change_script.to_hex + ) + + # Verify structural properties of the built transaction. + expect(tx.inputs.first.txid_hex).to eq(nonce_utxo.txid) + payment_out = tx.outputs.find { |o| o.locking_script.to_hex == payee_script.to_hex } + expect(payment_out).not_to be_nil + expect(payment_out.satoshis).to eq(amount_sats) + + # ---- Step 4: build the proof --------------------------------------- + req_info = { + method: 'GET', + path: '/api/premium', + query: '', + req_headers_sha256: BSV::X402::RequestBinding.compute_headers_hash({}), + req_body_sha256: BSV::X402::RequestBinding.compute_body_hash(nil) + } + proof = BSV::X402::ProofBuilder.build( + challenge: challenge, + transaction: tx, + request: req_info + ) + + expect(proof.challenge_sha256).to eq(challenge.sha256) + + # ---- Step 5: retry with proof → 200 -------------------------------- + second_env = Rack::MockRequest.env_for( + 'http://example.com/api/premium', + method: 'GET', + 'HTTP_X402_PROOF' => proof.to_header + ) + second_status, _second_headers, second_body = middleware.call(second_env) + + expect(second_status).to eq(200) + expect(second_body.first).to include('premium content') + end + end + + # ----------------------------------------------------------------------- + # E2E — POST flow with body binding + # ----------------------------------------------------------------------- + + describe 'POST flow: request body is bound to the challenge' do + let(:middleware) do + build_middleware do |x402| + x402.protect '/api/data' + end + end + + let(:post_body) { '{"query":"weather"}' } + + it 'binds the POST body hash and accepts a matching proof' do + # First request carries a body — the challenge must hash it. + first_env = Rack::MockRequest.env_for( + 'http://example.com/api/data', + method: 'POST', + input: post_body, + 'CONTENT_TYPE' => 'application/json' + ) + first_status, first_headers, = middleware.call(first_env) + expect(first_status).to eq(402) + + challenge = BSV::X402::Challenge.from_header(first_headers['x402-challenge']) + expected_body_sha256 = BSV::X402::RequestBinding.compute_body_hash(post_body) + expect(challenge.req_body_sha256).to eq(expected_body_sha256) + + # Build the transaction. + tx = BSV::X402::TransactionBuilder.build( + challenge: challenge, + funding_utxos: [funding_utxo], + nonce_unlocker: nonce_unlocker, + change_locking_script_hex: change_script.to_hex + ) + + req_info = { + method: 'POST', + path: '/api/data', + query: '', + req_headers_sha256: BSV::X402::RequestBinding.compute_headers_hash({}), + req_body_sha256: expected_body_sha256 + } + proof = BSV::X402::ProofBuilder.build( + challenge: challenge, + transaction: tx, + request: req_info + ) + + # Retry with the proof — body must be rewound in the env for the + # middleware to re-hash it and compare against the challenge. + second_env = Rack::MockRequest.env_for( + 'http://example.com/api/data', + method: 'POST', + input: post_body, + 'HTTP_X402_PROOF' => proof.to_header + ) + second_status, = middleware.call(second_env) + expect(second_status).to eq(200) + end + end + + # ----------------------------------------------------------------------- + # E2E — Client auto-pay through middleware (Rack::MockRequest) + # ----------------------------------------------------------------------- + + describe 'Client auto-pay through Rack middleware' do + let(:middleware) do + build_middleware do |x402| + x402.protect '/api/resource' + end + end + + # Adapts a Rack middleware into the Client's http_client callable interface. + # This lets us exercise the Client ↔ Middleware pair without a network. + def rack_http_client(rack_app) + lambda do |method, url, headers, body| + uri = URI.parse(url) + opts = { method: method } + opts[:input] = body if body + + # Forward headers that the Rack env helper understands. + rack_headers = headers.transform_keys do |k| + k =~ /\AHTTP_/i ? k : "HTTP_#{k.upcase.tr('-', '_')}" + end + + env = Rack::MockRequest.env_for("http://#{uri.host}#{uri.path}", opts.merge(rack_headers)) + status, resp_headers, resp_body_parts = rack_app.call(env) + resp_body = resp_body_parts.join + [status, resp_headers, resp_body] + end + end + + it 'auto-pays the 402 and returns the 200 response' do + client = BSV::X402::Client.new( + funding_utxos: [funding_utxo], + nonce_unlocker: nonce_unlocker, + change_locking_script_hex: change_script.to_hex, + broadcaster: nil, + http_client: rack_http_client(middleware) + ) + + status, _headers, body = client.get('http://example.com/api/resource') + + expect(status).to eq(200) + expect(body).to include('premium content') + end + end + + # ----------------------------------------------------------------------- + # Multiple protected routes — different prices, unprotected pass-through + # ----------------------------------------------------------------------- + + describe 'multiple routes' do + let(:middleware) do + build_middleware do |x402| + x402.protect '/api/cheap', amount_sats: 10 + x402.protect '/api/costly', amount_sats: 500 + end + end + + it 'returns 402 for each protected route' do + ['/api/cheap', '/api/costly'].each do |path| + response = Rack::MockRequest.new(middleware).get(path) + expect(response.status).to eq(402) + end + end + + it 'issues a 10-sat challenge for /api/cheap' do + response = Rack::MockRequest.new(middleware).get('/api/cheap') + challenge = BSV::X402::Challenge.from_header(response.headers['x402-challenge']) + expect(challenge.amount_sats).to eq(10) + end + + it 'issues a 500-sat challenge for /api/costly' do + response = Rack::MockRequest.new(middleware).get('/api/costly') + challenge = BSV::X402::Challenge.from_header(response.headers['x402-challenge']) + expect(challenge.amount_sats).to eq(500) + end + + it 'passes through unprotected routes with 200' do + response = Rack::MockRequest.new(middleware).get('/public/health') + expect(response.status).to eq(200) + end + + it 'accepts a valid proof on /api/cheap and returns 200' do + mock_req = Rack::MockRequest.new(middleware) + + first_response = mock_req.get('/api/cheap') + challenge = BSV::X402::Challenge.from_header(first_response.headers['x402-challenge']) + + tx = BSV::X402::TransactionBuilder.build( + challenge: challenge, + funding_utxos: [funding_utxo], + nonce_unlocker: nonce_unlocker, + change_locking_script_hex: change_script.to_hex + ) + + req_info = { + method: 'GET', path: '/api/cheap', query: '', + req_headers_sha256: BSV::X402::RequestBinding.compute_headers_hash({}), + req_body_sha256: BSV::X402::RequestBinding.compute_body_hash(nil) + } + proof = BSV::X402::ProofBuilder.build(challenge: challenge, transaction: tx, request: req_info) + + second_env = Rack::MockRequest.env_for( + 'http://example.com/api/cheap', + method: 'GET', + 'HTTP_X402_PROOF' => proof.to_header + ) + status, = middleware.call(second_env) + expect(status).to eq(200) + end + end + + # ----------------------------------------------------------------------- + # on_payment_verified callback fires with correct arguments + # ----------------------------------------------------------------------- + + describe 'on_payment_verified callback' do + it 'receives the Rack env and a successful VerificationResult' do + fired_args = [] + config.on_payment_verified = ->(env, result) { fired_args << [env, result] } + + middleware = build_middleware do |x402| + x402.protect '/api/callback' + end + + first_env = Rack::MockRequest.env_for('http://example.com/api/callback', method: 'GET') + _, first_headers, = middleware.call(first_env) + challenge = BSV::X402::Challenge.from_header(first_headers['x402-challenge']) + + tx = BSV::X402::TransactionBuilder.build( + challenge: challenge, + funding_utxos: [funding_utxo], + nonce_unlocker: nonce_unlocker, + change_locking_script_hex: change_script.to_hex + ) + + req_info = { + method: 'GET', path: '/api/callback', query: '', + req_headers_sha256: BSV::X402::RequestBinding.compute_headers_hash({}), + req_body_sha256: BSV::X402::RequestBinding.compute_body_hash(nil) + } + proof = BSV::X402::ProofBuilder.build(challenge: challenge, transaction: tx, request: req_info) + + second_env = Rack::MockRequest.env_for( + 'http://example.com/api/callback', + method: 'GET', + 'HTTP_X402_PROOF' => proof.to_header + ) + middleware.call(second_env) + + expect(fired_args.length).to eq(1) + _env_arg, result_arg = fired_args.first + expect(result_arg).to be_a(BSV::X402::VerificationResult) + expect(result_arg.success?).to be(true) + end + end + + # ----------------------------------------------------------------------- + # Error paths + # ----------------------------------------------------------------------- + + describe 'error paths' do + let(:middleware) do + build_middleware do |x402| + x402.protect '/api/pay' + end + end + + # Helper: get a fresh challenge from the middleware and store it. + def obtain_challenge(path = '/api/pay') + env = Rack::MockRequest.env_for("http://example.com#{path}", method: 'GET') + _, headers, = middleware.call(env) + BSV::X402::Challenge.from_header(headers['x402-challenge']) + end + + # Helper: build a valid payment tx + proof for a challenge. + def valid_proof_for(challenge, path = '/api/pay') + tx = BSV::X402::TransactionBuilder.build( + challenge: challenge, + funding_utxos: [funding_utxo], + nonce_unlocker: nonce_unlocker, + change_locking_script_hex: change_script.to_hex + ) + req_info = { + method: 'GET', path: path, query: '', + req_headers_sha256: BSV::X402::RequestBinding.compute_headers_hash({}), + req_body_sha256: BSV::X402::RequestBinding.compute_body_hash(nil) + } + BSV::X402::ProofBuilder.build(challenge: challenge, transaction: tx, request: req_info) + end + + context 'when the challenge has expired' do + it 'returns 402 on the proof retry (step 5: expired challenge)' do + # Obtain and store a fresh challenge. + challenge = obtain_challenge + + # Replace the stored challenge with an expired copy. + # We rebuild with the same sha256 key but an expired timestamp. + expired = BSV::X402::Challenge.new( + v: challenge.v, + scheme: challenge.scheme, + domain: challenge.domain, + method: challenge.method, + path: challenge.path, + query: challenge.query, + req_headers_sha256: challenge.req_headers_sha256, + req_body_sha256: challenge.req_body_sha256, + amount_sats: challenge.amount_sats, + payee_locking_script_hex: challenge.payee_locking_script_hex, + nonce_utxo: challenge.nonce_utxo, + expires_at: Time.now.to_i - 1, + require_mempool_accept: challenge.require_mempool_accept + ) + config.challenge_store.store(expired.sha256, expired) + + # Build the transaction using the expired challenge (it's not expired yet + # at construction time — we build manually to skip the expiry guard). + tx = BSV::Transaction::Transaction.new + tx.add_input( + BSV::Transaction::TransactionInput.new( + prev_tx_id: BSV::Transaction::TransactionInput.txid_from_hex(expired.nonce_utxo.txid), + prev_tx_out_index: expired.nonce_utxo.vout + ) + ) + tx.add_output( + BSV::Transaction::TransactionOutput.new( + satoshis: expired.amount_sats, + locking_script: BSV::Script::Script.from_hex(expired.payee_locking_script_hex) + ) + ) + + req_info = { + method: 'GET', path: '/api/pay', query: '', + req_headers_sha256: BSV::X402::RequestBinding.compute_headers_hash({}), + req_body_sha256: BSV::X402::RequestBinding.compute_body_hash(nil) + } + proof = BSV::X402::ProofBuilder.build( + challenge: expired, + transaction: tx, + request: req_info + ) + + env = Rack::MockRequest.env_for( + 'http://example.com/api/pay', + method: 'GET', + 'HTTP_X402_PROOF' => proof.to_header + ) + status, = middleware.call(env) + expect(status).to eq(402) + end + end + + context 'when the proof uses a tampered transaction (wrong nonce)' do + it 'returns 402 (step 7: nonce not spent)' do + challenge = obtain_challenge + + # Build a transaction that does NOT spend the nonce UTXO. + bad_tx = BSV::Transaction::Transaction.new + bad_tx.add_input( + BSV::Transaction::TransactionInput.new( + prev_tx_id: BSV::Transaction::TransactionInput.txid_from_hex('ff' * 32), + prev_tx_out_index: 0 + ) + ) + bad_tx.add_output( + BSV::Transaction::TransactionOutput.new( + satoshis: challenge.amount_sats, + locking_script: BSV::Script::Script.from_hex(challenge.payee_locking_script_hex) + ) + ) + + req_info = { + method: 'GET', path: '/api/pay', query: '', + req_headers_sha256: BSV::X402::RequestBinding.compute_headers_hash({}), + req_body_sha256: BSV::X402::RequestBinding.compute_body_hash(nil) + } + proof = BSV::X402::ProofBuilder.build( + challenge: challenge, + transaction: bad_tx, + request: req_info + ) + + env = Rack::MockRequest.env_for( + 'http://example.com/api/pay', + method: 'GET', + 'HTTP_X402_PROOF' => proof.to_header + ) + status, = middleware.call(env) + expect(status).to eq(402) + end + end + + context 'when the payment output is insufficient (wrong amount)' do + it 'returns 402 (step 8: insufficient payment)' do + challenge = obtain_challenge + + tx = BSV::Transaction::Transaction.new + tx.add_input( + BSV::Transaction::TransactionInput.new( + prev_tx_id: BSV::Transaction::TransactionInput.txid_from_hex(challenge.nonce_utxo.txid), + prev_tx_out_index: challenge.nonce_utxo.vout + ) + ) + # Underpay by 1 satoshi. + tx.add_output( + BSV::Transaction::TransactionOutput.new( + satoshis: challenge.amount_sats - 1, + locking_script: BSV::Script::Script.from_hex(challenge.payee_locking_script_hex) + ) + ) + + req_info = { + method: 'GET', path: '/api/pay', query: '', + req_headers_sha256: BSV::X402::RequestBinding.compute_headers_hash({}), + req_body_sha256: BSV::X402::RequestBinding.compute_body_hash(nil) + } + proof = BSV::X402::ProofBuilder.build( + challenge: challenge, + transaction: tx, + request: req_info + ) + + env = Rack::MockRequest.env_for( + 'http://example.com/api/pay', + method: 'GET', + 'HTTP_X402_PROOF' => proof.to_header + ) + status, = middleware.call(env) + expect(status).to eq(402) + end + end + + context 'when the payment is sent to a wrong payee script' do + it 'returns 402 (step 8: wrong locking script)' do + challenge = obtain_challenge + wrong_key = BSV::Primitives::PrivateKey.generate + wrong_script = BSV::Script::Script.p2pkh_lock(wrong_key.public_key.hash160) + + tx = BSV::Transaction::Transaction.new + tx.add_input( + BSV::Transaction::TransactionInput.new( + prev_tx_id: BSV::Transaction::TransactionInput.txid_from_hex(challenge.nonce_utxo.txid), + prev_tx_out_index: challenge.nonce_utxo.vout + ) + ) + tx.add_output( + BSV::Transaction::TransactionOutput.new( + satoshis: challenge.amount_sats, + locking_script: wrong_script + ) + ) + + req_info = { + method: 'GET', path: '/api/pay', query: '', + req_headers_sha256: BSV::X402::RequestBinding.compute_headers_hash({}), + req_body_sha256: BSV::X402::RequestBinding.compute_body_hash(nil) + } + proof = BSV::X402::ProofBuilder.build( + challenge: challenge, + transaction: tx, + request: req_info + ) + + env = Rack::MockRequest.env_for( + 'http://example.com/api/pay', + method: 'GET', + 'HTTP_X402_PROOF' => proof.to_header + ) + status, = middleware.call(env) + expect(status).to eq(402) + end + end + + context 'when the proof is replayed against a different path' do + it 'returns 400 (step 4: request binding mismatch)' do + # Obtain a challenge for /api/pay and build a valid proof for that path. + challenge = obtain_challenge('/api/pay') + proof = valid_proof_for(challenge, '/api/pay') + + # Now submit the /api/pay proof to the /api/pay middleware but with the + # PATH_INFO pointing to a different path. This exercises step 4 of the + # verifier: actual request path does not match the challenge path. + # + # We register /api/other on the same middleware instance (sharing the + # challenge store), then send the /api/pay proof to /api/other. + multi_middleware = BSV::X402::Middleware.new(inner_app, config: config) do |x402| + x402.protect '/api/pay' + x402.protect '/api/other' + end + + # The challenge for /api/pay is already in config.challenge_store. + # When we send the proof to /api/other, the verifier finds the challenge + # (same store) and then fails at step 4 because path '/api/other' ≠ '/api/pay'. + env = Rack::MockRequest.env_for( + 'http://example.com/api/other', + method: 'GET', + 'HTTP_X402_PROOF' => proof.to_header + ) + status, = multi_middleware.call(env) + # Step 4 path mismatch → 400 Bad Request. + expect(status).to eq(400) + end + end + + context 'when the X402-Proof header is malformed base64' do + it 'returns 400' do + env = Rack::MockRequest.env_for( + 'http://example.com/api/pay', + method: 'GET', + 'HTTP_X402_PROOF' => '!!!not-valid-base64!!!' + ) + status, = middleware.call(env) + expect(status).to eq(400) + end + end + + context 'when the X402-Proof header is valid base64 but invalid JSON' do + it 'returns 400' do + garbage = BSV::X402::Encoding.base64url_encode('not json {{{') + env = Rack::MockRequest.env_for( + 'http://example.com/api/pay', + method: 'GET', + 'HTTP_X402_PROOF' => garbage + ) + status, = middleware.call(env) + expect(status).to eq(400) + end + end + + context 'when the proof challenge_sha256 does not match any stored challenge' do + it 'returns 402 with a fresh challenge' do + unknown_proof = BSV::X402::Proof.new( + v: 1, + scheme: 'bsv-tx-v1', + challenge_sha256: 'cc' * 32, + request: { + 'method' => 'GET', 'path' => '/api/pay', 'query' => '', + 'req_headers_sha256' => 'a' * 64, + 'req_body_sha256' => 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' + }, + payment: { + 'txid' => 'ff' * 32, + 'rawtx_b64' => BSV::X402::Encoding.base64_encode("\x00" * 10) + } + ) + + env = Rack::MockRequest.env_for( + 'http://example.com/api/pay', + method: 'GET', + 'HTTP_X402_PROOF' => unknown_proof.to_header + ) + status, headers, = middleware.call(env) + expect(status).to eq(402) + # A fresh challenge must be issued. + expect(headers['x402-challenge']).not_to be_nil + end + end + + context 'when the nonce provider is exhausted' do + it 'returns 500' do + exhausted_provider = Object.new + def exhausted_provider.reserve_nonce + raise BSV::X402::ValidationError, 'nonce pool exhausted' + end + + config.nonce_provider = exhausted_provider + response = Rack::MockRequest.new(middleware).get('/api/pay') + expect(response.status).to eq(500) + end + end + end + + # ----------------------------------------------------------------------- + # Verifier step-level HTTP status mapping (spec §9) + # ----------------------------------------------------------------------- + + describe 'HTTP status codes for each verification failure step' do + let(:middleware) do + build_middleware do |x402| + x402.protect '/api/pay' + end + end + + # Steps 1, 2, 4, 6 → 400; steps 3, 5, 7, 8, 9 → 402. + + it 'returns 400 for a structurally invalid proof (step 1/2 via malformed base64)' do + env = Rack::MockRequest.env_for( + 'http://example.com/api/pay', + method: 'GET', + 'HTTP_X402_PROOF' => '!!!bad!!!' + ) + status, = middleware.call(env) + expect(status).to eq(400) + end + + it 'returns 402 for an unknown challenge_sha256 (triggers fresh challenge — step 3)' do + unknown_proof = BSV::X402::Proof.new( + v: 1, + scheme: 'bsv-tx-v1', + challenge_sha256: 'bb' * 32, + request: { + 'method' => 'GET', 'path' => '/api/pay', 'query' => '', + 'req_headers_sha256' => 'a' * 64, + 'req_body_sha256' => 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' + }, + payment: { + 'txid' => 'aa' * 32, + 'rawtx_b64' => BSV::X402::Encoding.base64_encode("\x00" * 10) + } + ) + env = Rack::MockRequest.env_for( + 'http://example.com/api/pay', + method: 'GET', + 'HTTP_X402_PROOF' => unknown_proof.to_header + ) + status, = middleware.call(env) + expect(status).to eq(402) + end + end +end +# rubocop:enable RSpec/DescribeClass diff --git a/spec/x402/middleware_spec.rb b/spec/x402/middleware_spec.rb new file mode 100644 index 0000000..6359a56 --- /dev/null +++ b/spec/x402/middleware_spec.rb @@ -0,0 +1,515 @@ +# frozen_string_literal: true + +require_relative 'spec_helper' +require 'rack' +require 'rack/mock' + +RSpec.describe BSV::X402::Middleware do + # ------------------------------------------------------------------ + # Fixtures + # ------------------------------------------------------------------ + + let(:nonce_utxo) do + BSV::X402::NonceUTXO.new( + txid: 'ee' * 32, + vout: 0, + satoshis: 1, + locking_script_hex: "76a914#{'22' * 20}88ac" + ) + end + + let(:nonce_provider) { BSV::X402::StaticNonceProvider.new(nonce_utxo) } + let(:payee_script_hex) { "76a914#{'33' * 20}88ac" } + let(:amount_sats) { 100 } + + let(:config) do + cfg = BSV::X402::Configuration.new + cfg.payee_locking_script_hex = payee_script_hex + cfg.amount_sats = amount_sats + cfg.nonce_provider = nonce_provider + cfg.expires_in = 300 + cfg.bound_headers = [] + cfg + end + + # Inner app always returns 200 OK. + let(:inner_app) { ->(_env) { [200, { 'content-type' => 'text/plain' }, ['OK']] } } + + # Build a Rack::MockRequest for the middleware under test. + def build_middleware(&block) + BSV::X402::Middleware.new(inner_app, config: config, &block) + end + + def get(middleware, path, headers = {}) + Rack::MockRequest.new(middleware).get(path, headers) + end + + # ------------------------------------------------------------------ + # Unprotected routes + # ------------------------------------------------------------------ + + describe 'unprotected routes' do + let(:middleware) do + build_middleware do |x402| + x402.protect '/api/premium' + end + end + + it 'passes through a request to an unprotected path' do + response = get(middleware, '/public') + expect(response.status).to eq(200) + expect(response.body).to eq('OK') + end + + it 'does not set X402-Challenge header for unprotected paths' do + response = get(middleware, '/public') + expect(response.headers['x402-challenge']).to be_nil + end + end + + # ------------------------------------------------------------------ + # Protected route without proof → 402 + # ------------------------------------------------------------------ + + describe 'protected route without proof' do + let(:middleware) do + build_middleware do |x402| + x402.protect '/api/premium' + end + end + + it 'returns 402 when no X402-Proof header is present' do + response = get(middleware, '/api/premium') + expect(response.status).to eq(402) + end + + it 'includes the X402-Challenge header in the 402 response' do + response = get(middleware, '/api/premium') + expect(response.headers['x402-challenge']).not_to be_nil + end + + it 'sets a valid base64url-encoded challenge in the header' do + response = get(middleware, '/api/premium') + header = response.headers['x402-challenge'] + challenge = BSV::X402::Challenge.from_header(header) + expect(challenge).to be_a(BSV::X402::Challenge) + end + + it 'returns exactly one X402-Challenge header' do + response = get(middleware, '/api/premium') + # Rack headers are strings; confirm header is a single value + expect(response.headers['x402-challenge']).to be_a(String) + end + + it 'binds the challenge to the correct method and path' do + response = get(middleware, '/api/premium') + challenge = BSV::X402::Challenge.from_header(response.headers['x402-challenge']) + expect(challenge.method).to eq('GET') + expect(challenge.path).to eq('/api/premium') + end + + it 'binds the challenge to the correct query string' do + response = Rack::MockRequest.new(middleware).get('/api/premium?foo=bar') + challenge = BSV::X402::Challenge.from_header(response.headers['x402-challenge']) + expect(challenge.query).to eq('foo=bar') + end + + it 'uses the global config amount_sats' do + response = get(middleware, '/api/premium') + challenge = BSV::X402::Challenge.from_header(response.headers['x402-challenge']) + expect(challenge.amount_sats).to eq(amount_sats) + end + + it 'returns a JSON body with error information' do + response = get(middleware, '/api/premium') + body = JSON.parse(response.body) + expect(body['error']).to eq('Payment Required') + expect(body['amount_sats']).to eq(amount_sats) + end + + it 'calls nonce_provider.reserve_nonce exactly once' do + mock_provider = instance_spy(BSV::X402::StaticNonceProvider) + allow(mock_provider).to receive(:reserve_nonce).and_return(nonce_utxo) + config.nonce_provider = mock_provider + + middleware = build_middleware do |x402| + x402.protect '/api/premium' + end + + get(middleware, '/api/premium') + + expect(mock_provider).to have_received(:reserve_nonce).once + end + end + + # ------------------------------------------------------------------ + # Per-route amount_sats override + # ------------------------------------------------------------------ + + describe 'per-route amount_sats override' do + let(:middleware) do + build_middleware do |x402| + x402.protect '/api/cheap', amount_sats: 10 + x402.protect '/api/expensive', amount_sats: 999 + end + end + + it 'uses the per-route amount for the cheap route' do + response = get(middleware, '/api/cheap') + challenge = BSV::X402::Challenge.from_header(response.headers['x402-challenge']) + expect(challenge.amount_sats).to eq(10) + end + + it 'uses the per-route amount for the expensive route' do + response = get(middleware, '/api/expensive') + challenge = BSV::X402::Challenge.from_header(response.headers['x402-challenge']) + expect(challenge.amount_sats).to eq(999) + end + end + + # ------------------------------------------------------------------ + # Glob matching + # ------------------------------------------------------------------ + + describe 'glob route matching' do + let(:middleware) do + build_middleware do |x402| + x402.protect '/api/v1/*' + end + end + + it 'protects a path matching the glob' do + response = get(middleware, '/api/v1/resource') + expect(response.status).to eq(402) + end + + it 'protects another matching path' do + response = get(middleware, '/api/v1/data') + expect(response.status).to eq(402) + end + + it 'does not protect a path outside the glob' do + response = get(middleware, '/api/v2/resource') + expect(response.status).to eq(200) + end + + it 'does not protect the parent path of the glob pattern' do + response = get(middleware, '/api/v1') + expect(response.status).to eq(200) + end + end + + # ------------------------------------------------------------------ + # Multiple routes — first-match-wins + # ------------------------------------------------------------------ + + describe 'multiple routes — first match wins' do + let(:middleware) do + build_middleware do |x402| + x402.protect '/api/v1/data', amount_sats: 50 + x402.protect '/api/v1/*', amount_sats: 200 + end + end + + it 'uses the first matching route (exact over glob)' do + response = get(middleware, '/api/v1/data') + challenge = BSV::X402::Challenge.from_header(response.headers['x402-challenge']) + expect(challenge.amount_sats).to eq(50) + end + + it 'uses the second route for non-exact matches' do + response = get(middleware, '/api/v1/other') + challenge = BSV::X402::Challenge.from_header(response.headers['x402-challenge']) + expect(challenge.amount_sats).to eq(200) + end + end + + # ------------------------------------------------------------------ + # Proc-based protection + # ------------------------------------------------------------------ + + describe 'proc-based protection' do + let(:protect_proc) { ->(env) { env['PATH_INFO'].start_with?('/paid/') } } + + let(:middleware) do + described_class.new(inner_app, config: config, protect: protect_proc) + end + + it 'protects paths matching the proc' do + response = get(middleware, '/paid/resource') + expect(response.status).to eq(402) + end + + it 'passes through paths not matching the proc' do + response = get(middleware, '/free/resource') + expect(response.status).to eq(200) + end + + it 'uses the global config amount_sats (no per-route override with proc)' do + response = get(middleware, '/paid/resource') + challenge = BSV::X402::Challenge.from_header(response.headers['x402-challenge']) + expect(challenge.amount_sats).to eq(amount_sats) + end + end + + # ------------------------------------------------------------------ + # Protected route with valid proof → passes through + # ------------------------------------------------------------------ + + describe 'protected route with valid proof' do + let(:middleware) do + build_middleware do |x402| + x402.protect '/api/pay' + end + end + + # Simulate the full two-request flow: + # 1. GET /api/pay (no proof) → 402 + challenge stored in challenge_store + # 2. Build a valid transaction spending the nonce from the challenge + # 3. Build a proof referencing the stored challenge's SHA-256 + # 4. GET /api/pay with X402-Proof header → 200 + + def build_valid_proof_for(path, app) + # First request — server stores the challenge and returns 402. + first_env = Rack::MockRequest.env_for("http://example.com#{path}", method: 'GET') + first_response = Rack::MockResponse.new(*app.call(first_env)) + challenge = BSV::X402::Challenge.from_header(first_response.headers['x402-challenge']) + + # Build a valid transaction spending the nonce UTXO from the challenge. + t = BSV::Transaction::Transaction.new + t.add_input( + BSV::Transaction::TransactionInput.new( + prev_tx_id: BSV::Transaction::TransactionInput.txid_from_hex(challenge.nonce_utxo.txid), + prev_tx_out_index: challenge.nonce_utxo.vout + ) + ) + t.add_output( + BSV::Transaction::TransactionOutput.new( + satoshis: challenge.amount_sats, + locking_script: BSV::Script::Script.from_binary([challenge.payee_locking_script_hex].pack('H*')) + ) + ) + + headers_sha256 = BSV::X402::RequestBinding.compute_headers_hash({}) + body_sha256 = BSV::X402::RequestBinding.compute_body_hash(nil) + + BSV::X402::Proof.new( + v: 1, + scheme: 'bsv-tx-v1', + challenge_sha256: challenge.sha256, + request: { + 'method' => challenge.method, + 'path' => challenge.path, + 'query' => challenge.query, + 'req_headers_sha256' => headers_sha256, + 'req_body_sha256' => body_sha256 + }, + payment: { + 'txid' => t.txid_hex, + 'rawtx_b64' => BSV::X402::Encoding.base64_encode(t.to_binary) + } + ) + end + + it 'passes through to the inner app when the proof is valid' do + proof = build_valid_proof_for('/api/pay', middleware) + + env = Rack::MockRequest.env_for( + 'http://example.com/api/pay', + method: 'GET', + 'HTTP_X402_PROOF' => proof.to_header + ) + + status, _headers, body = middleware.call(env) + expect(status).to eq(200) + expect(body.first).to eq('OK') + end + end + + # ------------------------------------------------------------------ + # Protected route with invalid proof → 400 or 402 + # ------------------------------------------------------------------ + + describe 'protected route with invalid proof' do + let(:middleware) do + build_middleware do |x402| + x402.protect '/api/pay' + end + end + + context 'when the X402-Proof header is malformed base64' do + it 'returns 400' do + env = Rack::MockRequest.env_for( + 'http://example.com/api/pay', + method: 'GET', + 'HTTP_X402_PROOF' => '!!!not-valid-base64!!!' + ) + + status, _headers, _body = middleware.call(env) + expect(status).to eq(400) + end + end + + context 'when the X402-Proof header is valid base64 but not valid JSON' do + it 'returns 400' do + garbage = BSV::X402::Encoding.base64url_encode('this is not json {{{') + env = Rack::MockRequest.env_for( + 'http://example.com/api/pay', + method: 'GET', + 'HTTP_X402_PROOF' => garbage + ) + + status, _headers, _body = middleware.call(env) + expect(status).to eq(400) + end + end + + context 'when the proof has wrong challenge_sha256 (challenge mismatch)' do + it 'returns 402 (step 3 failure)' do + bad_proof = BSV::X402::Proof.new( + v: 1, + scheme: 'bsv-tx-v1', + challenge_sha256: 'ff' * 32, + request: { + 'method' => 'GET', 'path' => '/api/pay', 'query' => '', + 'req_headers_sha256' => 'a' * 64, + 'req_body_sha256' => 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' + }, + payment: { 'txid' => 'aa' * 32, 'rawtx_b64' => BSV::X402::Encoding.base64_encode("\x00" * 10) } + ) + + env = Rack::MockRequest.env_for( + 'http://example.com/api/pay', + method: 'GET', + 'HTTP_X402_PROOF' => bad_proof.to_header + ) + + status, _headers, _body = middleware.call(env) + expect(status).to eq(402) + end + end + end + + # ------------------------------------------------------------------ + # on_payment_verified callback + # ------------------------------------------------------------------ + + describe 'on_payment_verified callback' do + it 'fires the callback after successful verification' do + fired = false + fired_env = nil + fired_result = nil + + callback = lambda do |env, result| + fired = true + fired_env = env + fired_result = result + end + + config.on_payment_verified = callback + + middleware = build_middleware do |x402| + x402.protect '/api/callback' + end + + # First request to obtain and store the challenge. + first_env = Rack::MockRequest.env_for('http://example.com/api/callback', method: 'GET') + first_resp = Rack::MockResponse.new(*middleware.call(first_env)) + challenge = BSV::X402::Challenge.from_header(first_resp.headers['x402-challenge']) + + # Build a valid transaction. + t = BSV::Transaction::Transaction.new + t.add_input( + BSV::Transaction::TransactionInput.new( + prev_tx_id: BSV::Transaction::TransactionInput.txid_from_hex(challenge.nonce_utxo.txid), + prev_tx_out_index: challenge.nonce_utxo.vout + ) + ) + t.add_output( + BSV::Transaction::TransactionOutput.new( + satoshis: challenge.amount_sats, + locking_script: BSV::Script::Script.from_binary([challenge.payee_locking_script_hex].pack('H*')) + ) + ) + + headers_sha256 = BSV::X402::RequestBinding.compute_headers_hash({}) + body_sha256 = BSV::X402::RequestBinding.compute_body_hash(nil) + + proof = BSV::X402::Proof.new( + v: 1, + scheme: 'bsv-tx-v1', + challenge_sha256: challenge.sha256, + request: { + 'method' => 'GET', 'path' => '/api/callback', 'query' => '', + 'req_headers_sha256' => headers_sha256, + 'req_body_sha256' => body_sha256 + }, + payment: { + 'txid' => t.txid_hex, + 'rawtx_b64' => BSV::X402::Encoding.base64_encode(t.to_binary) + } + ) + + env = Rack::MockRequest.env_for( + 'http://example.com/api/callback', + method: 'GET', + 'HTTP_X402_PROOF' => proof.to_header + ) + + middleware.call(env) + + expect(fired).to be(true) + expect(fired_env).to be(env) + expect(fired_result).to be_a(BSV::X402::VerificationResult) + expect(fired_result.success?).to be(true) + end + + it 'does not fire the callback when verification fails' do + fired = false + config.on_payment_verified = ->(_env, _result) { fired = true } + + middleware = build_middleware do |x402| + x402.protect '/api/callback' + end + + # No proof — returns 402 challenge, callback must not fire. + get(middleware, '/api/callback') + expect(fired).to be(false) + end + end + + # ------------------------------------------------------------------ + # Body hash in challenge + # ------------------------------------------------------------------ + + describe 'body hash in POST challenge' do + let(:middleware) do + build_middleware do |x402| + x402.protect '/api/post' + end + end + + it 'hashes the POST body and embeds it in the challenge' do + body_content = 'payload data' + env = Rack::MockRequest.env_for( + 'http://example.com/api/post', + method: 'POST', + input: body_content + ) + + status, headers, = middleware.call(env) + expect(status).to eq(402) + + challenge = BSV::X402::Challenge.from_header(headers['x402-challenge']) + expected_hash = OpenSSL::Digest::SHA256.hexdigest(body_content) + expect(challenge.req_body_sha256).to eq(expected_hash) + end + + it 'produces the empty-string SHA-256 for a body-less GET' do + response = get(middleware, '/api/post') + challenge = BSV::X402::Challenge.from_header(response.headers['x402-challenge']) + empty_sha256 = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' + expect(challenge.req_body_sha256).to eq(empty_sha256) + end + end +end diff --git a/spec/x402/nonce_provider_spec.rb b/spec/x402/nonce_provider_spec.rb new file mode 100644 index 0000000..6ab9b3d --- /dev/null +++ b/spec/x402/nonce_provider_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require_relative 'spec_helper' + +RSpec.describe 'BSV::X402::NonceProvider' do + describe BSV::X402::NonceProvider do + it 'raises NotImplementedError when reserve_nonce is called on an includer without override' do + klass = Class.new { include BSV::X402::NonceProvider } + expect { klass.new.reserve_nonce }.to raise_error(NotImplementedError, /reserve_nonce/) + end + + it 'can be included in a class that overrides reserve_nonce' do + nonce_utxo = BSV::X402::NonceUTXO.new( + txid: 'aa' * 32, vout: 0, satoshis: 1, locking_script_hex: '00' + ) + + klass = Class.new do + include BSV::X402::NonceProvider + + def reserve_nonce + BSV::X402::NonceUTXO.new( + txid: 'aa' * 32, vout: 0, satoshis: 1, locking_script_hex: '00' + ) + end + end + + result = klass.new.reserve_nonce + expect(result).to be_a(BSV::X402::NonceUTXO) + expect(result.txid).to eq(nonce_utxo.txid) + end + end + + describe BSV::X402::StaticNonceProvider do + subject(:provider) { described_class.new(nonce_utxo) } + + let(:nonce_utxo) do + BSV::X402::NonceUTXO.new( + txid: 'bb' * 32, + vout: 1, + satoshis: 5, + locking_script_hex: "76a914#{'aa' * 20}88ac" + ) + end + + it 'returns the fixed nonce UTXO' do + expect(provider.reserve_nonce).to be(nonce_utxo) + end + + it 'returns the same UTXO on every call' do + first = provider.reserve_nonce + second = provider.reserve_nonce + expect(first).to be(second) + end + + it 'includes NonceProvider' do + expect(described_class.ancestors).to include(BSV::X402::NonceProvider) + end + end +end diff --git a/spec/x402/nonce_utxo_spec.rb b/spec/x402/nonce_utxo_spec.rb new file mode 100644 index 0000000..5d3daf3 --- /dev/null +++ b/spec/x402/nonce_utxo_spec.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require_relative 'spec_helper' + +RSpec.describe BSV::X402::NonceUTXO do + let(:valid_attrs) do + { + txid: 'abcd1234' * 8, + vout: 0, + satoshis: 1, + locking_script_hex: "76a914#{'00' * 20}88ac" + } + end + + describe '.new' do + it 'constructs with valid attributes' do + utxo = described_class.new(**valid_attrs) + expect(utxo.txid).to eq(valid_attrs[:txid]) + expect(utxo.vout).to eq(0) + expect(utxo.satoshis).to eq(1) + expect(utxo.locking_script_hex).to eq(valid_attrs[:locking_script_hex]) + end + + it 'is frozen after construction' do + expect(described_class.new(**valid_attrs)).to be_frozen + end + + it 'raises ValidationError when satoshis is zero' do + expect { described_class.new(**valid_attrs, satoshis: 0) } + .to raise_error(BSV::X402::ValidationError, /satoshis must be greater than zero/) + end + + it 'raises ValidationError when satoshis is negative' do + expect { described_class.new(**valid_attrs, satoshis: -1) } + .to raise_error(BSV::X402::ValidationError, /satoshis must be greater than zero/) + end + + it 'raises ValidationError when satoshis is not an integer' do + expect { described_class.new(**valid_attrs, satoshis: '1') } + .to raise_error(BSV::X402::ValidationError) + end + end + + describe '.from_hash' do + it 'constructs from a string-keyed hash' do + hash = { + 'txid' => valid_attrs[:txid], + 'vout' => valid_attrs[:vout], + 'satoshis' => valid_attrs[:satoshis], + 'locking_script_hex' => valid_attrs[:locking_script_hex] + } + utxo = described_class.from_hash(hash) + expect(utxo.txid).to eq(valid_attrs[:txid]) + end + + it 'constructs from a symbol-keyed hash' do + utxo = described_class.from_hash(valid_attrs) + expect(utxo.satoshis).to eq(1) + end + + it 'raises ValidationError for a missing field' do + attrs_without_txid = valid_attrs.reject { |k, _| k == :txid } + expect { described_class.from_hash(attrs_without_txid) } + .to raise_error(BSV::X402::ValidationError, /txid/) + end + end + + describe '#to_hash' do + it 'returns a hash with string keys' do + utxo = described_class.new(**valid_attrs) + hash = utxo.to_hash + expect(hash.keys).to all(be_a(String)) + end + + it 'includes all four fields' do + utxo = described_class.new(**valid_attrs) + hash = utxo.to_hash + expect(hash.keys).to contain_exactly('txid', 'vout', 'satoshis', 'locking_script_hex') + end + + it 'round-trips through from_hash' do + original = described_class.new(**valid_attrs) + restored = described_class.from_hash(original.to_hash) + expect(restored.txid).to eq(original.txid) + expect(restored.satoshis).to eq(original.satoshis) + end + end +end diff --git a/spec/x402/proof_builder_spec.rb b/spec/x402/proof_builder_spec.rb new file mode 100644 index 0000000..76931b3 --- /dev/null +++ b/spec/x402/proof_builder_spec.rb @@ -0,0 +1,203 @@ +# frozen_string_literal: true + +require_relative 'spec_helper' + +RSpec.describe 'BSV::X402::ProofBuilder' do + # ------------------------------------------------------------------- + # Shared fixtures + # ------------------------------------------------------------------- + + let(:nonce_txid) { 'aabbccdd' * 8 } + let(:nonce_vout) { 0 } + let(:nonce_sats) { 1 } + let(:nonce_script) { "76a914#{'aa' * 20}88ac" } + + let(:nonce_utxo) do + BSV::X402::NonceUTXO.new( + txid: nonce_txid, + vout: nonce_vout, + satoshis: nonce_sats, + locking_script_hex: nonce_script + ) + end + + let(:payee_script_hex) { "76a914#{'11' * 20}88ac" } + let(:amount_sats) { 300 } + let(:expires_at) { Time.now.to_i + 3600 } + + let(:challenge) do + BSV::X402::Challenge.new( + v: 1, + scheme: 'bsv-tx-v1', + domain: 'example.com', + method: 'GET', + path: '/pay', + query: 'ref=test', + req_headers_sha256: 'b' * 64, + req_body_sha256: 'c' * 64, + amount_sats: amount_sats, + payee_locking_script_hex: payee_script_hex, + nonce_utxo: nonce_utxo, + expires_at: expires_at, + require_mempool_accept: false + ) + end + + # A minimal transaction with one nonce input and one payment output. + let(:tx) do + t = BSV::Transaction::Transaction.new + t.add_input( + BSV::Transaction::TransactionInput.new( + prev_tx_id: BSV::Transaction::TransactionInput.txid_from_hex(nonce_txid), + prev_tx_out_index: nonce_vout + ) + ) + t.add_output( + BSV::Transaction::TransactionOutput.new( + satoshis: amount_sats, + locking_script: BSV::Script::Script.from_binary([payee_script_hex].pack('H*')) + ) + ) + t + end + + let(:request) do + { + method: 'GET', + path: '/pay', + query: 'ref=test', + req_headers_sha256: 'b' * 64, + req_body_sha256: 'c' * 64 + } + end + + # Helper: build a proof with optional overrides. + def build_proof(**overrides) + BSV::X402::ProofBuilder.build( + challenge: overrides.fetch(:challenge, challenge), + transaction: overrides.fetch(:transaction, tx), + request: overrides.fetch(:request, request) + ) + end + + # ------------------------------------------------------------------- + # Happy path + # ------------------------------------------------------------------- + + it 'returns a Proof instance' do + expect(build_proof).to be_a(BSV::X402::Proof) + end + + it 'sets v to 1' do + expect(build_proof.v).to eq(1) + end + + it "sets scheme to 'bsv-tx-v1'" do + expect(build_proof.scheme).to eq('bsv-tx-v1') + end + + describe 'challenge_sha256' do + it "matches the challenge's own sha256" do + proof = build_proof + expect(proof.challenge_sha256).to eq(challenge.sha256) + end + + it 'changes when a different challenge is used' do + other_challenge = BSV::X402::Challenge.new( + v: 1, + scheme: 'bsv-tx-v1', + domain: 'other.com', + method: 'POST', + path: '/other', + query: '', + req_headers_sha256: 'd' * 64, + req_body_sha256: 'e' * 64, + amount_sats: 100, + payee_locking_script_hex: payee_script_hex, + nonce_utxo: nonce_utxo, + expires_at: expires_at, + require_mempool_accept: false + ) + + proof1 = build_proof + proof2 = build_proof(challenge: other_challenge) + expect(proof1.challenge_sha256).not_to eq(proof2.challenge_sha256) + end + end + + describe 'payment sub-object' do + it "sets txid to the transaction's txid_hex" do + proof = build_proof + expect(proof.payment['txid']).to eq(tx.txid_hex) + end + + it 'sets rawtx_b64 to standard base64 of the raw transaction bytes' do + proof = build_proof + expected = BSV::X402::Encoding.base64_encode(tx.to_binary) + expect(proof.payment['rawtx_b64']).to eq(expected) + end + + it 'encodes rawtx_b64 with standard base64 (not base64url)' do + proof = build_proof + # Standard base64 uses '+' and '/', not '-' and '_'. + # We verify the decoded bytes round-trip correctly. + decoded = Base64.strict_decode64(proof.payment['rawtx_b64']) + expect(decoded).to eq(tx.to_binary) + end + + it 'passes valid_txid? check on the resulting proof' do + proof = build_proof + expect(proof.valid_txid?).to be(true) + end + end + + describe 'request sub-object' do + it 'propagates method from the request hash' do + proof = build_proof + expect(proof.request['method']).to eq('GET') + end + + it 'propagates path from the request hash' do + proof = build_proof + expect(proof.request['path']).to eq('/pay') + end + + it 'propagates query from the request hash' do + proof = build_proof + expect(proof.request['query']).to eq('ref=test') + end + + it 'propagates req_headers_sha256 from the request hash' do + proof = build_proof + expect(proof.request['req_headers_sha256']).to eq('b' * 64) + end + + it 'propagates req_body_sha256 from the request hash' do + proof = build_proof + expect(proof.request['req_body_sha256']).to eq('c' * 64) + end + + it 'accepts string-keyed request hashes' do + string_request = { + 'method' => 'GET', + 'path' => '/pay', + 'query' => 'ref=test', + 'req_headers_sha256' => 'b' * 64, + 'req_body_sha256' => 'c' * 64 + } + proof = build_proof(request: string_request) + expect(proof.request['method']).to eq('GET') + end + end + + describe 'round-trip via header' do + it 'can be encoded to a header and decoded back' do + proof = build_proof + header_value = proof.to_header + decoded = BSV::X402::Proof.from_header(header_value) + + expect(decoded.challenge_sha256).to eq(proof.challenge_sha256) + expect(decoded.payment['txid']).to eq(proof.payment['txid']) + end + end +end diff --git a/spec/x402/proof_spec.rb b/spec/x402/proof_spec.rb new file mode 100644 index 0000000..695a4a5 --- /dev/null +++ b/spec/x402/proof_spec.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +require_relative 'spec_helper' + +RSpec.describe BSV::X402::Proof do + # Minimal valid raw transaction (coinbase-style, 64 bytes of zeros for testing). + # We use a known txid/rawtx pair rather than generating a real one. + # sha256d(raw_bytes).reverse in hex == txid + let(:raw_tx_bytes) { "\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00" } + let(:valid_rawtx_b64) { BSV::X402::Encoding.base64_encode(raw_tx_bytes) } + let(:computed_txid) do + hash = BSV::Primitives::Digest.sha256d(raw_tx_bytes) + hash.reverse.unpack1('H*') + end + + let(:valid_attrs) do + { + v: 1, + scheme: 'bsv-tx-v1', + challenge_sha256: 'a' * 64, + request: { + 'method' => 'GET', + 'path' => '/api/data', + 'query' => '', + 'req_headers_sha256' => 'b' * 64, + 'req_body_sha256' => 'c' * 64 + }, + payment: { + 'txid' => computed_txid, + 'rawtx_b64' => valid_rawtx_b64 + } + } + end + + describe '.new' do + it 'constructs with valid attributes' do + proof = described_class.new(**valid_attrs) + expect(proof.v).to eq(1) + expect(proof.scheme).to eq('bsv-tx-v1') + expect(proof.challenge_sha256).to eq('a' * 64) + end + + it 'is frozen after construction' do + expect(described_class.new(**valid_attrs)).to be_frozen + end + + it 'raises ValidationError when v is not 1' do + expect { described_class.new(**valid_attrs, v: 2) } + .to raise_error(BSV::X402::ValidationError, /v must be 1/) + end + + it 'raises ValidationError when scheme is wrong' do + expect { described_class.new(**valid_attrs, scheme: 'other') } + .to raise_error(BSV::X402::ValidationError, /scheme must be/) + end + + it 'raises ValidationError when challenge_sha256 is missing' do + expect { described_class.new(**valid_attrs, challenge_sha256: '') } + .to raise_error(BSV::X402::ValidationError, /challenge_sha256/) + end + + it 'raises ValidationError when request is missing a field' do + bad_request = valid_attrs[:request].reject { |k, _| k == 'method' } + expect { described_class.new(**valid_attrs, request: bad_request) } + .to raise_error(BSV::X402::ValidationError, /request.*method/) + end + + it 'raises ValidationError when payment txid is missing' do + bad_payment = valid_attrs[:payment].reject { |k, _| k == 'txid' } + expect { described_class.new(**valid_attrs, payment: bad_payment) } + .to raise_error(BSV::X402::ValidationError, /payment.*txid/) + end + end + + describe '.from_json' do + it 'parses a valid JSON string' do + json = described_class.new(**valid_attrs).to_json + proof = described_class.from_json(json) + expect(proof.challenge_sha256).to eq('a' * 64) + end + + it 'raises EncodingError for malformed JSON' do + expect { described_class.from_json('{not json}') } + .to raise_error(BSV::X402::EncodingError, /invalid JSON/) + end + end + + describe '.from_header / #to_header' do + it 'round-trips through base64url encoding' do + original = described_class.new(**valid_attrs) + restored = described_class.from_header(original.to_header) + + expect(restored.challenge_sha256).to eq(original.challenge_sha256) + expect(restored.request['method']).to eq(original.request['method']) + expect(restored.payment['txid']).to eq(original.payment['txid']) + end + + it 'raises EncodingError for truncated base64url' do + # Partial base64url that decodes but isn't valid JSON + truncated = BSV::X402::Encoding.base64url_encode('{"v":1')[0..3] + expect { described_class.from_header(truncated) } + .to raise_error(BSV::X402::EncodingError) + end + end + + describe '#to_json' do + it 'produces canonical JSON with sorted top-level keys' do + proof = described_class.new(**valid_attrs) + keys = JSON.parse(proof.to_json).keys + expect(keys).to eq(keys.sort) + end + end + + describe '#decoded_tx' do + it 'decodes the rawtx_b64 field to raw bytes' do + proof = described_class.new(**valid_attrs) + expect(proof.decoded_tx).to eq(raw_tx_bytes) + end + end + + describe '#valid_txid?' do + it 'returns true when txid matches double-SHA-256 of the raw transaction' do + proof = described_class.new(**valid_attrs) + expect(proof.valid_txid?).to be(true) + end + + it 'returns false when txid does not match the raw transaction' do + bad_payment = valid_attrs[:payment].merge('txid' => 'ff' * 32) + proof = described_class.new(**valid_attrs, payment: bad_payment) + expect(proof.valid_txid?).to be(false) + end + end +end diff --git a/spec/x402/request_binding_spec.rb b/spec/x402/request_binding_spec.rb new file mode 100644 index 0000000..e46386b --- /dev/null +++ b/spec/x402/request_binding_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require_relative 'spec_helper' + +RSpec.describe 'BSV::X402::RequestBinding' do + describe '.compute_headers_hash' do + it 'returns a lowercase hex SHA-256 digest of length 64' do + result = BSV::X402::RequestBinding.compute_headers_hash('host' => 'example.com') + expect(result).to match(/\A[0-9a-f]{64}\z/) + end + + it 'sorts headers by name in ascending byte order' do + result_a = BSV::X402::RequestBinding.compute_headers_hash('b' => '2', 'a' => '1') + result_b = BSV::X402::RequestBinding.compute_headers_hash('a' => '1', 'b' => '2') + expect(result_a).to eq(result_b) + end + + it 'lowercases header names before sorting' do + result_upper = BSV::X402::RequestBinding.compute_headers_hash('Content-Type' => 'application/json') + result_lower = BSV::X402::RequestBinding.compute_headers_hash('content-type' => 'application/json') + expect(result_upper).to eq(result_lower) + end + + it 'trims whitespace from header values' do + result_padded = BSV::X402::RequestBinding.compute_headers_hash('host' => ' example.com ') + result_clean = BSV::X402::RequestBinding.compute_headers_hash('host' => 'example.com') + expect(result_padded).to eq(result_clean) + end + + it 'produces different hashes for different header sets' do + a = BSV::X402::RequestBinding.compute_headers_hash('host' => 'a.example.com') + b = BSV::X402::RequestBinding.compute_headers_hash('host' => 'b.example.com') + expect(a).not_to eq(b) + end + + it 'accepts symbol keys' do + result_sym = BSV::X402::RequestBinding.compute_headers_hash(host: 'example.com') + result_str = BSV::X402::RequestBinding.compute_headers_hash('host' => 'example.com') + expect(result_sym).to eq(result_str) + end + end + + describe '.compute_body_hash' do + it 'returns the SHA-256 of the empty string for a nil body' do + empty_sha256 = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' + expect(BSV::X402::RequestBinding.compute_body_hash(nil)).to eq(empty_sha256) + end + + it 'returns the SHA-256 of the empty string for an empty body' do + empty_sha256 = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' + expect(BSV::X402::RequestBinding.compute_body_hash('')).to eq(empty_sha256) + end + + it 'returns a deterministic hash for a given body' do + body = 'hello world' + result_a = BSV::X402::RequestBinding.compute_body_hash(body) + result_b = BSV::X402::RequestBinding.compute_body_hash(body) + expect(result_a).to eq(result_b) + end + + it 'produces different hashes for different bodies' do + a = BSV::X402::RequestBinding.compute_body_hash('body a') + b = BSV::X402::RequestBinding.compute_body_hash('body b') + expect(a).not_to eq(b) + end + + it 'returns a lowercase hex string of length 64' do + result = BSV::X402::RequestBinding.compute_body_hash('data') + expect(result).to match(/\A[0-9a-f]{64}\z/) + end + end +end diff --git a/spec/x402/spec_helper.rb b/spec/x402/spec_helper.rb new file mode 100644 index 0000000..9822241 --- /dev/null +++ b/spec/x402/spec_helper.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'bsv-x402' + +RSpec.configure do |config| + config.expect_with :rspec do |expectations| + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + config.mock_with :rspec do |mocks| + mocks.verify_partial_doubles = true + end + + config.shared_context_metadata_behavior = :apply_to_host_groups + config.disable_monkey_patching! + config.order = :random + Kernel.srand config.seed +end diff --git a/spec/x402/transaction_builder_spec.rb b/spec/x402/transaction_builder_spec.rb new file mode 100644 index 0000000..85f9267 --- /dev/null +++ b/spec/x402/transaction_builder_spec.rb @@ -0,0 +1,227 @@ +# frozen_string_literal: true + +require_relative 'spec_helper' + +RSpec.describe 'BSV::X402::TransactionBuilder' do + # ------------------------------------------------------------------- + # Shared fixtures + # ------------------------------------------------------------------- + + # Keys generated once per example group for speed. + let(:nonce_key) { BSV::Primitives::PrivateKey.generate } + let(:funding_key) { BSV::Primitives::PrivateKey.generate } + + # Nonce UTXO — P2PKH-locked to the nonce key. + let(:nonce_locking_script) { BSV::Script::Script.p2pkh_lock(nonce_key.public_key.hash160) } + let(:nonce_txid) { 'aa' * 32 } + let(:nonce_vout) { 0 } + let(:nonce_sats) { 10 } + + let(:nonce_utxo) do + BSV::X402::NonceUTXO.new( + txid: nonce_txid, + vout: nonce_vout, + satoshis: nonce_sats, + locking_script_hex: nonce_locking_script.to_hex + ) + end + + # Payee script — a simple P2PKH. + let(:payee_key) { BSV::Primitives::PrivateKey.generate } + let(:payee_script_hex) { BSV::Script::Script.p2pkh_lock(payee_key.public_key.hash160).to_hex } + let(:amount_sats) { 500 } + + # Funding UTXO — P2PKH-locked to the funding key. + let(:funding_locking_script) { BSV::Script::Script.p2pkh_lock(funding_key.public_key.hash160) } + let(:funding_utxo) do + { + txid: 'bb' * 32, + vout: 0, + satoshis: 10_000, + locking_script_hex: funding_locking_script.to_hex, + private_key: funding_key + } + end + + # Change script. + let(:change_key) { BSV::Primitives::PrivateKey.generate } + let(:change_script_hex) { BSV::Script::Script.p2pkh_lock(change_key.public_key.hash160).to_hex } + + # Challenge + let(:challenge) do + BSV::X402::Challenge.new( + v: 1, + scheme: 'bsv-tx-v1', + domain: 'example.com', + method: 'GET', + path: '/resource', + query: '', + req_headers_sha256: 'a' * 64, + req_body_sha256: 'e' * 64, + amount_sats: amount_sats, + payee_locking_script_hex: payee_script_hex, + nonce_utxo: nonce_utxo, + expires_at: Time.now.to_i + 3600, + require_mempool_accept: false + ) + end + + # P2PKH nonce unlocker — signs using the nonce key. + let(:nonce_unlocker) do + p2pkh = BSV::Transaction::P2PKH.new(nonce_key) + ->(tx, idx) { p2pkh.sign(tx, idx) } + end + + # ------------------------------------------------------------------- + # Helper: build with defaults, allowing overrides. + # ------------------------------------------------------------------- + + def build(**overrides) + BSV::X402::TransactionBuilder.build( + challenge: overrides.fetch(:challenge, challenge), + funding_utxos: overrides.fetch(:funding_utxos, [funding_utxo]), + nonce_unlocker: overrides.fetch(:nonce_unlocker, nonce_unlocker), + change_locking_script_hex: overrides.fetch(:change_locking_script_hex, change_script_hex) + ) + end + + # ------------------------------------------------------------------- + # Happy path + # ------------------------------------------------------------------- + + describe 'nonce UTXO input' do + it 'adds the nonce UTXO as the first input' do + tx = build + expect(tx.inputs.first.txid_hex).to eq(nonce_txid) + expect(tx.inputs.first.prev_tx_out_index).to eq(nonce_vout) + end + + it 'calls the nonce_unlocker to produce an unlocking script for input 0' do + called_with = [] + custom_unlocker = lambda do |tx, idx| + called_with << [tx, idx] + BSV::Transaction::P2PKH.new(nonce_key).sign(tx, idx) + end + + build(nonce_unlocker: custom_unlocker) + + expect(called_with.length).to eq(1) + expect(called_with.first[1]).to eq(0) + end + + it 'sets an unlocking script on the nonce input after building' do + tx = build + expect(tx.inputs.first.unlocking_script).not_to be_nil + end + end + + describe 'funding UTXOs' do + it 'includes each funding UTXO as an input after the nonce' do + tx = build + expect(tx.inputs.length).to eq(2) # nonce + 1 funding + expect(tx.inputs[1].txid_hex).to eq(funding_utxo[:txid]) + end + + it 'adds multiple funding inputs when multiple UTXOs are provided' do + second_key = BSV::Primitives::PrivateKey.generate + second_lock = BSV::Script::Script.p2pkh_lock(second_key.public_key.hash160) + second_utxo = { + txid: 'cc' * 32, + vout: 1, + satoshis: 5_000, + locking_script_hex: second_lock.to_hex, + private_key: second_key + } + + tx = build(funding_utxos: [funding_utxo, second_utxo]) + expect(tx.inputs.length).to eq(3) # nonce + 2 funding + end + end + + describe 'payment output' do + it 'includes a payment output with the payee locking script' do + tx = build + matching = tx.outputs.find { |o| o.locking_script.to_hex == payee_script_hex } + expect(matching).not_to be_nil + end + + it 'sets the payment output satoshis to amount_sats from the challenge' do + tx = build + matching = tx.outputs.find { |o| o.locking_script.to_hex == payee_script_hex } + expect(matching.satoshis).to eq(amount_sats) + end + end + + describe 'change output' do + it 'includes a change output when change_locking_script_hex is provided and surplus exists' do + tx = build + change_output = tx.outputs.find { |o| o.locking_script.to_hex == change_script_hex } + expect(change_output).not_to be_nil + end + + it 'omits the change output when change_locking_script_hex is nil' do + tx = build(change_locking_script_hex: nil) + expect(tx.outputs.length).to eq(1) # only the payment output + end + + it 'omits the change output when there is no surplus after fee' do + # Use only nonce satoshis — just enough to cover the payment amount (barely). + tiny_utxo = { + txid: 'dd' * 32, + vout: 0, + satoshis: amount_sats, # exactly enough, no room for fee + locking_script_hex: funding_locking_script.to_hex, + private_key: funding_key + } + # With nonce (10 sats) + tiny_utxo (500 sats) = 510 sats total. + # Fee will consume the surplus, so change should be removed. + tx = build(funding_utxos: [tiny_utxo]) + change_output = tx.outputs.find { |o| o.locking_script.to_hex == change_script_hex } + expect(change_output).to be_nil + end + end + + describe 'signing' do + it 'produces a transaction with unlocking scripts on all inputs' do + tx = build + tx.inputs.each_with_index do |input, i| + expect(input.unlocking_script).not_to be_nil, "input #{i} has no unlocking script" + end + end + end + + describe 'error cases' do + it 'raises ChallengeExpiredError when the challenge has expired' do + expired_challenge = BSV::X402::Challenge.new( + v: 1, + scheme: 'bsv-tx-v1', + domain: 'example.com', + method: 'GET', + path: '/resource', + query: '', + req_headers_sha256: 'a' * 64, + req_body_sha256: 'e' * 64, + amount_sats: 100, + payee_locking_script_hex: payee_script_hex, + nonce_utxo: nonce_utxo, + expires_at: Time.now.to_i - 1, + require_mempool_accept: false + ) + + expect { build(challenge: expired_challenge) }.to raise_error(BSV::X402::ChallengeExpiredError) + end + + it 'raises InsufficientFundsError when UTXOs cannot cover the payment amount' do + poor_utxo = { + txid: 'ee' * 32, + vout: 0, + satoshis: 1, + locking_script_hex: funding_locking_script.to_hex, + private_key: funding_key + } + + # nonce (10) + poor (1) = 11 sats, but amount_sats is 500. + expect { build(funding_utxos: [poor_utxo]) }.to raise_error(BSV::X402::InsufficientFundsError) + end + end +end diff --git a/spec/x402/verification_result_spec.rb b/spec/x402/verification_result_spec.rb new file mode 100644 index 0000000..781b923 --- /dev/null +++ b/spec/x402/verification_result_spec.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require_relative 'spec_helper' + +RSpec.describe 'BSV::X402::VerificationResult' do + describe '.success' do + subject(:result) { BSV::X402::VerificationResult.success } + + it 'is frozen' do + expect(result).to be_frozen + end + + it '#success? returns true' do + expect(result.success?).to be(true) + end + + it '#failure? returns false' do + expect(result.failure?).to be(false) + end + + it '#step is nil' do + expect(result.step).to be_nil + end + + it '#reason is nil' do + expect(result.reason).to be_nil + end + + it '#http_status returns 200' do + expect(result.http_status).to eq(200) + end + end + + describe '.failure' do + subject(:result) { BSV::X402::VerificationResult.failure(step: 3, reason: 'challenge hash mismatch') } + + it 'is frozen' do + expect(result).to be_frozen + end + + it '#success? returns false' do + expect(result.success?).to be(false) + end + + it '#failure? returns true' do + expect(result.failure?).to be(true) + end + + it '#step returns the given step' do + expect(result.step).to eq(3) + end + + it '#reason returns the given reason' do + expect(result.reason).to eq('challenge hash mismatch') + end + end + + describe '#http_status' do + context 'when success' do + it 'returns 200' do + expect(BSV::X402::VerificationResult.success.http_status).to eq(200) + end + end + + context 'when step 1 (malformed proof structure)' do + it 'returns 400' do + expect(BSV::X402::VerificationResult.failure(step: 1, reason: 'x').http_status).to eq(400) + end + end + + context 'when step 2 (wrong version or scheme)' do + it 'returns 400' do + expect(BSV::X402::VerificationResult.failure(step: 2, reason: 'x').http_status).to eq(400) + end + end + + context 'when step 3 (challenge sha256 mismatch)' do + it 'returns 402' do + expect(BSV::X402::VerificationResult.failure(step: 3, reason: 'x').http_status).to eq(402) + end + end + + context 'when step 4 (request binding mismatch)' do + it 'returns 400' do + expect(BSV::X402::VerificationResult.failure(step: 4, reason: 'x').http_status).to eq(400) + end + end + + context 'when step 5 (challenge expired)' do + it 'returns 402' do + expect(BSV::X402::VerificationResult.failure(step: 5, reason: 'x').http_status).to eq(402) + end + end + + context 'when step 6 (bad transaction or txid mismatch)' do + it 'returns 400' do + expect(BSV::X402::VerificationResult.failure(step: 6, reason: 'x').http_status).to eq(400) + end + end + + context 'when step 7 (nonce UTXO not spent)' do + it 'returns 402' do + expect(BSV::X402::VerificationResult.failure(step: 7, reason: 'x').http_status).to eq(402) + end + end + + context 'when step 8 (insufficient payment)' do + it 'returns 402' do + expect(BSV::X402::VerificationResult.failure(step: 8, reason: 'x').http_status).to eq(402) + end + end + + context 'when step 9 (mempool rejection)' do + it 'returns 402' do + expect(BSV::X402::VerificationResult.failure(step: 9, reason: 'x').http_status).to eq(402) + end + end + end +end diff --git a/spec/x402/verifier_spec.rb b/spec/x402/verifier_spec.rb new file mode 100644 index 0000000..d5c5986 --- /dev/null +++ b/spec/x402/verifier_spec.rb @@ -0,0 +1,819 @@ +# frozen_string_literal: true + +require_relative 'spec_helper' + +RSpec.describe 'BSV::X402::Verifier' do + # ------------------------------------------------------------------- + # Shared fixtures + # ------------------------------------------------------------------- + + # Nonce UTXO used in challenge and consumed by the test transaction. + let(:nonce_txid) { 'aabbccdd' * 8 } + let(:nonce_vout) { 0 } + let(:nonce_sats) { 1 } + let(:nonce_script) { "76a914#{'aa' * 20}88ac" } + + let(:nonce_utxo) do + BSV::X402::NonceUTXO.new( + txid: nonce_txid, + vout: nonce_vout, + satoshis: nonce_sats, + locking_script_hex: nonce_script + ) + end + + # Payee locking script hex. + let(:payee_script_hex) { "76a914#{'11' * 20}88ac" } + + # Required payment amount. + let(:amount_sats) { 500 } + + # A future expiry timestamp — challenge is not expired by default. + let(:expires_at) { Time.now.to_i + 3600 } + + # Challenge bound to a specific HTTP request. + let(:challenge) do + BSV::X402::Challenge.new( + v: 1, + scheme: 'bsv-tx-v1', + domain: 'example.com', + method: 'GET', + path: '/api/resource', + query: 'page=1', + req_headers_sha256: 'a' * 64, + req_body_sha256: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', + amount_sats: amount_sats, + payee_locking_script_hex: payee_script_hex, + nonce_utxo: nonce_utxo, + expires_at: expires_at, + require_mempool_accept: false + ) + end + + # Build a valid BSV transaction: + # - input spending the nonce UTXO + # - output paying the required amount to the payee script + let(:tx) do + t = BSV::Transaction::Transaction.new + t.add_input( + BSV::Transaction::TransactionInput.new( + prev_tx_id: BSV::Transaction::TransactionInput.txid_from_hex(nonce_txid), + prev_tx_out_index: nonce_vout + ) + ) + t.add_output( + BSV::Transaction::TransactionOutput.new( + satoshis: amount_sats, + locking_script: BSV::Script::Script.from_binary([payee_script_hex].pack('H*')) + ) + ) + t + end + + let(:raw_tx_bytes) { tx.to_binary } + let(:txid_hex) { tx.txid_hex } + let(:rawtx_b64) { BSV::X402::Encoding.base64_encode(raw_tx_bytes) } + + # A valid proof that references the challenge and the test transaction. + let(:proof) do + BSV::X402::Proof.new( + v: 1, + scheme: 'bsv-tx-v1', + challenge_sha256: challenge.sha256, + request: { + 'method' => 'GET', + 'path' => '/api/resource', + 'query' => 'page=1', + 'req_headers_sha256' => 'a' * 64, + 'req_body_sha256' => 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' + }, + payment: { + 'txid' => txid_hex, + 'rawtx_b64' => rawtx_b64 + } + ) + end + + # The actual inbound HTTP request info supplied to the verifier. + let(:request) do + { + method: 'GET', + path: '/api/resource', + query: 'page=1', + headers_sha256: 'a' * 64, + body_sha256: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' + } + end + + # Helper: run the verifier with the current let bindings. + def verify(**overrides) + BSV::X402::Verifier.verify( + challenge: overrides.fetch(:challenge, challenge), + proof: overrides.fetch(:proof, proof), + request: overrides.fetch(:request, request), + settlement_verifier: overrides.fetch(:settlement_verifier, nil) + ) + end + + # ------------------------------------------------------------------- + # Happy path + # ------------------------------------------------------------------- + + describe 'happy path' do + it 'returns success when all steps pass' do + result = verify + expect(result.success?).to be(true) + expect(result.step).to be_nil + expect(result.reason).to be_nil + end + + it 'returns a VerificationResult' do + expect(verify).to be_a(BSV::X402::VerificationResult) + end + end + + # ------------------------------------------------------------------- + # Step 1: proof structure + # ------------------------------------------------------------------- + + describe 'step 1 — proof structure' do + it 'fails with step 1 and status 400 when proof is nil' do + result = verify(proof: nil) + expect(result.failure?).to be(true) + expect(result.step).to eq(1) + expect(result.http_status).to eq(400) + end + end + + # ------------------------------------------------------------------- + # Step 2: version and scheme + # ------------------------------------------------------------------- + + describe 'step 2 — version and scheme' do + # Proof.new validates v and scheme, so valid Proof objects always satisfy + # step 2. We use a simple duck-typed double to test the verifier's + # defence-in-depth guard independently of the Proof constructor. + + def proof_double(v:, scheme:) + dbl = instance_double(BSV::X402::Proof) + allow(dbl).to receive_messages(v: v, scheme: scheme, challenge_sha256: challenge.sha256, request: proof.request, payment: proof.payment, decoded_tx: raw_tx_bytes, valid_txid?: true) + dbl + end + + it 'fails with step 2 and status 400 when proof.v is wrong' do + bad_proof = proof_double(v: 99, scheme: 'bsv-tx-v1') + + result = verify(proof: bad_proof) + expect(result.step).to eq(2) + expect(result.http_status).to eq(400) + expect(result.reason).to match(/v must be 1/) + end + + it 'fails with step 2 and status 400 when proof.scheme is wrong' do + bad_proof = proof_double(v: 1, scheme: 'other-scheme') + + result = verify(proof: bad_proof) + expect(result.step).to eq(2) + expect(result.http_status).to eq(400) + end + end + + # ------------------------------------------------------------------- + # Step 3: challenge SHA-256 + # ------------------------------------------------------------------- + + describe 'step 3 — challenge_sha256' do + it 'fails with step 3 and status 402 when challenge_sha256 does not match' do + bad_proof = BSV::X402::Proof.new( + v: 1, scheme: 'bsv-tx-v1', + challenge_sha256: 'ff' * 32, + request: proof.request, payment: proof.payment + ) + + result = verify(proof: bad_proof) + expect(result.step).to eq(3) + expect(result.http_status).to eq(402) + end + + it 'fails when the challenge itself is different (tampered field)' do + tampered_challenge = BSV::X402::Challenge.new( + v: 1, scheme: 'bsv-tx-v1', domain: 'other.com', + method: 'GET', path: '/api/resource', query: 'page=1', + req_headers_sha256: 'a' * 64, + req_body_sha256: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', + amount_sats: amount_sats, + payee_locking_script_hex: payee_script_hex, + nonce_utxo: nonce_utxo, + expires_at: expires_at, + require_mempool_accept: false + ) + # proof.challenge_sha256 was computed from the original challenge, not the tampered one + result = verify(challenge: tampered_challenge) + expect(result.step).to eq(3) + end + end + + # ------------------------------------------------------------------- + # Step 4: request binding + # ------------------------------------------------------------------- + + describe 'step 4 — request binding' do + context 'when proof.request.method does not match challenge' do + it 'fails with step 4 and status 400' do + bad_proof = BSV::X402::Proof.new( + v: 1, scheme: 'bsv-tx-v1', + challenge_sha256: challenge.sha256, + request: proof.request.merge('method' => 'POST'), + payment: proof.payment + ) + result = verify(proof: bad_proof) + expect(result.step).to eq(4) + expect(result.http_status).to eq(400) + end + end + + context 'when actual request method does not match challenge' do + it 'fails with step 4 and status 400' do + bad_request = request.merge(method: 'POST') + result = verify(request: bad_request) + expect(result.step).to eq(4) + expect(result.http_status).to eq(400) + end + end + + context 'when proof.request.path does not match challenge' do + it 'fails with step 4 and status 400' do + bad_proof = BSV::X402::Proof.new( + v: 1, scheme: 'bsv-tx-v1', + challenge_sha256: challenge.sha256, + request: proof.request.merge('path' => '/other/path'), + payment: proof.payment + ) + result = verify(proof: bad_proof) + expect(result.step).to eq(4) + end + end + + context 'when actual request path does not match challenge' do + it 'fails with step 4 and status 400' do + bad_request = request.merge(path: '/different') + result = verify(request: bad_request) + expect(result.step).to eq(4) + end + end + + context 'when proof.request.query does not match challenge' do + it 'fails with step 4 and status 400' do + bad_proof = BSV::X402::Proof.new( + v: 1, scheme: 'bsv-tx-v1', + challenge_sha256: challenge.sha256, + request: proof.request.merge('query' => 'page=99'), + payment: proof.payment + ) + result = verify(proof: bad_proof) + expect(result.step).to eq(4) + end + end + + context 'when actual request query does not match challenge' do + it 'fails with step 4 and status 400' do + bad_request = request.merge(query: 'page=99') + result = verify(request: bad_request) + expect(result.step).to eq(4) + end + end + + context 'when proof.request.req_headers_sha256 does not match challenge' do + it 'fails with step 4 and status 400' do + bad_proof = BSV::X402::Proof.new( + v: 1, scheme: 'bsv-tx-v1', + challenge_sha256: challenge.sha256, + request: proof.request.merge('req_headers_sha256' => 'b' * 64), + payment: proof.payment + ) + result = verify(proof: bad_proof) + expect(result.step).to eq(4) + end + end + + context 'when actual request headers hash does not match challenge' do + it 'fails with step 4 and status 400' do + bad_request = request.merge(headers_sha256: 'c' * 64) + result = verify(request: bad_request) + expect(result.step).to eq(4) + end + end + + context 'when proof.request.req_body_sha256 does not match challenge' do + it 'fails with step 4 and status 400' do + bad_proof = BSV::X402::Proof.new( + v: 1, scheme: 'bsv-tx-v1', + challenge_sha256: challenge.sha256, + request: proof.request.merge('req_body_sha256' => 'd' * 64), + payment: proof.payment + ) + result = verify(proof: bad_proof) + expect(result.step).to eq(4) + end + end + + context 'when actual request body hash does not match challenge' do + it 'fails with step 4 and status 400' do + bad_request = request.merge(body_sha256: 'd' * 64) + result = verify(request: bad_request) + expect(result.step).to eq(4) + end + end + end + + # ------------------------------------------------------------------- + # Step 5: expiry + # ------------------------------------------------------------------- + + describe 'step 5 — expiry' do + it 'fails with step 5 and status 402 when challenge has expired' do + expired_challenge = BSV::X402::Challenge.new( + v: 1, scheme: 'bsv-tx-v1', domain: 'example.com', + method: 'GET', path: '/api/resource', query: 'page=1', + req_headers_sha256: 'a' * 64, + req_body_sha256: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', + amount_sats: amount_sats, + payee_locking_script_hex: payee_script_hex, + nonce_utxo: nonce_utxo, + expires_at: Time.now.to_i - 1, + require_mempool_accept: false + ) + # Recompute proof with the expired challenge's sha256 + expired_proof = BSV::X402::Proof.new( + v: 1, scheme: 'bsv-tx-v1', + challenge_sha256: expired_challenge.sha256, + request: proof.request, payment: proof.payment + ) + + result = BSV::X402::Verifier.verify( + challenge: expired_challenge, + proof: expired_proof, + request: request + ) + expect(result.step).to eq(5) + expect(result.http_status).to eq(402) + end + + it 'passes when expires_at equals current time (boundary — not yet expired)' do + now = Time.now.to_i + boundary_challenge = BSV::X402::Challenge.new( + v: 1, scheme: 'bsv-tx-v1', domain: 'example.com', + method: 'GET', path: '/api/resource', query: 'page=1', + req_headers_sha256: 'a' * 64, + req_body_sha256: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', + amount_sats: amount_sats, + payee_locking_script_hex: payee_script_hex, + nonce_utxo: nonce_utxo, + expires_at: now, + require_mempool_accept: false + ) + boundary_proof = BSV::X402::Proof.new( + v: 1, scheme: 'bsv-tx-v1', + challenge_sha256: boundary_challenge.sha256, + request: proof.request, payment: proof.payment + ) + + result = BSV::X402::Verifier.verify( + challenge: boundary_challenge, + proof: boundary_proof, + request: request + ) + # expired? returns true only when now > expires_at, so == is not expired + expect(result.step).not_to eq(5) + end + end + + # ------------------------------------------------------------------- + # Step 6: transaction decode and txid verification + # ------------------------------------------------------------------- + + describe 'step 6 — transaction decode and txid' do + it 'fails with step 6 and status 400 when rawtx_b64 is malformed' do + bad_proof = BSV::X402::Proof.new( + v: 1, scheme: 'bsv-tx-v1', + challenge_sha256: challenge.sha256, + request: proof.request, + payment: { 'txid' => txid_hex, 'rawtx_b64' => 'not-valid-base64!!!' } + ) + + result = verify(proof: bad_proof) + expect(result.step).to eq(6) + expect(result.http_status).to eq(400) + end + + it 'fails with step 6 and status 400 when rawtx_b64 is valid base64 but not a valid transaction' do + # Base64 of 5 zero bytes — too short to be a valid transaction + bad_tx_b64 = BSV::X402::Encoding.base64_encode("\x00" * 5) + bad_proof = BSV::X402::Proof.new( + v: 1, scheme: 'bsv-tx-v1', + challenge_sha256: challenge.sha256, + request: proof.request, + payment: { 'txid' => txid_hex, 'rawtx_b64' => bad_tx_b64 } + ) + + result = verify(proof: bad_proof) + expect(result.step).to eq(6) + expect(result.http_status).to eq(400) + end + + it 'fails with step 6 and status 400 when txid does not match the decoded transaction' do + wrong_txid_proof = BSV::X402::Proof.new( + v: 1, scheme: 'bsv-tx-v1', + challenge_sha256: challenge.sha256, + request: proof.request, + payment: { 'txid' => 'ff' * 32, 'rawtx_b64' => rawtx_b64 } + ) + + result = verify(proof: wrong_txid_proof) + expect(result.step).to eq(6) + expect(result.http_status).to eq(400) + end + end + + # ------------------------------------------------------------------- + # Step 7: nonce UTXO is spent + # ------------------------------------------------------------------- + + describe 'step 7 — nonce UTXO spend' do + it 'fails with step 7 and status 402 when no input spends the nonce UTXO' do + # Build a transaction spending a different outpoint + other_tx = BSV::Transaction::Transaction.new + other_tx.add_input( + BSV::Transaction::TransactionInput.new( + prev_tx_id: BSV::Transaction::TransactionInput.txid_from_hex('00' * 32), + prev_tx_out_index: 0 + ) + ) + other_tx.add_output( + BSV::Transaction::TransactionOutput.new( + satoshis: amount_sats, + locking_script: BSV::Script::Script.from_binary([payee_script_hex].pack('H*')) + ) + ) + other_raw = other_tx.to_binary + other_proof = BSV::X402::Proof.new( + v: 1, scheme: 'bsv-tx-v1', + challenge_sha256: challenge.sha256, + request: proof.request, + payment: { + 'txid' => other_tx.txid_hex, + 'rawtx_b64' => BSV::X402::Encoding.base64_encode(other_raw) + } + ) + + result = verify(proof: other_proof) + expect(result.step).to eq(7) + expect(result.http_status).to eq(402) + end + + it 'fails when input txid matches but vout is different' do + other_tx = BSV::Transaction::Transaction.new + # Same txid, wrong vout + other_tx.add_input( + BSV::Transaction::TransactionInput.new( + prev_tx_id: BSV::Transaction::TransactionInput.txid_from_hex(nonce_txid), + prev_tx_out_index: nonce_vout + 1 + ) + ) + other_tx.add_output( + BSV::Transaction::TransactionOutput.new( + satoshis: amount_sats, + locking_script: BSV::Script::Script.from_binary([payee_script_hex].pack('H*')) + ) + ) + other_raw = other_tx.to_binary + other_proof = BSV::X402::Proof.new( + v: 1, scheme: 'bsv-tx-v1', + challenge_sha256: challenge.sha256, + request: proof.request, + payment: { + 'txid' => other_tx.txid_hex, + 'rawtx_b64' => BSV::X402::Encoding.base64_encode(other_raw) + } + ) + + result = verify(proof: other_proof) + expect(result.step).to eq(7) + end + + it 'passes when nonce input is not at index 0 (any position acceptable)' do + multi_input_tx = BSV::Transaction::Transaction.new + # First input: some other spend + multi_input_tx.add_input( + BSV::Transaction::TransactionInput.new( + prev_tx_id: BSV::Transaction::TransactionInput.txid_from_hex('cc' * 32), + prev_tx_out_index: 0 + ) + ) + # Second input: nonce UTXO + multi_input_tx.add_input( + BSV::Transaction::TransactionInput.new( + prev_tx_id: BSV::Transaction::TransactionInput.txid_from_hex(nonce_txid), + prev_tx_out_index: nonce_vout + ) + ) + multi_input_tx.add_output( + BSV::Transaction::TransactionOutput.new( + satoshis: amount_sats, + locking_script: BSV::Script::Script.from_binary([payee_script_hex].pack('H*')) + ) + ) + multi_raw = multi_input_tx.to_binary + multi_proof = BSV::X402::Proof.new( + v: 1, scheme: 'bsv-tx-v1', + challenge_sha256: challenge.sha256, + request: proof.request, + payment: { + 'txid' => multi_input_tx.txid_hex, + 'rawtx_b64' => BSV::X402::Encoding.base64_encode(multi_raw) + } + ) + + result = verify(proof: multi_proof) + expect(result.success?).to be(true) + end + end + + # ------------------------------------------------------------------- + # Step 8: payment output + # ------------------------------------------------------------------- + + describe 'step 8 — payment output' do + it 'fails with step 8 and status 402 when no output matches the payee script' do + wrong_script_hex = "76a914#{'99' * 20}88ac" + wrong_tx = BSV::Transaction::Transaction.new + wrong_tx.add_input( + BSV::Transaction::TransactionInput.new( + prev_tx_id: BSV::Transaction::TransactionInput.txid_from_hex(nonce_txid), + prev_tx_out_index: nonce_vout + ) + ) + wrong_tx.add_output( + BSV::Transaction::TransactionOutput.new( + satoshis: amount_sats, + locking_script: BSV::Script::Script.from_binary([wrong_script_hex].pack('H*')) + ) + ) + wrong_raw = wrong_tx.to_binary + wrong_proof = BSV::X402::Proof.new( + v: 1, scheme: 'bsv-tx-v1', + challenge_sha256: challenge.sha256, + request: proof.request, + payment: { + 'txid' => wrong_tx.txid_hex, + 'rawtx_b64' => BSV::X402::Encoding.base64_encode(wrong_raw) + } + ) + + result = verify(proof: wrong_proof) + expect(result.step).to eq(8) + expect(result.http_status).to eq(402) + end + + it 'fails with step 8 and status 402 when output has correct script but insufficient satoshis' do + low_tx = BSV::Transaction::Transaction.new + low_tx.add_input( + BSV::Transaction::TransactionInput.new( + prev_tx_id: BSV::Transaction::TransactionInput.txid_from_hex(nonce_txid), + prev_tx_out_index: nonce_vout + ) + ) + low_tx.add_output( + BSV::Transaction::TransactionOutput.new( + satoshis: amount_sats - 1, + locking_script: BSV::Script::Script.from_binary([payee_script_hex].pack('H*')) + ) + ) + low_raw = low_tx.to_binary + low_proof = BSV::X402::Proof.new( + v: 1, scheme: 'bsv-tx-v1', + challenge_sha256: challenge.sha256, + request: proof.request, + payment: { + 'txid' => low_tx.txid_hex, + 'rawtx_b64' => BSV::X402::Encoding.base64_encode(low_raw) + } + ) + + result = verify(proof: low_proof) + expect(result.step).to eq(8) + expect(result.reason).to match(/satoshis/) + end + + it 'fails when one output has correct script (too low sats) and another has enough sats (wrong script)' do + # Neither single output satisfies both requirements. + split_tx = BSV::Transaction::Transaction.new + split_tx.add_input( + BSV::Transaction::TransactionInput.new( + prev_tx_id: BSV::Transaction::TransactionInput.txid_from_hex(nonce_txid), + prev_tx_out_index: nonce_vout + ) + ) + # Output 0: correct script, insufficient amount + split_tx.add_output( + BSV::Transaction::TransactionOutput.new( + satoshis: amount_sats - 1, + locking_script: BSV::Script::Script.from_binary([payee_script_hex].pack('H*')) + ) + ) + # Output 1: wrong script, sufficient amount + split_tx.add_output( + BSV::Transaction::TransactionOutput.new( + satoshis: amount_sats, + locking_script: BSV::Script::Script.from_binary(["76a914#{'99' * 20}88ac"].pack('H*')) + ) + ) + split_raw = split_tx.to_binary + split_proof = BSV::X402::Proof.new( + v: 1, scheme: 'bsv-tx-v1', + challenge_sha256: challenge.sha256, + request: proof.request, + payment: { + 'txid' => split_tx.txid_hex, + 'rawtx_b64' => BSV::X402::Encoding.base64_encode(split_raw) + } + ) + + result = verify(proof: split_proof) + expect(result.step).to eq(8) + end + + it 'passes when a single output satisfies both script and amount' do + result = verify + expect(result.success?).to be(true) + end + + it 'passes when satoshis exactly equal amount_sats (boundary)' do + # The default tx already has exactly amount_sats — just confirm explicitly + expect(tx.outputs[0].satoshis).to eq(amount_sats) + expect(verify.success?).to be(true) + end + end + + # ------------------------------------------------------------------- + # Step 9: mempool acceptance + # ------------------------------------------------------------------- + + describe 'step 9 — mempool acceptance' do + let(:mempool_challenge) do + BSV::X402::Challenge.new( + v: 1, scheme: 'bsv-tx-v1', domain: 'example.com', + method: 'GET', path: '/api/resource', query: 'page=1', + req_headers_sha256: 'a' * 64, + req_body_sha256: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', + amount_sats: amount_sats, + payee_locking_script_hex: payee_script_hex, + nonce_utxo: nonce_utxo, + expires_at: expires_at, + require_mempool_accept: true + ) + end + + let(:mempool_proof) do + BSV::X402::Proof.new( + v: 1, scheme: 'bsv-tx-v1', + challenge_sha256: mempool_challenge.sha256, + request: proof.request, payment: proof.payment + ) + end + + it 'skips step 9 when require_mempool_accept is false' do + called = false + settlement_verifier = lambda { |_raw| + called = true + true + } + + result = verify(settlement_verifier: settlement_verifier) + expect(result.success?).to be(true) + expect(called).to be(false) + end + + it 'skips step 9 when no settlement_verifier is provided even if require_mempool_accept is true' do + result = BSV::X402::Verifier.verify( + challenge: mempool_challenge, + proof: mempool_proof, + request: request, + settlement_verifier: nil + ) + expect(result.success?).to be(true) + end + + it 'passes when settlement_verifier returns true' do + settlement_verifier = ->(_raw) { true } + + result = BSV::X402::Verifier.verify( + challenge: mempool_challenge, + proof: mempool_proof, + request: request, + settlement_verifier: settlement_verifier + ) + expect(result.success?).to be(true) + end + + it 'fails with step 9 and status 402 when settlement_verifier returns false' do + settlement_verifier = ->(_raw) { false } + + result = BSV::X402::Verifier.verify( + challenge: mempool_challenge, + proof: mempool_proof, + request: request, + settlement_verifier: settlement_verifier + ) + expect(result.step).to eq(9) + expect(result.http_status).to eq(402) + end + + it 'passes the raw transaction bytes to the settlement_verifier' do + received = nil + settlement_verifier = lambda do |raw| + received = raw + true + end + + BSV::X402::Verifier.verify( + challenge: mempool_challenge, + proof: mempool_proof, + request: request, + settlement_verifier: settlement_verifier + ) + + expect(received).to eq(raw_tx_bytes) + end + end + + # ------------------------------------------------------------------- + # Step ordering invariant (V-1) + # ------------------------------------------------------------------- + + describe 'step ordering — invariant V-1' do + it 'fails at step 3 (challenge hash), not step 4 (binding), when both would fail' do + # Build a proof with wrong challenge_sha256 AND mismatched method. + bad_proof = BSV::X402::Proof.new( + v: 1, scheme: 'bsv-tx-v1', + challenge_sha256: 'ff' * 32, + request: proof.request.merge('method' => 'POST'), + payment: proof.payment + ) + result = verify(proof: bad_proof) + expect(result.step).to eq(3) + end + + it 'fails at step 4 (binding), not step 5 (expiry), when both would fail' do + expired_challenge = BSV::X402::Challenge.new( + v: 1, scheme: 'bsv-tx-v1', domain: 'example.com', + method: 'GET', path: '/api/resource', query: 'page=1', + req_headers_sha256: 'a' * 64, + req_body_sha256: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', + amount_sats: amount_sats, + payee_locking_script_hex: payee_script_hex, + nonce_utxo: nonce_utxo, + expires_at: Time.now.to_i - 1, + require_mempool_accept: false + ) + expired_proof = BSV::X402::Proof.new( + v: 1, scheme: 'bsv-tx-v1', + challenge_sha256: expired_challenge.sha256, + # mismatched path will fail step 4 + request: proof.request.merge('path' => '/wrong'), + payment: proof.payment + ) + bad_request = request.merge(path: '/wrong') + + result = BSV::X402::Verifier.verify( + challenge: expired_challenge, + proof: expired_proof, + request: bad_request + ) + expect(result.step).to eq(4) + end + + it 'fails at step 1 immediately, not at a later step, when proof is nil' do + result = verify(proof: nil) + expect(result.step).to eq(1) + end + end + + # ------------------------------------------------------------------- + # Request hash with string keys + # ------------------------------------------------------------------- + + describe 'string-keyed request hash' do + it 'accepts a string-keyed request hash' do + string_request = { + 'method' => 'GET', + 'path' => '/api/resource', + 'query' => 'page=1', + 'headers_sha256' => 'a' * 64, + 'body_sha256' => 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' + } + result = verify(request: string_request) + expect(result.success?).to be(true) + end + end +end diff --git a/spec/x402/x402_spec.rb b/spec/x402/x402_spec.rb new file mode 100644 index 0000000..d373136 --- /dev/null +++ b/spec/x402/x402_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require_relative 'spec_helper' + +RSpec.describe 'BSV::X402' do + it 'exposes the VERSION constant matching semver' do + expect(BSV::X402::VERSION).to match(/\A\d+\.\d+\.\d+\z/) + end + + it 'makes BSV::Primitives available transitively via bsv-sdk' do + expect(defined?(BSV::Primitives)).to eq('constant') + end + + it 'makes BSV::Transaction available transitively via bsv-sdk' do + expect(defined?(BSV::Transaction)).to eq('constant') + end + + describe '.configure' do + after { BSV::X402.reset_configuration! } + + it 'yields the configuration object' do + yielded = nil + BSV::X402.configure { |c| yielded = c } + expect(yielded).to be_a(BSV::X402::Configuration) + end + + it 'persists values set in the block' do + BSV::X402.configure { |c| c.amount_sats = 500 } + expect(BSV::X402.configuration.amount_sats).to eq(500) + end + end + + describe '.configuration' do + after { BSV::X402.reset_configuration! } + + it 'returns a Configuration instance' do + expect(BSV::X402.configuration).to be_a(BSV::X402::Configuration) + end + + it 'returns the same object on repeated calls' do + expect(BSV::X402.configuration).to equal(BSV::X402.configuration) + end + end + + describe '.reset_configuration!' do + it 'restores defaults' do + BSV::X402.configure { |c| c.amount_sats = 9999 } + BSV::X402.reset_configuration! + expect(BSV::X402.configuration.amount_sats).to eq(100) + end + end +end