mpp-rb 0.1.5 → 0.1.7

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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +150 -0
  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/extensions/mcp/verify.rb +1 -1
  8. data/lib/mpp/methods/evm/charge_intent.rb +38 -17
  9. data/lib/mpp/methods/evm/evm_method.rb +18 -2
  10. data/lib/mpp/methods/evm.rb +5 -2
  11. data/lib/mpp/methods/stripe/analytics_metadata.rb +24 -0
  12. data/lib/mpp/methods/stripe/charge_intent.rb +34 -11
  13. data/lib/mpp/methods/stripe/crypto_payment_recorder.rb +92 -0
  14. data/lib/mpp/methods/stripe/defaults.rb +1 -0
  15. data/lib/mpp/methods/stripe/machine_payments.rb +128 -0
  16. data/lib/mpp/methods/stripe/payment_intent_method.rb +103 -0
  17. data/lib/mpp/methods/stripe/payment_intent_options.rb +81 -0
  18. data/lib/mpp/methods/stripe/stripe_method.rb +30 -3
  19. data/lib/mpp/methods/stripe.rb +26 -0
  20. data/lib/mpp/methods/tempo/client_method.rb +58 -12
  21. data/lib/mpp/methods/tempo/defaults.rb +51 -0
  22. data/lib/mpp/methods/tempo/intents.rb +195 -125
  23. data/lib/mpp/methods/tempo/relay.rb +33 -31
  24. data/lib/mpp/methods/tempo/schemas.rb +3 -4
  25. data/lib/mpp/parsing.rb +29 -4
  26. data/lib/mpp/server/compose.rb +32 -15
  27. data/lib/mpp/server/decorator.rb +3 -1
  28. data/lib/mpp/server/intent.rb +6 -1
  29. data/lib/mpp/server/intent_lifecycle.rb +41 -0
  30. data/lib/mpp/server/method.rb +41 -0
  31. data/lib/mpp/server/middleware.rb +16 -2
  32. data/lib/mpp/server/mpp_handler.rb +87 -21
  33. data/lib/mpp/server/verify.rb +25 -15
  34. data/lib/mpp/server.rb +1 -0
  35. data/lib/mpp/validation.rb +27 -0
  36. data/lib/mpp/version.rb +1 -1
  37. data/lib/mpp.rb +14 -3
  38. metadata +22 -1
