Module: OpenReceive::SwapAddress

Defined in:
lib/openreceive/swap_address.rb

Overview

Ruby port of packages/js/core/src/swap/address.ts: address shape checks for swap deposit/refund networks, shared by the settlement engine so both engines apply one rule set.

These are checksum checks, not shape guards: a refund goes to whatever address the payer typed, so a transposed character must be refused here rather than sent somewhere unrecoverable. Tron is Base58Check (double-SHA-256 tail over the 0x41-prefixed payload), Ethereum is verified against EIP-55 whenever the address carries mixed case (an all-lower or all-upper address has no checksum bits to verify, and wallets accept it), and Solana must decode to exactly a 32-byte ed25519 public key.

Constant Summary collapse

BASE58_ALPHABET =
"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
BASE58_MAP =
BASE58_ALPHABET.each_char.with_index.to_h.freeze
ETH_ADDRESS_PATTERN =
/\A0x[0-9a-fA-F]{40}\z/
TRON_ADDRESS_PREFIX =
0x41
BASE58CHECK_CHECKSUM_BYTES =
4
TRON_ADDRESS_PATTERN =
/\AT[1-9A-HJ-NP-Za-km-z]{33}\z/
SOLANA_ADDRESS_PATTERN =
/\A[1-9A-HJ-NP-Za-km-z]{32,44}\z/

Class Method Summary collapse

Class Method Details

.decode_base58(value) ⇒ Object

Bitcoin/Solana base58 decode. Returns nil on invalid characters, else an array of byte values. Leading "1" chars are treated as leading zero bytes (matches JS decodeBase58, including the all-'1' zero-value input).



33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
# File 'lib/openreceive/swap_address.rb', line 33

def decode_base58(value)
  text = value.to_s
  return nil if text.empty?
  bytes = []
  text.each_char do |char|
    digit = BASE58_MAP[char]
    return nil if digit.nil?
    carry = digit
    index = 0
    while index < bytes.length
      carry += bytes[index] * 58
      bytes[index] = carry & 0xff
      carry >>= 8
      index += 1
    end
    while carry.positive?
      bytes << (carry & 0xff)
      carry >>= 8
    end
  end
  text.each_char do |char|
    break unless char == "1"
    bytes << 0
  end
  bytes.reverse
end

.network_for_pay_in_asset(pay_in_asset) ⇒ Object

Resolve the address network from an OpenReceive pay_in_asset code (USDT_ETH → "ETH", USDT_TRON → "TRX", SOL_SOL → "SOL"), or nil.



108
109
110
111
112
113
114
# File 'lib/openreceive/swap_address.rb', line 108

def network_for_pay_in_asset(pay_in_asset)
  suffix = pay_in_asset.to_s.split("_").last&.upcase
  return "ETH" if suffix == "ETH"
  return "SOL" if suffix == "SOL"
  return "TRX" if suffix == "TRON" || suffix == "TRX"
  nil
end

.refund_address_error(pay_in_asset, address, network_label) ⇒ Object

User-facing refund address error, or nil when the address is empty (callers keep required/empty-field handling) or valid. Copy mirrors the JS getSwapRefundAddressError strings exactly.



127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
# File 'lib/openreceive/swap_address.rb', line 127

def refund_address_error(pay_in_asset, address, network_label)
  trimmed = address.to_s.strip
  return nil if trimmed.empty?
  return nil if valid_for_pay_in_asset?(pay_in_asset, trimmed)
  network = network_for_pay_in_asset(pay_in_asset)
  if network == "ETH"
    if ETH_ADDRESS_PATTERN.match?(trimmed)
      return "That #{network_label} address failed its checksum. Copy it again from your wallet."
    end
    return "That doesn't look like an #{network_label} address. Use a 0x address."
  end
  if network == "TRX"
    if TRON_ADDRESS_PATTERN.match?(trimmed)
      return "That #{network_label} address failed its checksum. Copy it again from your wallet."
    end
    return "That doesn't look like a #{network_label} address. Use an address starting with T."
  end
  "That doesn't look like a #{network_label} address. Check you pasted the full address."
end

.valid_ethereum_address?(address) ⇒ Boolean

Returns:

  • (Boolean)


78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
# File 'lib/openreceive/swap_address.rb', line 78

def valid_ethereum_address?(address)
  return false unless ETH_ADDRESS_PATTERN.match?(address)

  body = address[2..]
  lowercase = body.downcase
  # No mixed case means no EIP-55 bits to verify.
  return true if body == lowercase || body == body.upcase

  digest = Keccak256.digest(lowercase).bytes
  lowercase.each_char.with_index.all? do |character, index|
    next true unless character.between?("a", "f")

    nibble = index.even? ? (digest[index / 2] >> 4) : (digest[index / 2] & 0x0f)
    (nibble >= 8) == (body[index] == character.upcase)
  end
end

.valid_for_network?(network, address) ⇒ Boolean

Returns:

  • (Boolean)


95
96
97
98
99
100
101
102
103
104
# File 'lib/openreceive/swap_address.rb', line 95

def valid_for_network?(network, address)
  return false if address.length > 200 || address.match?(/\s/)
  return valid_ethereum_address?(address) if network == "ETH"
  return valid_solana_address?(address) if network == "SOL"
  return valid_tron_address?(address) if network == "TRX" || network == "TRON"

  # An unknown network has no rule to apply, so nothing may be accepted for
  # it (the old `length >= 5` fallback validated almost anything).
  false
end

.valid_for_pay_in_asset?(pay_in_asset, address) ⇒ Boolean

Returns:

  • (Boolean)


116
117
118
119
120
121
122
# File 'lib/openreceive/swap_address.rb', line 116

def valid_for_pay_in_asset?(pay_in_asset, address)
  network = network_for_pay_in_asset(pay_in_asset)
  if network.nil?
    return address.length >= 5 && address.length <= 200 && !address.match?(/\s/)
  end
  valid_for_network?(network, address)
end

.valid_solana_address?(address) ⇒ Boolean

Returns:

  • (Boolean)


60
61
62
63
64
65
# File 'lib/openreceive/swap_address.rb', line 60

def valid_solana_address?(address)
  # Typical encoded length is 32–44; still require a 32-byte pubkey decode.
  return false unless SOLANA_ADDRESS_PATTERN.match?(address)
  decoded = decode_base58(address)
  !decoded.nil? && decoded.length == 32
end

.valid_tron_address?(address) ⇒ Boolean

Returns:

  • (Boolean)


67
68
69
70
71
72
73
74
75
76
# File 'lib/openreceive/swap_address.rb', line 67

def valid_tron_address?(address)
  return false unless TRON_ADDRESS_PATTERN.match?(address)
  decoded = decode_base58(address)
  return false if decoded.nil? || decoded.length != 21 + BASE58CHECK_CHECKSUM_BYTES
  return false unless decoded[0] == TRON_ADDRESS_PREFIX

  payload = decoded[0, 21].pack("C*")
  expected = Digest::SHA256.digest(Digest::SHA256.digest(payload)).bytes
  decoded[21, BASE58CHECK_CHECKSUM_BYTES] == expected[0, BASE58CHECK_CHECKSUM_BYTES]
end