mpp-rb 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +128 -1
  3. data/lib/mpp/challenge.rb +31 -8
  4. data/lib/mpp/challenge_echo.rb +14 -3
  5. data/lib/mpp/challenge_id.rb +30 -6
  6. data/lib/mpp/client/transport.rb +1 -1
  7. data/lib/mpp/errors.rb +22 -0
  8. data/lib/mpp/http/headers.rb +61 -0
  9. data/lib/mpp/http.rb +8 -0
  10. data/lib/mpp/methods/evm/assets.rb +103 -0
  11. data/lib/mpp/methods/evm/authorization.rb +140 -0
  12. data/lib/mpp/methods/evm/charge_intent.rb +129 -0
  13. data/lib/mpp/methods/evm/evm_method.rb +133 -0
  14. data/lib/mpp/methods/evm.rb +67 -0
  15. data/lib/mpp/methods/stripe/charge_intent.rb +10 -8
  16. data/lib/mpp/methods/stripe/crypto_payment_recorder.rb +55 -0
  17. data/lib/mpp/methods/stripe/defaults.rb +1 -0
  18. data/lib/mpp/methods/stripe/machine_payments.rb +130 -0
  19. data/lib/mpp/methods/stripe/stripe_method.rb +22 -3
  20. data/lib/mpp/methods/stripe.rb +23 -0
  21. data/lib/mpp/methods/tempo/attribution.rb +3 -9
  22. data/lib/mpp/methods/tempo/client_method.rb +35 -6
  23. data/lib/mpp/methods/tempo/fee_payer_client.rb +120 -0
  24. data/lib/mpp/methods/tempo/intents.rb +80 -48
  25. data/lib/mpp/methods/tempo/proof.rb +39 -12
  26. data/lib/mpp/methods/tempo/relay.rb +257 -0
  27. data/lib/mpp/methods/tempo.rb +2 -0
  28. data/lib/mpp/parsing.rb +22 -2
  29. data/lib/mpp/receipt.rb +4 -3
  30. data/lib/mpp/server/accept_payment.rb +194 -0
  31. data/lib/mpp/server/compose.rb +399 -0
  32. data/lib/mpp/server/decorator.rb +30 -7
  33. data/lib/mpp/server/method.rb +15 -0
  34. data/lib/mpp/server/middleware.rb +160 -18
  35. data/lib/mpp/server/mpp_handler.rb +322 -34
  36. data/lib/mpp/server/result.rb +100 -0
  37. data/lib/mpp/server/verify.rb +24 -14
  38. data/lib/mpp/server.rb +4 -0
  39. data/lib/mpp/version.rb +1 -1
  40. data/lib/mpp/x402/facilitator.rb +127 -0
  41. data/lib/mpp/x402/header.rb +131 -0
  42. data/lib/mpp/x402/server.rb +247 -0
  43. data/lib/mpp/x402/types.rb +33 -0
  44. data/lib/mpp/x402.rb +12 -0
  45. data/lib/mpp.rb +16 -3
  46. metadata +48 -1