@@ -0,0 +1,92 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ module Mpp
5
+ module Methods
6
+ module Stripe
7
+ # Records an already-settled crypto transfer as a Stripe PaymentIntent.
8
+ class CryptoPaymentRecorder
9
+ RAW_UNITS_PER_CENT = 10_000
10
+
11
+ def initialize(client:, network:, metadata: nil)
12
+ @client = client
13
+ @network = network
14
+ @metadata = metadata
15
+ end
16
+
17
+ def call(payload)
18
+ reference = payload[:receipt]&.reference
19
+ return unless reference.is_a?(String)
20
+
21
+ amount = Integer(payload.fetch(:request).fetch("amount"))
22
+ cents = (amount + (RAW_UNITS_PER_CENT / 2)) / RAW_UNITS_PER_CENT
23
+ return if cents < 1
24
+
25
+ analytics = AnalyticsMetadata.build(payload.fetch(:challenge))
26
+ options = payload[:payment_intent_options] || {}
27
+ configured_metadata = @metadata.is_a?(Hash) ? @metadata.transform_keys(&:to_s).transform_values(&:to_s) : {}
28
+ optional_metadata = configured_metadata.merge(options.fetch(:metadata, {}))
29
+ required_params = {
30
+ amount: cents,
31
+ currency: "usd",
32
+ confirm: true,
33
+ payment_method_data: {type: "crypto"},
34
+ payment_method_types: ["crypto"],
35
+ payment_method_options: {
36
+ crypto: {
37
+ mode: "transaction_verification",
38
+ transaction_verification_options: {network: @network, transaction_hash: reference}
39
+ }
40
+ },
41
+ metadata: analytics
42
+ }
43
+ params = required_params.dup
44
+ params[:customer] = options[:customer] if options.key?(:customer)
45
+ params[:hooks] = options[:hooks] if options.key?(:hooks)
46
+ params[:receipt_email] = options[:receipt_email] if options.key?(:receipt_email)
47
+ params[:metadata] = analytics.merge(optional_metadata)
48
+ has_optional_params = !optional_metadata.empty? || [:customer, :hooks, :receipt_email].any? do |key|
49
+ options.key?(key)
50
+ end
51
+
52
+ create(params, reference)
53
+ rescue => error
54
+ if defined?(required_params) && has_optional_params && definitive_invalid_request?(error)
55
+ Kernel.warn(
56
+ "[stripe] optional PaymentIntent recording fields were rejected; retrying without them: " \
57
+ "#{error.class}: #{error.message}"
58
+ )
59
+ begin
60
+ return create(required_params, "#{reference}_fallback")
61
+ rescue => fallback_error
62
+ error = fallback_error
63
+ end
64
+ end
65
+
66
+ Kernel.warn(
67
+ "[stripe] failed to record crypto payment " \
68
+ "network=#{@network.inspect} transaction_hash=#{reference.inspect}: #{error.class}: #{error.message}"
69
+ )
70
+ nil
71
+ end
72
+
73
+ private
74
+
75
+ def create(params, idempotency_key)
76
+ @client.v1.payment_intents.create(
77
+ params,
78
+ {stripe_version: Defaults::MACHINE_PAYMENTS_API_VERSION, idempotency_key: idempotency_key}
79
+ )
80
+ end
81
+
82
+ def definitive_invalid_request?(error)
83
+ return true if defined?(::Stripe::InvalidRequestError) && error.is_a?(::Stripe::InvalidRequestError)
84
+ return true if error.respond_to?(:type) && error.type == "StripeInvalidRequestError"
85
+ return true if error.respond_to?(:error) && error.error.respond_to?(:type) && error.error.type == "invalid_request_error"
86
+
87
+ false
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -6,6 +6,7 @@ module Mpp
6
6
  module Stripe
7
7
  module Defaults
8
8
  STRIPE_API_BASE = "https://api.stripe.com"
9
+ MACHINE_PAYMENTS_API_VERSION = "2026-07-29.preview"
9
10
  DEFAULT_CURRENCY = "usd"
10
11
  DEFAULT_DECIMALS = 2
11
12
  end
