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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +133 -0
- data/lib/mpp/body_digest.rb +37 -0
- data/lib/mpp/challenge.rb +115 -0
- data/lib/mpp/challenge_echo.rb +19 -0
- data/lib/mpp/challenge_id.rb +54 -0
- data/lib/mpp/client/transport.rb +137 -0
- data/lib/mpp/client.rb +9 -0
- data/lib/mpp/credential.rb +20 -0
- data/lib/mpp/errors.rb +190 -0
- data/lib/mpp/expires.rb +60 -0
- data/lib/mpp/extensions/mcp/capabilities.rb +23 -0
- data/lib/mpp/extensions/mcp/constants.rb +17 -0
- data/lib/mpp/extensions/mcp/decorator.rb +44 -0
- data/lib/mpp/extensions/mcp/errors.rb +110 -0
- data/lib/mpp/extensions/mcp/types.rb +205 -0
- data/lib/mpp/extensions/mcp/verify.rb +152 -0
- data/lib/mpp/extensions/mcp.rb +16 -0
- data/lib/mpp/json.rb +32 -0
- data/lib/mpp/methods/stripe/charge_intent.rb +90 -0
- data/lib/mpp/methods/stripe/client_method.rb +42 -0
- data/lib/mpp/methods/stripe/defaults.rb +14 -0
- data/lib/mpp/methods/stripe/stripe_method.rb +63 -0
- data/lib/mpp/methods/stripe.rb +14 -0
- data/lib/mpp/methods/tempo/account.rb +52 -0
- data/lib/mpp/methods/tempo/attribution.rb +112 -0
- data/lib/mpp/methods/tempo/client_method.rb +259 -0
- data/lib/mpp/methods/tempo/defaults.rb +77 -0
- data/lib/mpp/methods/tempo/fee_payer_envelope.rb +74 -0
- data/lib/mpp/methods/tempo/intents.rb +377 -0
- data/lib/mpp/methods/tempo/keychain.rb +31 -0
- data/lib/mpp/methods/tempo/proof.rb +127 -0
- data/lib/mpp/methods/tempo/rpc.rb +60 -0
- data/lib/mpp/methods/tempo/schemas.rb +96 -0
- data/lib/mpp/methods/tempo/transaction.rb +144 -0
- data/lib/mpp/methods/tempo.rb +22 -0
- data/lib/mpp/parsing.rb +252 -0
- data/lib/mpp/receipt.rb +31 -0
- data/lib/mpp/secure_compare.rb +25 -0
- data/lib/mpp/server/decorator.rb +32 -0
- data/lib/mpp/server/defaults.rb +45 -0
- data/lib/mpp/server/intent.rb +40 -0
- data/lib/mpp/server/method.rb +27 -0
- data/lib/mpp/server/middleware.rb +51 -0
- data/lib/mpp/server/mpp_handler.rb +97 -0
- data/lib/mpp/server/verify.rb +129 -0
- data/lib/mpp/server.rb +15 -0
- data/lib/mpp/store.rb +49 -0
- data/lib/mpp/units.rb +57 -0
- data/lib/mpp/version.rb +6 -0
- data/lib/mpp-rb.rb +3 -0
- data/lib/mpp.rb +68 -0
- 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
|