diff --git a/apps/simplex-store-service/src/StoreService/Names/Chain.hs b/apps/simplex-store-service/src/StoreService/Names/Chain.hs index a535a257c6..a30421c2f8 100644 --- a/apps/simplex-store-service/src/StoreService/Names/Chain.hs +++ b/apps/simplex-store-service/src/StoreService/Names/Chain.hs @@ -17,6 +17,7 @@ module StoreService.Names.Chain newChainClient, ethCall, latestBlock, + blockAt, transactionReceipt, transactionByHash, transactionCount, @@ -50,7 +51,7 @@ import Network.HTTP.Client (Manager) import qualified Network.HTTP.Client as HTTP import Network.HTTP.Client.TLS (newTlsManager) import Network.HTTP.Types (hContentType, methodPost, statusCode, statusIsSuccessful) -import Numeric (readHex) +import Numeric (readHex, showHex) import Simplex.Messaging.Crypto (keccak256) import Simplex.Messaging.Eth.Address (Address, addressFromPrivateKey) import Simplex.Messaging.Eth.RLP (RLPItem (..)) @@ -135,8 +136,15 @@ ethCall :: ChainClient -> Address -> ByteString -> IO (Either ChainError ByteStr ethCall c to calldata = rpc c "eth_call" [J.object ["to" .= to, "data" .= hex calldata], "latest"] bytes latestBlock :: ChainClient -> IO (Either ChainError Block) -latestBlock c = - rpc c "eth_getBlockByNumber" ["latest", J.Bool False] $ J.withObject "block" $ \b -> +latestBlock c = blockByNumber c "latest" + +-- | A block the node does not have is a failed read. +blockAt :: ChainClient -> Word64 -> IO (Either ChainError Block) +blockAt c number = blockByNumber c $ J.String $ "0x" <> T.pack (showHex number "") + +blockByNumber :: ChainClient -> J.Value -> IO (Either ChainError Block) +blockByNumber c tag = + rpc c "eth_getBlockByNumber" [tag, J.Bool False] $ J.withObject "block" $ \b -> Block <$> (quantity =<< b .: "number") <*> (quantity =<< b .: "timestamp") <*> (quantity =<< b .: "baseFeePerGas") -- | Nothing while the transaction is not mined, or after the node pruned its receipt. diff --git a/apps/simplex-store-service/src/StoreService/Names/Contracts.hs b/apps/simplex-store-service/src/StoreService/Names/Contracts.hs index 49d30626f9..c45ba22ba2 100644 --- a/apps/simplex-store-service/src/StoreService/Names/Contracts.hs +++ b/apps/simplex-store-service/src/StoreService/Names/Contracts.hs @@ -12,6 +12,7 @@ module StoreService.Names.Contracts secondsPerYear, nameStatus, nameRecords, + textRecord, chainPriceCents, codeLengthFor, sponsorNonce, @@ -115,11 +116,13 @@ nameRecords ct lh RegisteredName {rnOwner, rnResolver} | otherwise = withinDeadline ct "name records" $ together $ (\rdContact rdChannel rdNonce -> Just NameRecords {rdContact, rdChannel, rdNonce}) - <$> textRecord contactRecordKey - <*> textRecord channelRecordKey + <$> concurrent (textRecord ct lh contactRecordKey) + <*> concurrent (textRecord ct lh channelRecordKey) <*> concurrent (resolverNonce ct rnOwner) - where - textRecord key = concurrent $ call ct (resolver ct) ("text(bytes32,string)", abiTuple [Static $ nodeArg ct lh, Dynamic $ abiBytes key]) decodeString + +-- | The default resolver's record, "" when unset. +textRecord :: Contracts -> LabelHash -> ByteString -> IO (Either ChainError Text) +textRecord ct lh 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. diff --git a/apps/simplex-store-service/src/StoreService/Names/Registration.hs b/apps/simplex-store-service/src/StoreService/Names/Registration.hs index abf1c5834c..313f90ee9c 100644 --- a/apps/simplex-store-service/src/StoreService/Names/Registration.hs +++ b/apps/simplex-store-service/src/StoreService/Names/Registration.hs @@ -19,6 +19,8 @@ module StoreService.Names.Registration verifyRegisterIntent, setTextDigest, clearRecordsDigest, + setTextWithSigCalldata, + clearRecordsWithSigCalldata, ) where @@ -124,6 +126,23 @@ clearRecordsDigest domain node nonce deadline = "ClearRecords(bytes32 node,uint256 nonce,uint256 deadline)" [bytes32 node, VUint $ fromIntegral nonce, VUint $ fromIntegral deadline] +setTextWithSigCalldata :: Node -> Text -> Text -> Word64 -> Word64 -> RecoverableSignature -> ByteString +setTextWithSigCalldata (Node node) key value nonce deadline sig = + selector "setTextWithSig(bytes32,string,string,uint256,uint256,bytes)" + <> abiTuple + [ Static node, + Dynamic $ abiBytes $ encodeUtf8 key, + Dynamic $ abiBytes $ encodeUtf8 value, + Static $ uint256 nonce, + Static $ uint256 deadline, + Dynamic $ abiBytes $ serializeSignature sig + ] + +clearRecordsWithSigCalldata :: Node -> Word64 -> Word64 -> RecoverableSignature -> ByteString +clearRecordsWithSigCalldata (Node node) nonce deadline sig = + selector "clearRecordsWithSig(bytes32,uint256,uint256,bytes)" + <> abiTuple [Static node, Static $ uint256 nonce, Static $ uint256 deadline, Dynamic $ abiBytes $ serializeSignature sig] + bytes32 :: Node -> Eip712Value bytes32 (Node node) = VUint $ fromInteger $ os2ip node diff --git a/apps/simplex-store-service/src/StoreService/Names/Revert.hs b/apps/simplex-store-service/src/StoreService/Names/Revert.hs index fc6acd6bd5..4017553265 100644 --- a/apps/simplex-store-service/src/StoreService/Names/Revert.hs +++ b/apps/simplex-store-service/src/StoreService/Names/Revert.hs @@ -1,12 +1,15 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedStrings #-} -module StoreService.Names.Revert (RevertName (..), decodeRevert) where +module StoreService.Names.Revert (RevertName (..), decodeRevert, revertText) where import Data.ByteString (ByteString) import qualified Data.ByteString as B +import qualified Data.ByteString.Char8 as BC import Data.Map.Strict (Map) import qualified Data.Map.Strict as M +import Data.Text (Text) +import Data.Text.Encoding (decodeLatin1) import StoreService.Names.Abi (selector) -- | The custom errors of SimplexController, SimplexSponsor, BaseRegistrarImplementation and SimplexResolver. @@ -107,6 +110,10 @@ revertSignature = \case RNZeroAddress -> Just "ZeroAddress()" RNWithoutData -> Nothing +-- | The error's name as the contracts declare it, Nothing for a revert without data. +revertText :: RevertName -> Maybe Text +revertText = fmap (decodeLatin1 . BC.takeWhile (/= '(')) . revertSignature + revertSelectors :: Map ByteString RevertName revertSelectors = M.fromList [(selector signature, n) | n <- [minBound .. maxBound], Just signature <- [revertSignature n]] diff --git a/tests/Bots/StoreService/FakeEthNode.hs b/tests/Bots/StoreService/FakeEthNode.hs index ae23290aad..620c0142cc 100644 --- a/tests/Bots/StoreService/FakeEthNode.hs +++ b/tests/Bots/StoreService/FakeEthNode.hs @@ -13,15 +13,19 @@ module Bots.StoreService.FakeEthNode clearFault, setBlock, setFees, + revertDryRun, mine, dropTransaction, pruneReceipt, useNextNonce, sentTransactions, + awaitSent, revertFault, fakeChainId, fakeController, fakeSponsor, + fakeBaseRegistrar, + fakeResolver, fakeRegistrarKey, fakeGas, fakeTip, @@ -48,7 +52,7 @@ import Data.Word (Word64) import Network.HTTP.Types (badGateway502, badRequest400, hContentType, ok200) import Network.Wai (Application, responseLBS, strictRequestBody) import qualified Network.Wai.Handler.Warp as Warp -import Numeric (showHex) +import Numeric (readHex, showHex) import Simplex.Messaging.Crypto (keccak256) import qualified Simplex.Messaging.Crypto.Secp256k1 as S import Simplex.Messaging.Encoding.String (strDecode) @@ -84,9 +88,13 @@ data FakeState = FakeState -- | the transactions the node knows and has not mined, by nonce fsPool :: Map Word64 ByteString, fsReceipts :: Map ByteString (Bool, Word64), + -- | the revert data of the dry runs of these calldata + fsDryRuns :: Map ByteString ByteString, -- | the key's transaction count at the latest block fsUsed :: Word64, fsBlock :: (Word64, Word64), + -- | the times of the blocks mined or set, by number + fsBlockTimes :: Map Word64 Word64, -- | the suggested tip and the base fee fsFees :: (Word64, Word64), fsClosed :: Bool @@ -110,7 +118,7 @@ fakeResolver = address "0x2279b7a0a67db372996a5fab50d91eaa73d2ebe6" fakeRegistrarKey :: S.Secp256k1PrivateKey fakeRegistrarKey = either error id $ S.mkPrivateKey $ BA.replicate 32 0x46 --- | 2026-01-01, block 1; each mined block is this many seconds later. +-- | 2026-01-01, block 1 after the genesis block 0; each mined block is this many seconds later. genesisTime, blockSeconds :: Word64 genesisTime = 1767225600 blockSeconds = 12 @@ -125,8 +133,10 @@ withFakeEthNode action = do fsSent = [], fsPool = M.empty, fsReceipts = M.empty, + fsDryRuns = M.empty, fsUsed = 0, fsBlock = (1, genesisTime), + fsBlockTimes = M.fromList [(0, genesisTime - blockSeconds), (1, genesisTime)], fsFees = (fakeTip, fakeBaseFee), fsClosed = False } @@ -164,7 +174,11 @@ revertFault = RpcError 3 "execution reverted" . Just -- | Sets the latest block's number and time. setBlock :: FakeEthNode -> Word64 -> Word64 -> IO () -setBlock FakeEthNode {fnState} number time = atomically $ modifyTVar' fnState $ \s -> s {fsBlock = (number, time)} +setBlock FakeEthNode {fnState} number time = atomically $ modifyTVar' fnState $ \s -> s {fsBlock = (number, time), fsBlockTimes = M.insert number time $ fsBlockTimes s} + +-- | The dry run of this calldata reverts with this data, or passes again with Nothing. +revertDryRun :: FakeEthNode -> ByteString -> Maybe ByteString -> IO () +revertDryRun FakeEthNode {fnState} calldata revertData = atomically $ modifyTVar' fnState $ \s -> s {fsDryRuns = M.alter (const revertData) calldata $ fsDryRuns s} setFees :: FakeEthNode -> Word64 -> Word64 -> IO () setFees FakeEthNode {fnState} tip baseFee = atomically $ modifyTVar' fnState $ \s -> s {fsFees = (tip, baseFee)} @@ -177,7 +191,7 @@ mine FakeEthNode {fnState} txs = atomically $ do let number' = number + 1 mined = M.fromList [(h, (ok, number')) | (h, ok) <- txs] used = maximum $ fsUsed : [nonce + 1 | (nonce, raw) <- M.toList fsPool, keccak256 raw `M.member` mined] - writeTVar fnState s {fsBlock = (number', time + blockSeconds), fsReceipts = M.union mined fsReceipts, fsPool = M.dropWhileAntitone (< used) fsPool, fsUsed = used} + writeTVar fnState s {fsBlock = (number', time + blockSeconds), fsBlockTimes = M.insert number' (time + blockSeconds) $ fsBlockTimes s, fsReceipts = M.union mined fsReceipts, fsPool = M.dropWhileAntitone (< used) fsPool, fsUsed = used} pure number' -- | The node forgets a transaction it has not mined, as after its own restart. @@ -196,6 +210,13 @@ useNextNonce FakeEthNode {fnState} = atomically $ modifyTVar' fnState $ \s@FakeS sentTransactions :: FakeEthNode -> IO [ByteString] sentTransactions FakeEthNode {fnState} = reverse . fsSent <$> readTVarIO fnState +-- | Waits until the node has received at least this many raw transactions, and returns them in the order they were sent. +awaitSent :: FakeEthNode -> Int -> IO [ByteString] +awaitSent FakeEthNode {fnState} n = atomically $ do + sent <- fsSent <$> readTVar fnState + check $ length sent >= n + pure $ reverse sent + fakeApp :: TVar FakeState -> Application fakeApp st req respond = do body <- strictRequestBody req @@ -219,7 +240,7 @@ fakeApp st req respond = do answer :: TVar FakeState -> Text -> [J.Value] -> STM [JT.Pair] answer st method params = do - s@FakeState {fsCalls, fsSent, fsPool, fsReceipts, fsUsed, fsBlock = (number, time), fsFees = (tip, baseFee)} <- readTVar st + s@FakeState {fsCalls, fsSent, fsPool, fsReceipts, fsDryRuns, fsUsed, fsBlock = (number, time), fsBlockTimes, fsFees = (tip, baseFee)} <- readTVar st case (method, params) of ("eth_call", [call, _]) | Just (to, calldata) <- JT.parseMaybe callTarget call -> @@ -227,13 +248,21 @@ answer st method params = do Just (Returns ret) -> result $ hex ret Just (Reverts revertData) -> rpcError 3 "execution reverted" (Just revertData) Nothing -> rpcError (-32000) "no fake answer for this call" Nothing + ("eth_estimateGas", call : _) + | Just (_, calldata) <- JT.parseMaybe callTarget call, + Just revertData <- M.lookup calldata fsDryRuns -> + pure $ rpcError 3 "execution reverted" (Just revertData) ("eth_estimateGas", _) -> pure $ result $ quantity fakeGas ("eth_getTransactionCount", [_, J.String "latest"]) -> pure $ result $ quantity $ toInteger fsUsed ("eth_getTransactionCount", [_, J.String "pending"]) -> pure $ result $ quantity $ toInteger $ fsUsed + fromIntegral (length $ takeWhile (`M.member` fsPool) [fsUsed ..]) ("eth_maxPriorityFeePerGas", _) -> pure $ result $ quantity $ toInteger tip - ("eth_getBlockByNumber", [J.String "latest", _]) -> - pure $ result $ J.object ["number" .= quantity (toInteger number), "timestamp" .= quantity (toInteger time), "baseFeePerGas" .= quantity (toInteger baseFee)] + ("eth_getBlockByNumber", [J.String "latest", _]) -> pure $ result $ blockJson baseFee number time + -- a block not mined or set here has the time of the last one before it + ("eth_getBlockByNumber", [J.String n, _]) + | Just at <- fromQuantity n -> pure $ result $ case M.lookupLE at fsBlockTimes of + Just (_, t) | at <= number -> blockJson baseFee at t + _ -> J.Null ("eth_getTransactionReceipt", [J.String h]) | Just hash <- fromHex h -> pure $ result $ case M.lookup hash fsReceipts of Just (ok, block) -> J.object ["transactionHash" .= h, "status" .= quantity (if ok then 1 else 0), "blockNumber" .= quantity (toInteger block)] Nothing -> J.Null @@ -260,12 +289,20 @@ answer st method params = do result v = ["result" .= v] callTarget = J.withObject "call" $ \o -> (,) <$> o .: "to" <*> (maybe (fail "not hex data") pure . fromHex =<< o .: "data") +blockJson :: Word64 -> Word64 -> Word64 -> J.Value +blockJson baseFee number time = J.object ["number" .= quantity (toInteger number), "timestamp" .= quantity (toInteger time), "baseFeePerGas" .= quantity (toInteger baseFee)] + rpcError :: Int -> Text -> Maybe ByteString -> [JT.Pair] rpcError code message revertData = ["error" .= J.object (["code" .= code, "message" .= message] <> maybe [] (\d -> ["data" .= hex d]) revertData)] quantity :: Integer -> Text quantity n = "0x" <> T.pack (showHex n "") +fromQuantity :: Text -> Maybe Word64 +fromQuantity t = case T.stripPrefix "0x" t of + Just h | [(n, "")] <- readHex (T.unpack h) -> Just n + _ -> Nothing + fromHex :: Text -> Maybe ByteString fromHex t = T.stripPrefix "0x" t >>= eitherToMaybe . convertFromBase Base16 . encodeUtf8 diff --git a/tests/Bots/StoreService/NamesChainTests.hs b/tests/Bots/StoreService/NamesChainTests.hs index 874460ccbe..ab231e7f7c 100644 --- a/tests/Bots/StoreService/NamesChainTests.hs +++ b/tests/Bots/StoreService/NamesChainTests.hs @@ -28,6 +28,7 @@ namesChainTests = describe "StoreService names: chain" $ do g <- runIO C.newRandom describe "reverts" $ do it "names every contract error by the selector viem encodes it with" testRevertSelectors + it "writes a revert's name as the contracts declare it, none for a revert without data" testRevertText 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 @@ -46,6 +47,7 @@ namesChainTests = describe "StoreService names: chain" $ do describe "reads" $ do it "answers eth_call from the node by contract and calldata" $ testEthCall g it "reads the latest block's number, time and base fee" $ testLatestBlock g + it "reads a block by its number, and one the node does not have as a failed read" $ testBlockAt g it "follows a transaction from sent to mined with its receipt and the nonces" $ testFollowTransaction g describe "transactions" $ do it "signs a transaction whose hash is the node's keccak256 of the raw bytes" $ testSignedHash g @@ -106,6 +108,12 @@ testRevertSelectors = do decodeRevert (B.take 3 $ unhex "74480cc9") `shouldBe` Nothing decodeRevert "" `shouldBe` Just RNWithoutData +testRevertText :: IO () +testRevertText = do + revertText RNInvalidNonce `shouldBe` Just "InvalidNonce" + revertText RNSignatureExpired `shouldBe` Just "SignatureExpired" + revertText RNWithoutData `shouldBe` Nothing + testDryRunRevertNamed :: TVar ChaChaDRG -> IO () testDryRunRevertNamed g = withChain g $ \node c -> do setFault node "eth_estimateGas" $ revertFault insufficientAllowance @@ -224,6 +232,14 @@ testLatestBlock g = withChain g $ \node c -> do setBlock node 21000000 1798761600 latestBlock c `shouldReturn` Right Block {blockNumber = 21000000, blockTimestamp = 1798761600, blockBaseFee = fakeBaseFee} +testBlockAt :: TVar ChaChaDRG -> IO () +testBlockAt g = withChain g $ \node c -> do + setBlock node 100 1798761600 + mined <- mine node [] + blockAt c 100 `shouldReturn` Right Block {blockNumber = 100, blockTimestamp = 1798761600, blockBaseFee = fakeBaseFee} + blockAt c mined `shouldReturn` Right Block {blockNumber = 101, blockTimestamp = 1798761612, blockBaseFee = fakeBaseFee} + blockAt c 102 >>= (`shouldSatisfy` \case Left (ChainUnavailable _) -> True; _ -> False) + testFollowTransaction :: TVar ChaChaDRG -> IO () testFollowTransaction g = withChain g $ \node c@ChainClient {ccRegistrar} -> do let unknown = TxHash $ B.replicate 32 0xee diff --git a/tests/Bots/StoreService/NamesContractsTests.hs b/tests/Bots/StoreService/NamesContractsTests.hs index 89d6a38e73..61e240ce44 100644 --- a/tests/Bots/StoreService/NamesContractsTests.hs +++ b/tests/Bots/StoreService/NamesContractsTests.hs @@ -122,6 +122,7 @@ testRecords g = withContracts g $ \node ct -> do 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}) + textRecord ct alice "simplex.contact" `shouldReturn` Right "https://smp8.simplex.im/a#Y8m3" testOtherResolver :: TVar ChaChaDRG -> IO () testOtherResolver g = withContracts g $ \_ ct -> do