From 8ee394bcb3d6fe3975050a43295d1b9728d5f85b Mon Sep 17 00:00:00 2001 From: shum Date: Thu, 22 Oct 2026 00:15:33 +0000 Subject: [PATCH 1/5] store: build a name's node from its label hash --- .../src/StoreService/Names/Registration.hs | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/apps/simplex-store-service/src/StoreService/Names/Registration.hs b/apps/simplex-store-service/src/StoreService/Names/Registration.hs index 8c0748f4e9..abf1c5834c 100644 --- a/apps/simplex-store-service/src/StoreService/Names/Registration.hs +++ b/apps/simplex-store-service/src/StoreService/Names/Registration.hs @@ -5,7 +5,11 @@ module StoreService.Names.Registration ( Registration (..), RegisterIntent (..), Node, + nodeBytes, namehash, + labelNode, + contactRecordKey, + channelRecordKey, sponsorDomain, resolverDomain, makeCommitment, @@ -32,7 +36,7 @@ import Simplex.Messaging.Crypto.Secp256k1 (RecoverableSignature) import Simplex.Messaging.Encoding.String (strEncode) import Simplex.Messaging.Eth.Address (Address, recoverAddress) import Simplex.Messaging.Eth.EIP712 (Eip712Domain (..), Eip712Value (..), hashTypedData, serializeSignature) -import Simplex.Messaging.SimplexName (SimplexTLD) +import Simplex.Messaging.SimplexName (LabelHash (..), SimplexTLD) import StoreService.Names.Abi (AbiValue (..), abiArray, abiBytes, abiTuple, addressWord, selector, uint256) data Registration = Registration @@ -46,9 +50,16 @@ data Registration = Registration rChannelLink :: Maybe Text } --- | Only built by 'namehash', so it always holds 32 bytes. +-- | Only built by 'labelNode', so it always holds 32 bytes. newtype Node = Node ByteString +nodeBytes :: Node -> ByteString +nodeBytes (Node node) = node + +contactRecordKey, channelRecordKey :: ByteString +contactRecordKey = "simplex.contact" +channelRecordKey = "simplex.channel" + data RegisterIntent = RegisterIntent { riRegistration :: Registration, riNonce :: Word64, @@ -134,7 +145,7 @@ registrationTuple r@Registration {rLabel, rOwner, rDuration, rSecret, rResolver} recordCalls :: Registration -> [ByteString] recordCalls Registration {rLabel, rTLD, rContactLink, rChannelLink} = - catMaybes [setText "simplex.contact" <$> rContactLink, setText "simplex.channel" <$> rChannelLink] + catMaybes [setText contactRecordKey <$> rContactLink, setText channelRecordKey <$> rChannelLink] where setText key link = selector "setText(bytes32,string,string)" @@ -142,6 +153,9 @@ recordCalls Registration {rLabel, rTLD, rContactLink, rChannelLink} = Node node = namehash rTLD rLabel namehash :: SimplexTLD -> Text -> Node -namehash tld label = Node $ keccak256 $ tldNode <> keccak256 (encodeUtf8 label) +namehash tld = labelNode tld . LabelHash . keccak256 . encodeUtf8 + +labelNode :: SimplexTLD -> LabelHash -> Node +labelNode tld (LabelHash h) = Node $ keccak256 $ tldNode <> h where tldNode = keccak256 $ B.replicate 32 0 <> keccak256 (B.drop 1 $ strEncode tld) From 50c2afc79147b64ce507930a5b1a87dd6611fd11 Mon Sep 17 00:00:00 2001 From: shum Date: Thu, 22 Oct 2026 00:15:33 +0000 Subject: [PATCH 2/5] store: decode a uint256 that fits 64 bits --- apps/simplex-store-service/src/StoreService/Names/Abi.hs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/simplex-store-service/src/StoreService/Names/Abi.hs b/apps/simplex-store-service/src/StoreService/Names/Abi.hs index cd968e452d..0d3fbaaf90 100644 --- a/apps/simplex-store-service/src/StoreService/Names/Abi.hs +++ b/apps/simplex-store-service/src/StoreService/Names/Abi.hs @@ -13,6 +13,7 @@ module StoreService.Names.Abi selector, hex, decodeUint256, + decodeWord64, decodeAddress, decodeBool, decodeBytes32, @@ -31,6 +32,7 @@ import qualified Data.ByteString as B import Data.List (mapAccumL) import Data.Text (Text) import Data.Text.Encoding (decodeLatin1, decodeUtf8') +import Data.Word (Word64) import Simplex.Messaging.Crypto (keccak256) import Simplex.Messaging.Encoding.String (strDecode) import Simplex.Messaging.Eth.Address (Address, unAddress) @@ -43,6 +45,7 @@ data AbiError | AENotAddress | AENotBool | AENotUtf8 + | AERange deriving (Eq, Show) abiWordSize :: Int @@ -80,6 +83,11 @@ hex bs = "0x" <> decodeLatin1 (convertToBase Base16 bs) decodeUint256 :: ByteString -> Either AbiError Integer decodeUint256 = uintAt 0 +decodeWord64 :: ByteString -> Either AbiError Word64 +decodeWord64 ret = do + n <- decodeUint256 ret + if n <= toInteger (maxBound :: Word64) then Right (fromInteger n) else Left AERange + decodeAddress :: ByteString -> Either AbiError Address decodeAddress ret = do (padding, a) <- B.splitAt addressPadding <$> wordAt 0 ret From 401a1a43b39454f86ab9aa4b377f197b4edff41b Mon Sep 17 00:00:00 2001 From: shum Date: Thu, 22 Oct 2026 00:15:33 +0000 Subject: [PATCH 3/5] store: read names contracts --- .../src/StoreService/Names/Contracts.hs | 198 +++++++++++ .../src/StoreService/Names/Env.hs | 7 +- simplex-chat.cabal | 3 + .../Bots/StoreService/NamesContractsTests.hs | 318 ++++++++++++++++++ tests/Test.hs | 2 + 5 files changed, 526 insertions(+), 2 deletions(-) create mode 100644 apps/simplex-store-service/src/StoreService/Names/Contracts.hs create mode 100644 tests/Bots/StoreService/NamesContractsTests.hs diff --git a/apps/simplex-store-service/src/StoreService/Names/Contracts.hs b/apps/simplex-store-service/src/StoreService/Names/Contracts.hs new file mode 100644 index 0000000000..67e20571e9 --- /dev/null +++ b/apps/simplex-store-service/src/StoreService/Names/Contracts.hs @@ -0,0 +1,198 @@ +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module StoreService.Names.Contracts + ( Contracts, + NameStatus (..), + RegisteredName (..), + NameRecords (..), + CommitmentAges (..), + newContracts, + secondsPerYear, + nameStatus, + nameRecords, + chainPriceCents, + codeLengthFor, + sponsorNonce, + resolverNonce, + ownerOfToken, + nameExpires, + commitmentTime, + controllerAvailable, + commitmentAges, + ) +where + +import Control.Monad.Except (ExceptT (..), runExceptT) +import Data.Bifunctor (first) +import Data.ByteString (ByteString) +import qualified Data.ByteString.Char8 as B +import Data.IORef (IORef, atomicWriteIORef, newIORef, readIORef) +import qualified Data.Map.Strict as M +import Data.Text (Text) +import Data.Text.Encoding (decodeLatin1, encodeUtf8) +import Data.Word (Word64) +import Simplex.Chat.Badges.Code (maxNameLength, minNameLength) +import Simplex.Messaging.Eth.Address (Address) +import Simplex.Messaging.SimplexName (LabelHash (..), SimplexTLD) +import Simplex.Messaging.Util (tshow) +import StoreService.Config (ChainConfig (..)) +import StoreService.Names.Abi +import StoreService.Names.Chain (Block (..), ChainClient (..), ChainError (..), ethCall, latestBlock) +import StoreService.Names.Registration (channelRecordKey, contactRecordKey, labelNode, nodeBytes) + +data Contracts = Contracts + { ctChain :: ChainClient, + ctTLD :: SimplexTLD, + ctRegistrar :: IORef (Maybe RegistrarConstants), + ctCommitmentAges :: IORef (Maybe CommitmentAges) + } + +data RegistrarConstants = RegistrarConstants {rcRegistry :: Address, rcGracePeriod :: Integer} + +data NameStatus = NSAvailable | NSReserved | NSRegistered RegisteredName + deriving (Eq, Show) + +data RegisteredName = RegisteredName + { rnOwner :: Address, + rnResolver :: Address, + rnExpiresAt :: Word64, + rnGracePeriod :: Integer + } + deriving (Eq, Show) + +-- | The default resolver's records ("" when unset) and the owner's nonce for the next edit. +data NameRecords = NameRecords {rdContact :: Text, rdChannel :: Text, rdNonce :: Word64} + deriving (Eq, Show) + +data CommitmentAges = CommitmentAges {caMin :: Integer, caMax :: Integer} + deriving (Eq, Show) + +secondsPerYear :: Integer +secondsPerYear = 31536000 + +attoUsdPerCent :: Integer +attoUsdPerCent = 10 ^ (16 :: Int) + +-- | Reads nothing: the registrar's constants and the commitment ages are read on first use and kept once read. +newContracts :: SimplexTLD -> ChainClient -> IO Contracts +newContracts ctTLD ctChain = do + ctRegistrar <- newIORef Nothing + ctCommitmentAges <- newIORef Nothing + pure Contracts {ctChain, ctTLD, ctRegistrar, ctCommitmentAges} + +-- | Registered while @nameExpires + GRACE_PERIOD@ is not before the latest block's time. +nameStatus :: Contracts -> LabelHash -> IO (Either ChainError NameStatus) +nameStatus ct lh = runExceptT $ do + RegistrarConstants {rcRegistry, rcGracePeriod} <- ExceptT $ registrarConstants ct + expires <- ExceptT $ nameExpires ct lh + Block {blockTimestamp} <- ExceptT $ latestBlock $ ctChain ct + if toInteger expires + rcGracePeriod >= toInteger blockTimestamp + then do + let node = nodeArg ct lh + rnOwner <- ExceptT $ call ct rcRegistry ("owner(bytes32)", node) decodeAddress + rnResolver <- ExceptT $ call ct rcRegistry ("resolver(bytes32)", node) decodeAddress + pure $ NSRegistered RegisteredName {rnOwner, rnResolver, rnExpiresAt = expires, rnGracePeriod = rcGracePeriod} + else do + reason <- ExceptT $ call ct (controller ct) ("reservedNames(bytes32)", labelHashArg lh) decodeUint256 + pure $ if reason /= 0 then NSReserved else NSAvailable + +-- | Nothing for a name on another resolver contract, or none: the service reads no records from a contract it does not know. +nameRecords :: Contracts -> LabelHash -> RegisteredName -> IO (Either ChainError (Maybe NameRecords)) +nameRecords ct lh RegisteredName {rnOwner, rnResolver} + | rnResolver /= resolver ct = pure $ Right Nothing + | otherwise = runExceptT $ do + rdContact <- ExceptT $ textRecord contactRecordKey + rdChannel <- ExceptT $ textRecord channelRecordKey + rdNonce <- ExceptT $ resolverNonce ct rnOwner + pure $ Just NameRecords {rdContact, rdChannel, rdNonce} + where + textRecord key = call ct (resolver ct) ("text(bytes32,string)", abiTuple [Static $ nodeArg ct lh, Dynamic $ abiBytes key]) decodeString + +-- | Cents for a label of this length and these years. The oracle prices by length only, +-- so a placeholder label of that length keeps the label from the RPC node. +chainPriceCents :: Contracts -> Int -> Int -> IO (Either ChainError Int) +chainPriceCents ct len years = call ct (controller ct) ("rentPriceUSD(string,uint256)", args) $ \ret -> do + base <- decodeUint256 ret + let cents = base `div` attoUsdPerCent + if cents <= toInteger (maxBound :: Int) then Right (fromInteger cents) else Left AERange + where + args = abiTuple [Dynamic $ abiBytes $ B.replicate len 'a', Static $ uint256 $ toInteger years * secondsPerYear] + +-- | The shortest length from the shortest label such that every length up to the longest costs at most these cents +-- for these years, from one read of the oracle's whole price list, which need not fall with the length. +codeLengthFor :: Contracts -> Int -> Int -> IO (Either ChainError (Maybe Int)) +codeLengthFor ct cents years = runExceptT $ do + oracle <- ExceptT $ call ct (controller ct) ("prices()", "") decodeAddress + (basePerYear, exceptions) <- ExceptT $ call ct oracle ("prices()", "") decodePrices + let perYear len = M.findWithDefault basePerYear (toInteger len) $ M.fromList exceptions + covered len = perYear len * toInteger years <= toInteger cents + pure $ case takeWhile covered [maxNameLength, maxNameLength - 1 .. minNameLength] of + [] -> Nothing + lens -> Just $ last lens + +sponsorNonce :: Contracts -> Address -> IO (Either ChainError Word64) +sponsorNonce ct owner = call ct (cSponsor $ chainConfig ct) ("nonces(address)", addressWord owner) decodeWord64 + +resolverNonce :: Contracts -> Address -> IO (Either ChainError Word64) +resolverNonce ct owner = call ct (resolver ct) ("nonces(address)", addressWord owner) decodeWord64 + +-- | Nothing when nobody holds the name: @ownerOf@ reverts for it, and that revert is the answer. +ownerOfToken :: Contracts -> LabelHash -> IO (Either ChainError (Maybe Address)) +ownerOfToken ct lh = + call ct (baseRegistrar ct) ("ownerOf(uint256)", labelHashArg lh) decodeAddress >>= \case + Left (ChainReverted _) -> pure $ Right Nothing + r -> pure $ Just <$> r + +nameExpires :: Contracts -> LabelHash -> IO (Either ChainError Word64) +nameExpires ct lh = call ct (baseRegistrar ct) ("nameExpires(uint256)", labelHashArg lh) decodeWord64 + +-- | The block time of the commitment, Nothing when the controller holds none. +commitmentTime :: Contracts -> ByteString -> IO (Either ChainError (Maybe Word64)) +commitmentTime ct commitment = fmap nonZero <$> call ct (controller ct) ("commitments(bytes32)", commitment) decodeWord64 + where + nonZero t = if t == 0 then Nothing else Just t + +controllerAvailable :: Contracts -> Text -> IO (Either ChainError Bool) +controllerAvailable ct label = call ct (controller ct) ("available(string)", abiTuple [Dynamic $ abiBytes $ encodeUtf8 label]) decodeBool + +commitmentAges :: Contracts -> IO (Either ChainError CommitmentAges) +commitmentAges ct@Contracts {ctCommitmentAges} = readOnce ctCommitmentAges $ runExceptT $ do + caMin <- ExceptT $ call ct (controller ct) ("minCommitmentAge()", "") decodeUint256 + caMax <- ExceptT $ call ct (controller ct) ("maxCommitmentAge()", "") decodeUint256 + pure CommitmentAges {caMin, caMax} + +registrarConstants :: Contracts -> IO (Either ChainError RegistrarConstants) +registrarConstants ct@Contracts {ctRegistrar} = readOnce ctRegistrar $ runExceptT $ do + rcRegistry <- ExceptT $ call ct (baseRegistrar ct) ("ens()", "") decodeAddress + rcGracePeriod <- ExceptT $ call ct (baseRegistrar ct) ("GRACE_PERIOD()", "") decodeUint256 + pure RegistrarConstants {rcRegistry, rcGracePeriod} + +-- | Concurrent first uses may each read; they read the same immutable values. +readOnce :: IORef (Maybe a) -> IO (Either ChainError a) -> IO (Either ChainError a) +readOnce ref readChain = + readIORef ref >>= \case + Just a -> pure $ Right a + Nothing -> readChain >>= either (pure . Left) (\a -> Right a <$ atomicWriteIORef ref (Just a)) + +-- | An answer that does not decode is a node failure, as an RPC error is. +call :: Contracts -> Address -> (ByteString, ByteString) -> (ByteString -> Either AbiError a) -> IO (Either ChainError a) +call Contracts {ctChain} to (signature, args) decode = + (>>= first malformed . decode) <$> ethCall ctChain to (selector signature <> args) + where + malformed e = ChainUnavailable $ "eth_call: " <> decodeLatin1 signature <> " answered " <> tshow e + +labelHashArg :: LabelHash -> ByteString +labelHashArg (LabelHash h) = h + +nodeArg :: Contracts -> LabelHash -> ByteString +nodeArg Contracts {ctTLD} = nodeBytes . labelNode ctTLD + +chainConfig :: Contracts -> ChainConfig +chainConfig = ccConfig . ctChain + +controller, resolver, baseRegistrar :: Contracts -> Address +controller = cController . chainConfig +resolver = cResolver . chainConfig +baseRegistrar = cBaseRegistrar . chainConfig diff --git a/apps/simplex-store-service/src/StoreService/Names/Env.hs b/apps/simplex-store-service/src/StoreService/Names/Env.hs index a8c4442618..91216ae86b 100644 --- a/apps/simplex-store-service/src/StoreService/Names/Env.hs +++ b/apps/simplex-store-service/src/StoreService/Names/Env.hs @@ -7,6 +7,7 @@ import Simplex.Messaging.Agent.Protocol (UserId) import Simplex.Messaging.Agent.Store.Common (DBStore) import StoreService.Config (NamesConfig (..)) import StoreService.Names.Chain (ChainClient, newChainClient) +import StoreService.Names.Contracts (Contracts, newContracts) import StoreService.Names.Phases (Phases, newPhases) import StoreService.Names.Profiles (Profiles, chatProfile, defaultProfileLimits, monotonicSeconds, newProfiles, systemResolver) import StoreService.Names.Turns (Turns, newTurns) @@ -19,13 +20,15 @@ data NamesEnv = NamesEnv nePhases :: Phases, neTurns :: Turns, neChain :: ChainClient, + neContracts :: Contracts, neProfiles :: Profiles } newNamesEnv :: ChatController -> UserId -> NamesConfig -> DBStore -> IO NamesEnv -newNamesEnv neChat@ChatController {random} neUserId neConfig@NamesConfig {nChain} neStore = do +newNamesEnv neChat@ChatController {random} neUserId neConfig@NamesConfig {nTLD, nChain} neStore = do nePhases <- newPhases neTurns <- newTurns neChain <- newChainClient random nChain + neContracts <- newContracts nTLD neChain neProfiles <- newProfiles defaultProfileLimits monotonicSeconds (chatProfile neChat neUserId) systemResolver - pure NamesEnv {neChat, neUserId, neConfig, neStore, nePhases, neTurns, neChain, neProfiles} + pure NamesEnv {neChat, neUserId, neConfig, neStore, nePhases, neTurns, neChain, neContracts, neProfiles} diff --git a/simplex-chat.cabal b/simplex-chat.cabal index d2fb222221..ccb23e561e 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -444,6 +444,7 @@ executable simplex-store-service StoreService.Log StoreService.Names.Abi StoreService.Names.Chain + StoreService.Names.Contracts StoreService.Names.Env StoreService.Names.Label StoreService.Names.Links @@ -753,6 +754,7 @@ test-suite simplex-chat-test Bots.StoreService.GroupTests Bots.StoreService.NameRegistrationTests Bots.StoreService.NamesChainTests + Bots.StoreService.NamesContractsTests Bots.StoreService.NamesLabelTests Bots.StoreService.NamesLinksTests Bots.StoreService.NamesProfilesTests @@ -782,6 +784,7 @@ test-suite simplex-chat-test StoreService.Log StoreService.Names.Abi StoreService.Names.Chain + StoreService.Names.Contracts StoreService.Names.Env StoreService.Names.Label StoreService.Names.Links diff --git a/tests/Bots/StoreService/NamesContractsTests.hs b/tests/Bots/StoreService/NamesContractsTests.hs new file mode 100644 index 0000000000..ca1b426cff --- /dev/null +++ b/tests/Bots/StoreService/NamesContractsTests.hs @@ -0,0 +1,318 @@ +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module Bots.StoreService.NamesContractsTests (namesContractsTests) where + +import Bots.StoreService.FakeEthNode +import Control.Concurrent.STM (TVar) +import Crypto.Random (ChaChaDRG) +import Data.ByteArray.Encoding (Base (Base16), convertFromBase) +import Data.ByteString (ByteString) +import qualified Data.ByteString as B +import Data.Word (Word64) +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Encoding.String (strDecode) +import Simplex.Messaging.Eth.Address (Address) +import Simplex.Messaging.SimplexName (LabelHash, SimplexTLD (..), labelHash) +import StoreService.Config (ChainConfig (..)) +import StoreService.Names.Abi +import StoreService.Names.Chain +import StoreService.Names.Contracts +import StoreService.Names.Registration (labelNode, namehash, nodeBytes) +import Test.Hspec + +namesContractsTests :: Spec +namesContractsTests = describe "StoreService names: contracts" $ do + -- the calldata and answers marked viem are viem 2.33.3's encodeFunctionData and encodeFunctionResult + -- with the ABIs of ens-contracts PR #36, so a read the fake node answers sends viem's calldata + g <- runIO C.newRandom + it "builds a name's node from its label hash as viem's namehash" testNode + describe "status" $ do + it "reads a name nobody holds or reserved as available" $ testAvailable g + it "reads a reserved name" $ testReserved g + it "reads a registered name with the registry's owner and resolver" $ testRegistered g + it "keeps a name registered to the last second of its grace period" $ testGracePeriod g + it "reads the registry and the grace period once, and again after a failed read" $ testRegistrarOnce g + describe "records" $ do + it "reads the default resolver's records and the owner's nonce" $ testRecords g + it "reads no records from another resolver" $ testOtherResolver g + describe "prices" $ do + it "quotes 6, 7 and 12 letters at the launch list for 2 years" $ testLaunchPrices g + it "finds the code length for each launch price" $ testLaunchCodeLengths g + it "requires every longer length to be covered when the list is not monotone" $ testNonMonotone g + it "reads the oracle from the controller on each call" $ testOracleReplaced g + describe "other reads" $ do + it "reads the sponsor's and the resolver's nonces from their own contracts" $ testNonces g + it "reads ownerOf's revert as nobody, and a failed read as a failure" $ testOwnerOf g + it "reads a commitment's time, none as Nothing" $ testCommitmentTime g + it "reads the controller's availability and the name's expiry" $ testAvailability g + it "reads the commitment ages once" $ testCommitmentAges g + describe "failures" $ do + it "fails a status on a failed read, never reading it as available" $ testFailedRead g + it "fails a read whose answer does not decode or does not fit" $ testMalformed g + +testNode :: IO () +testNode = do + nodeBytes (labelNode TLDSimplex $ labelHash "alicebakery") `shouldBe` aliceNode + nodeBytes (namehash TLDSimplex "alicebakery") `shouldBe` aliceNode + +testAvailable :: TVar ChaChaDRG -> IO () +testAvailable g = withContracts g $ \node ct -> do + answerRegistrar node + setBlock node 100 now + answerCall node (registrarOf node) nameExpiresCall $ Returns $ uint256 (0 :: Int) + answerCall node fakeController reservedNamesCall $ Returns $ uint256 (0 :: Int) + nameStatus ct alice `shouldReturn` Right NSAvailable + +testReserved :: TVar ChaChaDRG -> IO () +testReserved g = withContracts g $ \node ct -> do + answerRegistrar node + setBlock node 100 now + answerCall node (registrarOf node) nameExpiresCall $ Returns $ uint256 (0 :: Int) + -- viem: reservedNames answering Reason 1 + answerCall node fakeController reservedNamesCall $ Returns $ unhex "0000000000000000000000000000000000000000000000000000000000000001" + nameStatus ct alice `shouldReturn` Right NSReserved + +testRegistered :: TVar ChaChaDRG -> IO () +testRegistered g = withContracts g $ \node ct -> do + answerRegistrar node + answerRegistered node (resolverOf node) + setBlock node 100 now + answerCall node (registrarOf node) nameExpiresCall $ Returns $ uint256 (now + year) + nameStatus ct alice `shouldReturn` Right (NSRegistered $ registeredAlice (resolverOf node) (now + year)) + +testGracePeriod :: TVar ChaChaDRG -> IO () +testGracePeriod g = withContracts g $ \node ct -> do + answerRegistrar node + answerRegistered node (resolverOf node) + answerCall node fakeController reservedNamesCall $ Returns $ uint256 (0 :: Int) + let expired = now - year + answerCall node (registrarOf node) nameExpiresCall $ Returns $ uint256 expired + setBlock node 100 $ expired + gracePeriod + nameStatus ct alice `shouldReturn` Right (NSRegistered $ registeredAlice (resolverOf node) expired) + setBlock node 101 $ expired + gracePeriod + 1 + nameStatus ct alice `shouldReturn` Right NSAvailable + +testRegistrarOnce :: TVar ChaChaDRG -> IO () +testRegistrarOnce g = withContracts g $ \node ct -> do + setBlock node 100 now + answerCall node (registrarOf node) nameExpiresCall $ Returns $ uint256 (0 :: Int) + answerCall node fakeController reservedNamesCall $ Returns $ uint256 (0 :: Int) + nameStatus ct alice `shouldReturn` Left (ChainUnavailable "eth_call: error -32000: no fake answer for this call") + answerRegistrar node + nameStatus ct alice `shouldReturn` Right NSAvailable + answerCall node (registrarOf node) ensCall $ Reverts "" + answerCall node (registrarOf node) gracePeriodCall $ Reverts "" + nameStatus ct alice `shouldReturn` Right NSAvailable + +testRecords :: TVar ChaChaDRG -> IO () +testRecords g = withContracts g $ \node ct -> do + let resolver = resolverOf node + answerCall node resolver contactTextCall $ Returns contactTextAnswer + -- an unset record is the empty string + answerCall node resolver channelTextCall $ Returns $ abiTuple [Dynamic $ abiBytes ""] + answerCall node resolver noncesCall $ Returns $ uint256 (3 :: Int) + nameRecords ct alice (registeredAlice resolver $ now + year) + `shouldReturn` Right (Just NameRecords {rdContact = "https://smp8.simplex.im/a#Y8m3", rdChannel = "", rdNonce = 3}) + +testOtherResolver :: TVar ChaChaDRG -> IO () +testOtherResolver g = withContracts g $ \_ ct -> do + nameRecords ct alice (registeredAlice otherResolver $ now + year) `shouldReturn` Right Nothing + +testLaunchPrices :: TVar ChaChaDRG -> IO () +testLaunchPrices g = withContracts g $ \node ct -> do + -- viem: rentPriceUSD answering {base: 40000 * 10^16, premium: 0} + answerCall node fakeController (rentPriceCall 6) $ Returns $ unhex "000000000000000000000000000000000000000000000015af1d78b58c4000000000000000000000000000000000000000000000000000000000000000000000" + answerCall node fakeController (rentPriceCall 7) $ Returns $ rentPriceAnswer 20000 + answerCall node fakeController (rentPriceCall 12) $ Returns $ rentPriceAnswer 10000 + mapM (\len -> chainPriceCents ct len 2) [6, 7, 12] `shouldReturn` [Right 40000, Right 20000, Right 10000] + +testLaunchCodeLengths :: TVar ChaChaDRG -> IO () +testLaunchCodeLengths g = withContracts g $ \node ct -> do + answerCall node fakeController pricesCall $ Returns $ addressWord oracle + answerCall node oracle pricesCall $ Returns launchPricesAnswer + mapM (\cents -> codeLengthFor ct cents 2) [40000, 20000, 10000, 9999] + `shouldReturn` [Right (Just 6), Right (Just 7), Right (Just 8), Right Nothing] + codeLengthFor ct 20000 4 `shouldReturn` Right (Just 8) + +testNonMonotone :: TVar ChaChaDRG -> IO () +testNonMonotone g = withContracts g $ \node ct -> do + answerCall node fakeController pricesCall $ Returns $ addressWord oracle + answerCall node oracle pricesCall $ Returns $ pricesAnswer 5000 [(6, 15000), (7, 20000)] + mapM (\cents -> codeLengthFor ct cents 2) [30000, 40000] `shouldReturn` [Right (Just 8), Right (Just 6)] + answerCall node oracle pricesCall $ Returns $ pricesAnswer 5000 [(63, 30000)] + mapM (\cents -> codeLengthFor ct cents 2) [59999, 60000] `shouldReturn` [Right Nothing, Right (Just 6)] + +testOracleReplaced :: TVar ChaChaDRG -> IO () +testOracleReplaced g = withContracts g $ \node ct -> do + let newOracle = address "0x8a791620dd6260079bf849dc5567adc3f2fdc318" + answerCall node fakeController pricesCall $ Returns $ addressWord oracle + answerCall node oracle pricesCall $ Returns launchPricesAnswer + answerCall node newOracle pricesCall $ Returns $ pricesAnswer 10000 [] + codeLengthFor ct 20000 2 `shouldReturn` Right (Just 7) + answerCall node fakeController pricesCall $ Returns $ addressWord newOracle + codeLengthFor ct 20000 2 `shouldReturn` Right (Just 6) + +testNonces :: TVar ChaChaDRG -> IO () +testNonces g = withContracts g $ \node ct -> do + answerCall node fakeSponsor noncesCall $ Returns $ uint256 (5 :: Int) + answerCall node (resolverOf node) noncesCall $ Returns $ uint256 (3 :: Int) + sponsorNonce ct owner `shouldReturn` Right 5 + resolverNonce ct owner `shouldReturn` Right 3 + +testOwnerOf :: TVar ChaChaDRG -> IO () +testOwnerOf g = withContracts g $ \node ct -> do + answerCall node (registrarOf node) ownerOfCall $ Returns $ addressWord owner + ownerOfToken ct alice `shouldReturn` Right (Just owner) + answerCall node (registrarOf node) ownerOfCall $ Reverts "" + ownerOfToken ct alice `shouldReturn` Right Nothing + setFault node "eth_call" HttpFailure + ownerOfToken ct alice `shouldReturn` Left (ChainUnavailable "eth_call: HTTP 502") + +testCommitmentTime :: TVar ChaChaDRG -> IO () +testCommitmentTime g = withContracts g $ \node ct -> do + answerCall node fakeController commitmentsCall $ Returns $ uint256 (0 :: Int) + commitmentTime ct commitment `shouldReturn` Right Nothing + answerCall node fakeController commitmentsCall $ Returns $ uint256 now + commitmentTime ct commitment `shouldReturn` Right (Just now) + +testAvailability :: TVar ChaChaDRG -> IO () +testAvailability g = withContracts g $ \node ct -> do + answerCall node fakeController availableCall $ Returns $ uint256 (1 :: Int) + controllerAvailable ct "alicebakery" `shouldReturn` Right True + answerCall node fakeController availableCall $ Returns $ uint256 (0 :: Int) + controllerAvailable ct "alicebakery" `shouldReturn` Right False + answerCall node (registrarOf node) nameExpiresCall $ Returns $ uint256 (now + year) + nameExpires ct alice `shouldReturn` Right (now + year) + +testCommitmentAges :: TVar ChaChaDRG -> IO () +testCommitmentAges g = withContracts g $ \node ct -> do + answerCall node fakeController minCommitmentAgeCall $ Returns $ uint256 (60 :: Int) + answerCall node fakeController maxCommitmentAgeCall $ Returns $ uint256 (86400 :: Int) + commitmentAges ct `shouldReturn` Right CommitmentAges {caMin = 60, caMax = 86400} + answerCall node fakeController maxCommitmentAgeCall $ Returns $ uint256 (3600 :: Int) + commitmentAges ct `shouldReturn` Right CommitmentAges {caMin = 60, caMax = 86400} + +testFailedRead :: TVar ChaChaDRG -> IO () +testFailedRead g = withContracts g $ \node ct -> do + answerRegistrar node + setBlock node 100 now + answerCall node fakeController reservedNamesCall $ Returns $ uint256 (0 :: Int) + answerCall node (registrarOf node) nameExpiresCall $ Reverts "" + nameStatus ct alice `shouldReturn` Left (ChainReverted Nothing) + answerCall node (registrarOf node) nameExpiresCall $ Returns $ uint256 (0 :: Int) + setFault node "eth_getBlockByNumber" HttpFailure + nameStatus ct alice `shouldReturn` Left (ChainUnavailable "eth_getBlockByNumber: HTTP 502") + clearFault node "eth_getBlockByNumber" + answerCall node fakeController reservedNamesCall $ Reverts "" + nameStatus ct alice `shouldReturn` Left (ChainReverted Nothing) + +testMalformed :: TVar ChaChaDRG -> IO () +testMalformed g = withContracts g $ \node ct -> do + answerCall node (registrarOf node) nameExpiresCall $ Returns $ B.replicate 31 0 + nameExpires ct alice `shouldReturn` Left (ChainUnavailable "eth_call: nameExpires(uint256) answered AEShort") + answerCall node fakeSponsor noncesCall $ Returns $ uint256 (toInteger (maxBound :: Word64) + 1) + sponsorNonce ct owner `shouldReturn` Left (ChainUnavailable "eth_call: nonces(address) answered AERange") + answerCall node fakeController pricesCall $ Returns $ addressWord oracle + answerCall node oracle pricesCall $ Returns $ B.dropEnd 32 launchPricesAnswer + codeLengthFor ct 40000 2 `shouldReturn` Left (ChainUnavailable "eth_call: prices() answered AEShort") + +-- fixtures + +withContracts :: TVar ChaChaDRG -> (FakeEthNode -> Contracts -> IO a) -> IO a +withContracts g action = withFakeEthNode $ \node -> do + ct <- newContracts TLDSimplex =<< newChainClient g (fakeChainConfig node) + action node ct + +answerRegistrar :: FakeEthNode -> IO () +answerRegistrar node = do + answerCall node (registrarOf node) ensCall $ Returns $ addressWord registry + answerCall node (registrarOf node) gracePeriodCall $ Returns $ uint256 gracePeriod + +answerRegistered :: FakeEthNode -> Address -> IO () +answerRegistered node resolver = do + answerCall node registry registryOwnerCall $ Returns $ addressWord owner + answerCall node registry registryResolverCall $ Returns $ addressWord resolver + +registeredAlice :: Address -> Word64 -> RegisteredName +registeredAlice rnResolver rnExpiresAt = RegisteredName {rnOwner = owner, rnResolver, rnExpiresAt, rnGracePeriod = toInteger gracePeriod} + +registrarOf, resolverOf :: FakeEthNode -> Address +registrarOf = cBaseRegistrar . fakeChainConfig +resolverOf = cResolver . fakeChainConfig + +rentPriceAnswer :: Integer -> ByteString +rentPriceAnswer cents = uint256 (cents * 10 ^ (16 :: Int)) <> uint256 (0 :: Int) + +pricesAnswer :: Integer -> [(Integer, Integer)] -> ByteString +pricesAnswer base exceptions = abiTuple [Static $ uint256 base, Dynamic $ uint256 (length exceptions) <> B.concat [uint256 len <> uint256 cents | (len, cents) <- exceptions]] + +-- viem: prices() answering the launch list, base 5000 and [{7, 10000}, {6, 20000}] +launchPricesAnswer :: ByteString +launchPricesAnswer = unhex "0000000000000000000000000000000000000000000000000000000000001388000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000007000000000000000000000000000000000000000000000000000000000000271000000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000004e20" + +-- viem: text answering "https://smp8.simplex.im/a#Y8m3" +contactTextAnswer :: ByteString +contactTextAnswer = unhex "0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001e68747470733a2f2f736d70382e73696d706c65782e696d2f612359386d330000" + +-- 2027-01-01, the term start +now :: Word64 +now = 1798761600 + +year, gracePeriod :: Word64 +year = 31536000 +gracePeriod = 7776000 + +alice :: LabelHash +alice = labelHash "alicebakery" + +-- viem: namehash("alicebakery.simplex") +aliceNode :: ByteString +aliceNode = unhex "0f4104919a21c22f566d9c7947c7c7d8addbf546c973851a7e449276ea54ec1a" + +commitment :: ByteString +commitment = unhex "f8096c3f3cfbadc9f3a108d2c586ccc816c0510711c8d6228aa4fa507324d30c" + +owner, registry, oracle, otherResolver :: Address +owner = address "0x014f9c09cceedef1b713110d04d3a07d5b5d9b73" +registry = address "0x5fbdb2315678afecb367f032d93f642f64180aa3" +oracle = address "0xa513e6e4b8f2a923d98304ec87f64353c4d5c853" +otherResolver = address "0x4826533b4897376654bb4d4ad88b7fafd0c98528" + +-- viem's calldata for the reads + +ensCall, gracePeriodCall, nameExpiresCall, ownerOfCall, reservedNamesCall, registryOwnerCall, registryResolverCall :: ByteString +ensCall = unhex "3f15457f" +gracePeriodCall = unhex "c1a287e2" +nameExpiresCall = unhex "d6e4fa86bcc46b7948ff6be55bc3ff4db5bc4be16638f29e818a89244dae11caaed7c9c6" +ownerOfCall = unhex "6352211ebcc46b7948ff6be55bc3ff4db5bc4be16638f29e818a89244dae11caaed7c9c6" +reservedNamesCall = unhex "389c0748bcc46b7948ff6be55bc3ff4db5bc4be16638f29e818a89244dae11caaed7c9c6" +registryOwnerCall = unhex "02571be30f4104919a21c22f566d9c7947c7c7d8addbf546c973851a7e449276ea54ec1a" +registryResolverCall = unhex "0178b8bf0f4104919a21c22f566d9c7947c7c7d8addbf546c973851a7e449276ea54ec1a" + +contactTextCall, channelTextCall, noncesCall :: ByteString +contactTextCall = unhex "59d1d43c0f4104919a21c22f566d9c7947c7c7d8addbf546c973851a7e449276ea54ec1a0000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000f73696d706c65782e636f6e746163740000000000000000000000000000000000" +channelTextCall = unhex "59d1d43c0f4104919a21c22f566d9c7947c7c7d8addbf546c973851a7e449276ea54ec1a0000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000f73696d706c65782e6368616e6e656c0000000000000000000000000000000000" +noncesCall = unhex "7ecebe00000000000000000000000000014f9c09cceedef1b713110d04d3a07d5b5d9b73" + +-- | rentPriceUSD("a" × length, 2 years) +rentPriceCall :: Int -> ByteString +rentPriceCall = \case + 6 -> unhex "9ff7ec0900000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000003c2670000000000000000000000000000000000000000000000000000000000000000066161616161610000000000000000000000000000000000000000000000000000" + 7 -> unhex "9ff7ec0900000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000003c2670000000000000000000000000000000000000000000000000000000000000000076161616161616100000000000000000000000000000000000000000000000000" + 12 -> unhex "9ff7ec0900000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000003c26700000000000000000000000000000000000000000000000000000000000000000c6161616161616161616161610000000000000000000000000000000000000000" + len -> error $ "no viem vector for length " <> show len + +pricesCall, commitmentsCall, availableCall, minCommitmentAgeCall, maxCommitmentAgeCall :: ByteString +pricesCall = unhex "d3419bf3" +commitmentsCall = unhex "839df945f8096c3f3cfbadc9f3a108d2c586ccc816c0510711c8d6228aa4fa507324d30c" +availableCall = unhex "aeb8ce9b0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000b616c69636562616b657279000000000000000000000000000000000000000000" +minCommitmentAgeCall = unhex "8d839ffe" +maxCommitmentAgeCall = unhex "ce1e09c0" + +unhex :: ByteString -> ByteString +unhex = either error id . convertFromBase Base16 + +address :: ByteString -> Address +address = either error id . strDecode diff --git a/tests/Test.hs b/tests/Test.hs index e2622cba97..ff5b85e87a 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -11,6 +11,7 @@ import Bots.StoreService.GroupIntegrationTests import Bots.StoreService.GroupTests import Bots.StoreService.NameRegistrationTests import Bots.StoreService.NamesChainTests +import Bots.StoreService.NamesContractsTests import Bots.StoreService.NamesLabelTests import Bots.StoreService.NamesLinksTests import Bots.StoreService.NamesProfilesTests @@ -95,6 +96,7 @@ main = do badgeNameRegistrationTests namesConfigTests namesChainTests + namesContractsTests namesLabelTests namesLinksTests namesProfilesTests From 1a96a57b1b11a66ff4e838bffce829d5d52a822d Mon Sep 17 00:00:00 2001 From: shum Date: Thu, 22 Oct 2026 00:33:58 +0000 Subject: [PATCH 4/5] store: read only a dataless revert as no owner --- .../src/StoreService/Names/Chain.hs | 6 +- .../src/StoreService/Names/Contracts.hs | 6 +- .../src/StoreService/Names/Revert.hs | 98 ++++++++++--------- tests/Bots/StoreService/NamesChainTests.hs | 9 +- .../Bots/StoreService/NamesContractsTests.hs | 24 ++++- 5 files changed, 83 insertions(+), 60 deletions(-) diff --git a/apps/simplex-store-service/src/StoreService/Names/Chain.hs b/apps/simplex-store-service/src/StoreService/Names/Chain.hs index 0cbbd395e2..695f4dd686 100644 --- a/apps/simplex-store-service/src/StoreService/Names/Chain.hs +++ b/apps/simplex-store-service/src/StoreService/Names/Chain.hs @@ -30,7 +30,7 @@ where import Control.Concurrent.STM (TVar) import Control.Exception (evaluate, try) -import Control.Monad ((>=>)) +import Control.Monad ((<=<), (>=>)) import Crypto.Random (ChaChaDRG) import Data.Aeson ((.:), (.:?), (.=)) import qualified Data.Aeson as J @@ -55,7 +55,7 @@ import Simplex.Messaging.Util (tshow) import StoreService.Config (ChainConfig (..)) import StoreService.Log (logWarn) import StoreService.Names.Abi (hex) -import StoreService.Names.Revert (RevertName, decodeRevert) +import StoreService.Names.Revert (RevertName (..), decodeRevert) import System.Timeout (timeout) data ChainClient = ChainClient @@ -227,7 +227,7 @@ rpc ChainClient {ccManager, ccConfig = ChainConfig {cRpcUrl}, ccTimeout} method revertData <- e .:? "data" pure $ if code == revertCode || "execution reverted" `T.isPrefixOf` message - then ChainReverted $ decodeRevert =<< (JT.parseMaybe bytes =<< revertData) + then ChainReverted $ maybe (Just RNWithoutData) (decodeRevert <=< JT.parseMaybe bytes) revertData else unavailable $ "error " <> tshow code <> ": " <> T.take maxNodeMessageLength message unavailable reason = ChainUnavailable $ method <> ": " <> reason diff --git a/apps/simplex-store-service/src/StoreService/Names/Contracts.hs b/apps/simplex-store-service/src/StoreService/Names/Contracts.hs index 67e20571e9..38c9733855 100644 --- a/apps/simplex-store-service/src/StoreService/Names/Contracts.hs +++ b/apps/simplex-store-service/src/StoreService/Names/Contracts.hs @@ -41,6 +41,7 @@ import StoreService.Config (ChainConfig (..)) import StoreService.Names.Abi import StoreService.Names.Chain (Block (..), ChainClient (..), ChainError (..), ethCall, latestBlock) import StoreService.Names.Registration (channelRecordKey, contactRecordKey, labelNode, nodeBytes) +import StoreService.Names.Revert (RevertName (..)) data Contracts = Contracts { ctChain :: ChainClient, @@ -138,11 +139,12 @@ sponsorNonce ct owner = call ct (cSponsor $ chainConfig ct) ("nonces(address)", resolverNonce :: Contracts -> Address -> IO (Either ChainError Word64) resolverNonce ct owner = call ct (resolver ct) ("nonces(address)", addressWord owner) decodeWord64 --- | Nothing when nobody holds the name: @ownerOf@ reverts for it, and that revert is the answer. +-- | Nothing when nobody holds the name: @ownerOf@ then fails the registrar's bare @require@ on the expiry, +-- a revert without data. Any other revert is a failed read. ownerOfToken :: Contracts -> LabelHash -> IO (Either ChainError (Maybe Address)) ownerOfToken ct lh = call ct (baseRegistrar ct) ("ownerOf(uint256)", labelHashArg lh) decodeAddress >>= \case - Left (ChainReverted _) -> pure $ Right Nothing + Left (ChainReverted (Just RNWithoutData)) -> pure $ Right Nothing r -> pure $ Just <$> r nameExpires :: Contracts -> LabelHash -> IO (Either ChainError Word64) diff --git a/apps/simplex-store-service/src/StoreService/Names/Revert.hs b/apps/simplex-store-service/src/StoreService/Names/Revert.hs index e9a0a00bf0..fc6acd6bd5 100644 --- a/apps/simplex-store-service/src/StoreService/Names/Revert.hs +++ b/apps/simplex-store-service/src/StoreService/Names/Revert.hs @@ -55,59 +55,65 @@ data RevertName | RNTransferToSelf | RNUnexpiredCommitmentExists | RNZeroAddress + | -- | a revert with empty or no data, as a bare @require@ or @revert()@ gives + RNWithoutData deriving (Eq, Show, Enum, Bounded) -revertSignature :: RevertName -> ByteString +-- | Nothing for a revert without data, which has no selector. +revertSignature :: RevertName -> Maybe ByteString revertSignature = \case - RNAlreadyFrozen -> "AlreadyFrozen()" - RNCommitmentNotFound -> "CommitmentNotFound(bytes32)" - RNCommitmentTooNew -> "CommitmentTooNew(bytes32,uint256,uint256)" - RNCommitmentTooOld -> "CommitmentTooOld(bytes32,uint256,uint256)" - RNDurationTooShort -> "DurationTooShort(uint256)" - RNFrozen -> "Frozen()" - RNInsufficientAllowance -> "InsufficientAllowance(uint256,uint256)" - RNInsufficientValue -> "InsufficientValue()" - RNInvalidEVMAddress -> "InvalidEVMAddress(bytes)" - RNInvalidNonce -> "InvalidNonce()" - RNInvalidSignature -> "InvalidSignature()" - RNLabelTooLong -> "LabelTooLong(uint256,uint256)" - RNMaxCommitmentAgeTooHigh -> "MaxCommitmentAgeTooHigh()" - RNMaxCommitmentAgeTooLow -> "MaxCommitmentAgeTooLow()" - RNMinCharLengthCanOnlyDecrease -> "MinCharLengthCanOnlyDecrease()" - RNMinCharLengthZero -> "MinCharLengthZero()" - RNMinCommitmentAgeZero -> "MinCommitmentAgeZero()" - RNNameNotAvailable -> "NameNotAvailable(string)" - RNNameNotReserved -> "NameNotReserved(string)" - RNNameReserved -> "NameReserved(string)" - RNNameTooShort -> "NameTooShort(string,uint8)" - RNNftGateCanOnlyBeDisabled -> "NftGateCanOnlyBeDisabled()" - RNNftRequired -> "NftRequired()" - RNNodeNotOwnedByRegistrar -> "NodeNotOwnedByRegistrar()" - RNNotBeneficiary -> "NotBeneficiary()" - RNNotNameOwner -> "NotNameOwner()" - RNNotOffered -> "NotOffered()" - RNNotOwnerOrBeneficiary -> "NotOwnerOrBeneficiary()" - RNNotSponsor -> "NotSponsor()" - RNNotSubnameRegistrar -> "NotSubnameRegistrar()" - RNOffsetOutOfBoundsError -> "OffsetOutOfBoundsError(uint256,uint256)" - RNOwnerMustBeCaller -> "OwnerMustBeCaller()" - RNPublicSalesClosed -> "PublicSalesClosed()" - RNReasonRequired -> "ReasonRequired()" - RNRecipientMustBeCaller -> "RecipientMustBeCaller()" - RNReentrantCall -> "ReentrantCall()" - RNResolverRequiredWhenDataSupplied -> "ResolverRequiredWhenDataSupplied()" - RNReverseRecordNotSupported -> "ReverseRecordNotSupported()" - RNSignatureExpired -> "SignatureExpired()" - RNTransferFailed -> "TransferFailed()" - RNTransferToSelf -> "TransferToSelf()" - RNUnexpiredCommitmentExists -> "UnexpiredCommitmentExists(bytes32)" - RNZeroAddress -> "ZeroAddress()" + RNAlreadyFrozen -> Just "AlreadyFrozen()" + RNCommitmentNotFound -> Just "CommitmentNotFound(bytes32)" + RNCommitmentTooNew -> Just "CommitmentTooNew(bytes32,uint256,uint256)" + RNCommitmentTooOld -> Just "CommitmentTooOld(bytes32,uint256,uint256)" + RNDurationTooShort -> Just "DurationTooShort(uint256)" + RNFrozen -> Just "Frozen()" + RNInsufficientAllowance -> Just "InsufficientAllowance(uint256,uint256)" + RNInsufficientValue -> Just "InsufficientValue()" + RNInvalidEVMAddress -> Just "InvalidEVMAddress(bytes)" + RNInvalidNonce -> Just "InvalidNonce()" + RNInvalidSignature -> Just "InvalidSignature()" + RNLabelTooLong -> Just "LabelTooLong(uint256,uint256)" + RNMaxCommitmentAgeTooHigh -> Just "MaxCommitmentAgeTooHigh()" + RNMaxCommitmentAgeTooLow -> Just "MaxCommitmentAgeTooLow()" + RNMinCharLengthCanOnlyDecrease -> Just "MinCharLengthCanOnlyDecrease()" + RNMinCharLengthZero -> Just "MinCharLengthZero()" + RNMinCommitmentAgeZero -> Just "MinCommitmentAgeZero()" + RNNameNotAvailable -> Just "NameNotAvailable(string)" + RNNameNotReserved -> Just "NameNotReserved(string)" + RNNameReserved -> Just "NameReserved(string)" + RNNameTooShort -> Just "NameTooShort(string,uint8)" + RNNftGateCanOnlyBeDisabled -> Just "NftGateCanOnlyBeDisabled()" + RNNftRequired -> Just "NftRequired()" + RNNodeNotOwnedByRegistrar -> Just "NodeNotOwnedByRegistrar()" + RNNotBeneficiary -> Just "NotBeneficiary()" + RNNotNameOwner -> Just "NotNameOwner()" + RNNotOffered -> Just "NotOffered()" + RNNotOwnerOrBeneficiary -> Just "NotOwnerOrBeneficiary()" + RNNotSponsor -> Just "NotSponsor()" + RNNotSubnameRegistrar -> Just "NotSubnameRegistrar()" + RNOffsetOutOfBoundsError -> Just "OffsetOutOfBoundsError(uint256,uint256)" + RNOwnerMustBeCaller -> Just "OwnerMustBeCaller()" + RNPublicSalesClosed -> Just "PublicSalesClosed()" + RNReasonRequired -> Just "ReasonRequired()" + RNRecipientMustBeCaller -> Just "RecipientMustBeCaller()" + RNReentrantCall -> Just "ReentrantCall()" + RNResolverRequiredWhenDataSupplied -> Just "ResolverRequiredWhenDataSupplied()" + RNReverseRecordNotSupported -> Just "ReverseRecordNotSupported()" + RNSignatureExpired -> Just "SignatureExpired()" + RNTransferFailed -> Just "TransferFailed()" + RNTransferToSelf -> Just "TransferToSelf()" + RNUnexpiredCommitmentExists -> Just "UnexpiredCommitmentExists(bytes32)" + RNZeroAddress -> Just "ZeroAddress()" + RNWithoutData -> Nothing revertSelectors :: Map ByteString RevertName -revertSelectors = M.fromList [(selector $ revertSignature n, n) | n <- [minBound .. maxBound]] +revertSelectors = M.fromList [(selector signature, n) | n <- [minBound .. maxBound], Just signature <- [revertSignature n]] -- | The error a revert's data names by its selector, if it is one of the contracts' errors. decodeRevert :: ByteString -> Maybe RevertName -decodeRevert revertData = M.lookup (B.take selectorSize revertData) revertSelectors +decodeRevert revertData + | B.null revertData = Just RNWithoutData + | otherwise = M.lookup (B.take selectorSize revertData) revertSelectors where selectorSize = 4 diff --git a/tests/Bots/StoreService/NamesChainTests.hs b/tests/Bots/StoreService/NamesChainTests.hs index 899a91b80e..3a3c1f9a54 100644 --- a/tests/Bots/StoreService/NamesChainTests.hs +++ b/tests/Bots/StoreService/NamesChainTests.hs @@ -31,7 +31,7 @@ namesChainTests = describe "StoreService names: chain" $ do it "reads a dry run's revert with data by its name" $ testDryRunRevertNamed g it "reads an eth_call revert with data by its name" $ testCallRevertNamed g it "reads ownerOf of a name nobody holds as a revert" $ testOwnerOfRevert g - it "reads a revert without code 3 or data as a revert" $ testRevertWithoutData g + it "reads a revert without code 3 or data as a revert without data" $ testRevertWithoutData g it "reads a revert of an unknown error as an unnamed revert" $ testUnknownRevert g describe "failures" $ do it "reads an RPC error as unavailable, without the URL" $ testRpcError g @@ -101,9 +101,10 @@ viemSelectors = testRevertSelectors :: IO () testRevertSelectors = do - map snd viemSelectors `shouldBe` [minBound .. maxBound] + map snd viemSelectors `shouldBe` filter (/= RNWithoutData) [minBound .. maxBound] [(n, decodeRevert $ unhex sel <> uint256 (0 :: Int)) | (sel, n) <- viemSelectors] `shouldBe` [(n, Just n) | (_, n) <- viemSelectors] decodeRevert (B.take 3 $ unhex "74480cc9") `shouldBe` Nothing + decodeRevert "" `shouldBe` Just RNWithoutData testDryRunRevertNamed :: TVar ChaChaDRG -> IO () testDryRunRevertNamed g = withChain g $ \node c -> do @@ -120,12 +121,12 @@ testOwnerOfRevert :: TVar ChaChaDRG -> IO () testOwnerOfRevert g = withChain g $ \node c@ChainClient {ccConfig = ChainConfig {cBaseRegistrar}} -> do let ownerOf = selector "ownerOf(uint256)" <> C.keccak256 "bakery1" answerCall node cBaseRegistrar ownerOf $ Reverts "" - ethCall c cBaseRegistrar ownerOf `shouldReturn` Left (ChainReverted Nothing) + ethCall c cBaseRegistrar ownerOf `shouldReturn` Left (ChainReverted $ Just RNWithoutData) testRevertWithoutData :: TVar ChaChaDRG -> IO () testRevertWithoutData g = withChain g $ \node c -> do setFault node "eth_call" $ RpcError (-32000) "execution reverted" Nothing - ethCall c fakeController availableCall `shouldReturn` Left (ChainReverted Nothing) + ethCall c fakeController availableCall `shouldReturn` Left (ChainReverted $ Just RNWithoutData) testUnknownRevert :: TVar ChaChaDRG -> IO () testUnknownRevert g = withChain g $ \node c -> do diff --git a/tests/Bots/StoreService/NamesContractsTests.hs b/tests/Bots/StoreService/NamesContractsTests.hs index ca1b426cff..a788e87249 100644 --- a/tests/Bots/StoreService/NamesContractsTests.hs +++ b/tests/Bots/StoreService/NamesContractsTests.hs @@ -20,6 +20,7 @@ import StoreService.Names.Abi import StoreService.Names.Chain import StoreService.Names.Contracts import StoreService.Names.Registration (labelNode, namehash, nodeBytes) +import StoreService.Names.Revert (RevertName (..)) import Test.Hspec namesContractsTests :: Spec @@ -44,7 +45,8 @@ namesContractsTests = describe "StoreService names: contracts" $ do it "reads the oracle from the controller on each call" $ testOracleReplaced g describe "other reads" $ do it "reads the sponsor's and the resolver's nonces from their own contracts" $ testNonces g - it "reads ownerOf's revert as nobody, and a failed read as a failure" $ testOwnerOf g + it "reads ownerOf's revert without data as nobody" $ testOwnerOfNobody g + it "reads any other ownerOf revert or failure as a failed read" $ testOwnerOfFailed g it "reads a commitment's time, none as Nothing" $ testCommitmentTime g it "reads the controller's availability and the name's expiry" $ testAvailability g it "reads the commitment ages once" $ testCommitmentAges g @@ -161,12 +163,24 @@ testNonces g = withContracts g $ \node ct -> do sponsorNonce ct owner `shouldReturn` Right 5 resolverNonce ct owner `shouldReturn` Right 3 -testOwnerOf :: TVar ChaChaDRG -> IO () -testOwnerOf g = withContracts g $ \node ct -> do +testOwnerOfNobody :: TVar ChaChaDRG -> IO () +testOwnerOfNobody g = withContracts g $ \node ct -> do answerCall node (registrarOf node) ownerOfCall $ Returns $ addressWord owner ownerOfToken ct alice `shouldReturn` Right (Just owner) + -- the registrar's require(expiries[tokenId] > block.timestamp), as anvil answers it with "0x" and reth without data answerCall node (registrarOf node) ownerOfCall $ Reverts "" ownerOfToken ct alice `shouldReturn` Right Nothing + setFault node "eth_call" $ RpcError 3 "execution reverted" Nothing + ownerOfToken ct alice `shouldReturn` Right Nothing + +testOwnerOfFailed :: TVar ChaChaDRG -> IO () +testOwnerOfFailed g = withContracts g $ \node ct -> do + -- viem's encodeErrorResult of Error("ERC721: invalid token ID") + answerCall node (registrarOf node) ownerOfCall $ Reverts $ unhex "08c379a0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000184552433732313a20696e76616c696420746f6b656e2049440000000000000000" + ownerOfToken ct alice `shouldReturn` Left (ChainReverted Nothing) + -- viem's encodeErrorResult of ReentrantCall() + answerCall node (registrarOf node) ownerOfCall $ Reverts $ unhex "37ed32e8" + ownerOfToken ct alice `shouldReturn` Left (ChainReverted $ Just RNReentrantCall) setFault node "eth_call" HttpFailure ownerOfToken ct alice `shouldReturn` Left (ChainUnavailable "eth_call: HTTP 502") @@ -200,13 +214,13 @@ testFailedRead g = withContracts g $ \node ct -> do setBlock node 100 now answerCall node fakeController reservedNamesCall $ Returns $ uint256 (0 :: Int) answerCall node (registrarOf node) nameExpiresCall $ Reverts "" - nameStatus ct alice `shouldReturn` Left (ChainReverted Nothing) + nameStatus ct alice `shouldReturn` Left (ChainReverted $ Just RNWithoutData) answerCall node (registrarOf node) nameExpiresCall $ Returns $ uint256 (0 :: Int) setFault node "eth_getBlockByNumber" HttpFailure nameStatus ct alice `shouldReturn` Left (ChainUnavailable "eth_getBlockByNumber: HTTP 502") clearFault node "eth_getBlockByNumber" answerCall node fakeController reservedNamesCall $ Reverts "" - nameStatus ct alice `shouldReturn` Left (ChainReverted Nothing) + nameStatus ct alice `shouldReturn` Left (ChainReverted $ Just RNWithoutData) testMalformed :: TVar ChaChaDRG -> IO () testMalformed g = withContracts g $ \node ct -> do From 119e1dc74d989bc018d835be166c5c58cb4d90f1 Mon Sep 17 00:00:00 2001 From: shum Date: Thu, 22 Oct 2026 00:35:40 +0000 Subject: [PATCH 5/5] store: bound a name's reads by one deadline --- .../src/StoreService/Names/Contracts.hs | 50 +++++++++++++------ simplex-chat.cabal | 1 + .../Bots/StoreService/NamesContractsTests.hs | 30 +++++++++++ 3 files changed, 66 insertions(+), 15 deletions(-) diff --git a/apps/simplex-store-service/src/StoreService/Names/Contracts.hs b/apps/simplex-store-service/src/StoreService/Names/Contracts.hs index 38c9733855..0102b8c8a0 100644 --- a/apps/simplex-store-service/src/StoreService/Names/Contracts.hs +++ b/apps/simplex-store-service/src/StoreService/Names/Contracts.hs @@ -3,7 +3,7 @@ {-# LANGUAGE OverloadedStrings #-} module StoreService.Names.Contracts - ( Contracts, + ( Contracts (ctDeadline), NameStatus (..), RegisteredName (..), NameRecords (..), @@ -24,12 +24,15 @@ module StoreService.Names.Contracts ) where +import Control.Concurrent.Async (Concurrently (..)) import Control.Monad.Except (ExceptT (..), runExceptT) import Data.Bifunctor (first) import Data.ByteString (ByteString) import qualified Data.ByteString.Char8 as B import Data.IORef (IORef, atomicWriteIORef, newIORef, readIORef) +import Data.Functor.Compose (Compose (..)) import qualified Data.Map.Strict as M +import Data.Maybe (fromMaybe) import Data.Text (Text) import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Word (Word64) @@ -42,12 +45,15 @@ import StoreService.Names.Abi import StoreService.Names.Chain (Block (..), ChainClient (..), ChainError (..), ethCall, latestBlock) import StoreService.Names.Registration (channelRecordKey, contactRecordKey, labelNode, nodeBytes) import StoreService.Names.Revert (RevertName (..)) +import System.Timeout (timeout) data Contracts = Contracts { ctChain :: ChainClient, ctTLD :: SimplexTLD, ctRegistrar :: IORef (Maybe RegistrarConstants), - ctCommitmentAges :: IORef (Maybe CommitmentAges) + ctCommitmentAges :: IORef (Maybe CommitmentAges), + -- | microseconds for all the reads of one name status or of one name's records + ctDeadline :: Int } data RegistrarConstants = RegistrarConstants {rcRegistry :: Address, rcGracePeriod :: Integer} @@ -76,24 +82,27 @@ secondsPerYear = 31536000 attoUsdPerCent :: Integer attoUsdPerCent = 10 ^ (16 :: Int) +-- | One call's timeout, so a node that hangs fails a status or a records read once, not once per read. +nameReadsDeadline :: Int +nameReadsDeadline = 10000000 + -- | Reads nothing: the registrar's constants and the commitment ages are read on first use and kept once read. newContracts :: SimplexTLD -> ChainClient -> IO Contracts newContracts ctTLD ctChain = do ctRegistrar <- newIORef Nothing ctCommitmentAges <- newIORef Nothing - pure Contracts {ctChain, ctTLD, ctRegistrar, ctCommitmentAges} + pure Contracts {ctChain, ctTLD, ctRegistrar, ctCommitmentAges, ctDeadline = nameReadsDeadline} -- | Registered while @nameExpires + GRACE_PERIOD@ is not before the latest block's time. nameStatus :: Contracts -> LabelHash -> IO (Either ChainError NameStatus) -nameStatus ct lh = runExceptT $ do - RegistrarConstants {rcRegistry, rcGracePeriod} <- ExceptT $ registrarConstants ct - expires <- ExceptT $ nameExpires ct lh - Block {blockTimestamp} <- ExceptT $ latestBlock $ ctChain ct +nameStatus ct@Contracts {ctChain} lh = withinDeadline ct "name status" $ runExceptT $ do + (RegistrarConstants {rcRegistry, rcGracePeriod}, expires, Block {blockTimestamp}) <- + ExceptT $ together $ (,,) <$> concurrent (registrarConstants ct) <*> concurrent (nameExpires ct lh) <*> concurrent (latestBlock ctChain) if toInteger expires + rcGracePeriod >= toInteger blockTimestamp then do let node = nodeArg ct lh - rnOwner <- ExceptT $ call ct rcRegistry ("owner(bytes32)", node) decodeAddress - rnResolver <- ExceptT $ call ct rcRegistry ("resolver(bytes32)", node) decodeAddress + registryRead signature = concurrent $ call ct rcRegistry (signature, node) decodeAddress + (rnOwner, rnResolver) <- ExceptT $ together $ (,) <$> registryRead "owner(bytes32)" <*> registryRead "resolver(bytes32)" pure $ NSRegistered RegisteredName {rnOwner, rnResolver, rnExpiresAt = expires, rnGracePeriod = rcGracePeriod} else do reason <- ExceptT $ call ct (controller ct) ("reservedNames(bytes32)", labelHashArg lh) decodeUint256 @@ -103,13 +112,14 @@ nameStatus ct lh = runExceptT $ do nameRecords :: Contracts -> LabelHash -> RegisteredName -> IO (Either ChainError (Maybe NameRecords)) nameRecords ct lh RegisteredName {rnOwner, rnResolver} | rnResolver /= resolver ct = pure $ Right Nothing - | otherwise = runExceptT $ do - rdContact <- ExceptT $ textRecord contactRecordKey - rdChannel <- ExceptT $ textRecord channelRecordKey - rdNonce <- ExceptT $ resolverNonce ct rnOwner - pure $ Just NameRecords {rdContact, rdChannel, rdNonce} + | otherwise = + withinDeadline ct "name records" $ together $ + (\rdContact rdChannel rdNonce -> Just NameRecords {rdContact, rdChannel, rdNonce}) + <$> textRecord contactRecordKey + <*> textRecord channelRecordKey + <*> concurrent (resolverNonce ct rnOwner) where - textRecord key = call ct (resolver ct) ("text(bytes32,string)", abiTuple [Static $ nodeArg ct lh, Dynamic $ abiBytes key]) decodeString + textRecord key = concurrent $ call ct (resolver ct) ("text(bytes32,string)", abiTuple [Static $ nodeArg ct lh, Dynamic $ abiBytes key]) decodeString -- | Cents for a label of this length and these years. The oracle prices by length only, -- so a placeholder label of that length keeps the label from the RPC node. @@ -178,6 +188,16 @@ readOnce ref readChain = Just a -> pure $ Right a Nothing -> readChain >>= either (pure . Left) (\a -> Right a <$ atomicWriteIORef ref (Just a)) +-- | Reads run at once; the first failure in argument order is the answer. +together :: Compose Concurrently (Either ChainError) a -> IO (Either ChainError a) +together = runConcurrently . getCompose + +concurrent :: IO (Either ChainError a) -> Compose Concurrently (Either ChainError) a +concurrent = Compose . Concurrently + +withinDeadline :: Contracts -> Text -> IO (Either ChainError a) -> IO (Either ChainError a) +withinDeadline Contracts {ctDeadline} what action = fromMaybe (Left $ ChainUnavailable $ what <> ": timed out") <$> timeout ctDeadline action + -- | An answer that does not decode is a node failure, as an RPC error is. call :: Contracts -> Address -> (ByteString, ByteString) -> (ByteString -> Either AbiError a) -> IO (Either ChainError a) call Contracts {ctChain} to (signature, args) decode = diff --git a/simplex-chat.cabal b/simplex-chat.cabal index ccb23e561e..60b701922a 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -473,6 +473,7 @@ executable simplex-store-service ghc-options: -O2 -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=missing-methods -Werror=incomplete-uni-patterns -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -Werror=name-shadowing -threaded -rtsopts build-depends: aeson ==2.2.* + , async ==2.2.* , attoparsec ==0.14.* , base >=4.7 && <5 , base64-bytestring >=1.0 && <1.3 diff --git a/tests/Bots/StoreService/NamesContractsTests.hs b/tests/Bots/StoreService/NamesContractsTests.hs index a788e87249..e167eca8b6 100644 --- a/tests/Bots/StoreService/NamesContractsTests.hs +++ b/tests/Bots/StoreService/NamesContractsTests.hs @@ -10,6 +10,7 @@ import Crypto.Random (ChaChaDRG) import Data.ByteArray.Encoding (Base (Base16), convertFromBase) import Data.ByteString (ByteString) import qualified Data.ByteString as B +import Data.Time.Clock (NominalDiffTime, diffUTCTime, getCurrentTime) import Data.Word (Word64) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String (strDecode) @@ -53,6 +54,8 @@ namesContractsTests = describe "StoreService names: contracts" $ do describe "failures" $ do it "fails a status on a failed read, never reading it as available" $ testFailedRead g it "fails a read whose answer does not decode or does not fit" $ testMalformed g + it "fails a status within its deadline when one method hangs" $ testStatusDeadline g + it "fails records within their deadline when the calls hang" $ testRecordsDeadline g testNode :: IO () testNode = do @@ -232,8 +235,35 @@ testMalformed g = withContracts g $ \node ct -> do answerCall node oracle pricesCall $ Returns $ B.dropEnd 32 launchPricesAnswer codeLengthFor ct 40000 2 `shouldReturn` Left (ChainUnavailable "eth_call: prices() answered AEShort") +testStatusDeadline :: TVar ChaChaDRG -> IO () +testStatusDeadline g = withContracts g $ \node ct -> do + answerRegistrar node + answerCall node (registrarOf node) nameExpiresCall $ Returns $ uint256 (0 :: Int) + answerCall node fakeController reservedNamesCall $ Returns $ uint256 (0 :: Int) + setFault node "eth_getBlockByNumber" Hang + elapsed <- timed $ nameStatus ct {ctDeadline = testDeadline} alice `shouldReturn` Left (ChainUnavailable "name status: timed out") + elapsed `shouldSatisfy` (< 2) + clearFault node "eth_getBlockByNumber" + nameStatus ct {ctDeadline = testDeadline} alice `shouldReturn` Right NSAvailable + +testRecordsDeadline :: TVar ChaChaDRG -> IO () +testRecordsDeadline g = withContracts g $ \node ct -> do + setFault node "eth_call" Hang + elapsed <- timed $ nameRecords ct {ctDeadline = testDeadline} alice (registeredAlice (resolverOf node) $ now + year) `shouldReturn` Left (ChainUnavailable "name records: timed out") + elapsed `shouldSatisfy` (< 2) + -- fixtures +-- | Far below a call's 10-second timeout, so only the deadline can end a hung read in time. +testDeadline :: Int +testDeadline = 300000 + +timed :: IO () -> IO NominalDiffTime +timed action = do + start <- getCurrentTime + action + (`diffUTCTime` start) <$> getCurrentTime + withContracts :: TVar ChaChaDRG -> (FakeEthNode -> Contracts -> IO a) -> IO a withContracts g action = withFakeEthNode $ \node -> do ct <- newContracts TLDSimplex =<< newChainClient g (fakeChainConfig node)