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+.



68
69
70
71
72
# File 'lib/x402/bsv/brc121_gateway.rb', line 68

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+)



79
80
81
82
83
84
# File 'lib/x402/bsv/brc121_gateway.rb', line 79

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>)


89
90
91
# File 'lib/x402/bsv/brc121_gateway.rb', line 89

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



103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
# File 'lib/x402/bsv/brc121_gateway.rb', line 103

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_to_arc!(headers["x-bsv-beef"])

  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