mpp-rb 0.1.1 → 0.1.3

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.
@@ -6,16 +6,16 @@ module Mpp
6
6
  module Tempo
7
7
  # EIP-712 proof credentials for zero-amount challenges.
8
8
  #
9
- # Domain: { name: "MPP", version: "1", chainId }
10
- # Types: { Proof: [{ name: "challengeId", type: "string" }] }
11
- # Message: { challengeId: <challenge.id> }
9
+ # Domain: { name: "MPP", version: "2", chainId }
10
+ # Types: { Proof: [{ name: "challengeId", type: "string" }, { name: "realm", type: "string" }] }
11
+ # Message: { challengeId: <challenge.id>, realm: <challenge.realm> }
12
12
  module Proof
13
13
  DOMAIN_NAME = "MPP"
14
- DOMAIN_VERSION = "1"
14
+ DOMAIN_VERSION = "2"
15
15
 
16
16
  # EIP-712 domain separator type hash
17
17
  DOMAIN_TYPE_HASH = "EIP712Domain(string name,string version,uint256 chainId)"
18
- PROOF_TYPE_HASH = "Proof(string challengeId)"
18
+ PROOF_TYPE_HASH = "Proof(string challengeId,string realm)"
19
19
 
20
20
  module_function
21
21
 
@@ -36,35 +36,36 @@ module Mpp
36
36
  )
37
37
  end
38
38
 
39
- # Compute the EIP-712 struct hash for Proof(challengeId).
40
- def struct_hash(challenge_id)
39
+ # Compute the EIP-712 struct hash for Proof(challengeId, realm).
40
+ def struct_hash(challenge_id, realm)
41
41
  keccak256(
42
42
  abi_encode(
43
43
  keccak256(PROOF_TYPE_HASH),
44
- keccak256(challenge_id)
44
+ keccak256(challenge_id),
45
+ keccak256(realm)
45
46
  )
46
47
  )
47
48
  end
48
49
 
49
50
  # Compute the full EIP-712 signing hash.
50
- def signing_hash(chain_id:, challenge_id:)
51
+ def signing_hash(chain_id:, challenge_id:, realm:)
51
52
  keccak256(
52
- "\x19\x01".b + domain_separator(chain_id) + struct_hash(challenge_id)
53
+ "\x19\x01".b + domain_separator(chain_id) + struct_hash(challenge_id, realm)
53
54
  )
54
55
  end
55
56
 
56
57
  # Sign a proof credential (client-side).
57
- def sign(account:, chain_id:, challenge_id:)
58
- hash = signing_hash(chain_id: chain_id, challenge_id: challenge_id)
58
+ def sign(account:, chain_id:, challenge_id:, realm:)
59
+ hash = signing_hash(chain_id: chain_id, challenge_id: challenge_id, realm: realm)
59
60
  sig = account.sign_hash(hash)
60
61
  "0x#{sig.unpack1("H*")}"
61
62
  end
62
63
 
63
64
  # Verify a proof credential signature (server-side).
64
- def verify(address:, chain_id:, challenge_id:, signature:)
65
+ def verify(address:, chain_id:, challenge_id:, realm:, signature:)
65
66
  Kernel.require "eth"
66
67
 
67
- hash = signing_hash(chain_id: chain_id, challenge_id: challenge_id)
68
+ hash = signing_hash(chain_id: chain_id, challenge_id: challenge_id, realm: realm)
68
69
  sig_bytes = [signature.delete_prefix("0x")].pack("H*")
69
70
 
70
71
  # Recover the signer address from the signature
@@ -106,7 +107,10 @@ module Mpp
106
107
  end
107
108
 
108
109
  def uint256(value)
109
- [value].pack("Q>").rjust(32, "\x00".b)
110
+ value = Integer(value)
111
+ raise ArgumentError, "uint256 out of range" if value.negative? || value >= (1 << 256)
112
+
113
+ [value.to_s(16).rjust(64, "0")].pack("H*")
110
114
  end
111
115
 
112
116
  def recover_address(hash, sig_bytes)
@@ -45,29 +45,50 @@ module Mpp
45
45
  require_eth!
46
46
  require_rlp!
47
47
 
