mpp-rb 0.1.0

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 (54) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +133 -0
  4. data/lib/mpp/body_digest.rb +37 -0
  5. data/lib/mpp/challenge.rb +115 -0
  6. data/lib/mpp/challenge_echo.rb +19 -0
  7. data/lib/mpp/challenge_id.rb +54 -0
  8. data/lib/mpp/client/transport.rb +137 -0
  9. data/lib/mpp/client.rb +9 -0
  10. data/lib/mpp/credential.rb +20 -0
  11. data/lib/mpp/errors.rb +190 -0
  12. data/lib/mpp/expires.rb +60 -0
  13. data/lib/mpp/extensions/mcp/capabilities.rb +23 -0
  14. data/lib/mpp/extensions/mcp/constants.rb +17 -0
  15. data/lib/mpp/extensions/mcp/decorator.rb +44 -0
  16. data/lib/mpp/extensions/mcp/errors.rb +110 -0
  17. data/lib/mpp/extensions/mcp/types.rb +205 -0
  18. data/lib/mpp/extensions/mcp/verify.rb +152 -0
  19. data/lib/mpp/extensions/mcp.rb +16 -0
  20. data/lib/mpp/json.rb +32 -0
  21. data/lib/mpp/methods/stripe/charge_intent.rb +90 -0
  22. data/lib/mpp/methods/stripe/client_method.rb +42 -0
  23. data/lib/mpp/methods/stripe/defaults.rb +14 -0
  24. data/lib/mpp/methods/stripe/stripe_method.rb +63 -0
  25. data/lib/mpp/methods/stripe.rb +14 -0
  26. data/lib/mpp/methods/tempo/account.rb +52 -0
  27. data/lib/mpp/methods/tempo/attribution.rb +112 -0
  28. data/lib/mpp/methods/tempo/client_method.rb +259 -0
  29. data/lib/mpp/methods/tempo/defaults.rb +77 -0
  30. data/lib/mpp/methods/tempo/fee_payer_envelope.rb +74 -0
  31. data/lib/mpp/methods/tempo/intents.rb +377 -0
  32. data/lib/mpp/methods/tempo/keychain.rb +31 -0
  33. data/lib/mpp/methods/tempo/proof.rb +127 -0
  34. data/lib/mpp/methods/tempo/rpc.rb +60 -0
  35. data/lib/mpp/methods/tempo/schemas.rb +96 -0
  36. data/lib/mpp/methods/tempo/transaction.rb +144 -0
  37. data/lib/mpp/methods/tempo.rb +22 -0
  38. data/lib/mpp/parsing.rb +252 -0
  39. data/lib/mpp/receipt.rb +31 -0
  40. data/lib/mpp/secure_compare.rb +25 -0
  41. data/lib/mpp/server/decorator.rb +32 -0
  42. data/lib/mpp/server/defaults.rb +45 -0
  43. data/lib/mpp/server/intent.rb +40 -0
  44. data/lib/mpp/server/method.rb +27 -0
  45. data/lib/mpp/server/middleware.rb +51 -0
  46. data/lib/mpp/server/mpp_handler.rb +97 -0
  47. data/lib/mpp/server/verify.rb +129 -0
  48. data/lib/mpp/server.rb +15 -0
  49. data/lib/mpp/store.rb +49 -0
  50. data/lib/mpp/units.rb +57 -0
  51. data/lib/mpp/version.rb +6 -0
  52. data/lib/mpp-rb.rb +3 -0
  53. data/lib/mpp.rb +68 -0
  54. metadata +111 -0
