Module: Mpp::Methods::Tempo::Proof

Defined in:
lib/mpp/methods/tempo/proof.rb

Overview

EIP-712 proof credentials for zero-amount challenges.

Domain: { name: "MPP", version: "3", chainId } Types: { Proof: [{ name: "account", type: "address" }, { name: "challengeId", type: "string" }, { name: "realm", type: "string" }] } Message: { account: , challengeId: <challenge.id>, realm: <challenge.realm> }

The account field binds the signature to one payer wallet, so a proof signed for one account cannot be replayed against another. This matches mpp-go, mpp-rs and mppx, which all sign and verify domain version "3" and reject version "2".

Constant Summary collapse

DOMAIN_NAME =
"MPP"
DOMAIN_VERSION =
"3"
DOMAIN_TYPE_HASH =

EIP-712 domain separator type hash

"EIP712Domain(string name,string version,uint256 chainId)"
PROOF_TYPE_HASH =
"Proof(address account,string challengeId,string realm)"

Class Method Summary collapse

Class Method Details

.abi_encode(*values) ⇒ Object

ABI-encode values (packed 32-byte words).



125
126
127
128
129
130
131
132
133
134
# File 'lib/mpp/methods/tempo/proof.rb', line 125

def abi_encode(*values)
  values.map { |v|
    case v
    when String
      v.b.rjust(32, "\x00".b)
    when Integer
      uint256(v)
    end
  }.join
end

.address_word(address) ⇒ Object

Encode an address as a 32-byte EIP-712 word. Unlike a string field the address is not hashed, it is the raw 20 bytes left-padded to 32.

Raises:

  • (ArgumentError)


117
118
119
120
121
122
# File 'lib/mpp/methods/tempo/proof.rb', line 117

def address_word(address)
  hex = address.to_s.delete_prefix("0x")
  raise ArgumentError, "invalid address: #{address}" unless hex.match?(/\A[a-fA-F0-9]{40}\z/)

  [hex].pack("H*")
end

.domain_separator(chain_id) ⇒ Object

Compute the EIP-712 domain separator.



35
36
37
38
39
40
41
42
43
44
# File 'lib/mpp/methods/tempo/proof.rb', line 35

def domain_separator(chain_id)
  keccak256(
    abi_encode(
      keccak256(DOMAIN_TYPE_HASH),
      keccak256(DOMAIN_NAME),
      keccak256(DOMAIN_VERSION),
      uint256(chain_id)
    )
  )
end

.keccak256(data) ⇒ Object



29
30
31
32
# File 'lib/mpp/methods/tempo/proof.rb', line 29

def keccak256(data)
  Kernel.require "eth"
  Eth::Util.keccak256(data)
end

.parse_source(source_str) ⇒ Object

Parse a proof source DID. Returns { address:, chain_id: } or nil.



102
103
104
105
106
107
108
109
110
111
112
113
# File 'lib/mpp/methods/tempo/proof.rb', line 102

def parse_source(source_str)
  match = source_str.match(/\Adid:pkh:eip155:(0|[1-9]\d*):(.+)\z/)
  return nil unless match

  chain_id = Integer(match[1])
  address = match[2]
  return nil unless address.match?(/\A0x[a-fA-F0-9]{40}\z/)

  {address: address, chain_id: chain_id}
rescue ArgumentError
  nil
end

.recover_address(hash, sig_bytes) ⇒ Object



143
144
145
146
147
148
149
150
151
152
153
154
# File 'lib/mpp/methods/tempo/proof.rb', line 143

def recover_address(hash, sig_bytes)
  Kernel.require "eth"

  return nil unless sig_bytes.bytesize == 65

  sig_hex = "0x#{sig_bytes.unpack1("H*")}"
  # Use raw ecrecover (not personal_recover which adds EIP-191 prefix)
  recovered_key = Eth::Signature.recover(hash, sig_hex)
  Eth::Util.public_key_to_address(recovered_key).to_s
rescue => _e
  nil
end

.sign(account:, chain_id:, challenge_id:, realm:) ⇒ Object

Sign a proof credential (client-side). The signer is also the payer.



66
67
68
69
70
71
72
73
74
75
# File 'lib/mpp/methods/tempo/proof.rb', line 66

def sign(account:, chain_id:, challenge_id:, realm:)
  hash = signing_hash(
    chain_id: chain_id,
    account: .address.to_s,
    challenge_id: challenge_id,
    realm: realm
  )
  sig = .sign_hash(hash)
  "0x#{sig.unpack1("H*")}"
end

.signing_hash(chain_id:, account:, challenge_id:, realm:) ⇒ Object

Compute the full EIP-712 signing hash.



59
60
61
62
63
# File 'lib/mpp/methods/tempo/proof.rb', line 59

def signing_hash(chain_id:, account:, challenge_id:, realm:)
  keccak256(
    "\x19\x01".b + domain_separator(chain_id) + struct_hash(, challenge_id, realm)
  )
end

.source(address:, chain_id:) ⇒ Object

Construct source DID for proof credentials.



97
98
99
# File 'lib/mpp/methods/tempo/proof.rb', line 97

def source(address:, chain_id:)
  "did:pkh:eip155:#{chain_id}:#{address}"
end

.struct_hash(account, challenge_id, realm) ⇒ Object

Compute the EIP-712 struct hash for Proof(account, challengeId, realm).



47
48
49
50
51
52
53
54
55
56
# File 'lib/mpp/methods/tempo/proof.rb', line 47

def struct_hash(, challenge_id, realm)
  keccak256(
    abi_encode(
      keccak256(PROOF_TYPE_HASH),
      address_word(),
      keccak256(challenge_id),
      keccak256(realm)
    )
  )
end

.uint256(value) ⇒ Object

Raises:

  • (ArgumentError)


136
137
138
139
140
141
# File 'lib/mpp/methods/tempo/proof.rb', line 136

def uint256(value)
  value = Integer(value)
  raise ArgumentError, "uint256 out of range" if value.negative? || value >= (1 << 256)

  [value.to_s(16).rjust(64, "0")].pack("H*")
end

.verify(address:, chain_id:, challenge_id:, realm:, signature:) ⇒ Object

Verify a proof credential signature (server-side).



78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
# File 'lib/mpp/methods/tempo/proof.rb', line 78

def verify(address:, chain_id:, challenge_id:, realm:, signature:)
  Kernel.require "eth"

  hash = signing_hash(
    chain_id: chain_id,
    account: address,
    challenge_id: challenge_id,
    realm: realm
  )
  sig_bytes = [signature.delete_prefix("0x")].pack("H*")

  # Recover the signer address from the signature
  recovered = recover_address(hash, sig_bytes)
  return false unless recovered

  recovered.downcase == address.downcase
end