@@ -0,0 +1,130 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ module Mpp
5
+ module Methods
6
+ module Stripe
7
+ # Configures Stripe-backed MPP methods with static deposit addresses.
8
+ class MachinePayments
9
+ SPT_MINIMUM_MINOR_UNITS = 50
10
+
11
+ attr_reader :spt, :tempo, :base
12
+
13
+ def initialize(network_id:, livemode:, client:, deposit_addresses: nil, metadata: nil)
14
+ raise ArgumentError, "client must respond to #v1" unless client.respond_to?(:v1)
15
+
16
+ addresses = normalize_deposit_addresses(deposit_addresses)
17
+ @spt = SptFactory.new(network_id: network_id, client: client, metadata: metadata)
18
+ @tempo = TempoFactory.new(livemode: livemode, client: client, recipient: addresses[:tempo], metadata: metadata)
19
+ @base = BaseFactory.new(livemode: livemode, client: client, recipient: addresses[:base], metadata: metadata)
20
+ end
21
+
22
+ # Tempo is preferred when configured; Base requires an explicit facilitator.
23
+ def default_methods
24
+ methods = [spt.charge]
25
+ methods.unshift(tempo.charge) if tempo.configured?
26
+ methods
27
+ end
28
+
29
+ def self.minimum_amount(minimum)
30
+ lambda do |request|
31
+ Integer(request.fetch("amount")) >= minimum
32
+ rescue ArgumentError, TypeError, KeyError
33
+ false
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def normalize_deposit_addresses(deposit_addresses)
40
+ return {} if deposit_addresses.nil?
41
+ raise ArgumentError, "deposit_addresses must be a Hash" unless deposit_addresses.is_a?(Hash)
42
+
43
+ deposit_addresses.each_with_object({}) do |(network, address), result|
44
+ unless [:tempo, :base].include?(network)
45
+ raise ArgumentError, "unsupported deposit address network: #{network}"
46
+ end
47
+ unless address.is_a?(String) && address.match?(/\A0x[0-9a-fA-F]{40}\z/)
48
+ raise ArgumentError, "deposit_addresses[:#{network}] must be a 0x-prefixed 40-hex-character address"
49
+ end
50
+
51
+ result[network] = address.dup.freeze
52
+ end
53
+ end
54
+ end
55
+
56
+ class SptFactory
57
+ def initialize(network_id:, client:, metadata:)
58
+ @network_id = network_id
59
+ @client = client
60
+ @metadata = metadata
61
+ end
62
+
63
+ def charge
64
+ method = Mpp::Methods::Stripe::StripeMethod.new(
65
+ secret_key: nil,
66
+ network_id: @network_id,
67
+ payment_methods: ["card", "link"],
68
+ metadata: @metadata,
69
+ can_offer: MachinePayments.minimum_amount(MachinePayments::SPT_MINIMUM_MINOR_UNITS)
70
+ )
71
+ method.intents = {"charge" => Mpp::Methods::Stripe::ChargeIntent.new(secret_key: nil, client: @client)}
72
+ method
73
+ end
74
+ end
75
+
76
+ class TempoFactory
77
+ def initialize(livemode:, client:, recipient:, metadata:)
78
+ @livemode = livemode
79
+ @client = client
80
+ @recipient = recipient
81
+ @metadata = metadata
82
+ end
83
+
84
+ def configured?
85
+ !@recipient.nil?
86
+ end
87
+
88
+ def charge
89
+ raise ArgumentError, "deposit_addresses[:tempo] is required for Tempo payments" unless configured?
90
+
91
+ chain_id = @livemode ? Mpp::Methods::Tempo::Defaults::CHAIN_ID : Mpp::Methods::Tempo::Defaults::TESTNET_CHAIN_ID
92
+ currency = @livemode ? Mpp::Methods::Tempo::Defaults::USDC : Mpp::Methods::Tempo::Defaults::PATH_USD
93
+ recorder = CryptoPaymentRecorder.new(client: @client, network: "tempo", metadata: @metadata)
94
+ Mpp::Methods::Tempo.tempo(
95
+ intents: {"charge" => Mpp::Methods::Tempo::ChargeIntent.new(chain_id: chain_id)},
96
+ chain_id: chain_id,
97
+ currency: currency,
98
+ recipient: @recipient,
99
+ decimals: Mpp::Methods::Tempo::Defaults::PATH_USD_DECIMALS,
100
+ can_offer: MachinePayments.minimum_amount(CryptoPaymentRecorder::RAW_UNITS_PER_CENT),
101
+ on_payment_success: ->(payload) { recorder.call(payload) }
102
+ )
103
+ end
104
+ end
105
+
106
+ class BaseFactory
107
+ def initialize(livemode:, client:, recipient:, metadata:)
108
+ @livemode = livemode
109
+ @client = client
110
+ @recipient = recipient
111
+ @metadata = metadata
112
+ end
113
+
114
+ def charge(x402:)
115
+ raise ArgumentError, "deposit_addresses[:base] is required for Base payments" if @recipient.nil?
116
+
117
+ currency = @livemode ? Mpp::Methods::Evm::Assets::BASE_USDC : Mpp::Methods::Evm::Assets::BASE_SEPOLIA_USDC
118
+ recorder = CryptoPaymentRecorder.new(client: @client, network: "base", metadata: @metadata)
119
+ Mpp::Methods::Evm.charge(
120
+ currency: currency,
121
+ recipient: @recipient,
122
+ x402: x402,
123
+ can_offer: MachinePayments.minimum_amount(CryptoPaymentRecorder::RAW_UNITS_PER_CENT),
124
+ on_payment_success: ->(payload) { recorder.call(payload) }
125
+ )
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -9,17 +9,24 @@ module Mpp
9
9
  # Stripe payment method implementation.
