mpp-rb 0.1.3 → 0.1.5
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 +63 -1
- data/lib/mpp/challenge.rb +15 -5
- data/lib/mpp/client/transport.rb +5 -4
- data/lib/mpp/errors.rb +22 -0
- data/lib/mpp/http/headers.rb +61 -0
- data/lib/mpp/http.rb +8 -0
- data/lib/mpp/methods/evm/assets.rb +103 -0
- data/lib/mpp/methods/evm/authorization.rb +140 -0
- data/lib/mpp/methods/evm/charge_intent.rb +129 -0
- data/lib/mpp/methods/evm/evm_method.rb +117 -0
- data/lib/mpp/methods/evm.rb +64 -0
- data/lib/mpp/methods/stripe/charge_intent.rb +4 -1
- data/lib/mpp/methods/tempo/attribution.rb +3 -9
- data/lib/mpp/methods/tempo/client_method.rb +17 -6
- data/lib/mpp/methods/tempo/fee_payer_client.rb +120 -0
- data/lib/mpp/methods/tempo/intents.rb +27 -7
- data/lib/mpp/methods/tempo/proof.rb +39 -12
- data/lib/mpp/methods/tempo/relay.rb +257 -0
- data/lib/mpp/methods/tempo.rb +2 -0
- data/lib/mpp/parsing.rb +2 -0
- data/lib/mpp/receipt.rb +4 -3
- data/lib/mpp/server/accept_payment.rb +194 -0
- data/lib/mpp/server/compose.rb +387 -0
- data/lib/mpp/server/decorator.rb +28 -7
- data/lib/mpp/server/middleware.rb +162 -30
- data/lib/mpp/server/mpp_handler.rb +267 -33
- data/lib/mpp/server/result.rb +100 -0
- data/lib/mpp/server.rb +4 -0
- data/lib/mpp/version.rb +1 -1
- data/lib/mpp/x402/facilitator.rb +127 -0
- data/lib/mpp/x402/header.rb +131 -0
- data/lib/mpp/x402/server.rb +247 -0
- data/lib/mpp/x402/types.rb +33 -0
- data/lib/mpp/x402.rb +12 -0
- data/lib/mpp.rb +6 -3
- metadata +32 -1
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# typed: false
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Mpp
|
|
5
|
+
module Methods
|
|
6
|
+
module Evm
|
|
7
|
+
# EVM payment method. Speaks native Payment-auth and x402 exact.
|
|
8
|
+
class EvmMethod
|
|
9
|
+
attr_reader :name, :currency, :recipient, :decimals, :chain_id, :authorization, :x402
|
|
10
|
+
attr_accessor :intents
|
|
11
|
+
|
|
12
|
+
def initialize(currency:, recipient:, decimals:, chain_id:, authorization:, x402:)
|
|
13
|
+
@name = "evm"
|
|
14
|
+
@currency = Authorization.checksum_address(currency)
|
|
15
|
+
@recipient = Authorization.checksum_address(recipient)
|
|
16
|
+
@decimals = decimals
|
|
17
|
+
@chain_id = chain_id
|
|
18
|
+
@authorization = authorization
|
|
19
|
+
@x402 = x402
|
|
20
|
+
@intents = {}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def transform_request(request, _credential)
|
|
24
|
+
method_details = request.fetch("methodDetails", {})
|
|
25
|
+
method_details = {} unless method_details.is_a?(Hash)
|
|
26
|
+
|
|
27
|
+
method_details["chainId"] = @chain_id
|
|
28
|
+
method_details["credentialTypes"] = ["authorization"]
|
|
29
|
+
request.merge("methodDetails" => method_details, "currency" => @currency, "recipient" => @recipient)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def payment_requirements(request)
|
|
33
|
+
charge_intent.payment_requirements(request)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def x402_matches?(payload, request)
|
|
37
|
+
accepted = payload["accepted"]
|
|
38
|
+
return false unless accepted.is_a?(Hash)
|
|
39
|
+
|
|
40
|
+
Mpp::X402::Server.canonical_equal?(accepted, payment_requirements(request))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def decorate_challenge(headers, challenge, url: nil, http_method: nil, request: nil)
|
|
44
|
+
return headers if url.nil? || url.empty?
|
|
45
|
+
|
|
46
|
+
requirements = payment_requirements(request || challenge.request)
|
|
47
|
+
extensions = Mpp::X402::Server.route_extensions(challenge, http_method)
|
|
48
|
+
body = Mpp::X402::Server.payment_required_body(
|
|
49
|
+
requirements: requirements,
|
|
50
|
+
resource_url: url,
|
|
51
|
+
extensions: extensions,
|
|
52
|
+
error: "#{Mpp::X402::PAYMENT_SIGNATURE_HEADER} header is required"
|
|
53
|
+
)
|
|
54
|
+
headers["PAYMENT-REQUIRED"] = Mpp::X402::Header.encode_payment_required(body)
|
|
55
|
+
headers
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def decorate_receipt(headers, receipt, credential, payment_signature: nil)
|
|
59
|
+
return headers if payment_signature.nil? || payment_signature.empty?
|
|
60
|
+
|
|
61
|
+
payload = credential.payload
|
|
62
|
+
request = decode_request(credential)
|
|
63
|
+
chain_id = request.dig("methodDetails", "chainId") || @chain_id
|
|
64
|
+
headers["PAYMENT-RESPONSE"] = Mpp::X402::Header.encode_payment_response({
|
|
65
|
+
"network" => "eip155:#{chain_id}",
|
|
66
|
+
"payer" => payload["from"],
|
|
67
|
+
"success" => true,
|
|
68
|
+
"transaction" => receipt.reference
|
|
69
|
+
})
|
|
70
|
+
headers
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def bind_x402_credential(payment_signature, challenge:, request:, url:, body:, http_method: nil)
|
|
74
|
+
payment_payload = Mpp::X402::Header.decode_payment_signature(payment_signature)
|
|
75
|
+
requirements = payment_requirements(request)
|
|
76
|
+
authorization = Mpp::X402::Server.bind_credential(
|
|
77
|
+
payment_payload: payment_payload,
|
|
78
|
+
requirements: requirements,
|
|
79
|
+
resource_url: url,
|
|
80
|
+
challenge: challenge,
|
|
81
|
+
body: body,
|
|
82
|
+
route_binding: @x402[:route_binding],
|
|
83
|
+
http_method: http_method
|
|
84
|
+
)
|
|
85
|
+
echo = challenge.to_echo
|
|
86
|
+
Mpp::Credential.new(
|
|
87
|
+
challenge: echo,
|
|
88
|
+
payload: authorization.merge("_x402" => true),
|
|
89
|
+
source: source_for(authorization["from"])
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def charge_intent
|
|
96
|
+
intent = @intents["charge"]
|
|
97
|
+
raise ArgumentError, "evm method is missing charge intent" unless intent
|
|
98
|
+
|
|
99
|
+
intent
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def decode_request(credential)
|
|
103
|
+
echo = credential.challenge
|
|
104
|
+
return {} if echo.request.nil? || echo.request.empty?
|
|
105
|
+
|
|
106
|
+
Mpp::Parsing.b64_decode(echo.request)
|
|
107
|
+
rescue Mpp::ParseError
|
|
108
|
+
{}
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def source_for(address)
|
|
112
|
+
"did:pkh:eip155:#{@chain_id}:#{Authorization.checksum_address(address)}"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Mpp
|
|
5
|
+
module Methods
|
|
6
|
+
module Evm
|
|
7
|
+
autoload :Assets, "mpp/methods/evm/assets"
|
|
8
|
+
autoload :Authorization, "mpp/methods/evm/authorization"
|
|
9
|
+
autoload :ChargeIntent, "mpp/methods/evm/charge_intent"
|
|
10
|
+
autoload :EvmMethod, "mpp/methods/evm/evm_method"
|
|
11
|
+
|
|
12
|
+
# Factory for a server-side EVM charge method with inline x402 exact support.
|
|
13
|
+
def self.charge(currency:, recipient:, x402:, authorization: nil, chain_id: nil, decimals: nil)
|
|
14
|
+
resolved = Assets.resolve(
|
|
15
|
+
currency,
|
|
16
|
+
authorization: authorization,
|
|
17
|
+
chain_id: chain_id,
|
|
18
|
+
decimals: decimals
|
|
19
|
+
)
|
|
20
|
+
x402_options = normalize_x402(x402)
|
|
21
|
+
|
|
22
|
+
charge_intent = ChargeIntent.new(
|
|
23
|
+
authorization: resolved.fetch(:authorization),
|
|
24
|
+
facilitator: X402::Facilitator.resolve(x402_options.fetch(:facilitator)),
|
|
25
|
+
max_timeout_seconds: x402_options.fetch(:max_timeout_seconds),
|
|
26
|
+
route_binding: x402_options.fetch(:route_binding)
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
method = EvmMethod.new(
|
|
30
|
+
currency: resolved.fetch(:address),
|
|
31
|
+
recipient: recipient,
|
|
32
|
+
decimals: resolved.fetch(:decimals),
|
|
33
|
+
chain_id: resolved.fetch(:chain_id),
|
|
34
|
+
authorization: resolved.fetch(:authorization),
|
|
35
|
+
x402: x402_options
|
|
36
|
+
)
|
|
37
|
+
method.intents = {"charge" => charge_intent}
|
|
38
|
+
method
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.normalize_x402(x402)
|
|
42
|
+
options = x402.each_with_object({}) do |(key, value), acc|
|
|
43
|
+
acc[key.to_sym] = value
|
|
44
|
+
end
|
|
45
|
+
facilitator = options[:facilitator]
|
|
46
|
+
if facilitator.nil? || (facilitator.is_a?(String) && facilitator.empty?)
|
|
47
|
+
raise ArgumentError, "evm.charge requires x402: { facilitator: ... }"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
route_binding = (options[:route_binding] || options[:routeBinding] || :resource).to_sym
|
|
51
|
+
unless [:resource, :required].include?(route_binding)
|
|
52
|
+
raise ArgumentError, "x402 route_binding must be :resource or :required"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
{
|
|
56
|
+
facilitator: facilitator,
|
|
57
|
+
max_timeout_seconds: Integer(options[:max_timeout_seconds] || options[:maxTimeoutSeconds] || 300),
|
|
58
|
+
route_binding: route_binding
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
private_class_method :normalize_x402
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -106,8 +106,11 @@ module Mpp
|
|
|
106
106
|
|
|
107
107
|
private
|
|
108
108
|
|
|
109
|
+
# Include the SPT so a retry with a fresh token is a new PaymentIntent,
|
|
110
|
+
# matching mppx (`prefix_challengeId_spt`). Same challenge + same SPT
|
|
111
|
+
# still collapses via Stripe idempotency.
|
|
109
112
|
def stripe_idempotency_key(credential)
|
|
110
|
-
"
|
|
113
|
+
"mpp_#{credential.challenge.id}_#{credential.payload["spt"]}"
|
|
111
114
|
end
|
|
112
115
|
end
|
|
113
116
|
end
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# typed: false
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
-
require "
|
|
4
|
+
require "digest/keccak"
|
|
5
5
|
require "securerandom"
|
|
6
6
|
|
|
7
7
|
module Mpp
|
|
@@ -13,15 +13,9 @@ module Mpp
|
|
|
13
13
|
|
|
14
14
|
module_function
|
|
15
15
|
|
|
16
|
-
#
|
|
16
|
+
# Ethereum Keccak-256. SHA3-256 is not a substitute (different padding).
|
|
17
17
|
def keccak256(data)
|
|
18
|
-
|
|
19
|
-
Kernel.require "eth"
|
|
20
|
-
Eth::Util.keccak256(data)
|
|
21
|
-
rescue LoadError
|
|
22
|
-
# Fallback: use OpenSSL's SHA3-256 (not exactly keccak, but close)
|
|
23
|
-
# For production, the eth gem should be installed
|
|
24
|
-
OpenSSL::Digest.new("SHA3-256").digest(data)
|
|
18
|
+
Digest::Keccak.digest(data, 256)
|
|
25
19
|
end
|
|
26
20
|
|
|
27
21
|
# Compute TAG = keccak256("mpp")[0:4]
|
|
@@ -19,16 +19,18 @@ module Mpp
|
|
|
19
19
|
class TempoMethod
|
|
20
20
|
attr_reader :name, :account, :fee_payer, :fee_payer_allowed_fee_tokens,
|
|
21
21
|
:root_account, :rpc_url, :chain_id, :currency, :recipient, :decimals, :client_id,
|
|
22
|
-
:expected_recipients
|
|
22
|
+
:expected_recipients, :relay
|
|
23
23
|
attr_accessor :intents
|
|
24
24
|
|
|
25
25
|
def initialize(account: nil, fee_payer: nil, root_account: nil,
|
|
26
26
|
rpc_url: Defaults::RPC_URL, chain_id: nil, currency: nil,
|
|
27
27
|
recipient: nil, decimals: 6, client_id: nil,
|
|
28
|
-
expected_recipients: nil, fee_payer_allowed_fee_tokens: nil
|
|
28
|
+
expected_recipients: nil, fee_payer_allowed_fee_tokens: nil,
|
|
29
|
+
relay: nil)
|
|
29
30
|
@name = "tempo"
|
|
30
31
|
@account = account
|
|
31
32
|
@fee_payer = fee_payer
|
|
33
|
+
@relay = relay
|
|
32
34
|
@fee_payer_allowed_fee_tokens =
|
|
33
35
|
fee_payer_allowed_fee_tokens&.map { |token| token.to_s.downcase }
|
|
34
36
|
@root_account = root_account
|
|
@@ -249,13 +251,19 @@ module Mpp
|
|
|
249
251
|
# Factory function to create a configured TempoMethod.
|
|
250
252
|
def self.tempo(intents:, account: nil, fee_payer: nil, chain_id: nil, rpc_url: nil,
|
|
251
253
|
root_account: nil, currency: nil, recipient: nil, decimals: 6, client_id: nil,
|
|
252
|
-
expected_recipients: nil, fee_payer_allowed_fee_tokens: nil)
|
|
254
|
+
expected_recipients: nil, fee_payer_allowed_fee_tokens: nil, relay: nil)
|
|
253
255
|
rpc_url ||= chain_id ? Defaults.rpc_url_for_chain(chain_id) : Defaults::RPC_URL
|
|
254
256
|
currency ||= Defaults.default_currency_for_chain(chain_id)
|
|
255
257
|
|
|
258
|
+
if fee_payer == true
|
|
259
|
+
raise ArgumentError, "fee_payer: true requires account:" unless account
|
|
260
|
+
|
|
261
|
+
fee_payer = account
|
|
262
|
+
end
|
|
263
|
+
|
|
256
264
|
method = TempoMethod.new(
|
|
257
265
|
account: account,
|
|
258
|
-
fee_payer: fee_payer,
|
|
266
|
+
fee_payer: FeePayerClient.resolve_optional(fee_payer),
|
|
259
267
|
rpc_url: rpc_url,
|
|
260
268
|
chain_id: chain_id,
|
|
261
269
|
root_account: root_account,
|
|
@@ -264,12 +272,15 @@ module Mpp
|
|
|
264
272
|
decimals: decimals,
|
|
265
273
|
client_id: client_id,
|
|
266
274
|
expected_recipients: expected_recipients,
|
|
267
|
-
fee_payer_allowed_fee_tokens: fee_payer_allowed_fee_tokens
|
|
275
|
+
fee_payer_allowed_fee_tokens: fee_payer_allowed_fee_tokens,
|
|
276
|
+
relay: Relay.resolve_optional(relay)
|
|
268
277
|
)
|
|
269
278
|
|
|
270
279
|
intents.each_value do |intent|
|
|
271
280
|
intent.rpc_url = rpc_url if intent.respond_to?(:rpc_url=) && intent.rpc_url.nil?
|
|
272
|
-
intent.
|
|
281
|
+
if intent.respond_to?(:fee_payer) || intent.respond_to?(:relay)
|
|
282
|
+
intent.instance_variable_set(:@_method, method)
|
|
283
|
+
end
|
|
273
284
|
end
|
|
274
285
|
method.intents = intents.dup
|
|
275
286
|
method
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# typed: false
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
require "net/http"
|
|
6
|
+
require "uri"
|
|
7
|
+
require_relative "../../http/headers"
|
|
8
|
+
|
|
9
|
+
module Mpp
|
|
10
|
+
module Methods
|
|
11
|
+
module Tempo
|
|
12
|
+
# HTTP client for a hosted Tempo fee-payer JSON-RPC endpoint.
|
|
13
|
+
#
|
|
14
|
+
# `resolve` accepts:
|
|
15
|
+
# * a URL string (public / unauthenticated sponsor)
|
|
16
|
+
# * a config hash (`url:` plus optional `headers:` as a Hash or per-request proc)
|
|
17
|
+
# * any object that responds to `#cosign`
|
|
18
|
+
class FeePayerClient
|
|
19
|
+
SIGN_METHOD = "eth_signRawTransaction"
|
|
20
|
+
DEFAULT_TIMEOUT = Rpc::DEFAULT_TIMEOUT
|
|
21
|
+
|
|
22
|
+
attr_reader :base_url
|
|
23
|
+
|
|
24
|
+
def initialize(url, headers: nil)
|
|
25
|
+
@base_url = Mpp::Http::Headers.normalize_base_url(url)
|
|
26
|
+
raise ArgumentError, "fee_payer url is required" if @base_url.empty?
|
|
27
|
+
|
|
28
|
+
@headers = headers
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.resolve_optional(fee_payer)
|
|
32
|
+
return if fee_payer.nil? || fee_payer == false
|
|
33
|
+
return fee_payer unless hosted_config?(fee_payer)
|
|
34
|
+
|
|
35
|
+
resolve(fee_payer)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.resolve(fee_payer)
|
|
39
|
+
return fee_payer if fee_payer.is_a?(FeePayerClient)
|
|
40
|
+
return new(fee_payer) if fee_payer.is_a?(String) && !fee_payer.empty?
|
|
41
|
+
return from_config(fee_payer) if fee_payer.is_a?(Hash)
|
|
42
|
+
return fee_payer if duck_type?(fee_payer)
|
|
43
|
+
|
|
44
|
+
raise ArgumentError, "fee_payer must be an Account, URL, {url:, headers:}, or an object that implements #cosign"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.from_config(config)
|
|
48
|
+
cfg = Mpp::Http::Headers.symbolize(config)
|
|
49
|
+
url = cfg[:url] || cfg[:base_url] || cfg[:baseUrl]
|
|
50
|
+
new(url.to_s, headers: cfg[:headers])
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.hosted_config?(fee_payer)
|
|
54
|
+
return true if fee_payer.is_a?(FeePayerClient)
|
|
55
|
+
return true if fee_payer.is_a?(String)
|
|
56
|
+
return true if fee_payer.is_a?(Hash)
|
|
57
|
+
return true if duck_type?(fee_payer) && !fee_payer.respond_to?(:sign_hash)
|
|
58
|
+
|
|
59
|
+
false
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.duck_type?(fee_payer)
|
|
63
|
+
!fee_payer.nil? && fee_payer.respond_to?(:cosign)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def cosign(raw_tx)
|
|
67
|
+
result = post_rpc(SIGN_METHOD, [raw_tx])
|
|
68
|
+
unless result.is_a?(String) && !result.empty?
|
|
69
|
+
raise Mpp::VerificationFailedError.new(reason: "Fee payer returned no signed transaction")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
result
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def post_rpc(method, params)
|
|
78
|
+
uri = URI.parse(@base_url)
|
|
79
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
80
|
+
http.use_ssl = uri.scheme == "https"
|
|
81
|
+
http.read_timeout = DEFAULT_TIMEOUT
|
|
82
|
+
|
|
83
|
+
request = Net::HTTP::Post.new(uri)
|
|
84
|
+
request["Content-Type"] = "application/json"
|
|
85
|
+
Mpp::Http::Headers.resolve(@headers, "/#{method}").each { |key, value| request[key] = value }
|
|
86
|
+
request.body = JSON.generate({
|
|
87
|
+
"jsonrpc" => "2.0",
|
|
88
|
+
"method" => method,
|
|
89
|
+
"params" => params,
|
|
90
|
+
"id" => 1
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
response = http.request(request)
|
|
94
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
95
|
+
raise Mpp::VerificationFailedError.new(reason: "fee payer returned HTTP #{response.code}")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
body = response.body.to_s
|
|
99
|
+
if body.empty?
|
|
100
|
+
raise Mpp::VerificationFailedError.new(reason: "fee payer returned an empty body")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
parsed = JSON.parse(body)
|
|
104
|
+
unless parsed.is_a?(Hash)
|
|
105
|
+
raise Mpp::VerificationFailedError.new(reason: "fee payer returned JSON that is not an object")
|
|
106
|
+
end
|
|
107
|
+
if parsed.key?("error")
|
|
108
|
+
raise Mpp::VerificationFailedError.new(reason: "fee payer RPC error: #{parsed["error"]}")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
parsed["result"]
|
|
112
|
+
rescue JSON::ParserError
|
|
113
|
+
raise Mpp::VerificationFailedError.new(reason: "fee payer returned invalid JSON")
|
|
114
|
+
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ETIMEDOUT, Net::OpenTimeout, Net::ReadTimeout, SocketError => e
|
|
115
|
+
raise Mpp::VerificationFailedError.new(reason: "fee payer request failed: #{e.message}")
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -39,6 +39,10 @@ module Mpp
|
|
|
39
39
|
@_method&.fee_payer_allowed_fee_tokens
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
+
def relay
|
|
43
|
+
@_method&.relay
|
|
44
|
+
end
|
|
45
|
+
|
|
42
46
|
def verify(credential, request)
|
|
43
47
|
req = Schemas::ChargeRequest.from_hash(request)
|
|
44
48
|
|
|
@@ -49,6 +53,8 @@ module Mpp
|
|
|
49
53
|
raise Mpp::VerificationError, "Request has expired" if expires < Time.now.utc
|
|
50
54
|
end
|
|
51
55
|
|
|
56
|
+
return relay.verify(credential, request) if relay
|
|
57
|
+
|
|
52
58
|
payload_data = credential.payload
|
|
53
59
|
unless payload_data.is_a?(Hash) && payload_data.key?("type")
|
|
54
60
|
raise Mpp::VerificationError,
|
|
@@ -141,14 +147,13 @@ module Mpp
|
|
|
141
147
|
simulate_payload = nil
|
|
142
148
|
|
|
143
149
|
if request.method_details.fee_payer
|
|
144
|
-
|
|
150
|
+
payer = fee_payer
|
|
151
|
+
raise Mpp::VerificationError, "No fee payer configured" unless payer
|
|
152
|
+
|
|
153
|
+
if local_fee_payer?(payer)
|
|
145
154
|
raw_tx, simulate_payload = cosign_as_fee_payer(raw_tx, request.currency, request: request, challenge: credential.challenge)
|
|
146
155
|
else
|
|
147
|
-
|
|
148
|
-
result = Rpc.call(fee_payer_url, "eth_signRawTransaction", [raw_tx])
|
|
149
|
-
raise Mpp::VerificationError, "Fee payer returned no signed transaction" unless result
|
|
150
|
-
|
|
151
|
-
raw_tx = result
|
|
156
|
+
raw_tx = payer.cosign(raw_tx)
|
|
152
157
|
end
|
|
153
158
|
end
|
|
154
159
|
|
|
@@ -458,6 +463,10 @@ module Mpp
|
|
|
458
463
|
Mpp::Receipt.success(credential.challenge.id)
|
|
459
464
|
end
|
|
460
465
|
|
|
466
|
+
def local_fee_payer?(payer)
|
|
467
|
+
!FeePayerClient.hosted_config?(payer)
|
|
468
|
+
end
|
|
469
|
+
|
|
461
470
|
def cosign_as_fee_payer(raw_tx, fee_token, request: nil, challenge: nil)
|
|
462
471
|
require "eth"
|
|
463
472
|
require "rlp"
|
|
@@ -703,6 +712,10 @@ module Mpp
|
|
|
703
712
|
}
|
|
704
713
|
end
|
|
705
714
|
|
|
715
|
+
# Expected calldata length for transferWithMemo(address,uint256,bytes32):
|
|
716
|
+
# 4 (selector) + 32 (address) + 32 (amount) + 32 (memo) = 100 bytes = 200 hex chars
|
|
717
|
+
MAX_TRANSFER_CALLDATA_HEX_LENGTH = 200
|
|
718
|
+
|
|
706
719
|
def validate_fee_payer_calls(calls, request, challenge: nil)
|
|
707
720
|
if calls.length != 1
|
|
708
721
|
raise Mpp::VerificationError, "Invalid transaction: contains unauthorized extra calls"
|
|
@@ -715,7 +728,14 @@ module Mpp
|
|
|
715
728
|
unless call.to.downcase == request.currency.downcase
|
|
716
729
|
raise Mpp::VerificationError, "Invalid transaction: no matching payment call found"
|
|
717
730
|
end
|
|
718
|
-
|
|
731
|
+
|
|
732
|
+
call_data_hex = call.data.delete_prefix("0x")
|
|
733
|
+
if call_data_hex.length > MAX_TRANSFER_CALLDATA_HEX_LENGTH
|
|
734
|
+
raise Mpp::VerificationError,
|
|
735
|
+
"Invalid transaction: calldata contains trailing padding (#{call_data_hex.length / 2} bytes, expected #{MAX_TRANSFER_CALLDATA_HEX_LENGTH / 2})"
|
|
736
|
+
end
|
|
737
|
+
|
|
738
|
+
unless match_transfer_calldata(call_data_hex, request, challenge: challenge)
|
|
719
739
|
raise Mpp::VerificationError, "Invalid transaction: no matching payment call found"
|
|
720
740
|
end
|
|
721
741
|
end
|
|
@@ -6,16 +6,23 @@ module Mpp
|
|
|
6
6
|
module Tempo
|
|
7
7
|
# EIP-712 proof credentials for zero-amount challenges.
|
|
8
8
|
#
|
|
9
|
-
# Domain: { name: "MPP", version: "
|
|
10
|
-
# Types: { Proof: [{ name: "
|
|
11
|
-
#
|
|
9
|
+
# Domain: { name: "MPP", version: "3", chainId }
|
|
10
|
+
# Types: { Proof: [{ name: "account", type: "address" },
|
|
11
|
+
# { name: "challengeId", type: "string" },
|
|
12
|
+
# { name: "realm", type: "string" }] }
|
|
13
|
+
# Message: { account: <payer>, challengeId: <challenge.id>, realm: <challenge.realm> }
|
|
14
|
+
#
|
|
15
|
+
# The account field binds the signature to one payer wallet, so a proof
|
|
16
|
+
# signed for one account cannot be replayed against another. This matches
|
|
17
|
+
# mpp-go, mpp-rs and mppx, which all sign and verify domain version "3"
|
|
18
|
+
# and reject version "2".
|
|
12
19
|
module Proof
|
|
13
20
|
DOMAIN_NAME = "MPP"
|
|
14
|
-
DOMAIN_VERSION = "
|
|
21
|
+
DOMAIN_VERSION = "3"
|
|
15
22
|
|
|
16
23
|
# EIP-712 domain separator type hash
|
|
17
24
|
DOMAIN_TYPE_HASH = "EIP712Domain(string name,string version,uint256 chainId)"
|
|
18
|
-
PROOF_TYPE_HASH = "Proof(string challengeId,string realm)"
|
|
25
|
+
PROOF_TYPE_HASH = "Proof(address account,string challengeId,string realm)"
|
|
19
26
|
|
|
20
27
|
module_function
|
|
21
28
|
|
|
@@ -36,11 +43,12 @@ module Mpp
|
|
|
36
43
|
)
|
|
37
44
|
end
|
|
38
45
|
|
|
39
|
-
# Compute the EIP-712 struct hash for Proof(challengeId, realm).
|
|
40
|
-
def struct_hash(challenge_id, realm)
|
|
46
|
+
# Compute the EIP-712 struct hash for Proof(account, challengeId, realm).
|
|
47
|
+
def struct_hash(account, challenge_id, realm)
|
|
41
48
|
keccak256(
|
|
42
49
|
abi_encode(
|
|
43
50
|
keccak256(PROOF_TYPE_HASH),
|
|
51
|
+
address_word(account),
|
|
44
52
|
keccak256(challenge_id),
|
|
45
53
|
keccak256(realm)
|
|
46
54
|
)
|
|
@@ -48,15 +56,20 @@ module Mpp
|
|
|
48
56
|
end
|
|
49
57
|
|
|
50
58
|
# Compute the full EIP-712 signing hash.
|
|
51
|
-
def signing_hash(chain_id:, challenge_id:, realm:)
|
|
59
|
+
def signing_hash(chain_id:, account:, challenge_id:, realm:)
|
|
52
60
|
keccak256(
|
|
53
|
-
"\x19\x01".b + domain_separator(chain_id) + struct_hash(challenge_id, realm)
|
|
61
|
+
"\x19\x01".b + domain_separator(chain_id) + struct_hash(account, challenge_id, realm)
|
|
54
62
|
)
|
|
55
63
|
end
|
|
56
64
|
|
|
57
|
-
# Sign a proof credential (client-side).
|
|
65
|
+
# Sign a proof credential (client-side). The signer is also the payer.
|
|
58
66
|
def sign(account:, chain_id:, challenge_id:, realm:)
|
|
59
|
-
hash = signing_hash(
|
|
67
|
+
hash = signing_hash(
|
|
68
|
+
chain_id: chain_id,
|
|
69
|
+
account: account.address.to_s,
|
|
70
|
+
challenge_id: challenge_id,
|
|
71
|
+
realm: realm
|
|
72
|
+
)
|
|
60
73
|
sig = account.sign_hash(hash)
|
|
61
74
|
"0x#{sig.unpack1("H*")}"
|
|
62
75
|
end
|
|
@@ -65,7 +78,12 @@ module Mpp
|
|
|
65
78
|
def verify(address:, chain_id:, challenge_id:, realm:, signature:)
|
|
66
79
|
Kernel.require "eth"
|
|
67
80
|
|
|
68
|
-
hash = signing_hash(
|
|
81
|
+
hash = signing_hash(
|
|
82
|
+
chain_id: chain_id,
|
|
83
|
+
account: address,
|
|
84
|
+
challenge_id: challenge_id,
|
|
85
|
+
realm: realm
|
|
86
|
+
)
|
|
69
87
|
sig_bytes = [signature.delete_prefix("0x")].pack("H*")
|
|
70
88
|
|
|
71
89
|
# Recover the signer address from the signature
|
|
@@ -94,6 +112,15 @@ module Mpp
|
|
|
94
112
|
nil
|
|
95
113
|
end
|
|
96
114
|
|
|
115
|
+
# Encode an address as a 32-byte EIP-712 word. Unlike a string field the
|
|
116
|
+
# address is not hashed, it is the raw 20 bytes left-padded to 32.
|
|
117
|
+
def address_word(address)
|
|
118
|
+
hex = address.to_s.delete_prefix("0x")
|
|
119
|
+
raise ArgumentError, "invalid address: #{address}" unless hex.match?(/\A[a-fA-F0-9]{40}\z/)
|
|
120
|
+
|
|
121
|
+
[hex].pack("H*")
|
|
122
|
+
end
|
|
123
|
+
|
|
97
124
|
# ABI-encode values (packed 32-byte words).
|
|
98
125
|
def abi_encode(*values)
|
|
99
126
|
values.map { |v|
|