@@ -0,0 +1,128 @@
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
+ method = Mpp::Methods::Tempo.tempo(
94
+ intents: {"charge" => Mpp::Methods::Tempo::ChargeIntent.new(chain_id: chain_id)},
95
+ chain_id: chain_id,
96
+ currency: currency,
97
+ recipient: @recipient,
98
+ decimals: Mpp::Methods::Tempo::Defaults::PATH_USD_DECIMALS,
99
+ can_offer: MachinePayments.minimum_amount(CryptoPaymentRecorder::RAW_UNITS_PER_CENT)
100
+ )
101
+ PaymentIntentMethod.new(method: method, client: @client, network: "tempo", metadata: @metadata)
102
+ end
103
+ end
104
+
105
+ class BaseFactory
106
+ def initialize(livemode:, client:, recipient:, metadata:)
107
+ @livemode = livemode
108
+ @client = client
109
+ @recipient = recipient
110
+ @metadata = metadata
111
+ end
112
+
113
+ def charge(x402:)
114
+ raise ArgumentError, "deposit_addresses[:base] is required for Base payments" if @recipient.nil?
115
+
116
+ currency = @livemode ? Mpp::Methods::Evm::Assets::BASE_USDC : Mpp::Methods::Evm::Assets::BASE_SEPOLIA_USDC
117
+ method = Mpp::Methods::Evm.charge(
118
+ currency: currency,
119
+ recipient: @recipient,
120
+ x402: x402,
121
+ can_offer: MachinePayments.minimum_amount(CryptoPaymentRecorder::RAW_UNITS_PER_CENT)
122
+ )
123
+ PaymentIntentMethod.new(method: method, client: @client, network: "base", metadata: @metadata)
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,103 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ module Mpp
5
+ module Methods
6
+ module Stripe
7
+ # Decorates a crypto method with private, request-scoped Stripe input.
8
+ class PaymentIntentMethod
9
+ def initialize(method:, client:, network:, metadata: nil)
10
+ @method = method
11
+ @client = client
12
+ @network = network
13
+ @metadata = metadata
14
+ end
15
+
16
+ def prepare_intent(intent, input)
17
+ options_input = input[:payment_intent_options]
18
+ PaymentIntentOptions.validate_input(options_input)
19
+ sanitized = input.except(:payment_intent_options)
20
+ decorated = WrappedIntent.new(
21
+ intent: intent,
22
+ options_input: options_input,
23
+ client: @client,
24
+ network: @network,
25
+ metadata: @metadata
26
+ )
27
+ [decorated, sanitized]
28
+ end
29
+
30
+ def method_missing(name, *args, **kwargs, &block)
31
+ return super unless @method.respond_to?(name)
32
+
33
+ @method.public_send(name, *args, **kwargs, &block)
34
+ end
35
+
36
+ def respond_to_missing?(name, include_private = false)
37
+ @method.respond_to?(name, include_private) || super
38
+ end
39
+
40
+ # Per-attempt intent view retaining resolved input outside protocol state.
41
+ class WrappedIntent
42
+ attr_reader :name
43
+
44
+ def initialize(intent:, options_input:, client:, network:, metadata:)
45
+ @intent = intent
46
+ @name = intent.name
47
+ @options_input = options_input
48
+ @client = client
49
+ @network = network
50
+ @metadata = metadata
51
+
52
+ # Mirror the rail's capabilities so server dispatch can validate
53
+ # before entering the terminal operation that resolves options.
54
+ if intent.respond_to?(:validate)
55
+ define_singleton_method(:validate) do |credential, request|
56
+ @intent.validate(credential, request)
57
+ end
58
+ end
59
+ if intent.respond_to?(:broadcast)
60
+ define_singleton_method(:broadcast) do |credential, request|
61
+ options = resolve_options(credential, request)
62
+ receipt = @intent.broadcast(credential, request)
63
+ record_payment(credential, request, receipt, options)
64
+ end
65
+ end
66
+ end
67
+
68
+ # @deprecated Use #validate and #broadcast when supported by the rail.
69
+ def verify(credential, request)
70
+ if respond_to?(:validate) || respond_to?(:broadcast)
71
+ return Mpp::Server::IntentLifecycle.call(self, credential, request)
72
+ end
73
+
74
+ options = resolve_options(credential, request)
75
+ receipt = @intent.verify(credential, request)
76
+ record_payment(credential, request, receipt, options)
77
+ end
78
+
79
+ private
80
+
81
+ def resolve_options(credential, request)
82
+ PaymentIntentOptions.resolve(
83
+ @options_input,
84
+ challenge: credential.challenge,
85
+ credential: credential,
86
+ request: request
87
+ )
88
+ end
89
+
90
+ def record_payment(credential, request, receipt, options)
91
+ CryptoPaymentRecorder.new(client: @client, network: @network, metadata: @metadata).call(
92
+ challenge: credential.challenge,
93
+ receipt: receipt,
94
+ request: request,
95
+ payment_intent_options: options
96
+ )
97
+ receipt
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,81 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ module Mpp
5
+ module Methods
6
+ module Stripe
7
+ # Validation and deferred resolution for request-scoped PaymentIntent input.
8
+ module PaymentIntentOptions
9
+ module_function
10
+
11
+ def validate_input(input)
12
+ return input if input.nil? || input.respond_to?(:call)
13
+
14
+ validate(input)
15
+ end
16
+
17
+ def resolve(input, challenge:, credential:, request:)
18
+ value = if input.respond_to?(:call)
19
+ input.call(challenge: challenge, credential: credential, request: request)
20
+ else
21
+ input
22
+ end
23
+ return if value.nil?
24
+
25
+ validate(value)
26
+ end
27
+
28
+ def validate(value)
29
+ raise ArgumentError, "payment_intent_options must be a Hash or callable" unless value.is_a?(Hash)
30
+
31
+ input = symbolize(value)
32
+ options = {}
33
+ options[:customer] = non_empty_string(input[:customer], "customer") if input.key?(:customer)
34
+ options[:receipt_email] = non_empty_string(input[:receipt_email], "receipt_email") if input.key?(:receipt_email)
35
+ options[:metadata] = metadata(input[:metadata]) if input.key?(:metadata)
36
+ options[:hooks] = hooks(input[:hooks]) if input.key?(:hooks)
37
+ options.freeze
38
+ end
39
+
40
+ def metadata(value)
41
+ raise ArgumentError, "payment_intent_options metadata must be a Hash" unless value.is_a?(Hash)
42
+
43
+ value.each_with_object({}) do |(key, entry), result|
44
+ valid_key = key.is_a?(String) || key.is_a?(Symbol)
45
+ unless valid_key && entry.is_a?(String)
46
+ raise ArgumentError, "payment_intent_options metadata must contain string values"
47
+ end
48
+ result[key.to_s] = entry
49
+ end.freeze
50
+ end
51
+
52
+ def hooks(value)
53
+ hooks = symbolize_hash(value, "hooks")
54
+ inputs = symbolize_hash(hooks[:inputs], "hooks.inputs")
55
+ tax = symbolize_hash(inputs[:tax], "hooks.inputs.tax")
56
+ calculation = non_empty_string(tax[:calculation], "hooks.inputs.tax.calculation")
57
+ {inputs: {tax: {calculation: calculation}}}.freeze
58
+ end
59
+
60
+ def non_empty_string(value, name)
61
+ unless value.is_a?(String) && !value.empty?
62
+ raise ArgumentError, "payment_intent_options #{name} must be a non-empty String"
63
+ end
64
+ value
65
+ end
66
+
67
+ def symbolize_hash(value, name)
68
+ raise ArgumentError, "payment_intent_options #{name} must be a Hash" unless value.is_a?(Hash)
69
+
70
+ symbolize(value)
71
+ end
72
+
73
+ def symbolize(value)
74
+ value.each_with_object({}) do |(key, entry), result|
75
+ result[key.respond_to?(:to_sym) ? key.to_sym : key] = entry
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+ 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,28 @@ 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
+
51
+ # Keep Stripe-only request input out of the canonical challenge while
52
+ # retaining it on an isolated SPT intent for terminal verification.
53
+ def prepare_intent(intent, input)
54
+ options_input = input[:payment_intent_options]
55
+ PaymentIntentOptions.validate_input(options_input)
56
+ [intent.with_payment_intent_options(options_input), input.except(:payment_intent_options)]
57
+ end
58
+
36
59
  # Transform request - injects Stripe-specific methodDetails.
