haskoin-node-2.4.0: test/common/Haskoin/Node/Test.hs
-- | Helpers shared by the unit and regtest test suites.
module Haskoin.Node.Test
( -- * Logging
withTestLogger,
-- * Timing
within,
eventually,
receiveWithin,
expectNothingWithin,
-- * Block chains
mkBlocks,
mkHeaders,
coinbaseTx,
serveHeaders,
headersMessage,
nowTimestamp,
-- * Database
withTestDB,
withDBAt,
-- * In-memory connections
Remote (..),
memoryConnection,
remoteSend,
remoteSendRaw,
remoteReceive,
remoteReceiveMatch,
remoteClose,
remoteVersion,
respondHonestly,
honestRemote,
encodeMessage,
-- * Nodes
TestNode (..),
withTestNode,
setTimeout,
setMaxPeerLife,
waitForPeer,
waitForDisconnect,
waitForBest,
zeroAddress,
)
where
import Conduit (awaitForever, yield)
import Control.Monad (forM_, unless)
import Data.ByteString (ByteString)
import Data.ByteString qualified as B
import Data.Default (def)
import Data.List (elemIndex)
import Data.Maybe (fromMaybe, listToMaybe, mapMaybe)
import Data.Serialize (decode, encode, runGet, runPut)
import Data.Time.Clock (NominalDiffTime)
import Data.Time.Clock.POSIX (getPOSIXTime)
import Data.Word (Word32, Word64)
import Database.RocksDB (DB)
import Database.RocksDB qualified as R
import Haskoin
import Haskoin.Node
import Haskoin.Node qualified as NC (NodeConfig (..))
import NQE
import Network.Socket (SockAddr (..))
import System.Environment (lookupEnv)
import System.Random (randomIO)
import Test.HUnit (assertFailure)
import UnliftIO
import UnliftIO.Concurrent (threadDelay)
-- | Run with logging to stderr if HASKOIN_TEST_LOG is set, silently otherwise.
withTestLogger :: (Logger -> IO a) -> IO a
withTestLogger f =
lookupEnv "HASKOIN_TEST_LOG" >>= \case
Just _ -> withLogger LevelDebug f
Nothing -> f Logger {level = LevelError, source = "", push = \_ -> return ()}
-- | Fail the test if the action does not complete in the given seconds.
within :: Double -> String -> IO a -> IO a
within s what act =
timeout (round (s * 1000000)) act >>= \case
Nothing -> assertFailure $ "Timed out after " <> show s <> "s: " <> what
Just x -> return x
-- | Fail the test unless the condition becomes true in the given seconds.
eventually :: Double -> String -> IO Bool -> IO ()
eventually s what cond = within s what go
where
go = cond >>= \b -> unless b (threadDelay 20000 >> go)
-- | Receive the first matching message in the given seconds.
receiveWithin ::
(InChan mbox) =>
Double ->
String ->
mbox msg ->
(msg -> Maybe a) ->
IO a
receiveWithin s what mbox f = within s what (receiveMatch mbox f)
-- | Fail the test if a matching message arrives in the given seconds.
expectNothingWithin ::
(InChan mbox) =>
Double ->
String ->
mbox msg ->
(msg -> Maybe a) ->
IO ()
expectNothingWithin s what mbox f =
timeout (round (s * 1000000)) (receiveMatch mbox f) >>= \case
Nothing -> return ()
Just _ -> assertFailure $ "Unexpected: " <> what
-- | Current time as a block timestamp.
nowTimestamp :: IO Word32
nowTimestamp = floor <$> getPOSIXTime
-- | Coinbase-like transaction that makes each block unique.
coinbaseTx :: Word32 -> Int -> Tx
coinbaseTx tag i =
Tx
{ version = 1,
inputs =
[ TxIn
{ outpoint = OutPoint {hash = z, index = maxBound},
script = encode (tag, fromIntegral i :: Word64),
sequence = maxBound
}
],
outputs = [TxOut {value = 5000000000, script = B.singleton 0x51}],
witness = [],
locktime = 0
}
where
z = "0000000000000000000000000000000000000000000000000000000000000000"
-- | Mine blocks on top of a parent header. Timestamps start at the given
-- time and increase by one second. Use different tags to build forks.
mkBlocks :: Network -> Word32 -> BlockHeader -> Word32 -> Int -> [Block]
mkBlocks net tag parent t0 n = go parent 0
where
go p i
| i >= n = []
| otherwise =
let tx = coinbaseTx tag i
h =
mineBlock net 0 $
BlockHeader
{ version = 0x20000000,
prev = headerHash p,
merkle = either error id (buildMerkleRoot [txHash tx]),
timestamp = max (t0 + fromIntegral i) (p.timestamp + 1),
bits = net.genesisHeader.bits,
nonce = 0
}
in Block {header = h, txs = [tx]} : go h (i + 1)
-- | Headers for 'mkBlocks'.
mkHeaders :: Network -> Word32 -> BlockHeader -> Word32 -> Int -> [BlockHeader]
mkHeaders net tag parent t0 n = map (.header) (mkBlocks net tag parent t0 n)
-- | Answer a header request like a full node would. The chain excludes the
-- genesis block.
serveHeaders :: Network -> [BlockHeader] -> GetHeaders -> [BlockHeader]
serveHeaders net chain gh =
take 2000 (stop_at (drop start chain))
where
hashes = headerHash net.genesisHeader : map headerHash chain
start =
fromMaybe 0 $
listToMaybe [i | l <- gh.locator, Just i <- [elemIndex l hashes]]
z = "0000000000000000000000000000000000000000000000000000000000000000"
stop_at hs
| gh.stop == z = hs
| otherwise = case break ((== gh.stop) . headerHash) hs of
(a, b : _) -> a <> [b]
(a, []) -> a
headersMessage :: [BlockHeader] -> Message
headersMessage hs = MHeaders (Headers [(h, VarInt 0) | h <- hs])
-- | Temporary RocksDB database.
withTestDB :: (DB -> IO a) -> IO a
withTestDB f =
withSystemTempDirectory "haskoin-node-test-" $ \dir -> withDBAt dir f
-- | RocksDB database at a path, created if missing.
withDBAt :: FilePath -> (DB -> IO a) -> IO a
withDBAt dir = R.withDB dir def {R.createIfMissing = True}
-- | Remote end of an in-memory connection to a local peer process.
data Remote = Remote
{ net :: !Network,
-- | bytes for the local peer, 'Nothing' closes the connection
outbox :: !(Mailbox (Maybe ByteString)),
-- | bytes from the local peer, 'Nothing' when it closed the connection
inbox :: !(Inbox (Maybe ByteString)),
buffer :: !(IORef ByteString),
-- | set when the local peer closes the connection
closed :: !(TVar Bool)
}
-- | Connection to a remote peer implemented by the provided script. The
-- connection closes when the script returns.
memoryConnection :: Network -> (Remote -> IO ()) -> WithConnection
memoryConnection net script f = do
(to_local_in, to_local) <- newMailbox
(from_local_in, from_local) <- newMailbox
buf <- newIORef B.empty
closed <- newTVarIO False
let remote =
Remote
{ net = net,
outbox = to_local,
inbox = from_local_in,
buffer = buf,
closed = closed
}
src =
receive to_local_in >>= \case
Nothing -> return ()
Just b -> yield b >> src
snk = awaitForever $ \b -> Just b `send` from_local
close_local = do
atomically (writeTVar closed True)
Nothing `send` from_local
withAsync (script remote `finally` (Nothing `send` to_local)) $ \_ ->
f (Conduits src snk) `finally` close_local
encodeMessage :: Network -> Message -> ByteString
encodeMessage net = runPut . putMessage net
remoteSend :: Remote -> Message -> IO ()
remoteSend r m = Just (encodeMessage r.net m) `send` r.outbox
remoteSendRaw :: Remote -> ByteString -> IO ()
remoteSendRaw r bs = Just bs `send` r.outbox
remoteClose :: Remote -> IO ()
remoteClose r = Nothing `send` r.outbox
-- | Receive next message from the local peer. 'Nothing' if it closed the
-- connection.
remoteReceive :: Remote -> IO (Maybe Message)
remoteReceive r =
take_bytes 24 >>= \case
Nothing -> return Nothing
Just hdr -> do
len <- case decode hdr of
Left e -> assertFailure $ "Bad header from local peer: " <> e
Right (MessageHeader _ _ len _) -> return len
take_bytes (fromIntegral len) >>= \case
Nothing -> return Nothing
Just payload ->
case runGet (getMessage r.net) (hdr <> payload) of
Left e -> assertFailure $ "Bad message from local peer: " <> e
Right m -> return (Just m)
where
take_bytes n = do
b <- readIORef r.buffer
if B.length b >= n
then do
let (x, y) = B.splitAt n b
writeIORef r.buffer y
return (Just x)
else
receive r.inbox >>= \case
Nothing -> return Nothing
Just more -> writeIORef r.buffer (b <> more) >> take_bytes n
-- | Receive messages from the local peer until one matches.
remoteReceiveMatch ::
Double -> String -> Remote -> (Message -> Maybe a) -> IO a
remoteReceiveMatch s what r f = within s what go
where
go =
remoteReceive r >>= \case
Nothing -> assertFailure $ "Connection closed awaiting " <> what
Just m -> maybe go return (f m)
zeroAddress :: Word64 -> NetworkAddress
zeroAddress srv = NetworkAddress srv (sockToHostAddress (SockAddrInet 0 0))
-- | Version message for a remote peer.
remoteVersion :: Network -> Word64 -> Word64 -> IO Version
remoteVersion net services nonce = do
now <- nowTimestamp
return $
buildVersion
net
nonce
0
(zeroAddress services)
(zeroAddress 0)
(fromIntegral now)
-- | React to a message like a well-behaved full node with the provided
-- blocks (excluding genesis).
respondHonestly :: Remote -> TVar [Block] -> Message -> IO ()
respondHonestly r blocks = \case
MVersion _ -> remoteSend r MVerAck
MPing (Ping n) -> remoteSend r (MPong (Pong n))
MGetHeaders gh -> do
bs <- readTVarIO blocks
remoteSend r . headersMessage $ serveHeaders r.net (map (.header) bs) gh
MGetData (GetData ivs) -> do
bs <- readTVarIO blocks
let find_block (InvVector t h)
| t `elem` [InvBlock, InvWitnessBlock] =
listToMaybe [b | b <- bs, (headerHash b.header).get == h]
| otherwise = Nothing
found = mapMaybe (\iv -> (,) iv <$> find_block iv) ivs
missing = [iv | iv <- ivs, iv `notElem` map fst found]
forM_ found $ \(_, b) -> remoteSend r (MBlock b)
unless (null missing) $ remoteSend r (MNotFound (NotFound missing))
_ -> return ()
-- | Remote that completes the handshake and serves the provided blocks.
honestRemote :: TVar [Block] -> Remote -> IO ()
honestRemote blocks r = do
v <- remoteVersion r.net nodeNetwork =<< randomIO
remoteSend r (MVersion v)
let go =
remoteReceive r >>= \case
Nothing -> return ()
Just m -> respondHonestly r blocks m >> go
go
data TestNode = TestNode
{ node :: !Node,
events :: !(Inbox NodeEvent),
net :: !Network
}
-- | Run a node against the provided peers using a temporary database. The
-- configuration can be adjusted before the node starts.
withTestNode ::
Network ->
[String] ->
(SockAddr -> WithConnection) ->
(NodeConfig -> NodeConfig) ->
(TestNode -> IO a) ->
IO a
withTestNode net peers connect modify f =
withTestDB $ \db ->
withTestLogger $ \logger ->
withPublisher $ \pub ->
withSubscription pub $ \sub -> do
let cfg =
NodeConfig
{ maxPeers = 20,
db = db,
cf = Nothing,
peers = peers,
discover = False,
address = zeroAddress 0,
net = net,
pub = pub,
timeout = 120,
maxPeerLife = 48 * 3600,
connect = connect,
logger = logger
}
withNode (modify cfg) $ \n ->
f TestNode {node = n, events = sub, net = net}
setTimeout :: NominalDiffTime -> NodeConfig -> NodeConfig
setTimeout t c = c {NC.timeout = t}
setMaxPeerLife :: NominalDiffTime -> NodeConfig -> NodeConfig
setMaxPeerLife t c = c {NC.maxPeerLife = t}
waitForPeer :: TestNode -> IO Peer
waitForPeer t =
receiveWithin 30 "peer connection" t.events $ \case
PeerEvent (PeerConnected p) -> Just p
_ -> Nothing
waitForDisconnect :: TestNode -> Peer -> IO ()
waitForDisconnect t p =
receiveWithin 30 ("disconnection of peer " <> show p) t.events $ \case
PeerEvent (PeerDisconnected p') | p == p' -> Just ()
_ -> Nothing
-- | Wait until the best block header satisfies the condition.
waitForBest :: Double -> TestNode -> (BlockNode -> Bool) -> IO BlockNode
waitForBest s t f = do
eventually s "best block header" $ f <$> chainGetBest t.node.chain
chainGetBest t.node.chain