48
- Eth::Util.keccak256([TYPE_ID].pack("C") + RLP.encode(unsigned_rlp_fields))
48
+ Eth::Util.keccak256([TYPE_ID].pack("C") + RLP.encode(signing_rlp_fields))
49
49
  end
50
50
 
51
- # Hash for fee payer to sign — includes sender_signature in the RLP.
51
+ # Hash for fee payer to sign.
52
52
  def fee_payer_signature_hash
53
53
  require_eth!
54
54
  require_rlp!
55
55
 
56
- fields = unsigned_rlp_fields
57
- fields.insert(11, sender_signature)
58
- Eth::Util.keccak256([TYPE_ID].pack("C") + RLP.encode(fields))
56
+ Eth::Util.keccak256([FeePayer::TYPE_ID].pack("C") + RLP.encode(fee_payer_signing_rlp_fields))
59
57
  end
60
58
 
61
59
  private
62
60
 
63
61
  def rlp_fields
62
+ fields = signing_rlp_fields
63
+ fields << signature_envelope(sender_signature) if sender_signature
64
+ fields
65
+ end
66
+
67
+ def signing_rlp_fields
64
68
  fields = unsigned_rlp_fields
65
- fields.insert(11, sender_signature)
66
- fields.insert(12, fee_payer_signature || EMPTY_SIGNATURE)
69
+ fields << key_authorization if key_authorization
67
70
  fields
68
71
  end
69
72
 
70
73
  def unsigned_rlp_fields
74
+ [
75
+ chain_id,
76
+ max_priority_fee_per_gas,
77
+ max_fee_per_gas,
78
+ gas_limit,
79
+ calls.map(&:as_rlp_list),
80
+ access_list || EMPTY_LIST,
81
+ nonce_key,
82
+ nonce,
83
+ encode_optional_uint(valid_before),
84
+ encode_optional_uint(valid_after),
85
+ fee_token ? pack_hex(fee_token) : "".b,
86
+ fee_payer_field,
87
+ tempo_authorization_list || EMPTY_LIST
88
+ ]
89
+ end
90
+
91
+ def fee_payer_signing_rlp_fields
71
92
  fields = [
72
93
  chain_id,
73
94
  max_priority_fee_per_gas,
@@ -80,12 +101,48 @@ module Mpp
80
101
  encode_optional_uint(valid_before),
81
102
  encode_optional_uint(valid_after),
82
103
  fee_token ? pack_hex(fee_token) : "".b,
104
+ pack_hex(sender_address),
83
105
  tempo_authorization_list || EMPTY_LIST
84
106
  ]
85
107
  fields << key_authorization if key_authorization
86
108
  fields
87
109
  end
88
110
 
111
+ def fee_payer_field
112
+ return signature_tuple(fee_payer_signature) if fee_payer_signature && fee_payer_signature != EMPTY_SIGNATURE
113
+ return EMPTY_SIGNATURE if fee_token.nil?
114
+
115
+ "".b
116
+ end
117
+
118
+ def signature_tuple(signature)
119
+ normalized = normalized_signature(signature)
120
+ [
121
+ normalized.getbyte(64).zero? ? "".b : normalized[64],
122
+ trim_leading_zeroes(normalized[0, 32]),
123
+ trim_leading_zeroes(normalized[32, 32])
124
+ ]
125
+ end
126
+
127
+ def signature_envelope(signature)
128
+ normalized_signature(signature)
129
+ end
130
+
131
+ def normalized_signature(signature)
132
+ bytes = signature.b
133
+ raise ArgumentError, "signature must be 65 bytes, got #{bytes.bytesize}" unless bytes.bytesize == 65
134
+
135
+ v = bytes.getbyte(64)
136
+ parity = (v >= 27) ? v - 27 : v
137
+ raise ArgumentError, "signature parity must be 0 or 1, got #{v}" unless [0, 1].include?(parity)
138
+
139
+ bytes[0, 64] + [parity].pack("C")
140
+ end
141
+
142
+ def trim_leading_zeroes(value)
143
+ value.sub(/\A\x00+/n, "")
144
+ end
145
+
89
146
  def pack_hex(value)
90
147
  [value.delete_prefix("0x")].pack("H*")
91
148
  end
