Class: X402::BSV::BRC121Gateway

Inherits:
Object
  • Object
show all
Defined in:
lib/x402/bsv/brc121_gateway.rb

Overview

BRC-121 ("Simple 402 Payments") gateway for BSV settlement-gated HTTP.

BRC-121 is the BSV Association's simple HTTP payment protocol. The server is stateless: replay protection comes from a 30-second timestamp freshness window (§5 step 2) plus the wallet's handling of duplicate incoming transactions.

Unlike BRC-105, BRC-121 does not use server-generated derivation prefixes or a PrefixStore. The client generates the derivation prefix, chooses a timestamp as the suffix, constructs a BRC-29 payment, and submits it all in one round trip with no prior handshake.

Spec: https://hub.bsvblockchain.org/brc/payments/0121

== Headers

Challenge (server → client): x-bsv-sats — required satoshi amount x-bsv-server — server's identity public key (compressed hex)

Proof (client → server): x-bsv-beef — base64 BEEF transaction (the proof header) x-bsv-sender — client's identity public key (compressed hex) x-bsv-nonce — base64 BRC-29 derivation prefix x-bsv-time — decimal Unix millisecond timestamp x-bsv-vout — decimal output index of the payment

== Replay protection

BRC-121 §5 step 2: the +x-bsv-time+ header MUST be within 30 seconds of the server's current time. This is the primary defence against capture and delayed replay.

BRC-121 §5 step 5 also specifies checking +isMerge+ on the wallet's internalization result. The current Ruby +BSV::Wallet::WalletClient+ does not return an +isMerge+ field, so this gateway additionally uses an +X402::BSV::TxidStore+ to reject duplicate txids within the freshness window.

Implements the three-method gateway interface required by Middleware: challenge_headers(rack_request, route) → Hash proof_header_names → Array settle!(header_name, proof_payload, rack_request, route) → SettlementResult

Constant Summary collapse

PROOF_HEADER =
"x-bsv-beef"
FRESHNESS_WINDOW_MS =
30_000
COMPRESSED_PUBKEY_HEX =
/\A0[23][0-9a-f]{64}\z/
BASE64_NONCE =

BRC-29 derivation prefix is base64. Cap length at 128 chars (~96 bytes decoded) — well above realistic nonce sizes.

%r{\A[A-Za-z0-9+/]{1,128}={0,2}\z}
PROTOCOL =
"wallet payment"
CLIENT_HEADERS =
%w[x-bsv-beef x-bsv-sender x-bsv-nonce x-bsv-time x-bsv-vout].freeze

Instance Method Summary collapse

Constructor Details

#initialize(wallet:, txid_store: nil, arc_client: nil) ⇒ BRC121Gateway

Returns a new instance of BRC121Gateway.

Parameters:

  • wallet (#internalize_action, #get_public_key)

    BRC-100 wallet. Must respond to +#internalize_action(args)+ (per bsv-ruby-sdk +BSV::Wallet::WalletClient+) and +#get_public_key(identity_key: true)+ for the server identity key.

  • txid_store (#record_if_unseen!, nil) (defaults to: nil)

    replay protection for settled txids. Defaults to +X402::BSV::TxidStore::Memory.new+.

  • arc_client (#broadcast) (defaults to: nil)

    ARC client used by the vendor to broadcast the payment transaction before the wallet internalises it. Required at +#settle!+ time — a nil broadcaster silently breaks the NO PAY -> NO CONTENT invariant, so we fail loudly instead. Production wiring via +Configuration+ always supplies one; direct-construction unit tests must inject a double.



83
84
85
86
87
# File 'lib/x402/bsv/brc121_gateway.rb', line 83

def initialize(wallet:, txid_store: nil, arc_client: nil)
  @wallet = wallet
  @txid_store = txid_store || TxidStore::Memory.new
  @arc_client = arc_client
end

Instance Method Details

#challenge_headers(_rack_request, route) ⇒ Hash

Issue a 402 challenge with BRC-121 headers.

Parameters:

Returns:

  • (Hash)

    challenge headers (+x-bsv-sats+, +x-bsv-server+)



94
95
96
97
98
99
# File 'lib/x402/bsv/brc121_gateway.rb', line 94

def challenge_headers(_rack_request, route)
  {
    "x-bsv-sats" => route.resolve_amount_sats.to_s,
    "x-bsv-server" => server_identity_key
  }
end

#proof_header_namesArray<String>

Header names that carry the proof/payment from the client.

Returns:

  • (Array<String>)


104
105
106
# File 'lib/x402/bsv/brc121_gateway.rb', line 104

def proof_header_names
  [PROOF_HEADER]
end

#settle!(_header_name, _proof_payload, rack_request, route) ⇒ SettlementResult

Verify and internalise a BRC-121 payment.

Parameters:

  • _header_name (String)

    which proof header matched

  • _proof_payload (String)

    the +x-bsv-beef+ header value (read directly from the rack env alongside the other four client headers)

  • rack_request (Rack::Request)
  • route (X402::Configuration::Route)

Returns:

Raises:

  • (VerificationError)

    on missing/invalid headers, stale timestamp, insufficient payment, replay, or internalisation failure



118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
# File 'lib/x402/bsv/brc121_gateway.rb', line 118

def settle!(_header_name, _proof_payload, rack_request, route)
  required_sats = route.resolve_amount_sats
  headers = extract_client_headers!(rack_request)
  validate_sender_identity_key!(headers["x-bsv-sender"])
  validate_nonce!(headers["x-bsv-nonce"])
  validate_timestamp_freshness!(headers["x-bsv-time"])
  output_index = parse_output_index!(headers["x-bsv-vout"])
  subject_tx = parse_beef_transaction(headers["x-bsv-beef"])

  check_txid_unique!(subject_tx.txid_hex)

  paid_sats = verify_payment_output!(subject_tx, output_index, required_sats)

  broadcast!(subject_tx, route)

  result = internalize_payment!(
    beef_b64: headers["x-bsv-beef"],
    output_index: output_index,
    derivation_prefix: headers["x-bsv-nonce"],
    derivation_suffix: Base64.strict_encode64(headers["x-bsv-time"]),
    sender_identity_key: headers["x-bsv-sender"]
  )

  check_internalization_result!(result)

  log_settlement_success(subject_tx, paid_sats, required_sats)
  build_settlement_result(subject_tx, paid_sats)
end