10
10
  # Handles SPT-based payments through Stripe's Business Network.
11
11
  class StripeMethod
12
- attr_reader :name, :currency, :recipient, :decimals
12
+ attr_reader :name, :currency, :recipient, :decimals, :on_payment_success
13
13
  attr_accessor :intents
14
14
 
15
15
  def initialize(secret_key:, network_id:, payment_methods: nil,
16
16
  metadata: nil, currency: Defaults::DEFAULT_CURRENCY,
17
- decimals: Defaults::DEFAULT_DECIMALS, external_id: nil)
17
+ decimals: Defaults::DEFAULT_DECIMALS, external_id: nil,
18
+ on_payment_success: nil, can_offer: nil)
18
19
  unless payment_methods.is_a?(Array) &&
19
20
  payment_methods.any? &&
20
21
  payment_methods.all? { |type| type.is_a?(String) && !type.strip.empty? }
21
22
  raise ArgumentError, "payment_methods must be a non-empty array of Stripe payment method type strings"
22
23
  end
24
+ unless on_payment_success.nil? || on_payment_success.respond_to?(:call)
25
+ raise ArgumentError, "on_payment_success must be callable"
26
+ end
27
+ unless can_offer.nil? || can_offer.respond_to?(:call)
28
+ raise ArgumentError, "can_offer must be callable"
29
+ end
23
30
 
24
31
  @name = "stripe"
25
32
  @secret_key = secret_key
@@ -27,12 +34,20 @@ module Mpp
27
34
  @payment_methods = payment_methods
28
35
  @metadata = metadata
29
36
  @external_id = external_id
37
+ @on_payment_success = on_payment_success
38
+ @can_offer = can_offer
30
39
  @currency = currency
31
40
  @recipient = network_id
32
41
  @decimals = decimals
33
42
  @intents = {}
34
43
  end
35
44
 
45
+ def can_offer?(request)
46
+ return true unless @can_offer
47
+
48
+ @can_offer.call(request)
49
+ end
50
+
36
51
  # Transform request - injects Stripe-specific methodDetails.
37
52
  def transform_request(request, _credential)
38
53
  method_details = request.fetch("methodDetails", {})
@@ -53,6 +68,8 @@ module Mpp
53
68
  metadata: nil, currency: Defaults::DEFAULT_CURRENCY,
54
69
  decimals: Defaults::DEFAULT_DECIMALS,
55
70
  external_id: nil,
71
+ on_payment_success: nil,
72
+ can_offer: nil,
56
73
  api_base: Defaults::STRIPE_API_BASE)
57
74
  charge_intent = ChargeIntent.new(secret_key: secret_key, api_base: api_base)
58
75
 
@@ -63,7 +80,9 @@ module Mpp
63
80
  metadata: metadata,
64
81
  currency: currency,
65
82
  decimals: decimals,
66
- external_id: external_id
83
+ external_id: external_id,
84
+ on_payment_success: on_payment_success,
85
+ can_offer: can_offer
67
86
  )
68
87
 
69
88
  method.intents = {"charge" => charge_intent}
@@ -4,11 +4,34 @@
4
4
  module Mpp
5
5
  module Methods
6
6
  module Stripe
