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,377 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ require "time"
5
+ require "json"
6
+
7
+ module Mpp
8
+ module Methods
9
+ module Tempo
10
+ MAX_RECEIPT_RETRY_ATTEMPTS = 20
11
+ RECEIPT_RETRY_DELAY_SECONDS = 0.5
12
+
13
+ TRANSFER_SELECTOR = "a9059cbb"
14
+ TRANSFER_WITH_MEMO_SELECTOR = "95777d59"
15
+ TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
16
+ TRANSFER_WITH_MEMO_TOPIC = "0x57bc7354aa85aed339e000bccffabbc529466af35f0772c8f8ee1145927de7f0"
17
+
18
+ # Tempo charge intent for server-side verification.
19
+ class ChargeIntent
20
+ attr_reader :name
21
+ attr_accessor :rpc_url
22
+
23
+ def initialize(chain_id: nil, rpc_url: nil, timeout: 30, store: nil)
24
+ @name = "charge"
25
+ @rpc_url = rpc_url || (chain_id ? Defaults.rpc_url_for_chain(chain_id) : nil)
26
+ @_method = nil
27
+ @timeout = timeout
28
+ @store = store
29
+ end
30
+
31
+ def fee_payer
32
+ @_method&.fee_payer
33
+ end
34
+
35
+ def verify(credential, request)
36
+ req = Schemas::ChargeRequest.from_hash(request)
37
+
38
+ # Check challenge expiry
39
+ challenge_expires = credential.challenge.expires
40
+ if challenge_expires
41
+ expires = Time.iso8601(challenge_expires.gsub("Z", "+00:00"))
42
+ raise Mpp::VerificationError, "Request has expired" if expires < Time.now.utc
43
+ end
44
+
45
+ payload_data = credential.payload
46
+ unless payload_data.is_a?(Hash) && payload_data.key?("type")
47
+ raise Mpp::VerificationError,
48
+ "Invalid credential payload"
49
+ end
50
+
51
+ case payload_data["type"]
52
+ when "hash"
53
+ payload = Schemas::HashCredentialPayload.new(type: "hash", hash: payload_data["hash"])
54
+ verify_hash(payload, req)
55
+ when "transaction"
56
+ payload = Schemas::TransactionCredentialPayload.new(
57
+ type: "transaction", signature: payload_data["signature"]
58
+ )
59
+ verify_transaction(payload, req)
60
+ when "proof"
61
+ payload = Schemas::ProofCredentialPayload.new(
62
+ type: "proof", signature: payload_data["signature"]
63
+ )
64
+ verify_proof(payload, req, credential: credential)
65
+ else
66
+ raise Mpp::VerificationError, "Invalid credential type: #{payload_data["type"]}"
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ def get_rpc_url
73
+ raise Mpp::VerificationError, "No rpc_url configured on ChargeIntent" unless @rpc_url
74
+
75
+ @rpc_url
76
+ end
77
+
78
+ def verify_hash(payload, request)
79
+ if @store
80
+ store_key = "mpp:charge:#{payload.hash.downcase}"
81
+ raise Mpp::VerificationError, "Transaction hash already used" unless @store.put_if_absent(store_key,
82
+ payload.hash)
83
+ end
84
+
85
+ rpc_url = get_rpc_url
86
+ result = Rpc.call(rpc_url, "eth_getTransactionReceipt", [payload.hash])
87
+
88
+ raise Mpp::VerificationError, "Transaction not found" unless result
89
+ raise Mpp::VerificationError, "Transaction reverted" unless result["status"] == "0x1"
90
+ unless verify_transfer_logs(
91
+ result, request
92
+ )
93
+ raise Mpp::VerificationError,
94
+ "Transaction must contain a Transfer log matching request parameters"
95
+ end
96
+
97
+ Mpp::Receipt.success(payload.hash)
98
+ end
99
+
100
+ def verify_transaction(payload, request)
101
+ validate_transaction_payload(payload.signature, request)
102
+
103
+ raw_tx = payload.signature
104
+
105
+ if request.method_details.fee_payer
106
+ if fee_payer
107
+ raw_tx = cosign_as_fee_payer(raw_tx, request.currency, request: request)
108
+ else
109
+ fee_payer_url = request.method_details.fee_payer_url || Defaults::DEFAULT_FEE_PAYER_URL
110
+ result = Rpc.call(fee_payer_url, "eth_signRawTransaction", [raw_tx])
111
+ raise Mpp::VerificationError, "Fee payer returned no signed transaction" unless result
112
+
113
+ raw_tx = result
114
+ end
115
+ end
116
+
117
+ rpc_url = get_rpc_url
118
+ tx_hash = Rpc.call(rpc_url, "eth_sendRawTransaction", [raw_tx])
119
+ raise Mpp::VerificationError, "No transaction hash returned" unless tx_hash
120
+
121
+ receipt_data = T.let(nil, T.untyped)
122
+ MAX_RECEIPT_RETRY_ATTEMPTS.times do |attempt|
123
+ receipt_data = Rpc.call(rpc_url, "eth_getTransactionReceipt", [tx_hash])
124
+ break if receipt_data
125
+
126
+ sleep(RECEIPT_RETRY_DELAY_SECONDS) if attempt < MAX_RECEIPT_RETRY_ATTEMPTS - 1
127
+ end
128
+
129
+ raise Mpp::VerificationError, "Transaction receipt not found after retries" unless receipt_data
130
+ raise Mpp::VerificationError, "Transaction reverted" unless receipt_data["status"] == "0x1"
131
+ unless verify_transfer_logs(
132
+ receipt_data, request
133
+ )
134
+ raise Mpp::VerificationError,
135
+ "Transaction must contain a Transfer log matching request parameters"
136
+ end
137
+
138
+ Mpp::Receipt.success(tx_hash)
139
+ end
140
+
141
+ def verify_transfer_logs(receipt, request, expected_sender: nil)
142
+ expected_memo = request.method_details.memo
143
+
144
+ (receipt["logs"] || []).each do |log|
145
+ next unless log["address"]&.downcase == request.currency.downcase
146
+
147
+ topics = log["topics"] || []
148
+ next if topics.length < 3
149
+
150
+ from_address = "0x#{topics[1][-40..]}"
151
+ to_address = "0x#{topics[2][-40..]}"
152
+
153
+ next unless to_address.downcase == request.recipient.downcase
154
+ next if expected_sender && from_address.downcase != expected_sender.downcase
155
+
156
+ if expected_memo
157
+ next unless topics[0] == TRANSFER_WITH_MEMO_TOPIC
158
+ next if topics.length < 4
159
+
160
+ data = log.fetch("data", "0x")
161
+ next if data.length < 66
162
+
163
+ amount = data[2, 64].to_i(16)
164
+ memo = topics[3]
165
+ memo_clean = expected_memo.downcase
166
+ memo_clean = "0x#{memo_clean}" unless memo_clean.start_with?("0x")
167
+ return true if amount == Integer(request.amount) && memo.downcase == memo_clean
168
+ else
169
+ next unless topics[0] == TRANSFER_TOPIC
170
+
171
+ data = log.fetch("data", "0x")
172
+ next if data.length < 66
173
+
174
+ amount = data.delete_prefix("0x").to_i(16)
175
+ return true if amount == Integer(request.amount)
176
+ end
177
+ end
178
+
179
+ false
180
+ end
181
+
182
+ def validate_transaction_payload(signature, request)
183
+ # Best-effort pre-broadcast check
184
+ begin
185
+ require "rlp"
186
+ rescue LoadError
187
+ return
188
+ end
189
+
190
+ begin
191
+ tx_bytes = [signature.delete_prefix("0x")].pack("H*")
192
+ rescue ArgumentError
193
+ return
194
+ end
195
+
196
+ return if tx_bytes.empty? || ![0x76, 0x78].include?(tx_bytes.getbyte(0))
197
+
198
+ begin
199
+ decoded = RLP.decode(tx_bytes[1..])
200
+ rescue
201
+ return
202
+ end
203
+
204
+ return unless decoded.is_a?(Array) && decoded.length >= 5
205
+
206
+ calls_data = decoded[4] || []
207
+ raise Mpp::VerificationError, "Transaction contains no calls" if calls_data.empty?
208
+
209
+ found = calls_data.any? do |call_item|
210
+ next unless call_item.is_a?(Array) && call_item.length >= 3
211
+
212
+ call_to_bytes = call_item[0]
213
+ call_data_bytes = call_item[2]
214
+ next unless call_to_bytes && call_data_bytes
215
+
216
+ to_hex = call_to_bytes.is_a?(String) ? call_to_bytes.unpack1("H*") : call_to_bytes.to_s
217
+ next unless "0x#{to_hex}".downcase == request.currency.downcase
218
+
219
+ data_hex = call_data_bytes.is_a?(String) ? call_data_bytes.unpack1("H*") : call_data_bytes.to_s
220
+ match_transfer_calldata(data_hex, request)
221
+ end
222
+
223
+ raise Mpp::VerificationError, "Invalid transaction: no matching payment call found" unless found
224
+ end
225
+
226
+ def match_transfer_calldata(call_data_hex, request)
227
+ return false if call_data_hex.length < 136
228
+
229
+ selector = call_data_hex[0, 8].downcase
230
+ expected_memo = request.method_details.memo
231
+
232
+ if expected_memo
233
+ return false unless selector == TRANSFER_WITH_MEMO_SELECTOR
234
+ elsif ![TRANSFER_SELECTOR, TRANSFER_WITH_MEMO_SELECTOR].include?(selector)
235
+ return false
236
+ end
237
+
238
+ decoded_to = "0x#{call_data_hex[32, 40]}"
239
+ decoded_amount = call_data_hex[72, 64].to_i(16)
240
+
241
+ return false unless decoded_to.downcase == request.recipient.downcase
242
+ return false unless decoded_amount == Integer(request.amount)
243
+
244
+ if expected_memo
245
+ return false if call_data_hex.length < 200
246
+
247
+ decoded_memo = "0x#{call_data_hex[136, 64]}"
248
+ memo_clean = expected_memo.downcase
249
+ memo_clean = "0x#{memo_clean}" unless memo_clean.start_with?("0x")
250
+ return false unless decoded_memo.downcase == memo_clean
251
+ end
252
+
253
+ true
254
+ end
255
+
256
+ def verify_proof(payload, request, credential:)
257
+ raise Mpp::VerificationError, "Proof credentials are only valid for zero-amount challenges" unless Integer(request.amount).zero?
258
+ raise Mpp::VerificationError, "Proof credential must include a source" unless credential.source
259
+
260
+ resolved_chain_id = request.method_details.chain_id
261
+ source = Proof.parse_source(credential.source)
262
+ raise Mpp::VerificationError, "Proof credential source is invalid" unless source
263
+ raise Mpp::VerificationError, "Proof credential source chain mismatch" unless source[:chain_id] == resolved_chain_id
264
+
265
+ valid = Proof.verify(
266
+ address: source[:address],
267
+ chain_id: resolved_chain_id,
268
+ challenge_id: credential.challenge.id,
269
+ signature: payload.signature
270
+ )
271
+ raise Mpp::VerificationError, "Proof signature does not match source" unless valid
272
+
273
+ Mpp::Receipt.success(credential.challenge.id)
274
+ end
275
+
276
+ def cosign_as_fee_payer(raw_tx, fee_token, request: nil)
277
+ require "eth"
278
+ require "rlp"
279
+
280
+ raise Mpp::VerificationError, "No fee payer account configured" unless fee_payer
281
+
282
+ # Decode the 0x78 fee payer envelope
283
+ begin
284
+ all_bytes = [raw_tx.delete_prefix("0x")].pack("H*")
285
+ decoded, sender_addr_bytes, sender_sig, key_auth = FeePayer.decode(all_bytes)
286
+ rescue => e
287
+ raise Mpp::VerificationError, "Failed to deserialize client transaction: #{e.message}"
288
+ end
289
+
290
+ int = ->(b) {
291
+ if b.is_a?(String) && !b.empty?
292
+ b.unpack1("H*").to_i(16)
293
+ elsif b.is_a?(Integer)
294
+ b
295
+ else
296
+ 0
297
+ end
298
+ }
299
+
300
+ # Validate fee-payer invariants
301
+ fee_token_field = decoded[10]
302
+ if fee_token_field.is_a?(String) && !fee_token_field.empty?
303
+ raise Mpp::VerificationError, "Fee payer transaction must not include fee_token (server sets it)"
304
+ end
305
+
306
+ nonce_key = int.call(decoded[6])
307
+ unless nonce_key == (1 << 256) - 1
308
+ raise Mpp::VerificationError, "Fee payer envelope must use expiring nonce key (U256::MAX)"
309
+ end
310
+
311
+ valid_before_raw = decoded[8]
312
+ if !valid_before_raw.is_a?(String) || valid_before_raw.empty?
313
+ raise Mpp::VerificationError, "Fee payer envelope must include valid_before"
314
+ end
315
+ valid_before = int.call(valid_before_raw)
316
+ if valid_before <= Time.now.to_i
317
+ raise Mpp::VerificationError,
318
+ "Fee payer envelope expired: valid_before (#{valid_before}) is not in the future"
319
+ end
320
+
321
+ # Build calls from decoded RLP
322
+ calls_data = decoded[4] || []
323
+ calls = calls_data.map do |c|
324
+ Transaction::Call.new(
325
+ to: "0x#{c[0].unpack1("H*")}",
326
+ value: int.call(c[1]),
327
+ data: "0x#{c[2].unpack1("H*")}"
328
+ )
329
+ end
330
+
331
+ # Reconstruct transaction for sender signature recovery
332
+ tx_for_recovery = Transaction::SignedTransaction.new(
333
+ chain_id: int.call(decoded[0]),
334
+ max_priority_fee_per_gas: int.call(decoded[1]),
335
+ max_fee_per_gas: int.call(decoded[2]),
336
+ gas_limit: int.call(decoded[3]),
337
+ calls: calls,
338
+ access_list: Transaction::EMPTY_LIST,
339
+ nonce_key: int.call(decoded[6]),
340
+ nonce: int.call(decoded[7]),
341
+ valid_before: int.call(decoded[8]),
342
+ valid_after: (decoded[9].is_a?(String) && !decoded[9].empty?) ? int.call(decoded[9]) : nil,
343
+ fee_token: nil,
344
+ sender_signature: sender_sig,
345
+ fee_payer_signature: Transaction::EMPTY_SIGNATURE,
346
+ sender_address: "0x#{sender_addr_bytes.unpack1("H*")}",
347
+ tempo_authorization_list: decoded[12] || Transaction::EMPTY_LIST,
348
+ key_authorization: key_auth
349
+ )
350
+
351
+ # Verify sender signature
352
+ sender_hash = tx_for_recovery.signature_hash
353
+ recovered = Eth::Key.personal_recover(sender_hash, "0x#{sender_sig.unpack1("H*")}")
354
+ recovered_addr = Eth::Util.public_key_to_address(recovered).to_s
355
+ envelope_addr = "0x#{sender_addr_bytes.unpack1("H*")}"
356
+
357
+ unless recovered_addr.downcase == envelope_addr.downcase
358
+ raise Mpp::VerificationError, "Sender address does not match recovered signer"
359
+ end
360
+
361
+ # Build the final transaction with fee_token set
362
+ resolved_fee_token = fee_token || request&.currency
363
+ raise Mpp::VerificationError, "No fee token available" unless resolved_fee_token
364
+
365
+ tx_to_sign = tx_for_recovery.with(fee_token: resolved_fee_token)
366
+
367
+ # Fee payer signs over fields including sender_signature
368
+ fee_payer_hash = tx_to_sign.fee_payer_signature_hash
369
+ fee_payer_sig = fee_payer.sign_hash(fee_payer_hash)
370
+
371
+ signed = tx_to_sign.with(fee_payer_signature: fee_payer_sig)
372
+ "0x#{signed.encoded_2718.unpack1("H*")}"
373
+ end
374
+ end
375
+ end
376
+ end
377
+ end
@@ -0,0 +1,31 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Mpp
5
+ module Methods
6
+ module Tempo
7
+ module Keychain
8
+ SIGNATURE_TYPE = 0x03
9
+ SIGNATURE_LENGTH = 86
10
+
11
+ module_function
12
+
13
+ # Build a Keychain signature for a message hash.
14
+ #
15
+ # Format: 0x03 || root_account (20 bytes) || inner_sig (65 bytes)
16
+ # Total: 86 bytes
17
+ def build_signature(msg_hash:, access_key:, root_account:)
18
+ inner_sig = access_key.sign_hash(msg_hash)
19
+ root_bytes = [root_account.delete_prefix("0x")].pack("H*")
20
+
21
+ keychain_sig = [SIGNATURE_TYPE].pack("C") + root_bytes + inner_sig
22
+ unless keychain_sig.bytesize == SIGNATURE_LENGTH
23
+ Kernel.raise "Invalid keychain signature length: #{keychain_sig.bytesize}"
24
+ end
25
+
26
+ keychain_sig
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,127 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ module Mpp
5
+ module Methods
6
+ module Tempo
7
+ # EIP-712 proof credentials for zero-amount challenges.
8
+ #
9
+ # Domain: { name: "MPP", version: "1", chainId }
10
+ # Types: { Proof: [{ name: "challengeId", type: "string" }] }
11
+ # Message: { challengeId: <challenge.id> }
12
+ module Proof
13
+ DOMAIN_NAME = "MPP"
14
+ DOMAIN_VERSION = "1"
15
+
16
+ # EIP-712 domain separator type hash
17
+ DOMAIN_TYPE_HASH = "EIP712Domain(string name,string version,uint256 chainId)"
18
+ PROOF_TYPE_HASH = "Proof(string challengeId)"
19
+
20
+ module_function
21
+
22
+ def keccak256(data)
23
+ Kernel.require "eth"
24
+ Eth::Util.keccak256(data)
25
+ end
26
+
27
+ # Compute the EIP-712 domain separator.
28
+ def domain_separator(chain_id)
29
+ keccak256(
30
+ abi_encode(
31
+ keccak256(DOMAIN_TYPE_HASH),
32
+ keccak256(DOMAIN_NAME),
33
+ keccak256(DOMAIN_VERSION),
34
+ uint256(chain_id)
35
+ )
36
+ )
37
+ end
38
+
39
+ # Compute the EIP-712 struct hash for Proof(challengeId).
40
+ def struct_hash(challenge_id)
41
+ keccak256(
42
+ abi_encode(
43
+ keccak256(PROOF_TYPE_HASH),
44
+ keccak256(challenge_id)
45
+ )
46
+ )
47
+ end
48
+
49
+ # Compute the full EIP-712 signing hash.
50
+ def signing_hash(chain_id:, challenge_id:)
51
+ keccak256(
52
+ "\x19\x01".b + domain_separator(chain_id) + struct_hash(challenge_id)
53
+ )
54
+ end
55
+
56
+ # 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)
59
+ sig = account.sign_hash(hash)
60
+ "0x#{sig.unpack1("H*")}"
61
+ end
62
+
63
+ # Verify a proof credential signature (server-side).
64
+ def verify(address:, chain_id:, challenge_id:, signature:)
65
+ Kernel.require "eth"
66
+
67
+ hash = signing_hash(chain_id: chain_id, challenge_id: challenge_id)
68
+ sig_bytes = [signature.delete_prefix("0x")].pack("H*")
69
+
70
+ # Recover the signer address from the signature
71
+ recovered = recover_address(hash, sig_bytes)
72
+ return false unless recovered
73
+
74
+ recovered.downcase == address.downcase
75
+ end
76
+
77
+ # Construct source DID for proof credentials.
78
+ def source(address:, chain_id:)
79
+ "did:pkh:eip155:#{chain_id}:#{address}"
80
+ end
81
+
82
+ # Parse a proof source DID. Returns { address:, chain_id: } or nil.
83
+ def parse_source(source_str)
84
+ match = source_str.match(/\Adid:pkh:eip155:(0|[1-9]\d*):(.+)\z/)
85
+ return nil unless match
86
+
87
+ chain_id = Integer(match[1])
88
+ address = match[2]
89
+ return nil unless address.match?(/\A0x[a-fA-F0-9]{40}\z/)
90
+
91
+ {address: address, chain_id: chain_id}
92
+ rescue ArgumentError
93
+ nil
94
+ end
95
+
96
+ # ABI-encode values (packed 32-byte words).
97
+ def abi_encode(*values)
98
+ values.map { |v|
99
+ case v
100
+ when String
101
+ v.b.rjust(32, "\x00".b)
102
+ when Integer
103
+ uint256(v)
104
+ end
105
+ }.join
106
+ end
107
+
108
+ def uint256(value)
109
+ [value].pack("Q>").rjust(32, "\x00".b)
110
+ end
111
+
112
+ def recover_address(hash, sig_bytes)
113
+ Kernel.require "eth"
114
+
115
+ return nil unless sig_bytes.bytesize == 65
116
+
117
+ sig_hex = "0x#{sig_bytes.unpack1("H*")}"
118
+ # Use raw ecrecover (not personal_recover which adds EIP-191 prefix)
119
+ recovered_key = Eth::Signature.recover(hash, sig_hex)
120
+ Eth::Util.public_key_to_address(recovered_key).to_s
121
+ rescue => _e
122
+ nil
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,60 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ require "net/http"
5
+ require "uri"
6
+ require "json"
7
+
8
+ module Mpp
9
+ module Methods
10
+ module Tempo
11
+ module Rpc
12
+ DEFAULT_TIMEOUT = 30
13
+
14
+ module_function
15
+
16
+ # Make a JSON-RPC call.
17
+ def call(rpc_url, method, params, client: nil)
18
+ payload = {"jsonrpc" => "2.0", "method" => method, "params" => params, "id" => 1}
19
+
20
+ uri = URI.parse(rpc_url)
21
+ http = client || Net::HTTP.new(uri.host, uri.port)
22
+ http.use_ssl = uri.scheme == "https" unless client
23
+ http.read_timeout = DEFAULT_TIMEOUT unless client
24
+
25
+ request = Net::HTTP::Post.new(uri)
26
+ request["Content-Type"] = "application/json"
27
+ request.body = JSON.generate(payload)
28
+
29
+ response = http.request(request)
30
+ result = JSON.parse(response.body)
31
+
32
+ Kernel.raise "RPC error: #{result["error"]}" if result.key?("error")
33
+
34
+ result["result"]
35
+ end
36
+
37
+ # Fetch chain_id, nonce, and gas_price.
38
+ def get_tx_params(rpc_url, sender, client: nil)
39
+ # In Ruby, we make these calls sequentially (or use threads)
40
+ chain_id_hex = call(rpc_url, "eth_chainId", [], client: client)
41
+ nonce_hex = call(rpc_url, "eth_getTransactionCount", [sender, "pending"], client: client)
42
+ gas_hex = call(rpc_url, "eth_gasPrice", [], client: client)
43
+
44
+ [chain_id_hex.to_i(16), nonce_hex.to_i(16), gas_hex.to_i(16)]
45
+ end
46
+
47
+ # Estimate gas for a call.
48
+ def estimate_gas(rpc_url, from_addr, to, data, client: nil)
49
+ result = call(
50
+ rpc_url,
51
+ "eth_estimateGas",
52
+ [{"from" => from_addr, "to" => to, "data" => data}, "latest"],
53
+ client: client
54
+ )
55
+ result.to_i(16)
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,96 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ module Mpp
5
+ module Methods
6
+ module Tempo
7
+ module Schemas
8
+ HEX_PATTERN = /\A0x[a-fA-F0-9]+\z/
9
+
10
+ MethodDetails = Data.define(:chain_id, :fee_payer, :fee_payer_url, :memo) do
11
+ def initialize(chain_id: 4217, fee_payer: false, fee_payer_url: nil, memo: nil)
12
+ super
13
+ end
14
+
15
+ def self.from_hash(hash)
16
+ return new unless hash.is_a?(Hash)
17
+
18
+ new(
19
+ chain_id: hash["chainId"] || 4217,
20
+ fee_payer: hash["feePayer"] || false,
21
+ fee_payer_url: hash["feePayerUrl"],
22
+ memo: hash["memo"]
23
+ )
24
+ end
25
+ end
26
+
27
+ ChargeRequest = Data.define(:amount, :currency, :recipient, :description,
28
+ :external_id, :method_details) do
29
+ def initialize(amount:, currency:, recipient:, description: nil,
30
+ external_id: nil, method_details: nil)
31
+ raise ArgumentError, "currency must be a hex address" unless currency.match?(HEX_PATTERN)
32
+ raise ArgumentError, "recipient must be a hex address" unless recipient.match?(HEX_PATTERN)
33
+
34
+ method_details ||= MethodDetails.new
35
+ super
36
+ end
37
+
38
+ def self.from_hash(hash)
39
+ new(
40
+ amount: hash["amount"],
41
+ currency: hash["currency"],
42
+ recipient: hash["recipient"],
43
+ description: hash["description"],
44
+ external_id: hash["externalId"],
45
+ method_details: MethodDetails.from_hash(hash["methodDetails"])
46
+ )
47
+ end
48
+ end
49
+
50
+ HashCredentialPayload = Data.define(:type, :hash) do
51
+ def initialize(type:, hash:)
52
+ raise ArgumentError, "type must be 'hash'" unless type == "hash"
53
+ raise ArgumentError, "hash must be a hex string" unless hash.match?(HEX_PATTERN)
54
+
55
+ super
56
+ end
57
+ end
58
+
59
+ TransactionCredentialPayload = Data.define(:type, :signature) do
60
+ def initialize(type:, signature:)
61
+ raise ArgumentError, "type must be 'transaction'" unless type == "transaction"
62
+ raise ArgumentError, "signature must be a hex string" unless signature.match?(HEX_PATTERN)
63
+
64
+ super
65
+ end
66
+ end
67
+
68
+ ProofCredentialPayload = Data.define(:type, :signature) do
69
+ def initialize(type:, signature:)
70
+ raise ArgumentError, "type must be 'proof'" unless type == "proof"
71
+ raise ArgumentError, "signature must be a hex string" unless signature.match?(HEX_PATTERN)
72
+
73
+ super
74
+ end
75
+ end
76
+
77
+ module_function
78
+
79
+ def parse_credential_payload(data)
80
+ Kernel.raise ArgumentError, "Invalid credential payload" unless data.is_a?(Hash) && data.key?("type")
81
+
82
+ case data["type"]
83
+ when "hash"
84
+ HashCredentialPayload.new(type: "hash", hash: data["hash"])
85
+ when "transaction"
86
+ TransactionCredentialPayload.new(type: "transaction", signature: data["signature"])
87
+ when "proof"
88
+ ProofCredentialPayload.new(type: "proof", signature: data["signature"])
89
+ else
90
+ Kernel.raise ArgumentError, "Invalid credential type: #{data["type"]}"
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end