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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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.
Expand Down Expand Up @@ -134,6 +146,7 @@ RSpec/MultipleExpectations:
- 'spec/bsv/wallet/**/*'
- 'spec/bsv/attest_spec.rb'
- 'spec/bsv/attest/**/*'
- 'spec/x402/**/*'
- 'spec/integration/**/*'
- 'spec/conformance/**/*'

Expand All @@ -142,6 +155,7 @@ RSpec/MultipleMemoizedHelpers:
- 'spec/bsv/primitives/**/*'
- 'spec/bsv/script/**/*'
- 'spec/bsv/transaction/**/*'
- 'spec/x402/**/*'
- 'spec/conformance/**/*'

RSpec/ExampleLength:
Expand All @@ -153,6 +167,7 @@ RSpec/ExampleLength:
- 'spec/bsv/wallet/**/*'
- 'spec/bsv/attest_spec.rb'
- 'spec/bsv/attest/**/*'
- 'spec/x402/**/*'
- 'spec/integration/**/*'
- 'spec/conformance/**/*'

Expand All @@ -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:
Expand Down
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
1 change: 1 addition & 0 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
31 changes: 31 additions & 0 deletions bsv-x402.gemspec
Original file line number Diff line number Diff line change
@@ -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
129 changes: 129 additions & 0 deletions examples/x402_client.rb
Original file line number Diff line number Diff line change
@@ -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
92 changes: 92 additions & 0 deletions examples/x402_rack_app.rb
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions lib/bsv-x402.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# frozen_string_literal: true

require 'bsv-sdk'
require_relative 'bsv/x402'
Loading
Loading