7
+ extend T::Sig
8
+
7
9
  autoload :Defaults, "mpp/methods/stripe/defaults"
8
10
  # Eagerly require stripe_method so the Stripe.stripe factory method is available
9
11
  require_relative "stripe/stripe_method"
10
12
  autoload :ChargeIntent, "mpp/methods/stripe/charge_intent"
11
13
  autoload :ClientMethod, "mpp/methods/stripe/client_method"
14
+ autoload :CryptoPaymentRecorder, "mpp/methods/stripe/crypto_payment_recorder"
15
+ autoload :MachinePayments, "mpp/methods/stripe/machine_payments"
16
+
17
+ sig do
18
+ params(
19
+ network_id: String,
20
+ livemode: T::Boolean,
21
+ client: T.untyped,
22
+ deposit_addresses: T.untyped,
23
+ metadata: T.untyped
24
+ ).returns(MachinePayments)
25
+ end
26
+ def self.create(network_id:, livemode:, client:, deposit_addresses: nil, metadata: nil)
27
+ MachinePayments.new(
28
+ network_id: network_id,
29
+ livemode: livemode,
30
+ client: client,
31
+ deposit_addresses: deposit_addresses,
32
+ metadata: metadata
33
+ )
34
+ end
12
35
  end
13
36
  end
14
37
  end
@@ -1,7 +1,7 @@
1
1
  # typed: false
2
2
  # frozen_string_literal: true
3
3
 
4
- require "openssl"
4
+ require "digest/keccak"
5
5
  require "securerandom"
6
6
 
7
7
  module Mpp
@@ -13,15 +13,9 @@ module Mpp
13
13
 
14
14
  module_function
15
15
 
16
- # Compute keccak256 hash. Uses OpenSSL if available, otherwise pure Ruby.
16
+ # Ethereum Keccak-256. SHA3-256 is not a substitute (different padding).
17
17
  def keccak256(data)
18
- # Try eth gem's keccak first
19
- Kernel.require "eth"
20
- Eth::Util.keccak256(data)
21
- rescue LoadError
22
- # Fallback: use OpenSSL's SHA3-256 (not exactly keccak, but close)
23
- # For production, the eth gem should be installed
24
- OpenSSL::Digest.new("SHA3-256").digest(data)
18
+ Digest::Keccak.digest(data, 256)
25
19
  end
26
20
 
27
21
  # Compute TAG = keccak256("mpp")[0:4]
@@ -19,16 +19,25 @@ module Mpp
19
19
  class TempoMethod
20
20
  attr_reader :name, :account, :fee_payer, :fee_payer_allowed_fee_tokens,
21
21
  :root_account, :rpc_url, :chain_id, :currency, :recipient, :decimals, :client_id,
22
- :expected_recipients
22
+ :expected_recipients, :relay, :on_payment_success
23
23
  attr_accessor :intents
24
24
 
25
25
  def initialize(account: nil, fee_payer: nil, root_account: nil,
26
26
  rpc_url: Defaults::RPC_URL, chain_id: nil, currency: nil,
27
27
  recipient: nil, decimals: 6, client_id: nil,
28
- expected_recipients: nil, fee_payer_allowed_fee_tokens: nil)
28
+ expected_recipients: nil, fee_payer_allowed_fee_tokens: nil,
29
+ relay: nil, on_payment_success: nil, can_offer: nil)
30
+ unless on_payment_success.nil? || on_payment_success.respond_to?(:call)
31
+ raise ArgumentError, "on_payment_success must be callable"
32
+ end
33
+ if !can_offer.nil? && !can_offer.respond_to?(:call)
34
+ raise ArgumentError, "can_offer must be callable"
35
+ end
36
+
29
37
  @name = "tempo"
30
38
  @account = account
31
39
  @fee_payer = fee_payer
40
+ @relay = relay
32
41
  @fee_payer_allowed_fee_tokens =
33
42
  fee_payer_allowed_fee_tokens&.map { |token| token.to_s.downcase }
