{-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} module Bots.StoreService.NamesSenderTests (namesSenderTests) where import Bots.StoreService.FakeEthNode import Bots.StoreService.NamesStoreTests (issue, order, ownerA, placeCode, t0, usable) import Bots.StoreService.WebTests (capturingStderr, digestFixture, withServiceStore) import Control.Concurrent.Async (concurrently) import Control.Concurrent.STM import Control.Monad (forM_, replicateM_) import Crypto.Random (ChaChaDRG) import Data.ByteArray.Encoding (Base (Base16), convertFromBase) import Data.ByteString (ByteString) import qualified Data.ByteString as B import Data.List (sort) import Data.String (fromString) import qualified Data.Text as T import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime) import Data.Word (Word64, Word8) import Simplex.Messaging.Agent.Store.Common (DBStore, withTransaction) import qualified Simplex.Messaging.Agent.Store.DB as DB import qualified Simplex.Messaging.Crypto as C import StoreService.Config (ChainConfig (..)) import StoreService.Names.Abi (selector, uint256) import StoreService.Names.Chain import StoreService.Names.Phases (RegistrationId (..)) import StoreService.Names.Registration (commitCalldata) import StoreService.Names.Revert (RevertName (..)) import StoreService.Names.Sender import StoreService.Names.Store (NameTransaction (..), TxFor (..), TxStatus (..), pendingTransactions, registrationTransactions) import System.Timeout (timeout) import Test.Hspec namesSenderTests :: Spec namesSenderTests = describe "StoreService names: sender" $ do g <- runIO C.newRandom it "reads a signed transaction's request back from its raw bytes" $ testDecodeSignedTx g it "gives concurrent transactions their own nonces, each saved before it is sent" $ testConcurrentNonces g it "saves nothing and takes no nonce when the dry run reverts or the chain does not answer" $ testNothingSaved g it "refuses a second transaction for work with one in flight" $ testOneInFlight g it "settles a receipt only once it is confirmations deep, with its status" $ testConfirmationDepth g it "keeps a transaction whose send failed, and sends it in the next round" $ testFailedSend g it "replaces a stuck transaction once the market's fees pass its own, and settles it under the new hash" $ testFeeBump g it "rebroadcasts a saved raw transaction after a restart while the node does not know it" $ testRestartRebroadcast g it "hands a dropped transaction whose nonce was used to its owner, who judges it from chain state before any new signature" $ testDroppedNonceUsed g it "hands a mined transaction whose receipt was pruned to its owner, signing nothing more" $ testPrunedReceipt g it "logs a round that fails on the store, and settles the transaction in the next round" $ testFailedRound g it "logs a send the node keeps refusing once per error, an underfunded key as an error" $ testRefusedSendLogged g it "refuses a transaction whose block needs more than the fee cap, saving nothing, and signs at most the cap" $ testFeeCapSubmit g it "clamps a replacement at the fee cap, and keeps rebroadcasting once a replacement would pass it" $ testFeeCapReplace g data Setup = Setup { node :: FakeEthNode, store :: DBStore, clock :: TVar UTCTime, chain :: ChainClient, random :: TVar ChaChaDRG } withSetup :: TVar ChaChaDRG -> (Setup -> IO a) -> IO a withSetup = withCapSetup fakeMaxFeePerGas -- | Two code orders, r1 and r2, two confirmations, and this max fee per gas. withCapSetup :: Word64 -> TVar ChaChaDRG -> (Setup -> IO a) -> IO a withCapSetup cap random action = withFakeEthNode $ \node -> withServiceStore $ \store -> do forM_ [(1, "r1", "alicebakery"), (2, "r2", "bobbakery")] $ \(n, rid, label) -> do code <- issue store n usable placeCode store rid ownerA (order label n) code `shouldReturn` Right () clock <- newTVarIO t0 chain <- newChainClient random (fakeChainConfig node) {cConfirmations = 2, cMaxFeePerGas = cap} action Setup {node, store, clock, chain, random} start :: Setup -> IO Sender start Setup {random, chain, store, clock} = newSender random chain store (readTVarIO clock) passTime :: Setup -> NominalDiffTime -> IO () passTime Setup {clock} t = atomically $ modifyTVar' clock (addUTCTime t) testDecodeSignedTx :: TVar ChaChaDRG -> IO () testDecodeSignedTx g = withFakeEthNode $ \node -> do c <- newChainClient g $ fakeChainConfig node -- calldata over 55 bytes takes a long RLP length let request = TxRequest {trTo = fakeSponsor, trData = B.replicate 300 0xab, trNonce = 70000, trGasLimit = 450000, trMaxFeePerGas = 31000000000, trMaxPriorityFeePerGas = 0} Right SignedTx {stRaw} <- signTransaction g c request decodeSignedTx stRaw `shouldBe` Right request decodeSignedTx (B.cons 0x01 $ B.drop 1 stRaw) `shouldBe` Left "transaction: not an EIP-1559 transaction" decodeSignedTx (B.take 100 stRaw) `shouldBe` Left "RLP: truncated" testConcurrentNonces :: TVar ChaChaDRG -> IO () testConcurrentNonces g = withSetup g $ \s@Setup {node, store} -> do sender <- start s -- both dry runs pass before either reads the registrar's nonce setFault node "eth_getTransactionCount" $ Slow 300000 (Right a, Right b) <- concurrently (submitTransaction sender (TxCommit r1) fakeController $ commit 1) (submitTransaction sender (TxCommit r2) fakeController $ commit 2) clearFault node "eth_getTransactionCount" sort [ptNonce a, ptNonce b] `shouldBe` [0, 1] saved <- withTransaction store pendingTransactions map (\NameTransaction {ntFor, ntNonce, ntHash} -> (ntFor, ntNonce, ntHash)) saved `shouldMatchList` [(TxCommit r1, ptNonce a, ptHash a), (TxCommit r2, ptNonce b, ptHash b)] sentTransactions node `shouldReturn` map ntRaw saved mapM_ (mineHash node) [ptHash a, ptHash b] _ <- mine node [] followTransactions sender forM_ [a, b] $ \p -> outcomeOf p >>= (`shouldSatisfy` confirmedAs (ptHash p)) Right c <- submitTransaction sender (TxRegister r1) fakeSponsor $ commit 3 ptNonce c `shouldBe` 2 testNothingSaved :: TVar ChaChaDRG -> IO () testNothingSaved g = withSetup g $ \s@Setup {node, store} -> do sender <- start s setFault node "eth_estimateGas" $ revertFault insufficientAllowance submitTransaction sender (TxCommit r1) fakeController (commit 1) `refusedWith` SEChain (ChainReverted $ Just RNInsufficientAllowance) clearFault node "eth_estimateGas" setFault node "eth_maxPriorityFeePerGas" HttpFailure submitTransaction sender (TxCommit r1) fakeController (commit 1) `refusedWith` SEChain (ChainUnavailable "eth_maxPriorityFeePerGas: HTTP 502") clearFault node "eth_maxPriorityFeePerGas" withTransaction store pendingTransactions `shouldReturn` [] sentTransactions node `shouldReturn` [] Right p <- submitTransaction sender (TxCommit r1) fakeController (commit 1) ptNonce p `shouldBe` 0 testOneInFlight :: TVar ChaChaDRG -> IO () testOneInFlight g = withSetup g $ \s@Setup {node} -> do sender <- start s Right p <- submitTransaction sender (TxCommit r1) fakeController (commit 1) submitTransaction sender (TxCommit r1) fakeController (commit 1) `refusedWith` SEInFlight Right other <- submitTransaction sender (TxRegister r1) fakeSponsor (commit 2) ptNonce other `shouldBe` 1 (fmap ptHash <$> inFlightTransaction sender (TxCommit r1)) `shouldReturn` Just (ptHash p) length <$> sentTransactions node `shouldReturn` 2 testConfirmationDepth :: TVar ChaChaDRG -> IO () testConfirmationDepth g = withSetup g $ \s@Setup {node, store} -> do sender <- start s Right p <- submitTransaction sender (TxCommit r1) fakeController (commit 1) let TxHash h = ptHash p block <- mine node [(h, False)] followTransactions sender settled sender (TxCommit r1) `shouldReturn` False _ <- mine node [] followTransactions sender outcomeOf p `shouldReturn` TOConfirmed (ptHash p) Receipt {receiptSuccess = False, receiptBlock = block} settled sender (TxCommit r1) `shouldReturn` True statuses store r1 `shouldReturn` [(ptHash p, TSReverted, Just block)] testFailedSend :: TVar ChaChaDRG -> IO () testFailedSend g = withSetup g $ \s@Setup {node, store} -> do sender <- start s setFault node "eth_sendRawTransaction" HttpFailure Right p <- submitTransaction sender (TxCommit r1) fakeController (commit 1) clearFault node "eth_sendRawTransaction" sentTransactions node `shouldReturn` [] [NameTransaction {ntRaw, ntHash}] <- withTransaction store pendingTransactions ntHash `shouldBe` ptHash p followTransactions sender sentTransactions node `shouldReturn` [ntRaw] transactionByHash' s (ptHash p) `shouldReturn` Just TxPending testFeeBump :: TVar ChaChaDRG -> IO () testFeeBump g = withSetup g $ \s@Setup {node, store} -> do sender <- start s Right p <- submitTransaction sender (TxCommit r1) fakeController (commit 1) [firstRaw] <- sentTransactions node Right first@TxRequest {trMaxFeePerGas = firstMax, trMaxPriorityFeePerGas = firstTip} <- pure $ decodeSignedTx firstRaw (firstTip, firstMax) `shouldBe` (fakeTip, 2 * fakeBaseFee + fakeTip) passTime s $ replaceAfter - 1 followTransactions sender passTime s 1 followTransactions sender length <$> sentTransactions node `shouldReturn` 1 setFees node fakeTip (2 * fakeBaseFee) followTransactions sender length <$> sentTransactions node `shouldReturn` 1 passTime s replaceAfter followTransactions sender [_, bumpedRaw] <- sentTransactions node let bumpedHash = TxHash $ C.keccak256 bumpedRaw decodeSignedTx bumpedRaw `shouldBe` Right first {trMaxPriorityFeePerGas = fakeTip + fakeTip `div` 8 + 1, trMaxFeePerGas = 4 * fakeBaseFee + fakeTip} transactionByHash' s (ptHash p) `shouldReturn` Nothing transactionByHash' s bumpedHash `shouldReturn` Just TxPending map (\(h, status, _) -> (h, status)) <$> statuses store r1 `shouldReturn` [(ptHash p, TSPending), (bumpedHash, TSPending)] mineHash node bumpedHash _ <- mine node [] followTransactions sender TOConfirmed minedHash Receipt {receiptSuccess = True, receiptBlock} <- outcomeOf p minedHash `shouldBe` bumpedHash statuses store r1 `shouldReturn` [(ptHash p, TSDropped, Nothing), (bumpedHash, TSMined, Just receiptBlock)] testRestartRebroadcast :: TVar ChaChaDRG -> IO () testRestartRebroadcast g = withSetup g $ \s@Setup {node, store} -> do crashed <- start s Right p <- submitTransaction crashed (TxCommit r1) fakeController (commit 1) [raw] <- sentTransactions node let TxHash h = ptHash p dropTransaction node h restarted <- start s Just resumed <- inFlightTransaction restarted (TxCommit r1) (ptHash resumed, ptNonce resumed) `shouldBe` (ptHash p, 0) followTransactions restarted sentTransactions node `shouldReturn` [raw, raw] followTransactions restarted sentTransactions node `shouldReturn` [raw, raw] Right next <- submitTransaction restarted (TxCommit r2) fakeController (commit 2) ptNonce next `shouldBe` 1 _ <- mine node [(h, True)] _ <- mine node [] followTransactions restarted outcomeOf resumed >>= (`shouldSatisfy` confirmedAs (ptHash p)) map (\(_, status, _) -> status) <$> statuses store r1 `shouldReturn` [TSMined] testDroppedNonceUsed :: TVar ChaChaDRG -> IO () testDroppedNonceUsed g = withSetup g $ \s@Setup {node, store, chain} -> do sender <- start s Right p <- submitTransaction sender (TxCommit r1) fakeController (commit 1) let TxHash h = ptHash p dropTransaction node h useNextNonce node followTransactions sender settled sender (TxCommit r1) `shouldReturn` False _ <- mine node [] followTransactions sender outcomeOf p `shouldReturn` TONonceUsed statuses store r1 `shouldReturn` [(ptHash p, TSDropped, Nothing)] length <$> sentTransactions node `shouldReturn` 1 -- the owner: the commitment reads 0, so the commit never mined and is sent again answerCall node fakeController commitmentsCall $ Returns $ uint256 (0 :: Int) ethCall chain fakeController commitmentsCall `shouldReturn` Right (uint256 (0 :: Int)) Right again <- submitTransaction sender (TxCommit r1) fakeController (commit 1) ptNonce again `shouldBe` 1 length <$> sentTransactions node `shouldReturn` 2 testPrunedReceipt :: TVar ChaChaDRG -> IO () testPrunedReceipt g = withSetup g $ \s@Setup {node, store} -> do sender <- start s Right p <- submitTransaction sender (TxCommit r1) fakeController (commit 1) let TxHash h = ptHash p _ <- mine node [(h, True)] pruneReceipt node h followTransactions sender settled sender (TxCommit r1) `shouldReturn` False _ <- mine node [] followTransactions sender outcomeOf p `shouldReturn` TONonceUsed statuses store r1 `shouldReturn` [(ptHash p, TSDropped, Nothing)] followTransactions sender length <$> sentTransactions node `shouldReturn` 1 withTransaction store pendingTransactions `shouldReturn` [] testFailedRound :: TVar ChaChaDRG -> IO () testFailedRound g = withSetup g $ \s@Setup {node, store} -> do sender <- start s Right p <- submitTransaction sender (TxCommit r1) fakeController (commit 1) mineHash node (ptHash p) _ <- mine node [] renameTransactions store "sx_badge_service_name_transactions" "name_transactions_away" senderRound sender settled sender (TxCommit r1) `shouldReturn` False renameTransactions store "name_transactions_away" "sx_badge_service_name_transactions" senderRound sender outcomeOf p >>= (`shouldSatisfy` confirmedAs (ptHash p)) map (\(_, status, _) -> status) <$> statuses store r1 `shouldReturn` [TSMined] where renameTransactions store from to = withTransaction store $ \db -> DB.execute_ db $ fromString $ "ALTER TABLE " <> from <> " RENAME TO " <> to testRefusedSendLogged :: TVar ChaChaDRG -> IO () testRefusedSendLogged g = withSetup g $ \s@Setup {node} -> do sender <- start s -- the specs run in parallel, so this one's lines are told apart by a nonce no other spec reaches replicateM_ 9 $ useNextNonce node let underfunded = "insufficient funds for gas * price + value: balance 0, tx cost 4600000000000000, overshot 4600000000000000" logged <- capturingStderr $ do setFault node "eth_sendRawTransaction" $ RpcError (-32000) underfunded Nothing Right p <- submitTransaction sender (TxCommit r1) fakeController (commit 1) ptNonce p `shouldBe` 9 followTransactions sender followTransactions sender setFault node "eth_sendRawTransaction" HttpFailure followTransactions sender followTransactions sender clearFault node "eth_sendRawTransaction" followTransactions sender let linesWith tag message = length $ filter (\l -> tag `T.isPrefixOf` l && message `T.isInfixOf` l) $ T.lines logged linesWith "[ERROR" "names sender: nonce 9: not sent: ChainUnavailable \"eth_sendRawTransaction: error -32000: insufficient funds" `shouldBe` 1 linesWith "[WARN" "names sender: nonce 9: not sent: ChainUnavailable \"eth_sendRawTransaction: HTTP 502\"" `shouldBe` 1 linesWith "[" "names sender: nonce 9" `shouldBe` 2 linesWith "[" "names sender: rebroadcasting nonce 9" `shouldBe` 0 length <$> sentTransactions node `shouldReturn` 1 testFeeCapSubmit :: TVar ChaChaDRG -> IO () testFeeCapSubmit g = withSetup g $ \s@Setup {node, store} -> do sender <- start s setFees node fakeTip (fakeMaxFeePerGas - fakeTip + 1) submitTransaction sender (TxCommit r1) fakeController (commit 1) `refusedWith` SEFeeAboveCap (fakeMaxFeePerGas + 1) -- twice the base fee is past Word64, which would wrap to a near-zero fee let hugeBaseFee = maxBound `div` 2 + 1 :: Word64 setFees node fakeTip hugeBaseFee submitTransaction sender (TxCommit r1) fakeController (commit 1) `refusedWith` SEFeeAboveCap (toInteger hugeBaseFee + fakeTip) withTransaction store pendingTransactions `shouldReturn` [] sentTransactions node `shouldReturn` [] let baseFee = fakeMaxFeePerGas `div` 2 setFees node fakeTip baseFee Right p <- submitTransaction sender (TxCommit r1) fakeController (commit 1) ptNonce p `shouldBe` 0 [raw] <- sentTransactions node (\TxRequest {trMaxPriorityFeePerGas, trMaxFeePerGas} -> (trMaxPriorityFeePerGas, trMaxFeePerGas)) <$> decodeSignedTx raw `shouldBe` Right (fakeTip, fakeMaxFeePerGas) testFeeCapReplace :: TVar ChaChaDRG -> IO () testFeeCapReplace g = withCapSetup cap g $ \s@Setup {node} -> do sender <- start s Right p <- submitTransaction sender (TxCommit r1) fakeController (commit 1) [firstRaw] <- sentTransactions node Right first <- pure $ decodeSignedTx firstRaw trMaxFeePerGas first `shouldBe` 2 * fakeBaseFee + fakeTip setFees node fakeTip (2 * fakeBaseFee) passTime s replaceAfter followTransactions sender [_, bumpedRaw] <- sentTransactions node decodeSignedTx bumpedRaw `shouldBe` Right first {trMaxPriorityFeePerGas = fakeTip + fakeTip `div` 8 + 1, trMaxFeePerGas = cap} passTime s replaceAfter followTransactions sender length <$> sentTransactions node `shouldReturn` 2 let bumpedHash = C.keccak256 bumpedRaw dropTransaction node bumpedHash followTransactions sender sentTransactions node `shouldReturn` [firstRaw, bumpedRaw, bumpedRaw] (fmap ptHash <$> inFlightTransaction sender (TxCommit r1)) `shouldReturn` Just (TxHash bumpedHash) ptNonce p `shouldBe` 0 where -- above the first max fee and its bump, below the market's headroom fee and a second bump cap = 2 * fakeBaseFee + fakeTip + 1000000000 -- fixtures r1, r2 :: RegistrationId r1 = RegistrationId "r1" r2 = RegistrationId "r2" commit :: Word8 -> ByteString commit = commitCalldata . digestFixture commitmentsCall :: ByteString commitmentsCall = selector "commitments(bytes32)" <> digestFixture 1 -- | viem's encodeErrorResult of InsufficientAllowance(10000, 2500) insufficientAllowance :: ByteString insufficientAllowance = unhex "2a1b2dd8000000000000000000000000000000000000000000000000000000000000271000000000000000000000000000000000000000000000000000000000000009c4" mineHash :: FakeEthNode -> TxHash -> IO () mineHash node (TxHash h) = () <$ mine node [(h, True)] confirmedAs :: TxHash -> TxOutcome -> Bool confirmedAs hash = \case TOConfirmed h Receipt {receiptSuccess = True} -> h == hash _ -> False outcomeOf :: PendingTx -> IO TxOutcome outcomeOf p = timeout 2000000 (awaitOutcome p) >>= maybe (fail "the transaction is not settled") pure settled :: Sender -> TxFor -> IO Bool settled sender for = null <$> inFlightTransaction sender for statuses :: DBStore -> RegistrationId -> IO [(TxHash, TxStatus, Maybe Word64)] statuses store rid = withTransaction store $ \db -> map (\NameTransaction {ntHash, ntStatus, ntMinedBlock} -> (ntHash, ntStatus, ntMinedBlock)) <$> registrationTransactions db rid transactionByHash' :: Setup -> TxHash -> IO (Maybe TxState) transactionByHash' Setup {chain} h = either (error . show) id <$> transactionByHash chain h refusedWith :: IO (Either SendError PendingTx) -> SendError -> Expectation refusedWith submit expected = submit >>= \case Left e -> e `shouldBe` expected Right p -> expectationFailure $ "sent with nonce " <> show (ptNonce p) unhex :: ByteString -> ByteString unhex = either error id . convertFromBase Base16