@@ -112,11 +169,12 @@ module Mpp
112
169
  module_function
113
170
 
114
171
  def build_signed_transfer(account:, chain_id:, gas_limit:, gas_price:, nonce:, nonce_key:,
115
- currency:, transfer_data:, valid_before: nil, awaiting_fee_payer: false)
172
+ currency:, transfer_data:, max_priority_fee_per_gas: nil, max_fee_per_gas: nil,
173
+ valid_before: nil, awaiting_fee_payer: false)
116
174
  tx = SignedTransaction.new(
117
175
  chain_id: chain_id,
118
- max_priority_fee_per_gas: gas_price,
119
- max_fee_per_gas: gas_price,
176
+ max_priority_fee_per_gas: max_priority_fee_per_gas || gas_price,
177
+ max_fee_per_gas: max_fee_per_gas || gas_price,
120
178
  gas_limit: gas_limit,
121
179
  calls: [Call.new(to: currency, value: 0, data: transfer_data)],
122
180
  access_list: EMPTY_LIST,
@@ -10,6 +10,7 @@ module Mpp
10
10
  autoload :Attribution, "mpp/methods/tempo/attribution"
11
11
  autoload :Rpc, "mpp/methods/tempo/rpc"
12
12
  autoload :Transaction, "mpp/methods/tempo/transaction"
13
+ autoload :FeePayerPolicy, "mpp/methods/tempo/fee_payer_policy"
13
14
  autoload :Schemas, "mpp/methods/tempo/schemas"
14
15
  # Eagerly require client_method so the Tempo.tempo factory method is available
15
16
  require_relative "tempo/client_method"
data/lib/mpp/parsing.rb CHANGED
@@ -13,6 +13,7 @@ module Mpp
13
13
 
14
14
  # RFC 9110 auth-param regex: key="value" or key=token
15
15
  AUTH_PARAM_RE = /([a-zA-Z_][\w-]*+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^\s,]++))/
16
+ PAYMENT_METHOD_ID_RE = /\A[a-z]+\z/
16
17
 
17
18
  module_function
18
19
 
@@ -65,6 +66,11 @@ module Mpp
65
66
  params
66
67
  end
67
68
 
69
+ sig { params(method: T.untyped).void }
70
+ def validate_payment_method_id(method)
71
+ Kernel.raise Mpp::ParseError, "Invalid payment method ID" unless method.is_a?(String) && PAYMENT_METHOD_ID_RE.match?(method)
72
+ end
73
+
68
74
  # Parse a WWW-Authenticate header into a Challenge.
69
75
  sig { params(header: T.untyped).returns(Mpp::Challenge) }
70
76
  def parse_www_authenticate(header)
@@ -82,6 +88,7 @@ module Mpp
82
88
 
83
89
  method = params["method"]
84
90
  Kernel.raise Mpp::ParseError, "Missing 'method' field" unless method && !method.empty?
91
+ validate_payment_method_id(method)
85
92
 
86
93
  intent = params["intent"]
87
94
  Kernel.raise Mpp::ParseError, "Missing 'intent' field" unless intent && !intent.empty?
@@ -148,10 +155,13 @@ module Mpp
148
155
  Kernel.raise Mpp::ParseError, "Credential challenge must be an object" unless challenge_data.is_a?(Hash)
149
156
  Kernel.raise Mpp::ParseError, "Credential challenge missing required field: id" unless challenge_data.key?("id")
150
157
 
158
+ method = challenge_data["method"]
159
+ validate_payment_method_id(method)
160
+
151
161
  echo = Mpp::ChallengeEcho.new(
152
162
  id: challenge_data["id"].to_s,
153
163
  realm: (challenge_data["realm"] || "").to_s,
154
- method: (challenge_data["method"] || "").to_s,
164
+ method: method,
155
165
  intent: (challenge_data["intent"] || "").to_s,
156
166
  request: (challenge_data["request"] || "").to_s,
157
167
  expires: challenge_data["expires"]&.to_s,
@@ -213,6 +223,8 @@ module Mpp
213
223
  Kernel.raise Mpp::ParseError, "Invalid receipt status" unless status == "success"
214
224
 
215
225
  timestamp = parse_timestamp(data["timestamp"].to_s)
226
+ method = data["method"]
227
+ validate_payment_method_id(method)
216
228
 
217
229
  extra = data["extra"]
218
230
  extra = nil unless extra.is_a?(Hash)
@@ -221,7 +233,7 @@ module Mpp
221
233
  status: status,
222
234
  timestamp: timestamp,
223
235
  reference: data["reference"].to_s,
224
- method: (data["method"] || "").to_s,
236
+ method: method,
225
237
  external_id: data["externalId"]&.to_s,
226
238
  extra: extra
227
239
  )