@@ -0,0 +1,259 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "defaults"
5
+ require_relative "transaction"
6
+
7
+ module Mpp
8
+ module Methods
9
+ module Tempo
10
+ DEFAULT_GAS_LIMIT = 1_000_000
11
+ EXPIRING_NONCE_KEY = (1 << 256) - 1 # U256::MAX
12
+ FEE_PAYER_VALID_BEFORE_SECS = 25
13
+
14
+ class TransactionError < StandardError; end
15
+
16
+ # Tempo payment method implementation.
17
+ # Handles client-side credential creation for Tempo payments.
18
+ class TempoMethod
19
+ attr_reader :name, :account, :fee_payer, :root_account, :rpc_url,
20
+ :chain_id, :currency, :recipient, :decimals, :client_id,
21
+ :expected_recipients
22
+ attr_accessor :intents
23
+
24
+ def initialize(account: nil, fee_payer: nil, root_account: nil,
25
+ rpc_url: Defaults::RPC_URL, chain_id: nil, currency: nil,
26
+ recipient: nil, decimals: 6, client_id: nil,
27
+ expected_recipients: nil)
28
+ @name = "tempo"
29
+ @account = account
30
+ @fee_payer = fee_payer
31
+ @root_account = root_account
32
+ @rpc_url = rpc_url
33
+ @chain_id = chain_id
34
+ @currency = currency
35
+ @recipient = recipient
36
+ @decimals = decimals
37
+ @client_id = client_id
38
+ @expected_recipients = expected_recipients&.map(&:downcase)&.to_set
39
+ @intents = {}
40
+ end
41
+
42
+ # Create a credential to satisfy the given challenge.
43
+ #
44
+ # mode: :pull (default) — return signed transaction for server to broadcast
45
+ # :push — broadcast on-chain, return transaction hash
46
+ # :proof — zero-amount transaction proving account ownership
47
+ def create_credential(challenge, mode: nil)
48
+ raise ArgumentError, "No account configured for signing" unless @account
49
+ raise ArgumentError, "Unsupported intent: #{challenge.intent}" unless challenge.intent == "charge"
50
+
51
+ mode ||= :pull
52
+ request = challenge.request
53
+ method_details = request["methodDetails"]
54
+ method_details = {} unless method_details.is_a?(Hash)
55
+
56
+ validate_recipients(request, method_details) if @expected_recipients
57
+
58
+ use_fee_payer = method_details.fetch("feePayer", false)
59
+
60
+ nonce_key = request.fetch("nonce_key", 0)
61
+ if nonce_key.is_a?(String)
62
+ nonce_key = nonce_key.start_with?("0x") ? nonce_key.to_i(16) : nonce_key.to_i
63
+ end
64
+
65
+ memo = method_details["memo"]
66
+ memo ||= Attribution.encode(server_id: challenge.realm, client_id: @client_id, challenge_id: challenge.id)
67
+
68
+ # Resolve RPC URL from challenge's chainId
69
+ resolved_rpc_url = @rpc_url
70
+ expected_chain_id = nil
71
+ challenge_chain_id = method_details["chainId"]
72
+ if challenge_chain_id
73
+ begin
74
+ parsed_chain_id = Integer(challenge_chain_id)
75
+ resolved = Defaults::CHAIN_RPC_URLS[parsed_chain_id]
76
+ if resolved
77
+ resolved_rpc_url = resolved
78
+ expected_chain_id = parsed_chain_id
79
+ end
80
+ rescue ArgumentError, TypeError
81
+ # ignore
82
+ end
83
+ end
84
+
85
+ expected_chain_id ||= @chain_id
86
+
87
+ # Proof mode: sign EIP-712 typed data (no transaction needed)
88
+ if mode == :proof
89
+ chain_id = expected_chain_id || @chain_id
90
+ raise ArgumentError, "chain_id required for proof mode" unless chain_id
91
+
92
+ signature = Proof.sign(
93
+ account: @account,
94
+ chain_id: chain_id,
95
+ challenge_id: challenge.id
96
+ )
97
+
98
+ return Mpp::Credential.new(
99
+ challenge: challenge.to_echo,
100
+ payload: {"type" => "proof", "signature" => signature},
101
+ source: Proof.source(address: @account.address, chain_id: chain_id)
102
+ )
103
+ end
104
+
105
+ raw_tx, chain_id = build_tempo_transfer(
106
+ amount: request["amount"],
107
+ currency: request["currency"],
108
+ recipient: request["recipient"],
109
+ nonce_key: nonce_key,
110
+ memo: memo,
111
+ rpc_url: resolved_rpc_url,
112
+ expected_chain_id: expected_chain_id,
113
+ awaiting_fee_payer: use_fee_payer
114
+ )
115
+
116
+ payload = if mode == :push
117
+ tx_hash = Rpc.call(resolved_rpc_url, "eth_sendRawTransaction", [raw_tx])
118
+ raise TransactionError, "No transaction hash returned" unless tx_hash
119
+ {"type" => "hash", "hash" => tx_hash}
120
+ else
121
+ {"type" => "transaction", "signature" => raw_tx}
122
+ end
123
+
124
+ Mpp::Credential.new(
125
+ challenge: challenge.to_echo,
126
+ payload: payload,
127
+ source: "did:pkh:eip155:#{chain_id}:#{@account.address}"
128
+ )
129
+ end
130
+
131
+ # Transform request - adds default methodDetails if needed.
132
+ def transform_request(request, _credential)
133
+ request
134
+ end
135
+
136
+ private
137
+
138
+ def validate_recipients(request, method_details)
139
+ recipient = request["recipient"]
140
+ if recipient && !@expected_recipients.include?(recipient.downcase)
141
+ raise ArgumentError, "Unexpected recipient: #{recipient}"
142
+ end
143
+
144
+ splits = method_details["splits"]
145
+ return unless splits.is_a?(Array)
146
+
147
+ splits.each do |split|
148
+ addr = split["recipient"]
149
+ next unless addr
150
+ unless @expected_recipients.include?(addr.downcase)
151
+ raise ArgumentError, "Unexpected split recipient: #{addr}"
152
+ end
153
+ end
154
+ end
155
+
156
+ def build_tempo_transfer(amount:, currency:, recipient:, nonce_key: 0,
157
+ memo: nil, rpc_url: nil, expected_chain_id: nil,
158
+ awaiting_fee_payer: false)
159
+ raise ArgumentError, "No account configured" unless @account
160
+
161
+ resolved_rpc = rpc_url || @rpc_url
162
+
163
+ transfer_data = if memo
164
+ encode_transfer_with_memo(recipient, Integer(amount), memo)
165
+ else
166
+ encode_transfer(recipient, Integer(amount))
167
+ end
168
+
169
+ chain_id, on_chain_nonce, gas_price = Rpc.get_tx_params(resolved_rpc, @account.address)
170
+
171
+ if expected_chain_id && chain_id != expected_chain_id
172
+ raise TransactionError,
173
+ "Chain ID mismatch: RPC returned #{chain_id}, expected #{expected_chain_id} from challenge"
174
+ end
175
+
176
+ if awaiting_fee_payer
177
+ resolved_nonce_key = EXPIRING_NONCE_KEY
178
+ resolved_nonce = 0
179
+ valid_before = Time.now.to_i + FEE_PAYER_VALID_BEFORE_SECS
180
+ else
181
+ resolved_nonce_key = nonce_key
182
+ resolved_nonce = on_chain_nonce
183
+ valid_before = nil
184
+ end
185
+
186
+ gas_limit = DEFAULT_GAS_LIMIT
187
+ begin
188
+ estimated = Rpc.estimate_gas(resolved_rpc, @account.address, currency, transfer_data)
189
+ gas_limit = [gas_limit, estimated + 5_000].max
190
+ rescue
191
+ # fallback to default
192
+ end
193
+ Transaction.build_signed_transfer(
194
+ account: @account,
195
+ chain_id: chain_id,
196
+ gas_limit: gas_limit,
197
+ gas_price: gas_price,
198
+ nonce: resolved_nonce,
199
+ nonce_key: resolved_nonce_key,
200
+ currency: currency,
201
+ transfer_data: transfer_data,
202
+ valid_before: valid_before,
203
+ awaiting_fee_payer: awaiting_fee_payer
204
+ )
205
+ rescue LoadError => e
206
+ raise TransactionError, e.message
207
+ end
208
+
209
+ def encode_transfer(to, amount)
210
+ selector = "a9059cbb"
211
+ to_padded = to.delete_prefix("0x").downcase.rjust(64, "0")
212
+ amount_padded = amount.to_s(16).rjust(64, "0")
213
+ "0x#{selector}#{to_padded}#{amount_padded}"
214
+ end
215
+
216
+ def encode_transfer_with_memo(to, amount, memo)
217
+ selector = "95777d59"
218
+ to_padded = to.delete_prefix("0x").downcase.rjust(64, "0")
219
+ amount_padded = amount.to_s(16).rjust(64, "0")
220
+ memo_clean = memo.delete_prefix("0x")
221
+ unless memo_clean.length == 64
222
+ raise ArgumentError,
223
+ "memo must be exactly 32 bytes (64 hex chars), got #{memo_clean.length}"
224
+ end
225
+
226
+ "0x#{selector}#{to_padded}#{amount_padded}#{memo_clean.downcase}"
227
+ end
228
+ end
229
+
230
+ # Factory function to create a configured TempoMethod.
231
+ def self.tempo(intents:, account: nil, fee_payer: nil, chain_id: nil, rpc_url: nil,
232
+ root_account: nil, currency: nil, recipient: nil, decimals: 6, client_id: nil,
233
+ expected_recipients: nil)
234
+ rpc_url ||= chain_id ? Defaults.rpc_url_for_chain(chain_id) : Defaults::RPC_URL
235
+ currency ||= Defaults.default_currency_for_chain(chain_id)
236
+
237
+ method = TempoMethod.new(
238
+ account: account,
239
+ fee_payer: fee_payer,
240
+ rpc_url: rpc_url,
241
+ chain_id: chain_id,
242
+ root_account: root_account,
243
+ currency: currency,
244
+ recipient: recipient,
245
+ decimals: decimals,
246
+ client_id: client_id,
247
+ expected_recipients: expected_recipients
248
+ )
249
+
250
+ intents.each_value do |intent|
251
+ intent.rpc_url = rpc_url if intent.respond_to?(:rpc_url=) && intent.rpc_url.nil?
252
+ intent.instance_variable_set(:@_method, method) if intent.respond_to?(:fee_payer)
253
+ end
254
+ method.intents = intents.dup
255
+ method
256
+ end
257
+ end
258
+ end
259
+ end
@@ -0,0 +1,77 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Mpp
5
+ module Methods
6
+ module Tempo
7
+ module Defaults
8
+ # Mainnet
9
+ CHAIN_ID = 4217
10
+ RPC_URL = "https://rpc.tempo.xyz"
11
+ PATH_USD = "0x20c0000000000000000000000000000000000000"
12
+ USDC = "0x20C000000000000000000000b9537d11c60E8b50"
13
+ PATH_USD_DECIMALS = 6
14
+
15
+ # Testnet (Moderato)
16
+ TESTNET_CHAIN_ID = 42_431
17
+ TESTNET_RPC_URL = "https://rpc.moderato.tempo.xyz"
18
+
19
+ # Testnet only - fee payer service sponsors gas on testnet
20
+ DEFAULT_FEE_PAYER_URL = "https://sponsor.moderato.tempo.xyz"
21
+
22
+ # Chain ID -> default currency mapping
23
+ DEFAULT_CURRENCIES = T.let({
24
+ CHAIN_ID => USDC,
25
+ TESTNET_CHAIN_ID => PATH_USD
26
+ }.freeze, T::Hash[T.untyped, T.untyped])
27
+
28
+ # Chain ID -> default RPC URL mapping
29
+ CHAIN_RPC_URLS = T.let({
30
+ CHAIN_ID => RPC_URL,
31
+ TESTNET_CHAIN_ID => TESTNET_RPC_URL
32
+ }.freeze, T::Hash[T.untyped, T.untyped])
33
+
34
+ # Chain ID -> escrow contract address mapping
35
+ ESCROW_CONTRACTS = T.let({
36
+ CHAIN_ID => "0x33b901018174DDabE4841042ab76ba85D4e24f25",
37
+ TESTNET_CHAIN_ID => "0xe1c4d3dce17bc111181ddf716f75bae49e61a336"
38
+ }.freeze, T::Hash[T.untyped, T.untyped])
39
+
40
+ extend T::Sig
41
+
42
+ module_function
43
+
44
+ sig { params(chain_id: Integer).returns(String) }
45
+ def rpc_url_for_chain(chain_id)
46
+ url = CHAIN_RPC_URLS[chain_id]
47
+ return url if url
48
+
49
+ Kernel.raise ArgumentError,
50
+ "Unknown chain_id #{chain_id}. Known chains: #{CHAIN_RPC_URLS.keys}. Pass rpc_url explicitly."
51
+ end
52
+
53
+ sig { params(chain_id: T.nilable(Integer)).returns(String) }
54
+ def default_currency_for_chain(chain_id)
55
+ return PATH_USD if chain_id.nil?
56
+
57
+ DEFAULT_CURRENCIES.fetch(chain_id, PATH_USD)
58
+ end
59
+
60
+ sig { params(chain_id: T.nilable(Integer), testnet: T::Boolean).returns(String) }
61
+ def resolve_currency(chain_id: nil, testnet: false)
62
+ id = chain_id || (testnet ? TESTNET_CHAIN_ID : CHAIN_ID)
63
+ DEFAULT_CURRENCIES.fetch(id, PATH_USD)
64
+ end
65
+
66
+ sig { params(chain_id: Integer).returns(String) }
67
+ def escrow_contract_for_chain(chain_id)
68
+ addr = ESCROW_CONTRACTS[chain_id]
69
+ return addr if addr
70
+
71
+ Kernel.raise ArgumentError,
72
+ "Unknown chain_id #{chain_id}. Known chains: #{ESCROW_CONTRACTS.keys}. Pass escrow_contract explicitly."
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,74 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ module Mpp
5
+ module Methods
6
+ module Tempo
7
+ module FeePayer
8
+ TYPE_ID = 0x78
9
+
10
+ module_function
11
+
12
+ # Encode a sender-signed transaction as a 0x78 fee payer envelope.
13
+ # Requires the `rlp` gem.
14
+ #
15
+ # Wire format: 0x78 || RLP([fields...])
16
+ def encode(signed_tx)
17
+ Kernel.require "rlp"
18
+
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.to_s.b
22
+
23
+ fields = [
24
+ signed_tx.chain_id,
25
+ signed_tx.max_priority_fee_per_gas,
26
+ signed_tx.max_fee_per_gas,
27
+ signed_tx.gas_limit,
28
+ signed_tx.calls.map(&:as_rlp_list),
29
+ signed_tx.access_list.map(&:as_rlp_list),
30
+ signed_tx.nonce_key,
31
+ signed_tx.nonce,
32
+ encode_optional_uint(signed_tx.valid_before),
33
+ encode_optional_uint(signed_tx.valid_after),
34
+ signed_tx.fee_token ? signed_tx.fee_token.to_s.b : "".b,
35
+ sender_addr,
36
+ signed_tx.tempo_authorization_list.to_a
37
+ ]
38
+
39
+ fields << RLP.decode(signed_tx.key_authorization) if signed_tx.key_authorization
40
+ fields << sig_bytes
41
+
42
+ [TYPE_ID].pack("C") + RLP.include(fields)
43
+ end
44
+
45
+ # Decode a 0x78 fee payer envelope.
46
+ #
47
+ # Returns [decoded_fields, sender_address_bytes, sender_signature_bytes, key_authorization_or_nil]
48
+ def decode(data)
49
+ Kernel.require "rlp"
50
+
51
+ Kernel.raise ArgumentError, "Not a fee payer envelope (expected 0x78 prefix)" unless data.getbyte(0) == TYPE_ID
52
+
53
+ decoded = RLP.decode(data[1..])
54
+ Kernel.raise ArgumentError, "Malformed fee payer envelope" unless decoded.is_a?(Array) && decoded.length >= 14
55
+
56
+ sender_address = decoded[11]
57
+ sender_signature = decoded[-1]
58
+
59
+ # 15 fields = key_authorization present (index 13), signature at 14
60
+ # 14 fields = no key_authorization, signature at 13
61
+ key_authorization = (RLP.include(decoded[13]) if decoded.length == 15)
62
+
63
+ [decoded, sender_address.to_s.b, sender_signature.to_s.b, key_authorization]
64
+ end
65
+
66
+ def encode_optional_uint(value)
67
+ return "".b unless value
68
+
69
+ value.is_a?(Integer) ? value : value.to_i
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end