[names] eth transaction signing for name registration (#1887)

* implement eth transaction signing

* Update src/Simplex/Messaging/Eth/RLP.hs

Co-authored-by: Evgeny <[email protected]>

* fix

Co-authored-by: Evgeny <[email protected]>

* address review comments

* self-review

* $$, puns

---------

Co-authored-by: Evgeny Poberezkin <[email protected]>
Co-authored-by: Evgeny @ SimpleX Chat <[email protected]>
This commit is contained in:
brenzi
2026-10-05 12:17:14 +01:00
committed by GitHub
co-authored by Evgeny Evgeny @ SimpleX Chat
parent 9f907a2740
commit 6770e732b7
7 changed files with 226 additions and 14 deletions
+17 -12
View File
@@ -10,12 +10,13 @@ held by the device.
## What is deliberately absent
- **No RLP encoder, and no transaction building.** RLP is only needed to
construct raw transactions or EIP-7702 authorizations. The client does
neither, so the `RSLV` resolver path in this repo stays strictly read-only.
- **No signing and no stealth addresses.** This change adds key and address
derivation only. Signing, the EIP-712 typed-data hashing it requires, and
stealth addresses are added with the first code that uses them.
- **No EIP-7702 authorizations.** RLP is implemented only as far as EIP-1559
transactions need it, for registering names; the `RSLV` resolver path in
this repo stays strictly read-only.
- **No stealth addresses and no EIP-712.** Signing covers recoverable ECDSA
over a 32-byte digest and EIP-1559 transactions, which name registration
requires. EIP-712 typed-data hashing and stealth addresses are added with
the first code that uses them.
- **No BIP-32 public derivation.** We always hold the seed, so CKDpub, xpub
serialization and fingerprints are not implemented. Non-hardened *private*
derivation is, because BIP-44 paths end in non-hardened components.
@@ -37,6 +38,8 @@ Simplex.Messaging.Crypto.BIP39.English embedded upstream 2048-word list
Simplex.Messaging.Crypto.BIP32 HD derivation, WalletMaster
Simplex.Messaging.Crypto.BIP44 BIP-44 paths, AccountIndex
Simplex.Messaging.Eth.Address addresses, EIP-55
Simplex.Messaging.Eth.RLP Recursive Length Prefix encoding
Simplex.Messaging.Eth.Transaction signed EIP-1559 transactions
```
`keccak256` is added to `Simplex.Messaging.Crypto`, next to `sha3_256`.
@@ -211,12 +214,14 @@ c-sources: cbits/libsecp256k1/src/{secp256k1,precomputed_ecmult,precomputed_e
No `include-dirs` are needed: every libsecp256k1 include is quoted and relative
to the including file. The embedded wordlist makes `file-embed` a library
dependency; before this change only the `smp-server` and `xftp-server`
executables and the test suite used it.
executables and the test suite used it. `wide-word` provides `Word128` and
`Word256` for transaction fields, each as narrow as both geth and reth accept.
Built with no `-D` of our own. The table-size settings (`ECMULT_WINDOW_SIZE`,
`COMB_BLOCKS`, `COMB_TEETH`) have `#ifndef` defaults in the headers, and the
checked-in precomputed tables are generated for those defaults; every other
option, such as the optional modules and x86-64 assembly, is off unless a `-D`
Built with one `-D` of our own, `ENABLE_MODULE_RECOVERY=1`, for recoverable
signatures. The table-size settings (`ECMULT_WINDOW_SIZE`, `COMB_BLOCKS`,
`COMB_TEETH`) have `#ifndef` defaults in the headers, and the checked-in
precomputed tables are generated for those defaults; every other option, such
as the other optional modules and x86-64 assembly, is off unless a `-D`
enables it. `secp256k1.c` defines `SECP256K1_BUILD` itself.
32-bit targets (armv7a-android, i686 musl) are covered by libsecp256k1's own
@@ -269,7 +274,7 @@ the C code independently of the Haskell build.
## Tests
`tests/CoreTests/EthCryptoTests.hs`, 93 examples, with published vectors read
`tests/CoreTests/EthCryptoTests.hs`, 104 examples, with published vectors read
from vendored upstream files in `tests/fixtures`, each pinned by a sha256 test:
- **BIP-39**: all 24 English vectors of `trezor/python-mnemonic/vectors.json`:
+5 -2
View File
@@ -155,6 +155,8 @@ library
Simplex.Messaging.Encoding
Simplex.Messaging.Encoding.String
Simplex.Messaging.Eth.Address
Simplex.Messaging.Eth.RLP
Simplex.Messaging.Eth.Transaction
Simplex.Messaging.Names.Record
Simplex.Messaging.Notifications.Client
Simplex.Messaging.Notifications.Protocol
@@ -339,8 +341,8 @@ library
cbits/blst/src
cbits/libbbs/include
cbits/libbbs/src
-- libsecp256k1 requires no -D flags: every setting has an #ifndef default that matches the checked-in tables
cc-options: -D__BLST_PORTABLE__
-- libsecp256k1's only -D flag enables its recovery module: every other setting has an #ifndef default that matches the checked-in tables
cc-options: -D__BLST_PORTABLE__ -DENABLE_MODULE_RECOVERY=1
if flag(commoncrypto)
cc-options: -DBBS_CRYPTO_CC
frameworks: Security
@@ -408,6 +410,7 @@ library
, transformers ==0.6.*
, unliftio ==0.2.*
, unliftio-core ==0.2.*
, wide-word ==0.1.6.*
, yaml ==0.11.*
, zstd ==0.1.3.*
default-language: Haskell2010
+47
View File
@@ -1,4 +1,5 @@
{-# LANGUAGE ForeignFunctionInterface #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE ScopedTypeVariables #-}
-- | FFI bindings to libsecp256k1.
@@ -7,6 +8,7 @@ module Simplex.Messaging.Crypto.Secp256k1
Secp256k1PublicKey,
Secp256k1Context,
PubKeyFormat (..),
RecoverableSignature (..),
mkPrivateKey,
unPrivateKey,
withContext,
@@ -14,6 +16,7 @@ module Simplex.Messaging.Crypto.Secp256k1
secp256k1PublicKey_,
serializePublicKey,
privateKeyTweakAdd,
signRecoverable,
)
where
@@ -26,6 +29,7 @@ import Crypto.Random (ChaChaDRG)
import Data.ByteArray (ScrubbedBytes)
import qualified Data.ByteArray as BA
import Data.ByteString (ByteString)
import qualified Data.ByteString as B
import Foreign hiding (void)
import Foreign.C
import qualified Simplex.Messaging.Crypto as C
@@ -46,6 +50,16 @@ uncompressedSize = 65
pubKeyInternalSize :: Int
pubKeyInternalSize = 64
-- | Internal size of @secp256k1_ecdsa_recoverable_signature@.
recSigInternalSize :: Int
recSigInternalSize = 65
compactSigSize :: Int
compactSigSize = 64
digestSize :: Int
digestSize = 32
-- Types
newtype Secp256k1PrivateKey = Secp256k1PrivateKey ScrubbedBytes
@@ -59,12 +73,22 @@ newtype Secp256k1Context = Secp256k1Context (Ptr Ctx)
data PubKeyFormat = Compressed | Uncompressed
deriving (Eq, Show)
-- | @r@ and @s@, 32 bytes big-endian each, and the recovery id, EIP-1559's @yParity@.
data RecoverableSignature = RecoverableSignature
{ rsR :: ByteString,
rsS :: ByteString,
rsRecId :: Word8
}
deriving (Eq, Show)
-- FFI
data Ctx
data PubKeyRaw
data RecSigRaw
foreign import ccall "secp256k1_context_create"
c_context_create :: CUInt -> IO (Ptr Ctx)
@@ -86,6 +110,12 @@ foreign import ccall "secp256k1_ec_pubkey_serialize"
foreign import ccall "secp256k1_ec_seckey_tweak_add"
c_ec_seckey_tweak_add :: Ptr Ctx -> Ptr Word8 -> Ptr Word8 -> IO CInt
foreign import ccall "secp256k1_ecdsa_sign_recoverable"
c_ecdsa_sign_recoverable :: Ptr Ctx -> Ptr RecSigRaw -> Ptr Word8 -> Ptr Word8 -> Ptr () -> Ptr () -> IO CInt
foreign import ccall "secp256k1_ecdsa_recoverable_signature_serialize_compact"
c_ecdsa_recoverable_signature_serialize_compact :: Ptr Ctx -> Ptr Word8 -> Ptr CInt -> Ptr RecSigRaw -> IO CInt
-- SECP256K1_CONTEXT_NONE = SECP256K1_FLAGS_TYPE_CONTEXT
contextNone :: CUInt
contextNone = 1
@@ -146,3 +176,20 @@ privateKeyTweakAdd (Secp256k1PrivateKey sk) tweak
BA.withByteArray tweak $ \twPtr -> do
(rc, sk') <- BA.copyRet sk $ \skPtr -> c_ec_seckey_tweak_add staticContext skPtr twPtr
pure $ if rc == 1 then Just (Secp256k1PrivateKey sk') else Nothing
-- | Signs a 32-byte digest, deterministically (RFC 6979) and always low-@s@, as EIP-2 requires.
signRecoverable :: TVar ChaChaDRG -> Secp256k1PrivateKey -> ByteString -> IO (Either String RecoverableSignature)
signRecoverable g (Secp256k1PrivateKey sk) digest
| B.length digest /= digestSize = pure $ Left $ "digest: expected 32 bytes, got " <> show (B.length digest)
| otherwise = withContext g $ \(Secp256k1Context ctx) ->
allocaBytes recSigInternalSize $ \sigPtr -> do
rc <- BA.withByteArray digest $ \msgPtr -> BA.withByteArray sk $ \skPtr -> c_ecdsa_sign_recoverable ctx sigPtr msgPtr skPtr nullPtr nullPtr
if rc /= 1
then pure $ Left "secp256k1_ecdsa_sign_recoverable failed on a validated key"
else do
(recId, compact) <- BA.allocRet compactSigSize $ \outPtr ->
alloca $ \recIdPtr -> do
void $ c_ecdsa_recoverable_signature_serialize_compact ctx outPtr recIdPtr sigPtr
peek recIdPtr
let (rsR, rsS) = B.splitAt (compactSigSize `div` 2) compact
pure $ Right RecoverableSignature {rsR, rsS, rsRecId = fromIntegral recId}
+4
View File
@@ -5,6 +5,7 @@
module Simplex.Messaging.Eth.Address
( Address,
unAddress,
addressFromPublicKey,
addressFromPrivateKey,
deriveAddress,
@@ -48,6 +49,9 @@ instance StrEncoding Address where
addressSize :: Int
addressSize = 20
unAddress :: Address -> ByteString
unAddress (Address bs) = bs
-- | The last 20 bytes of @keccak256@ of the uncompressed public key with its @0x04@ SEC1 prefix removed.
addressFromPublicKey :: S.Secp256k1PublicKey -> IO Address
addressFromPublicKey pk = Address . B.drop 12 . keccak256 . B.drop 1 <$> S.serializePublicKey S.Uncompressed pk
+34
View File
@@ -0,0 +1,34 @@
{-# LANGUAGE LambdaCase #-}
-- | Recursive Length Prefix, the encoding of Ethereum transactions.
module Simplex.Messaging.Eth.RLP
( RLPItem (..),
rlpEncode,
scalarItem,
)
where
import Data.ByteString (ByteString)
import qualified Data.ByteString as B
import Data.Word (Word32)
import Simplex.Messaging.Encoding (smpEncode)
data RLPItem = RLPBytes ByteString | RLPList [RLPItem]
rlpEncode :: RLPItem -> ByteString
rlpEncode = \case
RLPBytes b
| B.length b == 1 && B.head b < 0x80 -> b
| otherwise -> lengthPrefixed 0x80 b
RLPList items -> lengthPrefixed 0xc0 $ B.concat $ map rlpEncode items
where
lengthPrefixed offset payload
| len < 56 = B.cons (offset + fromIntegral len) payload
| otherwise = B.cons (offset + 55 + fromIntegral (B.length lenBytes)) lenBytes <> payload
where
len = B.length payload
lenBytes = B.dropWhile (== 0) $ smpEncode (fromIntegral len :: Word32)
-- | Big-endian with no leading zeros, and zero as the empty string.
scalarItem :: ByteString -> RLPItem
scalarItem = RLPBytes . B.dropWhile (== 0)
+58
View File
@@ -0,0 +1,58 @@
{-# LANGUAGE NamedFieldPuns #-}
-- | EIP-1559 (type 2) transactions with an empty access list, signed for @eth_sendRawTransaction@.
module Simplex.Messaging.Eth.Transaction
( Eip1559Tx (..),
signEip1559Tx,
)
where
import Control.Concurrent.STM (TVar)
import Crypto.Random (ChaChaDRG)
import Data.Bits (shiftR)
import Data.ByteString (ByteString)
import qualified Data.ByteString as B
import Data.WideWord.Word128 (Word128 (..))
import Data.WideWord.Word256 (Word256 (..))
import Data.Word (Word32, Word64)
import Simplex.Messaging.Crypto (keccak256)
import Simplex.Messaging.Crypto.Secp256k1 (RecoverableSignature (..), Secp256k1PrivateKey, signRecoverable)
import Simplex.Messaging.Encoding (smpEncode)
import Simplex.Messaging.Eth.Address (Address, unAddress)
import Simplex.Messaging.Eth.RLP
import Simplex.Messaging.Util ((<$$>))
-- | Each width is the narrowest that both geth and reth accept.
data Eip1559Tx = Eip1559Tx
{ txChainId :: Word64,
txNonce :: Word64,
txMaxPriorityFeePerGas :: Word128,
txMaxFeePerGas :: Word128,
txGasLimit :: Word64,
txTo :: Address,
txValue :: Word256,
txData :: ByteString
}
-- | @0x02 || rlp([chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, gasLimit, to, value, data, accessList, yParity, r, s])@, signing @keccak256@ of the same without the last three.
signEip1559Tx :: TVar ChaChaDRG -> Secp256k1PrivateKey -> Eip1559Tx -> IO (Either String ByteString)
signEip1559Tx g sk Eip1559Tx {txChainId, txNonce, txMaxPriorityFeePerGas, txMaxFeePerGas, txGasLimit, txTo, txValue, txData} =
signed <$$> signRecoverable g sk (keccak256 $ typed fields)
where
signed RecoverableSignature {rsR, rsS, rsRecId} = typed $ fields <> [scalarItem (B.singleton rsRecId), scalarItem rsR, scalarItem rsS]
fields =
[ w64 txChainId,
w64 txNonce,
w128 txMaxPriorityFeePerGas,
w128 txMaxFeePerGas,
w64 txGasLimit,
RLPBytes $ unAddress txTo,
w256 txValue,
RLPBytes txData,
RLPList []
]
w64 = scalarItem . be64
w128 (Word128 hi lo) = scalarItem $ be64 hi <> be64 lo
w256 (Word256 a3 a2 a1 a0) = scalarItem $ be64 a3 <> be64 a2 <> be64 a1 <> be64 a0
be64 n = smpEncode (fromIntegral (n `shiftR` 32) :: Word32) <> smpEncode (fromIntegral n :: Word32)
typed items = B.cons 0x02 $ rlpEncode $ RLPList items
+61
View File
@@ -32,6 +32,9 @@ import Simplex.Messaging.Crypto.BIP44
import qualified Simplex.Messaging.Crypto.Secp256k1 as S
import Simplex.Messaging.Encoding.String (strDecode, strEncode)
import Simplex.Messaging.Eth.Address
import Simplex.Messaging.Eth.RLP
import Simplex.Messaging.Eth.Transaction
import Simplex.Messaging.Util ((<$$>))
import Test.Hspec hiding (fit, it)
import Util
@@ -44,6 +47,9 @@ ethCryptoTests = do
describe "BIP-32" $ bip32Tests g
describe "BIP-44 derivation" $ derivationTests g
describe "EIP-55 addresses" eip55Tests
describe "RLP" rlpTests
describe "recoverable signing" $ signingTests g
describe "EIP-1559 transactions" $ transactionTests g
-- helpers
@@ -307,3 +313,58 @@ eip55Tests = do
"0xdbF03B407c01E7cD3CBea99509d93f8DDDC8C6FB",
"0xD1220A0cf47c7B9Be7A2E6BA89F429762e7b9aDb"
]
rlpTests :: Spec
rlpTests = do
forM_ rlpVectors $ \(name, item, expected) ->
it ("encodes " <> name) $ toHex (rlpEncode item) `shouldBe` expected
it "encodes scalars without leading zeros" $ do
toHex (rlpEncode $ scalarItem $ B.replicate 32 0) `shouldBe` "80"
toHex (rlpEncode $ scalarItem $ hx "000f") `shouldBe` "0f"
toHex (rlpEncode $ scalarItem $ hx "000400") `shouldBe` "820400"
where
rlpVectors =
[ ("the empty string", RLPBytes "", "80"),
("the empty list", RLPList [], "c0"),
("dog", RLPBytes "dog", "83646f67"),
("[cat, dog]", RLPList [RLPBytes "cat", RLPBytes "dog"], "c88363617483646f67"),
("the set-theoretic three", RLPList [RLPList [], RLPList [RLPList []], RLPList [RLPList [], RLPList [RLPList []]]], "c7c0c1c0c3c0c1c0"),
("a 56-byte string", RLPBytes (B.replicate 56 0x61), "b838" <> B.concat (replicate 56 "61"))
]
-- expected signatures and transactions are viem's for the same key and inputs
signingTests :: TVar ChaChaDRG -> Spec
signingTests g = do
it "derives the EIP-155 example address" $
(strEncode <$> addressFromPrivateKey g txKey) `shouldReturn` "0x9d8A62f656a8d1615C1294fd71e9CFb3E4855A4F"
it "signs a digest as viem does" $
S.signRecoverable g txKey (C.keccak256 "simplex")
`shouldReturn` Right
S.RecoverableSignature
{ S.rsR = hx "8d5aa94d4288fb8297dd9ff2c9cc5026b09b5305ace1f0b48a864ab116f2d3b0",
S.rsS = hx "3eb3b2c117d918736df192c69cef499294f2a070aff74c2bf69776a179b7cdb1",
S.rsRecId = 1
}
it "refuses a digest that is not 32 bytes" $
(isLeft <$> S.signRecoverable g txKey "digest") `shouldReturn` True
transactionTests :: TVar ChaChaDRG -> Spec
transactionTests g = do
it "signs a contract call as viem does" $
(toHex <$$> signEip1559Tx g txKey contractCall)
`shouldReturn` Right "02f89383aa36a709843b9aca008506fc23ac00830186a094353535353535353535353535353535353535353580a4f14fcbc81111111111111111111111111111111111111111111111111111111111111111c001a08be78ee2e58a62763be161e3c4440c74c3b6ae56d667b143554e12239a6328e5a0790d86c0398a5295d981d12f4abc10d61b58917b7b7fd21215f7686457aafc19"
it "signs a value transfer with zero fields as viem does" $
(toHex <$$> signEip1559Tx g txKey transfer)
`shouldReturn` Right "02f86a01808001825208943535353535353535353535353535353535353535880de0b6b3a764000080c001a0d04dbcdcfa4c64fe9deb8f0fea4d7a1f07407e80fd2b066b8f6d58a9baf6af0ba074c14688697464225bbe29301db1df7f0c1b9484a52eaf76162524baba2f2791"
it "encodes every word of fields wider than 64 bits" $
(B.isInfixOf wideFields . toHex <$$> signEip1559Tx g txKey wide)
`shouldReturn` Right True
where
to = right $ strDecode "0x3535353535353535353535353535353535353535"
contractCall = Eip1559Tx {txChainId = 11155111, txNonce = 9, txMaxPriorityFeePerGas = 1000000000, txMaxFeePerGas = 30000000000, txGasLimit = 100000, txTo = to, txValue = 0, txData = hx "f14fcbc8" <> B.replicate 32 0x11}
transfer = Eip1559Tx {txChainId = 1, txNonce = 0, txMaxPriorityFeePerGas = 0, txMaxFeePerGas = 1, txGasLimit = 21000, txTo = to, txValue = 1000000000000000000, txData = ""}
wide = Eip1559Tx {txChainId = maxBound, txNonce = maxBound - 1, txMaxPriorityFeePerGas = 0x10000000000000000000000007, txMaxFeePerGas = 0x80000000000000010000000000000003, txGasLimit = 0x10000000001, txTo = to, txValue = 0x8000000000000001000000000000000100000000000000010000000000000009, txData = ""}
wideFields = "88ffffffffffffffff88fffffffffffffffe8d10000000000000000000000007908000000000000001000000000000000386010000000001943535353535353535353535353535353535353535a0800000000000000100000000000000010000000000000001000000000000000980c0"
txKey :: S.Secp256k1PrivateKey
txKey = right $ S.mkPrivateKey $ BA.replicate 32 0x46