34
43
  @root_account = root_account
@@ -39,9 +48,17 @@ module Mpp
39
48
  @decimals = decimals
40
49
  @client_id = client_id
41
50
  @expected_recipients = expected_recipients&.map(&:downcase)&.to_set
51
+ @on_payment_success = on_payment_success
52
+ @can_offer = can_offer
42
53
  @intents = {}
43
54
  end
44
55
 
56
+ def can_offer?(request)
57
+ return true unless @can_offer
58
+
59
+ @can_offer.call(request)
60
+ end
61
+
45
62
  # Create a credential to satisfy the given challenge.
46
63
  #
47
64
  # mode: :pull (default) — return signed transaction for server to broadcast
@@ -249,13 +266,20 @@ module Mpp
249
266
  # Factory function to create a configured TempoMethod.
250
267
  def self.tempo(intents:, account: nil, fee_payer: nil, chain_id: nil, rpc_url: nil,
251
268
  root_account: nil, currency: nil, recipient: nil, decimals: 6, client_id: nil,
252
- expected_recipients: nil, fee_payer_allowed_fee_tokens: nil)
269
+ expected_recipients: nil, fee_payer_allowed_fee_tokens: nil, relay: nil,
270
+ on_payment_success: nil, can_offer: nil)
253
271
  rpc_url ||= chain_id ? Defaults.rpc_url_for_chain(chain_id) : Defaults::RPC_URL
254
272
  currency ||= Defaults.default_currency_for_chain(chain_id)
255
273
 
274
+ if fee_payer == true
275
+ raise ArgumentError, "fee_payer: true requires account:" unless account
276
+
277
+ fee_payer = account
278
+ end
279
+
256
280
  method = TempoMethod.new(
257
281
  account: account,
258
- fee_payer: fee_payer,
282
+ fee_payer: FeePayerClient.resolve_optional(fee_payer),
259
283
  rpc_url: rpc_url,
260
284
  chain_id: chain_id,
261
285
  root_account: root_account,
@@ -264,12 +288,17 @@ module Mpp
264
288
  decimals: decimals,
265
289
  client_id: client_id,
266
290
  expected_recipients: expected_recipients,
267
- fee_payer_allowed_fee_tokens: fee_payer_allowed_fee_tokens
291
+ fee_payer_allowed_fee_tokens: fee_payer_allowed_fee_tokens,
292
+ relay: Relay.resolve_optional(relay),
293
+ on_payment_success: on_payment_success,
294
+ can_offer: can_offer
268
295
  )
269
296
 
270
297
  intents.each_value do |intent|
271
298
  intent.rpc_url = rpc_url if intent.respond_to?(:rpc_url=) && intent.rpc_url.nil?
272
- intent.instance_variable_set(:@_method, method) if intent.respond_to?(:fee_payer)
299
+ if intent.respond_to?(:fee_payer) || intent.respond_to?(:relay)
300
+ intent.instance_variable_set(:@_method, method)
301
+ end
273
302
  end
274
303
  method.intents = intents.dup
275
304
  method