37
60
  def transform_request(request, _credential)
38
61
  method_details = request.fetch("methodDetails", {})
@@ -53,6 +76,8 @@ module Mpp
53
76
  metadata: nil, currency: Defaults::DEFAULT_CURRENCY,
54
77
  decimals: Defaults::DEFAULT_DECIMALS,
55
78
  external_id: nil,
79
+ on_payment_success: nil,
80
+ can_offer: nil,
56
81
  api_base: Defaults::STRIPE_API_BASE)
57
82
  charge_intent = ChargeIntent.new(secret_key: secret_key, api_base: api_base)
58
83
 
@@ -63,7 +88,9 @@ module Mpp
63
88
  metadata: metadata,
64
89
  currency: currency,
65
90
  decimals: decimals,
66
- external_id: external_id
91
+ external_id: external_id,
92
+ on_payment_success: on_payment_success,
93
+ can_offer: can_offer
67
94
  )
68
95
 
69
96
  method.intents = {"charge" => charge_intent}
@@ -4,11 +4,37 @@
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 :AnalyticsMetadata, "mpp/methods/stripe/analytics_metadata"
16
+ autoload :MachinePayments, "mpp/methods/stripe/machine_payments"
17
+ autoload :PaymentIntentMethod, "mpp/methods/stripe/payment_intent_method"
18
+ autoload :PaymentIntentOptions, "mpp/methods/stripe/payment_intent_options"
19
+
20
+ sig do
21
+ params(
22
+ network_id: String,
23
+ livemode: T::Boolean,
24
+ client: T.untyped,
25
+ deposit_addresses: T.untyped,
26
+ metadata: T.untyped
27
+ ).returns(MachinePayments)
28
+ end
29
+ def self.create(network_id:, livemode:, client:, deposit_addresses: nil, metadata: nil)
30
+ MachinePayments.new(
31
+ network_id: network_id,
32
+ livemode: livemode,
33
+ client: client,
34
+ deposit_addresses: deposit_addresses,
35
+ metadata: metadata
36
+ )
37
+ end
12
38
  end