data/lib/mpp/receipt.rb CHANGED
@@ -18,13 +18,14 @@ module Mpp
18
18
  end
19
19
 
20
20
  # Create a success receipt with current timestamp.
21
- def self.success(reference, timestamp: nil, method: "tempo", external_id: nil)
21
+ def self.success(reference, timestamp: nil, method: "tempo", external_id: nil, extra: nil)
22
22
  new(
23
23
  status: "success",
24
24
  timestamp: timestamp || Time.now.utc,
25
25
  reference: reference,
26
26
  method: method,
27
- external_id: external_id
27
+ external_id: external_id,
28
+ extra: extra
28
29
  )
29
30
  end
30
31
  end
@@ -20,6 +20,7 @@ module Mpp
20
20
  "Cache-Control" => "no-store",
21
21
  "Content-Type" => "application/problem+json"
22
22
  }
23
+ Mpp::Server::Middleware.mark_authorization_bound_response(headers)
23
24
  {
24
25
  "_mpp_challenge" => true,
25
26
  "status" => 402,
@@ -1,6 +1,8 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
+ require "stringio"
5
+
4
6
  module Mpp
5
7
  module Server
6
8
  # Rack middleware that intercepts requests requiring payment.
@@ -26,15 +28,20 @@ module Mpp
26
28
  sig { params(env: T.untyped).returns(T::Array[T.untyped]) }
27
29
  def call(env)
28
30
  authorization = env["HTTP_AUTHORIZATION"]
31
+ body_capture = capture_request_body(env)
29
32
  status, headers, body = @app.call(env)
30
33
 
31
34
  charge_opts = env["mpp.charge"]
32
35
  return [status, headers, body] unless charge_opts
33
36
 
37
+ request_body = body_capture&.materialize
38
+ env["rack.input"] = StringIO.new(request_body || "") if body_capture
39
+
34
40
  amount = charge_opts[:amount]
35
- opts = charge_opts.except(:amount)
41
+ opts = charge_opts.except(:amount, :body)
42
+ opts[:mppx_scope] ||= mppx_scope(env)
36
43
 
37
- result = @handler.charge(authorization, amount, **opts)
44
+ result = @handler.charge(authorization, amount, **opts, body: request_body)
38
45
 
39
46
  if result.is_a?(Mpp::Challenge)
40
47
  resp = Mpp::Server::Decorator.make_challenge_response(result, @handler.realm)
@@ -43,9 +50,101 @@ module Mpp
43
50
 
44
51
  _credential, receipt = result
45
52
  headers["Payment-Receipt"] = receipt.to_payment_receipt
53
+ self.class.mark_authorization_bound_response(headers)
46
54
 
47
55
  [status, headers, body]
48
56
  end
57
+
58
+ sig { params(headers: T::Hash[T.untyped, T.untyped]).void }
59
+ def self.mark_authorization_bound_response(headers)
60
+ headers["Cache-Control"] = "no-store"
61
+
62
+ vary_values = headers["Vary"].to_s.split(",").map do |value|
63
+ value.strip.downcase
64
+ end
65
+ return if vary_values.include?("*") || vary_values.include?("authorization")
66
+
67
+ headers["Vary"] = [headers["Vary"], "Authorization"]
68
+ .compact
69
+ .reject(&:empty?)
70
+ .join(", ")
71
+ end
72
+
73
+ private
74
+
75
+ sig { params(env: T.untyped).returns(T.nilable(RackInputCapture)) }
76
+ def capture_request_body(env)
77
+ input = env["rack.input"]
78
+ return nil unless input&.respond_to?(:read)
79
+
80
+ capture = RackInputCapture.new(input)
81
+ env["rack.input"] = capture
82
+ capture
83
+ end
84
+
85
+ sig { params(env: T.untyped).returns(T::Hash[String, String]) }
86
+ def mppx_scope(env)
87
+ scope = T.let({}, T::Hash[String, String])
88
+ route = env["action_dispatch.route_uri_pattern"] || env["sinatra.route"] || env["roda.route"]
89
+ route = route.split(" ", 2).last if route.is_a?(String) && route.match?(/\A[A-Z]+\s+/)
90
+ scope["route"] = route if route.is_a?(String) && !route.empty?
91
+ path = env["PATH_INFO"]
92
+ scope["resource"] = path if path.is_a?(String) && !path.empty?
93
+ query = env["QUERY_STRING"]
94
+ scope["query"] = query if query.is_a?(String) && !query.empty?
95
+ scope
96
+ end
97
+
98
+ class RackInputCapture
99
+ extend T::Sig
100
+
101
+ sig { params(input: T.untyped).void }
102
+ def initialize(input)
103
+ @input = T.let(input, T.untyped)
104
+ @buffer = T.let(+"".b, String)
105
+ end
106
+
107
+ sig { params(args: T.untyped).returns(T.untyped) }
108
+ def read(*args)
109
+ chunk = @input.read(*args)
110
+ @buffer << chunk.b if chunk && !chunk.empty?
111
+ chunk
112
+ end
113
+
114
+ sig { params(args: T.untyped).returns(T.untyped) }
115
+ def gets(*args)
116
+ chunk = @input.gets(*args)
117
+ @buffer << chunk.b if chunk && !chunk.empty?
118
+ chunk
119
+ end
120
+
121
+ sig { params(block: T.nilable(T.proc.params(chunk: T.untyped).void)).returns(T.untyped) }
122
+ def each(&block)
123
+ return enum_for(:each) unless block
124
+
125
+ @input.each do |chunk|
126
+ @buffer << chunk.b if chunk && !chunk.empty?
127
+ block.call(chunk)
128
+ end
129
+ end
130
+
131
+ sig { returns(T.untyped) }
132
+ def rewind
133
+ @input.rewind if @input.respond_to?(:rewind)
134
+ @buffer = +"".b
135
+ end
136
+
137
+ sig { returns(T.untyped) }
138
+ def close
139
+ @input.close if @input.respond_to?(:close)
140
+ end
141
+
142
+ sig { returns(T.nilable(String)) }
143
+ def materialize
144
+ read
145
+ @buffer.empty? ? nil : @buffer.dup
146
+ end
147
+ end
49
148
  end
50
149
  end
51
150
  end
@@ -22,28 +22,51 @@ module Mpp
22
22
  sig { returns(T::Hash[String, T.untyped]) }
23
23
  attr_reader :defaults
24
24
 
25
- sig { params(method: T.untyped, realm: String, secret_key: String, defaults: T.nilable(T::Hash[String, T.untyped])).void }
26
- def initialize(method:, realm:, secret_key:, defaults: nil)
25
+ sig { params(method: T.untyped, realm: String, secret_key: String, defaults: T.nilable(T::Hash[String, T.untyped]), events: T.nilable(Mpp::Events::Dispatcher)).void }
26
+ def initialize(method:, realm:, secret_key:, defaults: nil, events: nil)
27
27
  @method = T.let(method, T.untyped)
28
28
  @realm = T.let(realm, String)
29
29
  @secret_key = T.let(secret_key, String)
30
30
  @defaults = T.let(defaults || {}, T::Hash[String, T.untyped])
31
+ @events = T.let(events || Mpp::Events.server_dispatcher, Mpp::Events::Dispatcher)
31
32
  end
32
33
 
33
34
  # Create with auto-detected realm and secret_key.
34
- sig { params(method: T.untyped, realm: T.untyped, secret_key: T.untyped).returns(T.attached_class) }
35
- def self.create(method:, realm: nil, secret_key: nil)
35
+ sig { params(method: T.untyped, realm: T.untyped, secret_key: T.untyped, events: T.nilable(Mpp::Events::Dispatcher)).returns(T.attached_class) }
36
+ def self.create(method:, realm: nil, secret_key: nil, events: nil)
36
37
  new(
37
38
  method: method,
38
39
  realm: realm || Defaults.detect_realm,
39
- secret_key: secret_key || Defaults.detect_secret_key
40
+ secret_key: secret_key || Defaults.detect_secret_key,
41
+ events: events
40
42
  )
41
43
  end
42
44
 
45
+ sig { params(name: String, handler: T.untyped, block: T.nilable(T.proc.params(payload: T.untyped).returns(T.untyped))).returns(T.proc.void) }
46
+ def on(name, handler = nil, &block)
47
+ @events.on(name, handler, &block)
48
+ end
49
+
50
+ sig { params(handler: T.untyped, block: T.nilable(T.proc.params(payload: T.untyped).returns(T.untyped))).returns(T.proc.void) }
51
+ def on_challenge_created(handler = nil, &block)
52
+ on(Mpp::Events::CHALLENGE_CREATED, handler, &block)
53
+ end
54
+
55
+ sig { params(handler: T.untyped, block: T.nilable(T.proc.params(payload: T.untyped).returns(T.untyped))).returns(T.proc.void) }
56
+ def on_payment_failed(handler = nil, &block)
57
+ on(Mpp::Events::PAYMENT_FAILED, handler, &block)
58
+ end
59
+
60
+ sig { params(handler: T.untyped, block: T.nilable(T.proc.params(payload: T.untyped).returns(T.untyped))).returns(T.proc.void) }
61
+ def on_payment_success(handler = nil, &block)
62
+ on(Mpp::Events::PAYMENT_SUCCESS, handler, &block)
63
+ end
64
+
43
65
  # Handle a charge intent.
44
- sig { params(authorization: T.nilable(String), amount: String, currency: T.nilable(String), recipient: T.nilable(String), expires: T.nilable(String), description: T.nilable(String), memo: T.nilable(String), fee_payer: T::Boolean, chain_id: T.nilable(Integer), extra: T.nilable(T::Hash[String, String])).returns(T.untyped) }
66
+ sig { params(authorization: T.nilable(String), amount: String, currency: T.nilable(String), recipient: T.nilable(String), expires: T.nilable(String), description: T.nilable(String), external_id: T.nilable(String), memo: T.nilable(String), fee_payer: T::Boolean, chain_id: T.nilable(Integer), extra: T.nilable(T::Hash[String, String]), mppx_scope: T.nilable(T::Hash[String, String]), body: T.untyped).returns(T.untyped) }
45
67
  def charge(authorization, amount, currency: nil, recipient: nil, expires: nil,
46
- description: nil, memo: nil, fee_payer: false, chain_id: nil, extra: nil)
68
+ description: nil, external_id: nil, memo: nil, fee_payer: false, chain_id: nil,
69
+ extra: nil, mppx_scope: nil, body: nil)
47
70
  intent = @method.intents["charge"]
