Files
simplexmq/src/Simplex/Messaging/Eth/Address.hs
T
Alain BrenzikoferandClaude Opus 5 f53a6f3364 names: ROWN, the names an address owns
A recovered key has to be scanned for the accounts it already uses, which
needs a question the protocol could not ask: what does this address own.

ROWN carries one address and a page offset and is answered with ROWND,
from nameOwnedSMPVersion. The agent returns the relay it used so a scan
can ask the next account elsewhere: sending every account to one relay
would tell it they belong to one wallet.

inUse is the answer the scan acts on. Holding a name is only one way for
an account to be in use, so the resolver reports the nonce and balance it
is derived from; a scan that reads names alone hands out an account its
owner is already using.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHaVjENni2mwBUL77SwFvr
2026-09-22 09:19:47 +00:00

99 lines
3.8 KiB
Haskell

{-# LANGUAGE OverloadedStrings #-}
-- | Ethereum addresses: derivation from a public key, and EIP-55 mixed-case checksum encoding.
module Simplex.Messaging.Eth.Address
( Address,
addressFromPrivateKey,
ethereumPath,
)
where
import Control.Applicative (optional, (<|>))
import qualified Data.Attoparsec.ByteString.Char8 as A
import Data.Bits (shiftR, (.&.))
import qualified Data.ByteArray.Encoding as BAE
import Data.ByteString (ByteString)
import qualified Data.ByteString as B
import qualified Data.ByteString.Char8 as BC
import Data.Char (isDigit, isHexDigit, isLower, isUpper, toLower)
import Data.Word (Word32, Word8)
import Simplex.Messaging.Crypto.BIP32 (hardened)
import qualified Simplex.Messaging.Crypto.Secp256k1 as S
import Simplex.Messaging.Encoding
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Eth.Keccak (keccak256)
import Simplex.Messaging.Util ((<$?>))
-- | A 20-byte Ethereum address. 'Show' renders the EIP-55 checksummed form, as pasted into a block explorer.
newtype Address = Address ByteString
deriving (Eq, Ord)
instance Show Address where
show = BC.unpack . checksumAddress
-- | EIP-55 checksummed hex. Parsing accepts bare or @0x@-prefixed hex and verifies a mixed-case checksum.
-- | On the wire the address is its EIP-55 hex, length-prefixed, so a field
-- following it is not swallowed by the hex parser.
instance Encoding Address where
smpEncode = smpEncode . strEncode
smpP = strDecode <$?> smpP
instance StrEncoding Address where
strEncode = checksumAddress
strP = do
_ <- optional $ A.string "0x" <|> A.string "0X"
body <- A.takeWhile isHexDigit
if B.length body /= addressSize * 2
then fail $ "address: expected 40 hex digits, got " <> show (B.length body)
else case fromHex (BC.map toLower body) of
Left _ -> fail "address: not hexadecimal"
Right bs
| mixedCase body && checksumAddress (Address bs) /= "0x" <> body ->
fail "address: EIP-55 checksum mismatch"
| otherwise -> pure $ Address bs
where
mixedCase body = BC.any isUpper letters && BC.any isLower letters
where
letters = BC.filter (not . isDigit) body
addressSize :: Int
addressSize = 20
-- | The low 20 bytes of @keccak256@ of the uncompressed public key with its @0x04@ SEC1 prefix removed.
addressFromPublicKey :: S.Secp256k1PublicKey -> Address
addressFromPublicKey pk =
Address . B.drop 12 . keccak256 . B.drop 1 $ S.serializePublicKey S.Uncompressed pk
addressFromPrivateKey :: S.Secp256k1PrivateKey -> Address
addressFromPrivateKey = addressFromPublicKey . S.secp256k1PublicKey
-- | EIP-55: @0x@ and 40 hex digits whose case encodes a checksum over the lowercase hex form.
checksumAddress :: Address -> ByteString
checksumAddress (Address bs) = "0x" <> B.pack (zipWith adjust [0 ..] (B.unpack lowerHex))
where
lowerHex = toHex bs
hashed = keccak256 lowerHex
adjust :: Int -> Word8 -> Word8
adjust i c
| isHexLetter c && nibbleAt i >= 8 = upper c
| otherwise = c
nibbleAt i =
let byte = B.index hashed (i `div` 2)
in if even i then byte `shiftR` 4 else byte .&. 0x0F
isHexLetter c = c >= 0x61 && c <= 0x66 -- 'a'..'f'
upper c = c - 0x20
-- | BIP-44 path for Ethereum account @i@, address @k@: @m\/44'\/60'\/i'\/0\/k@. The account must be below @hardenedOffset@.
ethereumPath :: Word32 -> Word32 -> [Word32]
ethereumPath account address = [hardened 44, hardened 60, hardened account, 0, address]
-- hex via memory's Base16, which emits the lowercase form EIP-55 starts from; 'checksumAddress' is what introduces case
toHex :: ByteString -> ByteString
toHex = BAE.convertToBase BAE.Base16
-- | Decodes base16; a non-hex or odd-length input is a Left.
fromHex :: ByteString -> Either String ByteString
fromHex = BAE.convertFromBase BAE.Base16