13
39
  end
14
40
  end
@@ -18,32 +18,50 @@ module Mpp
18
18
  # Handles client-side credential creation for Tempo payments.
19
19
  class TempoMethod
20
20
  attr_reader :name, :account, :fee_payer, :fee_payer_allowed_fee_tokens,
21
- :root_account, :rpc_url, :chain_id, :currency, :recipient, :decimals, :client_id,
22
- :expected_recipients, :relay
21
+ :root_account, :rpc_url, :chain_id, :currency, :currencies, :recipient, :decimals, :client_id,
22
+ :expected_recipients, :relay, :on_payment_success, :fee_token
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
28
  expected_recipients: nil, fee_payer_allowed_fee_tokens: nil,
29
- relay: nil)
29
+ relay: nil, on_payment_success: nil, can_offer: nil, currencies: nil, fee_token: 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
+
30
37
  @name = "tempo"
31
38
  @account = account
32
39
  @fee_payer = fee_payer
33
40
  @relay = relay
41
+ @fee_token = fee_token
34
42
  @fee_payer_allowed_fee_tokens =
35
43
  fee_payer_allowed_fee_tokens&.map { |token| token.to_s.downcase }
36
44
  @root_account = root_account
37
45
  @rpc_url = rpc_url
38
46
  @chain_id = chain_id
39
- @currency = currency
47
+ # Ordered currencies offered by a server; the first is the primary.
48
+ @currencies = (currencies || [currency].compact).dup.freeze
49
+ @currency = currency || @currencies.first
40
50
  @recipient = recipient
41
51
  @decimals = decimals
42
52
  @client_id = client_id
43
53
  @expected_recipients = expected_recipients&.map(&:downcase)&.to_set
54
+ @on_payment_success = on_payment_success
55
+ @can_offer = can_offer
44
56
  @intents = {}
45
57
  end
46
58
 
59
+ def can_offer?(request)
60
+ return true unless @can_offer
61
+
62
+ @can_offer.call(request)
63
+ end
64
+
47
65
  # Create a credential to satisfy the given challenge.
48
66
  #
49
67
  # mode: :pull (default) — return signed transaction for server to broadcast
@@ -67,8 +85,7 @@ module Mpp
67
85
  nonce_key = nonce_key.start_with?("0x") ? nonce_key.to_i(16) : nonce_key.to_i
68
86
  end
69
87
 
70
- memo = method_details["memo"]
71
- memo ||= Attribution.encode(server_id: challenge.realm, client_id: @client_id, challenge_id: challenge.id)
88
+ memo = Attribution.encode(server_id: challenge.realm, client_id: @client_id, challenge_id: challenge.id)
72
89
 
