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,152 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "time"
5
+
6
+ module Mpp
7
+ module Extensions
8
+ module MCP
9
+ extend T::Sig
10
+
11
+ DEFAULT_CHALLENGE_TTL = T.let(5 * 60, Integer) # 5 minutes in seconds
12
+
13
+ module_function
14
+
15
+ # Verify a payment credential or generate a new challenge.
16
+ # Returns MCPChallenge or [MCPCredential, MCPReceipt].
17
+ sig { params(meta: T.untyped, intent: T.untyped, request: T.untyped, realm: String, secret_key: String, method: T.nilable(String), expires_in: Integer, description: T.nilable(String)).returns(T.untyped) }
18
+ def verify_or_challenge(meta:, intent:, request:, realm:, secret_key:,
19
+ method: nil, expires_in: DEFAULT_CHALLENGE_TTL, description: nil)
20
+ method_name = method || "tempo"
21
+ meta ||= {}
22
+
23
+ new_challenge = Kernel.lambda {
24
+ create_challenge(
25
+ method: method_name,
26
+ intent_name: intent.name,
27
+ request: request,
28
+ realm: realm,
29
+ secret_key: secret_key,
30
+ expires_in: expires_in,
31
+ description: description
32
+ )
33
+ }
34
+
35
+ credential_data = meta[META_CREDENTIAL]
36
+ return new_challenge.call unless credential_data
37
+
38
+ begin
39
+ mcp_credential = MCPCredential.from_dict(credential_data)
40
+ rescue KeyError, TypeError, NoMethodError => e
41
+ Kernel.raise MalformedCredentialError.new(detail: "Invalid credential structure: #{e}")
42
+ end
43
+
44
+ # Stateless challenge verification
45
+ echoed = mcp_credential.challenge
46
+ expected_id = Mpp.generate_challenge_id(
47
+ secret_key: secret_key,
48
+ realm: echoed.realm,
49
+ method: echoed.method,
50
+ intent: echoed.intent,
51
+ request: echoed.request,
52
+ expires: echoed.expires,
53
+ digest: echoed.digest,
54
+ opaque: echoed.opaque
55
+ )
56
+ return new_challenge.call unless Mpp.secure_compare(echoed.id, expected_id)
57
+
58
+ # Assert echoed fields match server's values
59
+ unless echoed.realm == realm && echoed.method == method_name && echoed.intent == intent.name
60
+ return new_challenge.call
61
+ end
62
+
63
+ # Assert echoed request matches server's current request
64
+ return new_challenge.call unless echoed.request == request
65
+
66
+ # Reject expired challenges as defense-in-depth
67
+ if echoed.expires
68
+ begin
69
+ expires_dt = Time.iso8601(echoed.expires.gsub("Z", "+00:00"))
70
+ return new_challenge.call if expires_dt < Time.now.utc
71
+ rescue ArgumentError
72
+ # continue to stricter check
73
+ end
74
+ end
75
+
76
+ # Verify echoed request parameters
77
+ echoed_request = echoed.request.is_a?(Hash) ? echoed.request : {}
78
+ request.each do |key, value|
79
+ next if key == "expires"
80
+
81
+ return new_challenge.call unless echoed_request[key] == value
82
+ end
83
+
84
+ # Enforce challenge expiry - fail closed
85
+ return new_challenge.call unless echoed.expires
86
+
87
+ begin
88
+ expires_dt = Time.iso8601(echoed.expires.gsub("Z", "+00:00"))
89
+ rescue ArgumentError
90
+ return new_challenge.call
91
+ end
92
+ return new_challenge.call if expires_dt < Time.now.utc
93
+
94
+ core_credential = mcp_credential.to_core
95
+
96
+ begin
97
+ core_receipt = intent.verify(core_credential, request)
98
+ rescue Mpp::VerificationError => e
99
+ Kernel.raise PaymentVerificationError.new(
100
+ challenges: [new_challenge.call],
101
+ reason: "verification-failed",
102
+ detail: e.message
103
+ )
104
+ end
105
+
106
+ mcp_receipt = MCPReceipt.from_core(
107
+ core_receipt,
108
+ challenge_id: mcp_credential.challenge.id,
109
+ method: mcp_credential.challenge.method,
110
+ settlement: extract_settlement(request)
111
+ )
112
+
113
+ [mcp_credential, mcp_receipt]
114
+ end
115
+
116
+ sig { params(method: T.untyped, intent_name: T.untyped, request: T.untyped, realm: T.untyped, secret_key: T.untyped, expires_in: BasicObject, description: T.untyped).returns(Mpp::Extensions::MCP::MCPChallenge) }
117
+ def create_challenge(method:, intent_name:, request:, realm:, secret_key:,
118
+ expires_in: DEFAULT_CHALLENGE_TTL, description: nil)
119
+ expires_time = Time.now.utc + expires_in
120
+ expires = expires_time.iso8601
121
+ expires = expires.sub(/\+00:00$/, "Z")
122
+
123
+ challenge_id = Mpp.generate_challenge_id(
124
+ secret_key: secret_key,
125
+ realm: realm,
126
+ method: method,
127
+ intent: intent_name,
128
+ request: request,
129
+ expires: expires
130
+ )
131
+
132
+ MCPChallenge.new(
133
+ id: challenge_id,
134
+ realm: realm,
135
+ method: method,
136
+ intent: intent_name,
137
+ request: request,
138
+ expires: expires,
139
+ description: description
140
+ )
141
+ end
142
+
143
+ sig { params(request: T.untyped).returns(T.nilable(T::Hash[T.untyped, T.untyped])) }
144
+ def extract_settlement(request)
145
+ settlement = {}
146
+ settlement["amount"] = request["amount"] if request.key?("amount")
147
+ settlement["currency"] = request["currency"] if request.key?("currency")
148
+ settlement.empty? ? nil : settlement
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,16 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "mcp/constants"
5
+ require_relative "mcp/types"
6
+ require_relative "mcp/verify"
7
+ require_relative "mcp/errors"
8
+ require_relative "mcp/decorator"
9
+ require_relative "mcp/capabilities"
10
+
11
+ module Mpp
12
+ module Extensions
13
+ module MCP
14
+ end
15
+ end
16
+ end
data/lib/mpp/json.rb ADDED
@@ -0,0 +1,32 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+
6
+ module Mpp
7
+ module Json
8
+ extend T::Sig
9
+
10
+ module_function
11
+
12
+ # Encode object as compact JSON with recursively sorted keys.
13
+ # Matches Python's json.dumps(separators=(",", ":"), sort_keys=True).
14
+ sig { params(obj: T.untyped).returns(String) }
15
+ def compact_encode(obj)
16
+ ::JSON.generate(deep_sort_keys(obj), space: "", object_nl: "", array_nl: "")
17
+ end
18
+
19
+ # Recursively sort hash keys for deterministic serialization.
20
+ sig { params(obj: T.anything).returns(T.untyped) }
21
+ def deep_sort_keys(obj)
22
+ case obj
23
+ when Hash
24
+ obj.sort_by { |k, _| k.to_s }.to_h.transform_values { |v| deep_sort_keys(v) }
25
+ when Array
26
+ obj.map { |v| deep_sort_keys(v) }
27
+ else
28
+ obj
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,90 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ require "time"
5
+
6
+ module Mpp
7
+ module Methods
8
+ module Stripe
9
+ # Server-side charge intent that verifies payment via Stripe PaymentIntents.
10
+ # Requires the `stripe` gem.
11
+ class ChargeIntent
12
+ attr_reader :name
13
+
14
+ def initialize(secret_key:, api_base: Defaults::STRIPE_API_BASE)
15
+ @name = "charge"
16
+ @secret_key = secret_key
17
+ @api_base = api_base
18
+ end
19
+
20
+ def verify(credential, request)
21
+ # Check challenge expiry
22
+ challenge_expires = credential.challenge.expires
23
+ if challenge_expires
24
+ expires = Time.iso8601(challenge_expires.gsub("Z", "+00:00"))
25
+ raise Mpp::VerificationError, "Request has expired" if expires < Time.now.utc
26
+ end
27
+
28
+ payload_data = credential.payload
29
+ unless payload_data.is_a?(Hash) && payload_data.key?("spt")
30
+ raise Mpp::VerificationError, "Invalid credential payload: missing spt"
31
+ end
32
+
33
+ spt = payload_data["spt"]
34
+ external_id = payload_data["externalId"]
35
+
36
+ # Build PaymentIntent params
37
+ params = {
38
+ amount: Integer(request["amount"]),
39
+ currency: request["currency"],
40
+ shared_payment_granted_token: spt,
41
+ confirm: true,
42
+ automatic_payment_methods: {
43
+ enabled: true,
44
+ allow_redirects: "never"
45
+ }
46
+ }
47
+
48
+ # Include metadata from methodDetails if present
49
+ method_details = request["methodDetails"]
50
+ if method_details.is_a?(Hash) && method_details["metadata"].is_a?(Hash)
51
+ params[:metadata] = method_details["metadata"].transform_values(&:to_s)
52
+ end
53
+
54
+ # Create PaymentIntent via Stripe SDK
55
+ begin
56
+ Kernel.require "stripe"
57
+ rescue LoadError
58
+ raise "stripe gem is required for Stripe charge verification. Install with: gem install stripe"
59
+ end
60
+
61
+ begin
62
+ client = ::Stripe::StripeClient.new(@secret_key)
63
+ result = client.v1.payment_intents.create(params)
64
+ rescue => e
65
+ raise Mpp::VerificationError, e.message
66
+ end
67
+
68
+ # https://docs.stripe.com/error-low-level#idempotency
69
+ if result.respond_to?(:last_response) &&
70
+ result.last_response&.headers&.[]("idempotent-replayed") == "true"
71
+ raise Mpp::VerificationError, "Payment has already been processed."
72
+ end
73
+
74
+ pi_id = result.id
75
+ status = result.status
76
+
77
+ if status == "requires_action"
78
+ raise Mpp::PaymentActionRequiredError.new(reason: "PaymentIntent #{pi_id} requires action")
79
+ end
80
+
81
+ unless status == "succeeded"
82
+ raise Mpp::VerificationError, "PaymentIntent #{pi_id} has status: #{status}"
83
+ end
84
+
85
+ Mpp::Receipt.success(pi_id, method: "stripe", external_id: external_id)
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,42 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ module Mpp
5
+ module Methods
6
+ module Stripe
7
+ # Client-side Stripe method for creating SPT-based credentials.
8
+ class ClientMethod
9
+ attr_reader :name
10
+
11
+ def initialize(create_spt:, payment_method: nil, external_id: nil)
12
+ @name = "stripe"
13
+ @create_spt = create_spt
14
+ @payment_method = payment_method
15
+ @external_id = external_id
16
+ end
17
+
18
+ # Create a credential to satisfy the given challenge.
19
+ def create_credential(challenge)
20
+ request = challenge.request
21
+ method_details = request["methodDetails"]
22
+ method_details = {} unless method_details.is_a?(Hash)
23
+
24
+ spt_id = @create_spt.call(
25
+ amount: request["amount"],
26
+ currency: request["currency"],
27
+ network_id: method_details["networkId"],
28
+ payment_method: @payment_method
29
+ )
30
+
31
+ payload = {"spt" => spt_id}
32
+ payload["externalId"] = @external_id if @external_id
33
+
34
+ Mpp::Credential.new(
35
+ challenge: challenge.to_echo,
36
+ payload: payload
37
+ )
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,14 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Mpp
5
+ module Methods
6
+ module Stripe
7
+ module Defaults
8
+ STRIPE_API_BASE = "https://api.stripe.com"
9
+ DEFAULT_CURRENCY = "usd"
10
+ DEFAULT_DECIMALS = 2
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,63 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "defaults"
5
+
6
+ module Mpp
7
+ module Methods
8
+ module Stripe
9
+ # Stripe payment method implementation.
10
+ # Handles SPT-based payments through Stripe's Business Network.
11
+ class StripeMethod
12
+ attr_reader :name, :currency, :recipient, :decimals
13
+ attr_accessor :intents
14
+
15
+ def initialize(secret_key:, network_id:, payment_methods: nil,
16
+ metadata: nil, currency: Defaults::DEFAULT_CURRENCY,
17
+ decimals: Defaults::DEFAULT_DECIMALS)
18
+ @name = "stripe"
19
+ @secret_key = secret_key
20
+ @network_id = network_id
21
+ @payment_methods = payment_methods
22
+ @metadata = metadata
23
+ @currency = currency
24
+ @recipient = network_id
25
+ @decimals = decimals
26
+ @intents = {}
27
+ end
28
+
29
+ # Transform request - injects Stripe-specific methodDetails.
30
+ def transform_request(request, _credential)
31
+ method_details = request.fetch("methodDetails", {})
32
+ method_details = {} unless method_details.is_a?(Hash)
33
+
34
+ method_details["networkId"] = @network_id
35
+ method_details["paymentMethods"] = @payment_methods if @payment_methods
36
+ method_details["metadata"] = @metadata if @metadata
37
+
38
+ request.merge("methodDetails" => method_details)
39
+ end
40
+ end
41
+
42
+ # Factory function to create a configured StripeMethod with ChargeIntent.
43
+ def self.stripe(secret_key:, network_id:, payment_methods: nil,
44
+ metadata: nil, currency: Defaults::DEFAULT_CURRENCY,
45
+ decimals: Defaults::DEFAULT_DECIMALS,
46
+ api_base: Defaults::STRIPE_API_BASE)
47
+ charge_intent = ChargeIntent.new(secret_key: secret_key, api_base: api_base)
48
+
49
+ method = StripeMethod.new(
50
+ secret_key: secret_key,
51
+ network_id: network_id,
52
+ payment_methods: payment_methods,
53
+ metadata: metadata,
54
+ currency: currency,
55
+ decimals: decimals
56
+ )
57
+
58
+ method.intents = {"charge" => charge_intent}
59
+ method
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,14 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Mpp
5
+ module Methods
6
+ module Stripe
7
+ autoload :Defaults, "mpp/methods/stripe/defaults"
8
+ # Eagerly require stripe_method so the Stripe.stripe factory method is available
9
+ require_relative "stripe/stripe_method"
10
+ autoload :ChargeIntent, "mpp/methods/stripe/charge_intent"
11
+ autoload :ClientMethod, "mpp/methods/stripe/client_method"
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,52 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ module Mpp
5
+ module Methods
6
+ module Tempo
7
+ # Wrapper around the eth gem for signing.
8
+ # Requires the `eth` gem to be installed.
9
+ class Account
10
+ attr_reader :key
11
+
12
+ def initialize(key)
13
+ @key = key
14
+ end
15
+
16
+ # Load from hex private key (0x-prefixed).
17
+ def self.from_key(private_key)
18
+ require "eth"
19
+ new(Eth::Key.new(priv: private_key.delete_prefix("0x")))
20
+ end
21
+
22
+ # Load from environment variable.
23
+ def self.from_env(var = "TEMPO_PRIVATE_KEY")
24
+ key = ENV.fetch(var, nil)
25
+ raise ArgumentError, "$#{var} not set" unless key && !key.empty?
26
+
27
+ from_key(key)
28
+ end
29
+
30
+ # Get the account's Ethereum address (checksummed).
31
+ def address
32
+ @key.address.to_s
33
+ end
34
+
35
+ # Get the private key as hex string.
36
+ def private_key
37
+ "0x#{@key.private_hex}"
38
+ end
39
+
40
+ # Sign a 32-byte hash, return 65-byte signature (r || s || v).
41
+ def sign_hash(msg_hash)
42
+ raise ArgumentError, "msg_hash must be 32 bytes, got #{msg_hash.bytesize}" unless msg_hash.bytesize == 32
43
+
44
+ sig = @key.sign(msg_hash)
45
+ # eth gem returns hex signature, parse r, s, v
46
+ sig_hex = sig.delete_prefix("0x")
47
+ [sig_hex].pack("H*")
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,112 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ require "openssl"
5
+ require "securerandom"
6
+
7
+ module Mpp
8
+ module Methods
9
+ module Tempo
10
+ module Attribution
11
+ VERSION = 0x01
12
+ ANONYMOUS = "\x00" * 10
13
+
14
+ module_function
15
+
16
+ # Compute keccak256 hash. Uses OpenSSL if available, otherwise pure Ruby.
17
+ def keccak256(data)
18
+ # Try eth gem's keccak first
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)
25
+ end
26
+
27
+ # Compute TAG = keccak256("mpp")[0:4]
28
+ def tag
29
+ @tag ||= keccak256("mpp".b)[0, 4]
30
+ end
31
+
32
+ def fingerprint(value)
33
+ keccak256(value.encode(Encoding::UTF_8))[0, 10]
34
+ end
35
+
36
+ # Encode an MPP attribution memo (32 bytes).
37
+ #
38
+ # Byte Layout:
39
+ # 0..3: TAG = keccak256("mpp")[0:4]
40
+ # 4: version (0x01)
41
+ # 5..14: serverId fingerprint
42
+ # 15..24: clientId fingerprint or zeros
43
+ # 25..31: random nonce
44
+ def encode(server_id:, client_id: nil, challenge_id: nil)
45
+ buf = "\x00".b * 32
46
+ buf[0, 4] = tag
47
+ buf[4] = [VERSION].pack("C")
48
+ buf[5, 10] = fingerprint(server_id)
49
+ buf[15, 10] = client_id ? fingerprint(client_id) : ANONYMOUS.b
50
+ buf[25, 7] = if challenge_id
51
+ keccak256(challenge_id.encode(Encoding::UTF_8))[0, 7]
52
+ else
53
+ SecureRandom.random_bytes(7)
54
+ end
55
+ "0x#{buf.unpack1("H*")}"
56
+ end
57
+
58
+ # Check if a memo is an MPP attribution memo.
59
+ def mpp_memo?(memo)
60
+ return false unless memo.length == 66
61
+
62
+ begin
63
+ memo_tag = [memo[2, 8]].pack("H*")
64
+ memo_version = memo[10, 2].to_i(16)
65
+ rescue ArgumentError
66
+ return false
67
+ end
68
+ memo_tag == tag && memo_version == VERSION
69
+ end
70
+
71
+ # Verify server fingerprint in memo.
72
+ def verify_server(memo, server_id)
73
+ return false unless mpp_memo?(memo)
74
+
75
+ begin
76
+ memo_server = [memo[12, 20]].pack("H*")
77
+ rescue ArgumentError
78
+ return false
79
+ end
80
+ memo_server == fingerprint(server_id)
81
+ end
82
+
83
+ # Decoded memo structure.
84
+ DecodedMemo = Data.define(:version, :server_fingerprint, :client_fingerprint, :nonce)
85
+
86
+ # Decode an MPP attribution memo.
87
+ def decode(memo)
88
+ return nil unless mpp_memo?(memo)
89
+
90
+ begin
91
+ version = memo[10, 2].to_i(16)
92
+ server_fingerprint = "0x#{memo[12, 20]}"
93
+ client_hex = memo[32, 20]
94
+ nonce = "0x#{memo[52..]}"
95
+
96
+ client_bytes = [client_hex].pack("H*")
97
+ client_fingerprint = (client_bytes == ANONYMOUS.b) ? nil : "0x#{client_hex}"
98
+ rescue ArgumentError
99
+ return nil
100
+ end
101
+
102
+ DecodedMemo.new(
103
+ version: version,
104
+ server_fingerprint: server_fingerprint,
105
+ client_fingerprint: client_fingerprint,
106
+ nonce: nonce
107
+ )
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end