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.
- checksums.yaml +4 -4
- data/README.md +37 -5
- data/lib/mpp/body_digest.rb +1 -1
- data/lib/mpp/challenge.rb +71 -6
- data/lib/mpp/client/transport.rb +153 -18
- data/lib/mpp/errors.rb +3 -0
- data/lib/mpp/events.rb +124 -0
- data/lib/mpp/extensions/mcp/types.rb +2 -1
- data/lib/mpp/methods/stripe/charge_intent.rb +41 -16
- data/lib/mpp/methods/stripe/client_method.rb +5 -1
- data/lib/mpp/methods/stripe/stripe_method.rb +15 -4
- data/lib/mpp/methods/tempo/attribution.rb +13 -0
- data/lib/mpp/methods/tempo/client_method.rb +28 -8
- data/lib/mpp/methods/tempo/fee_payer_envelope.rb +21 -6
- data/lib/mpp/methods/tempo/fee_payer_policy.rb +45 -0
- data/lib/mpp/methods/tempo/intents.rb +427 -69
- data/lib/mpp/methods/tempo/proof.rb +19 -15
- data/lib/mpp/methods/tempo/transaction.rb +68 -10
- data/lib/mpp/methods/tempo.rb +1 -0
- data/lib/mpp/parsing.rb +14 -2
- data/lib/mpp/receipt.rb +3 -2
- data/lib/mpp/server/decorator.rb +1 -0
- data/lib/mpp/server/middleware.rb +101 -2
- data/lib/mpp/server/mpp_handler.rb +40 -8
- data/lib/mpp/server/verify.rb +173 -27
- data/lib/mpp/version.rb +1 -1
- data/lib/mpp.rb +5 -3
- metadata +5 -7
|
@@ -14,12 +14,19 @@ module Mpp
|
|
|
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)
|
|
17
|
+
decimals: Defaults::DEFAULT_DECIMALS, external_id: nil)
|
|
18
|
+
unless payment_methods.is_a?(Array) &&
|
|
19
|
+
payment_methods.any? &&
|
|
20
|
+
payment_methods.all? { |type| type.is_a?(String) && !type.strip.empty? }
|
|
21
|
+
raise ArgumentError, "payment_methods must be a non-empty array of Stripe payment method type strings"
|
|
22
|
+
end
|
|
23
|
+
|
|
18
24
|
@name = "stripe"
|
|
19
25
|
@secret_key = secret_key
|
|
20
26
|
@network_id = network_id
|
|
21
27
|
@payment_methods = payment_methods
|
|
22
28
|
@metadata = metadata
|
|
29
|
+
@external_id = external_id
|
|
23
30
|
@currency = currency
|
|
24
31
|
@recipient = network_id
|
|
25
32
|
@decimals = decimals
|
|
@@ -32,10 +39,12 @@ module Mpp
|
|
|
32
39
|
method_details = {} unless method_details.is_a?(Hash)
|
|
33
40
|
|
|
34
41
|
method_details["networkId"] = @network_id
|
|
35
|
-
method_details["
|
|
42
|
+
method_details["paymentMethodTypes"] = @payment_methods
|
|
36
43
|
method_details["metadata"] = @metadata if @metadata
|
|
37
44
|
|
|
38
|
-
request.merge("methodDetails" => method_details)
|
|
45
|
+
transformed = request.merge("methodDetails" => method_details)
|
|
46
|
+
transformed["externalId"] = @external_id if !@external_id.nil? && !transformed.key?("externalId")
|
|
47
|
+
transformed
|
|
39
48
|
end
|
|
40
49
|
end
|
|
41
50
|
|
|
@@ -43,6 +52,7 @@ module Mpp
|
|
|
43
52
|
def self.stripe(secret_key:, network_id:, payment_methods: nil,
|
|
44
53
|
metadata: nil, currency: Defaults::DEFAULT_CURRENCY,
|
|
45
54
|
decimals: Defaults::DEFAULT_DECIMALS,
|
|
55
|
+
external_id: nil,
|
|
46
56
|
api_base: Defaults::STRIPE_API_BASE)
|
|
47
57
|
charge_intent = ChargeIntent.new(secret_key: secret_key, api_base: api_base)
|
|
48
58
|
|
|
@@ -52,7 +62,8 @@ module Mpp
|
|
|
52
62
|
payment_methods: payment_methods,
|
|
53
63
|
metadata: metadata,
|
|
54
64
|
currency: currency,
|
|
55
|
-
decimals: decimals
|
|
65
|
+
decimals: decimals,
|
|
66
|
+
external_id: external_id
|
|
56
67
|
)
|
|
57
68
|
|
|
58
69
|
method.intents = {"charge" => charge_intent}
|
|
@@ -80,6 +80,19 @@ module Mpp
|
|
|
80
80
|
memo_server == fingerprint(server_id)
|
|
81
81
|
end
|
|
82
82
|
|
|
83
|
+
# Verify challenge-bound nonce in memo.
|
|
84
|
+
def verify_challenge_binding(memo, challenge_id)
|
|
85
|
+
return false unless challenge_id
|
|
86
|
+
return false unless mpp_memo?(memo)
|
|
87
|
+
|
|
88
|
+
begin
|
|
89
|
+
memo_nonce = [memo[52, 14]].pack("H*")
|
|
90
|
+
rescue ArgumentError
|
|
91
|
+
return false
|
|
92
|
+
end
|
|
93
|
+
memo_nonce == keccak256(challenge_id.encode(Encoding::UTF_8))[0, 7]
|
|
94
|
+
end
|
|
95
|
+
|
|
83
96
|
# Decoded memo structure.
|
|
84
97
|
DecodedMemo = Data.define(:version, :server_fingerprint, :client_fingerprint, :nonce)
|
|
85
98
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require_relative "defaults"
|
|
5
|
+
require_relative "fee_payer_policy"
|
|
5
6
|
require_relative "transaction"
|
|
6
7
|
|
|
7
8
|
module Mpp
|
|
@@ -16,18 +17,20 @@ module Mpp
|
|
|
16
17
|
# Tempo payment method implementation.
|
|
17
18
|
# Handles client-side credential creation for Tempo payments.
|
|
18
19
|
class TempoMethod
|
|
19
|
-
attr_reader :name, :account, :fee_payer, :
|
|
20
|
-
:chain_id, :currency, :recipient, :decimals, :client_id,
|
|
20
|
+
attr_reader :name, :account, :fee_payer, :fee_payer_allowed_fee_tokens,
|
|
21
|
+
:root_account, :rpc_url, :chain_id, :currency, :recipient, :decimals, :client_id,
|
|
21
22
|
:expected_recipients
|
|
22
23
|
attr_accessor :intents
|
|
23
24
|
|
|
24
25
|
def initialize(account: nil, fee_payer: nil, root_account: nil,
|
|
25
26
|
rpc_url: Defaults::RPC_URL, chain_id: nil, currency: nil,
|
|
26
27
|
recipient: nil, decimals: 6, client_id: nil,
|
|
27
|
-
expected_recipients: nil)
|
|
28
|
+
expected_recipients: nil, fee_payer_allowed_fee_tokens: nil)
|
|
28
29
|
@name = "tempo"
|
|
29
30
|
@account = account
|
|
30
31
|
@fee_payer = fee_payer
|
|
32
|
+
@fee_payer_allowed_fee_tokens =
|
|
33
|
+
fee_payer_allowed_fee_tokens&.map { |token| token.to_s.downcase }
|
|
31
34
|
@root_account = root_account
|
|
32
35
|
@rpc_url = rpc_url
|
|
33
36
|
@chain_id = chain_id
|
|
@@ -65,13 +68,21 @@ module Mpp
|
|
|
65
68
|
memo = method_details["memo"]
|
|
66
69
|
memo ||= Attribution.encode(server_id: challenge.realm, client_id: @client_id, challenge_id: challenge.id)
|
|
67
70
|
|
|
68
|
-
# Resolve RPC URL from challenge's chainId
|
|
71
|
+
# Resolve RPC URL from challenge's chainId. Normalize the configured pin
|
|
72
|
+
# once (it may be a String from ENV/config) so it compares equal to
|
|
73
|
+
# integer chain ids everywhere, including the downstream RPC check.
|
|
69
74
|
resolved_rpc_url = @rpc_url
|
|
70
75
|
expected_chain_id = nil
|
|
76
|
+
configured_chain_id = @chain_id.nil? ? nil : Integer(@chain_id)
|
|
71
77
|
challenge_chain_id = method_details["chainId"]
|
|
72
78
|
if challenge_chain_id
|
|
73
79
|
begin
|
|
74
80
|
parsed_chain_id = Integer(challenge_chain_id)
|
|
81
|
+
# Chain pinning: reject a challenge whose chainId conflicts with the
|
|
82
|
+
# configured chain, before any RPC call or signing.
|
|
83
|
+
if configured_chain_id && parsed_chain_id != configured_chain_id
|
|
84
|
+
raise TransactionError, "Chain ID mismatch: expected #{configured_chain_id}, got #{parsed_chain_id}"
|
|
85
|
+
end
|
|
75
86
|
resolved = Defaults::CHAIN_RPC_URLS[parsed_chain_id]
|
|
76
87
|
if resolved
|
|
77
88
|
resolved_rpc_url = resolved
|
|
@@ -82,7 +93,7 @@ module Mpp
|
|
|
82
93
|
end
|
|
83
94
|
end
|
|
84
95
|
|
|
85
|
-
expected_chain_id ||=
|
|
96
|
+
expected_chain_id ||= configured_chain_id
|
|
86
97
|
|
|
87
98
|
# Proof mode: sign EIP-712 typed data (no transaction needed)
|
|
88
99
|
if mode == :proof
|
|
@@ -92,7 +103,8 @@ module Mpp
|
|
|
92
103
|
signature = Proof.sign(
|
|
93
104
|
account: @account,
|
|
94
105
|
chain_id: chain_id,
|
|
95
|
-
challenge_id: challenge.id
|
|
106
|
+
challenge_id: challenge.id,
|
|
107
|
+
realm: challenge.realm
|
|
96
108
|
)
|
|
97
109
|
|
|
98
110
|
return Mpp::Credential.new(
|
|
@@ -172,6 +184,11 @@ module Mpp
|
|
|
172
184
|
raise TransactionError,
|
|
173
185
|
"Chain ID mismatch: RPC returned #{chain_id}, expected #{expected_chain_id} from challenge"
|
|
174
186
|
end
|
|
187
|
+
if awaiting_fee_payer
|
|
188
|
+
policy = FeePayerPolicy.for_chain_id(chain_id)
|
|
189
|
+
max_fee_per_gas = [gas_price, policy.max_fee_per_gas].min
|
|
190
|
+
max_priority_fee_per_gas = [gas_price, policy.max_priority_fee_per_gas].min
|
|
191
|
+
end
|
|
175
192
|
|
|
176
193
|
if awaiting_fee_payer
|
|
177
194
|
resolved_nonce_key = EXPIRING_NONCE_KEY
|
|
@@ -195,6 +212,8 @@ module Mpp
|
|
|
195
212
|
chain_id: chain_id,
|
|
196
213
|
gas_limit: gas_limit,
|
|
197
214
|
gas_price: gas_price,
|
|
215
|
+
max_priority_fee_per_gas: max_priority_fee_per_gas,
|
|
216
|
+
max_fee_per_gas: max_fee_per_gas,
|
|
198
217
|
nonce: resolved_nonce,
|
|
199
218
|
nonce_key: resolved_nonce_key,
|
|
200
219
|
currency: currency,
|
|
@@ -230,7 +249,7 @@ module Mpp
|
|
|
230
249
|
# Factory function to create a configured TempoMethod.
|
|
231
250
|
def self.tempo(intents:, account: nil, fee_payer: nil, chain_id: nil, rpc_url: nil,
|
|
232
251
|
root_account: nil, currency: nil, recipient: nil, decimals: 6, client_id: nil,
|
|
233
|
-
expected_recipients: nil)
|
|
252
|
+
expected_recipients: nil, fee_payer_allowed_fee_tokens: nil)
|
|
234
253
|
rpc_url ||= chain_id ? Defaults.rpc_url_for_chain(chain_id) : Defaults::RPC_URL
|
|
235
254
|
currency ||= Defaults.default_currency_for_chain(chain_id)
|
|
236
255
|
|
|
@@ -244,7 +263,8 @@ module Mpp
|
|
|
244
263
|
recipient: recipient,
|
|
245
264
|
decimals: decimals,
|
|
246
265
|
client_id: client_id,
|
|
247
|
-
expected_recipients: expected_recipients
|
|
266
|
+
expected_recipients: expected_recipients,
|
|
267
|
+
fee_payer_allowed_fee_tokens: fee_payer_allowed_fee_tokens
|
|
248
268
|
)
|
|
249
269
|
|
|
250
270
|
intents.each_value do |intent|
|
|
@@ -17,8 +17,8 @@ module Mpp
|
|
|
17
17
|
Kernel.require "rlp"
|
|
18
18
|
|
|
19
19
|
sender_sig = signed_tx.sender_signature
|
|
20
|
-
sig_bytes = sender_sig.respond_to?(:to_bytes) ? sender_sig.to_bytes : sender_sig.to_s.b
|
|
21
|
-
sender_addr = signed_tx.sender_address
|
|
20
|
+
sig_bytes = normalize_signature(sender_sig.respond_to?(:to_bytes) ? sender_sig.to_bytes : sender_sig.to_s.b)
|
|
21
|
+
sender_addr = pack_hex(signed_tx.sender_address)
|
|
22
22
|
|
|
23
23
|
fields = [
|
|
24
24
|
signed_tx.chain_id,
|
|
@@ -26,12 +26,12 @@ module Mpp
|
|
|
26
26
|
signed_tx.max_fee_per_gas,
|
|
27
27
|
signed_tx.gas_limit,
|
|
28
28
|
signed_tx.calls.map(&:as_rlp_list),
|
|
29
|
-
signed_tx.access_list.map(
|
|
29
|
+
signed_tx.access_list.map { |entry| entry.respond_to?(:as_rlp_list) ? entry.as_rlp_list : entry },
|
|
30
30
|
signed_tx.nonce_key,
|
|
31
31
|
signed_tx.nonce,
|
|
32
32
|
encode_optional_uint(signed_tx.valid_before),
|
|
33
33
|
encode_optional_uint(signed_tx.valid_after),
|
|
34
|
-
signed_tx.fee_token ? signed_tx.fee_token
|
|
34
|
+
signed_tx.fee_token ? pack_hex(signed_tx.fee_token) : "".b,
|
|
35
35
|
sender_addr,
|
|
36
36
|
signed_tx.tempo_authorization_list.to_a
|
|
37
37
|
]
|
|
@@ -39,7 +39,7 @@ module Mpp
|
|
|
39
39
|
fields << RLP.decode(signed_tx.key_authorization) if signed_tx.key_authorization
|
|
40
40
|
fields << sig_bytes
|
|
41
41
|
|
|
42
|
-
[TYPE_ID].pack("C") + RLP.
|
|
42
|
+
[TYPE_ID].pack("C") + RLP.encode(fields)
|
|
43
43
|
end
|
|
44
44
|
|
|
45
45
|
# Decode a 0x78 fee payer envelope.
|
|
@@ -58,7 +58,7 @@ module Mpp
|
|
|
58
58
|
|
|
59
59
|
# 15 fields = key_authorization present (index 13), signature at 14
|
|
60
60
|
# 14 fields = no key_authorization, signature at 13
|
|
61
|
-
key_authorization = (RLP.
|
|
61
|
+
key_authorization = (RLP.encode(decoded[13]) if decoded.length == 15)
|
|
62
62
|
|
|
63
63
|
[decoded, sender_address.to_s.b, sender_signature.to_s.b, key_authorization]
|
|
64
64
|
end
|
|
@@ -68,6 +68,21 @@ module Mpp
|
|
|
68
68
|
|
|
69
69
|
value.is_a?(Integer) ? value : value.to_i
|
|
70
70
|
end
|
|
71
|
+
|
|
72
|
+
def pack_hex(value)
|
|
73
|
+
[value.to_s.delete_prefix("0x")].pack("H*")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def normalize_signature(signature)
|
|
77
|
+
bytes = signature.b
|
|
78
|
+
Kernel.raise ArgumentError, "signature must be 65 bytes, got #{bytes.bytesize}" unless bytes.bytesize == 65
|
|
79
|
+
|
|
80
|
+
v = bytes.getbyte(64)
|
|
81
|
+
parity = (v >= 27) ? v - 27 : v
|
|
82
|
+
Kernel.raise ArgumentError, "signature parity must be 0 or 1, got #{v}" unless [0, 1].include?(parity)
|
|
83
|
+
|
|
84
|
+
bytes[0, 64] + [parity].pack("C")
|
|
85
|
+
end
|
|
71
86
|
end
|
|
72
87
|
end
|
|
73
88
|
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# typed: false
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Mpp
|
|
5
|
+
module Methods
|
|
6
|
+
module Tempo
|
|
7
|
+
module FeePayerPolicy
|
|
8
|
+
Policy = Data.define(
|
|
9
|
+
:max_gas,
|
|
10
|
+
:max_fee_per_gas,
|
|
11
|
+
:max_priority_fee_per_gas,
|
|
12
|
+
:max_total_fee,
|
|
13
|
+
:max_validity_window_seconds
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
DEFAULT = Policy.new(
|
|
17
|
+
max_gas: 2_000_000,
|
|
18
|
+
max_fee_per_gas: 100_000_000_000,
|
|
19
|
+
max_priority_fee_per_gas: 10_000_000_000,
|
|
20
|
+
max_total_fee: 50_000_000_000_000_000,
|
|
21
|
+
max_validity_window_seconds: 15 * 60
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
TESTNET = Policy.new(
|
|
25
|
+
max_gas: DEFAULT.max_gas,
|
|
26
|
+
max_fee_per_gas: DEFAULT.max_fee_per_gas,
|
|
27
|
+
max_priority_fee_per_gas: 50_000_000_000,
|
|
28
|
+
max_total_fee: DEFAULT.max_total_fee,
|
|
29
|
+
max_validity_window_seconds: DEFAULT.max_validity_window_seconds
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
def for_chain_id(chain_id)
|
|
35
|
+
case chain_id
|
|
36
|
+
when Defaults::CHAIN_ID, Defaults::TESTNET_CHAIN_ID
|
|
37
|
+
TESTNET
|
|
38
|
+
else
|
|
39
|
+
DEFAULT
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|