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.
@@ -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
- "mpp-stripe-charge-#{credential.challenge.id}"
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 "openssl"
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
- # Compute keccak256 hash. Uses OpenSSL if available, otherwise pure Ruby.
16
+ # Ethereum Keccak-256. SHA3-256 is not a substitute (different padding).
17
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)
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.instance_variable_set(:@_method, method) if intent.respond_to?(:fee_payer)
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
- if fee_payer
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
- fee_payer_url = request.method_details.fee_payer_url || Defaults::DEFAULT_FEE_PAYER_URL
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
- unless match_transfer_calldata(call.data.delete_prefix("0x"), request, challenge: challenge)
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: "2", chainId }
10
- # Types: { Proof: [{ name: "challengeId", type: "string" }, { name: "realm", type: "string" }] }
11
- # Message: { challengeId: <challenge.id>, realm: <challenge.realm> }
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 = "2"
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(chain_id: chain_id, challenge_id: challenge_id, realm: realm)
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(chain_id: chain_id, challenge_id: challenge_id, realm: realm)
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|