48
71
  raise ArgumentError, "Method #{@method.name} does not support charge intent" unless intent
49
72
 
@@ -60,6 +83,7 @@ module Mpp
60
83
  "currency" => resolved_currency,
61
84
  "recipient" => resolved_recipient
62
85
  }
86
+ request["externalId"] = external_id unless external_id.nil?
63
87
 
64
88
  if extra
65
89
  extra.each do |k, v|
@@ -67,6 +91,12 @@ module Mpp
67
91
  end
68
92
  request["extra"] = extra
69
93
  end
94
+ if mppx_scope
95
+ mppx_scope.each do |k, v|
96
+ raise ArgumentError, "mppx_scope must be a dict[str, str]" unless k.is_a?(String) && v.is_a?(String)
97
+ end
98
+ request["_mppx_scope"] = mppx_scope
99
+ end
70
100
 
71
101
  resolved_chain_id = chain_id
72
102
  resolved_chain_id ||= @method.chain_id if @method.respond_to?(:chain_id)
@@ -89,7 +119,9 @@ module Mpp
89
119
  secret_key: @secret_key,
90
120
  method: @method.name,
91
121
  description: description,
92
- expires: expires
122
+ expires: expires,
123
+ events: @events,
124
+ body: body
93
125
  )
94
126
  end
95
127
  end