73
90
  # Resolve RPC URL from challenge's chainId. Normalize the configured pin
74
91
  # once (it may be a String from ENV/config) so it compares equal to
@@ -249,31 +266,60 @@ module Mpp
249
266
  end
250
267
 
251
268
  # Factory function to create a configured TempoMethod.
252
- def self.tempo(intents:, account: nil, fee_payer: nil, chain_id: nil, rpc_url: nil,
269
+ #
270
+ # A server offers one charge per accepted currency, in order. Without
271
+ # `currencies:` it offers OUSD then USDC.e on mainnet and OUSD then
272
+ # pathUSD on Moderato; other chains keep their single default.
273
+ # `currencies:` replaces the defaults. The deprecated `currency:` accepts
274
+ # exactly that token. `currency` keeps its previous default for clients.
275
+ #
276
+ # A local fee payer pays gas in `fee_token:` when set, else in the first
277
+ # allowed fee token it holds, independent of the charge currency.
278
+ def self.tempo(intents:, account: nil, fee_payer: nil, chain_id: Defaults::CHAIN_ID, rpc_url: nil,
253
279
  root_account: nil, currency: nil, recipient: nil, decimals: 6, client_id: nil,
254
- expected_recipients: nil, fee_payer_allowed_fee_tokens: nil, relay: nil)
280
+ expected_recipients: nil, fee_payer_allowed_fee_tokens: nil, relay: nil,
281
+ on_payment_success: nil, can_offer: nil, currencies: nil, fee_token: nil)
255
282
  rpc_url ||= chain_id ? Defaults.rpc_url_for_chain(chain_id) : Defaults::RPC_URL
256
- currency ||= Defaults.default_currency_for_chain(chain_id)
257
283
 
258
284
  if fee_payer == true
259
285
  raise ArgumentError, "fee_payer: true requires account:" unless account
260
286
 
261
287
  fee_payer = account
262
288
  end
289
+ fee_payer = FeePayerClient.resolve_optional(fee_payer)
290
+ if fee_token && (fee_payer.nil? || FeePayerClient.hosted_config?(fee_payer))
291
+ raise ArgumentError, "fee_token can only be configured for a local fee payer"
292
+ end
293
+
294
+ if fee_token && !Defaults::ADDRESS_PATTERN.match?(fee_token)
295
+ raise ArgumentError, "Invalid Tempo fee token address: #{fee_token.inspect}"
296
+ end
297
+ if fee_payer_allowed_fee_tokens&.empty?
298
+ raise ArgumentError, "fee_payer_allowed_fee_tokens must contain at least one token"
299
+ end
300
+
301
+ legacy_currency = Defaults.default_currency_for_chain(chain_id)
302
+ primary_currency = currency || currencies&.first || legacy_currency
303
+ # `currency:` is deprecated in favor of `currencies: [currency]`.
304
+ currencies = Defaults.accepted_currencies(chain_id: chain_id, currency: currency, currencies: currencies)
263
305
 
264
306
  method = TempoMethod.new(
265
307
  account: account,
266
- fee_payer: FeePayerClient.resolve_optional(fee_payer),
308
+ fee_payer: fee_payer,
267
309
  rpc_url: rpc_url,
268
310
  chain_id: chain_id,
269
311
  root_account: root_account,
270
- currency: currency,
312
+ currency: primary_currency,
313
+ currencies: currencies,
271
314
  recipient: recipient,
272
315
  decimals: decimals,
273
316
  client_id: client_id,
274
317
  expected_recipients: expected_recipients,
275
318
  fee_payer_allowed_fee_tokens: fee_payer_allowed_fee_tokens,
276
- relay: Relay.resolve_optional(relay)
319
+ relay: Relay.resolve_optional(relay),
320
+ on_payment_success: on_payment_success,
321
+ can_offer: can_offer,
322
+ fee_token: fee_token
277
323
  )
278
324
 
279
325
  intents.each_value do |intent|