mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-04 10:47:32 +00:00
378 lines
19 KiB
Haskell
378 lines
19 KiB
Haskell
{-# 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
|