@@ -0,0 +1,120 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "net/http"
6
+ require "uri"
7
+ require_relative "../../http/headers"
8
+
9
+ module Mpp
10
+ module Methods
11
+ module Tempo
12
+ # HTTP client for a hosted Tempo fee-payer JSON-RPC endpoint.
13
+ #
14
+ # `resolve` accepts:
15
+ # * a URL string (public / unauthenticated sponsor)
16
+ # * a config hash (`url:` plus optional `headers:` as a Hash or per-request proc)
17
+ # * any object that responds to `#cosign`
18
+ class FeePayerClient
19
+ SIGN_METHOD = "eth_signRawTransaction"
20
+ DEFAULT_TIMEOUT = Rpc::DEFAULT_TIMEOUT
21
+
22
+ attr_reader :base_url
23
+
24
+ def initialize(url, headers: nil)
25
+ @base_url = Mpp::Http::Headers.normalize_base_url(url)
26
+ raise ArgumentError, "fee_payer url is required" if @base_url.empty?
27
+
28
+ @headers = headers
29
+ end
30
+
31
+ def self.resolve_optional(fee_payer)
32
+ return if fee_payer.nil? || fee_payer == false
33
+ return fee_payer unless hosted_config?(fee_payer)
34
+
35
+ resolve(fee_payer)
36
+ end
37
+
38
+ def self.resolve(fee_payer)
39
+ return fee_payer if fee_payer.is_a?(FeePayerClient)
40
+ return new(fee_payer) if fee_payer.is_a?(String) && !fee_payer.empty?
41
+ return from_config(fee_payer) if fee_payer.is_a?(Hash)
42
+ return fee_payer if duck_type?(fee_payer)
43
+
44
+ raise ArgumentError, "fee_payer must be an Account, URL, {url:, headers:}, or an object that implements #cosign"
45
+ end
46
+
47
+ def self.from_config(config)
48
+ cfg = Mpp::Http::Headers.symbolize(config)
49
+ url = cfg[:url] || cfg[:base_url] || cfg[:baseUrl]
50
+ new(url.to_s, headers: cfg[:headers])
51
+ end
52
+
53
+ def self.hosted_config?(fee_payer)
54
+ return true if fee_payer.is_a?(FeePayerClient)
55
+ return true if fee_payer.is_a?(String)
56
+ return true if fee_payer.is_a?(Hash)
57
+ return true if duck_type?(fee_payer) && !fee_payer.respond_to?(:sign_hash)
58
+
59
+ false
60
+ end
61
+
62
+ def self.duck_type?(fee_payer)
63
+ !fee_payer.nil? && fee_payer.respond_to?(:cosign)
64
+ end
65
+
66
+ def cosign(raw_tx)
67
+ result = post_rpc(SIGN_METHOD, [raw_tx])
68
+ unless result.is_a?(String) && !result.empty?
69
+ raise Mpp::VerificationFailedError.new(reason: "Fee payer returned no signed transaction")
70
+ end
71
+
72
+ result
73
+ end
74
+
75
+ private
76
+
77
+ def post_rpc(method, params)
78
+ uri = URI.parse(@base_url)
79
+ http = Net::HTTP.new(uri.host, uri.port)
80
+ http.use_ssl = uri.scheme == "https"
81
+ http.read_timeout = DEFAULT_TIMEOUT
82
+
83
+ request = Net::HTTP::Post.new(uri)
84
+ request["Content-Type"] = "application/json"
85
+ Mpp::Http::Headers.resolve(@headers, "/#{method}").each { |key, value| request[key] = value }
86
+ request.body = JSON.generate({
87
+ "jsonrpc" => "2.0",
88
+ "method" => method,
89
+ "params" => params,
90
+ "id" => 1
91
+ })
92
+
93
+ response = http.request(request)
94
+ unless response.is_a?(Net::HTTPSuccess)
95
+ raise Mpp::VerificationFailedError.new(reason: "fee payer returned HTTP #{response.code}")
96
+ end
97
+
98
+ body = response.body.to_s
99
+ if body.empty?
100
+ raise Mpp::VerificationFailedError.new(reason: "fee payer returned an empty body")
101
+ end
102
+
103
+ parsed = JSON.parse(body)
104
+ unless parsed.is_a?(Hash)
105
+ raise Mpp::VerificationFailedError.new(reason: "fee payer returned JSON that is not an object")
106
+ end
107
+ if parsed.key?("error")
108
+ raise Mpp::VerificationFailedError.new(reason: "fee payer RPC error: #{parsed["error"]}")
109
+ end
110
+
111
+ parsed["result"]
112
+ rescue JSON::ParserError
113
+ raise Mpp::VerificationFailedError.new(reason: "fee payer returned invalid JSON")
114
+ rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ETIMEDOUT, Net::OpenTimeout, Net::ReadTimeout, SocketError => e
115
+ raise Mpp::VerificationFailedError.new(reason: "fee payer request failed: #{e.message}")
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end