haskoin-node-2.5.0: test/unit/Haskoin/Node/PeerSpec.hs
-- | Peer process talking to a scripted remote over an in-memory connection.
module Haskoin.Node.PeerSpec (spec) where
import Control.Concurrent.Async (ExceptionInLinkedThread (..))
import Conduit (sinkNull, yieldMany)
import Control.Monad (forM_, forever)
import Data.ByteString qualified as B
import Data.Serialize (putWord32le, runPut)
import Data.Int (Int64)
import Data.Word (Word32)
import Haskoin
import Haskoin.Node
import Haskoin.Node.Peer.Lock (Lock (..))
import Haskoin.Node.Test
import NQE
import Test.HUnit (assertFailure)
import Test.Hspec hiding (pending)
import Test.Hspec.QuickCheck (prop)
import Test.QuickCheck (Positive (..), ioProperty)
import UnliftIO
import UnliftIO.Concurrent (threadDelay)
import System.Mem (getAllocationCounter)
data PeerTest = PeerTest
{ peer :: !Peer,
events :: !(Inbox PeerEvent),
remote :: !Remote,
thread :: !(Async ())
}
-- | Run a peer process connected to a remote that the test drives. The peer
-- knows no block headers.
withPeerTest :: Network -> (PeerTest -> IO a) -> IO a
withPeerTest net = withPeerTestKnowing net []
-- | Run a peer process that knows blocks with the given hashes at the given
-- heights, as if the node had stored their headers. Only the height of a
-- known block matters to the peer, so the header in its node is a stand-in.
withPeerTestKnowing ::
Network -> [(BlockHash, BlockHeight)] -> (PeerTest -> IO a) -> IO a
withPeerTestKnowing net known f = do
rv <- newEmptyMVar
let script r = putMVar rv r >> forever_wait
forever_wait = threadDelay maxBound >> forever_wait
lookup_block h = return (node_at <$> lookup h known)
node_at height =
BlockNode
{ header = net.genesisHeader,
height = height,
work = 0,
skip = headerHash net.genesisHeader
}
withTestLogger $ \logger ->
withPublisher $ \pub ->
withSubscription pub $ \events -> do
(inbox, mailbox) <- newMailbox
busy <- newTVarIO Free
pending <- newTVarIO 0
let cfg =
PeerConfig
{ pub = pub,
net = net,
label = "remote",
connect = memoryConnection net script,
logger = logger,
lookupBlock = lookup_block
}
p = wrapPeer cfg busy pending mailbox
withAsync (peer cfg p inbox) $ \thread -> do
remote <- within 5 "connection" (readMVar rv)
f PeerTest {peer = p, events, remote, thread}
-- | Next message received by the peer process from the remote.
received :: PeerTest -> IO Message
received t =
receiveWithin 5 "message from remote" t.events $ \case
PeerMessage p m | p == t.peer -> Just m
_ -> Nothing
-- | The peer process must stop in time with an error that satisfies the
-- predicate.
expectFailure :: PeerTest -> (PeerException -> Bool) -> IO ()
expectFailure t ok =
within 5 "peer process to stop" (waitCatch t.thread) >>= \case
Right () -> assertFailure "Peer process stopped without error"
Left e -> case peerException e of
Just pe | ok pe -> return ()
_ -> assertFailure ("Unexpected error: " <> show e)
where
peerException e =
case fromException e of
Just (ExceptionInLinkedThread _ inner) -> fromException inner
Nothing -> fromException e
-- | The peer process must stop in time without error.
expectStopped :: PeerTest -> IO ()
expectStopped t =
within 5 "peer process to stop" (waitCatch t.thread) >>= \case
Right () -> return ()
Left e -> assertFailure ("Unexpected error: " <> show e)
expectRunning :: PeerTest -> IO ()
expectRunning t =
poll t.thread >>= \case
Nothing -> return ()
Just r -> assertFailure $ "Peer process stopped: " <> show r
-- | Bytes allocated by a peer process to read one message of the given
-- payload size on Bitcoin, sent in pieces of 1460 bytes, the payload of a
-- full TCP segment. The counter is read in the thread that reads the
-- message, before the first piece and once the message has been passed on.
readAllocation :: Int -> IO Int64
readAllocation size = do
let bs = encodeMessage btc (MOther "zz" (B.replicate size 0))
pieces = chunks (repeat 1460) bs
_ <- evaluate (sum (map B.length pieces))
start <- newEmptyMVar
end <- newEmptyMVar
let src = do
liftIO (getAllocationCounter >>= putMVar start)
yieldMany pieces
liftIO (getAllocationCounter >>= putMVar end)
liftIO (forever (threadDelay maxBound))
withTestLogger $ \logger -> withPublisher $ \pub -> do
(inbox, mailbox) <- newMailbox
busy <- newTVarIO Free
pending <- newTVarIO 0
let cfg =
PeerConfig
{ pub = pub,
net = btc,
label = "remote",
connect = \f -> f (Conduits src sinkNull),
logger = logger,
lookupBlock = \_ -> return Nothing
}
withAsync (peer cfg (wrapPeer cfg busy pending mailbox) inbox) $ \_ -> do
counter0 <- within 30 "first piece" (readMVar start)
counter1 <- within 30 "message read" (readMVar end)
-- the counter goes down as the thread allocates
return (counter0 - counter1)
-- | Chop a byte string into pieces of the given sizes.
chunks :: [Int] -> B.ByteString -> [B.ByteString]
chunks _ b | B.null b = []
chunks [] b = [b]
chunks (n : ns) b = let (x, y) = B.splitAt (max 1 n) b in x : chunks ns y
-- | Valid message with the payload length field replaced.
withLength :: Word32 -> B.ByteString -> B.ByteString
withLength len bs = B.take 16 bs <> runPut (putWord32le len) <> B.drop 20 bs
-- | A version message with a user agent of the given length.
withUserAgent :: Int -> Version -> Version
withUserAgent n v =
Version
{ version = v.version,
services = v.services,
timestamp = v.timestamp,
addrRecv = v.addrRecv,
addrSend = v.addrSend,
nonce = v.nonce,
userAgent = VarString (B.replicate n 0x61),
startHeight = v.startHeight,
relay = v.relay
}
spec :: Spec
spec = do
let net = btcRegTest
t0 <- runIO nowTimestamp
let blocks = mkBlocks net 0 net.genesisHeader t0 5
b0 = blocks !! 0
bhash :: Block -> BlockHash
bhash b = headerHash b.header
block_iv :: Block -> InvVector
block_iv b = InvVector InvWitnessBlock (bhash b).get
-- the parent of 'coreSegWitBlock', block 301 of its chain
parent302 = "7bd5bfebea6d3194d92f9f292bc57d82c884f72c53bb11f529c8973fb63e52b9"
stripped b = b {txs = [tx {witness = []} | tx <- b.txs]}
describe "peer message framing" $ do
it "parses messages split across arbitrary chunks" $
withPeerTest net $ \t -> do
let bs = encodeMessage net (MPing (Ping 42))
forM_ (chunks (repeat 1) bs) (remoteSendRaw t.remote)
received t `shouldReturn` MPing (Ping 42)
prop "parses messages regardless of how they are chunked" $
\(sizes :: [Positive Int]) -> ioProperty $
withPeerTest net $ \t -> do
let msgs = [MPing (Ping 1), MBlock b0, MPong (Pong 2)]
bs = B.concat (map (encodeMessage net) msgs)
forM_ (chunks (map getPositive sizes) bs) (remoteSendRaw t.remote)
mapM (const (received t)) msgs `shouldReturn` msgs
it "parses several messages sent at once" $
withPeerTest net $ \t -> do
let msgs = [MPing (Ping 1), MVerAck, MPing (Ping 2)]
remoteSendRaw t.remote (B.concat (map (encodeMessage net) msgs))
mapM (const (received t)) msgs `shouldReturn` msgs
it "accepts unknown commands" $ withPeerTest net $ \t -> do
remoteSend t.remote (MOther "xyzzy" "\1\2\3")
remoteSend t.remote (MPing (Ping 7))
received t `shouldReturn` MOther "xyzzy" "\1\2\3"
received t `shouldReturn` MPing (Ping 7)
it "sends messages to the remote" $ withPeerTest net $ \t -> do
MPing (Ping 9) `sendMessage` t.peer
remoteReceiveMatch 5 "ping" t.remote Just `shouldReturn` MPing (Ping 9)
describe "peer reading" $ do
it "allocates in proportion to the size of a message sent in 1460-byte pieces" $ do
-- Reading should cost memory linear in the size of the message, as in
-- Bitcoin Core, which reads into one buffer. Twice the size should then
-- cost about twice the allocation; a cost that grows with the square of
-- the size would cost about four times. 4,000,000 bytes is the largest
-- message Bitcoin allows.
small <- readAllocation 2000000
large <- readAllocation 4000000
fromIntegral large / fromIntegral small `shouldSatisfy` (< (2.5 :: Double))
it "stops reading a peer that has 16 messages waiting to be handled" $
withPeerTest net $ \t -> do
-- The node keeps at most 16 messages from a peer waiting.
forM_ [1 .. 20] $ \i -> remoteSend t.remote (MPing (Ping i))
got <- mapM (const (received t)) [1 .. 16 :: Int]
got `shouldBe` [MPing (Ping i) | i <- [1 .. 16]]
expectNothingWithin 1 "a 17th message" t.events $ \case
PeerMessage _ _ -> Just ()
_ -> Nothing
peerMessageDone t.peer
received t `shouldReturn` MPing (Ping 17)
describe "peer protocol violations" $ do
it "disconnects on wrong network magic" $ withPeerTest net $ \t -> do
remoteSendRaw t.remote (encodeMessage btc (MPing (Ping 1)))
expectFailure t $ \case
WrongNetwork m -> m == btc.magic
_ -> False
it "disconnects on oversized message" $ withPeerTest net $ \t -> do
-- send only the header: the peer must not wait for the payload
let bs = encodeMessage net (MBlock b0)
remoteSendRaw t.remote (B.take 24 (withLength (33 * 1024 * 1024) bs))
expectFailure t $ \case
MessageTooLarge MCBlock _ -> True
_ -> False
it "disconnects on bad checksum" $ withPeerTest net $ \t -> do
let bs = encodeMessage net (MPing (Ping 1))
bad = B.take 20 bs <> "\0\0\0\0" <> B.drop 24 bs
remoteSendRaw t.remote bad
expectFailure t $ \case
InvalidPayload MCPing _ -> True
_ -> False
it "disconnects on malformed payload" $ withPeerTest net $ \t -> do
-- a ping payload is eight bytes, send three with a valid checksum
let payload = "\1\2\3"
bs = encodeMessage net (MOther "ping" payload)
remoteSendRaw t.remote bs
expectFailure t $ \case
InvalidPayload MCPing _ -> True
_ -> False
it "disconnects a peer that announces a message over 4,000,000 bytes on Bitcoin" $
withPeerTest btc $ \t -> do
-- Bitcoin Core: MAX_PROTOCOL_MESSAGE_LENGTH = 4,000,000. Only the
-- header is sent.
let bs = encodeMessage btc (MTx (coinbaseTx 0 0))
remoteSendRaw t.remote (B.take 24 (withLength 4000001 bs))
expectFailure t $ \case
MessageTooLarge MCTx 4000001 -> True
_ -> False
it "disconnects a peer that sends an inventory of 50,001 items" $
withPeerTest net $ \t -> do
-- Bitcoin Core: MAX_INV_SZ = 50,000
remoteSend t.remote (MInv (Inv (replicate 50001 (block_iv b0))))
expectFailure t $ \case
InvalidPayload MCInv _ -> True
_ -> False
it "accepts an inventory of 50,000 items" $ withPeerTest net $ \t -> do
remoteSend t.remote (MInv (Inv (replicate 50000 (block_iv b0))))
received t >>= \case
MInv (Inv ivs) -> length ivs `shouldBe` 50000
m -> assertFailure ("Unexpected message: " <> show (msgType m))
it "disconnects a peer that sends a locator of 102 hashes" $
withPeerTest net $ \t -> do
-- Bitcoin Core: MAX_LOCATOR_SZ = 101
let gh = GetHeaders {version = 70012, locator = replicate 102 (bhash b0), stop = bhash b0}
remoteSend t.remote (MGetHeaders gh)
expectFailure t $ \case
InvalidPayload MCGetHeaders _ -> True
_ -> False
it "accepts a locator of 101 hashes" $ withPeerTest net $ \t -> do
let gh = GetHeaders {version = 70012, locator = replicate 101 (bhash b0), stop = bhash b0}
remoteSend t.remote (MGetHeaders gh)
received t `shouldReturn` MGetHeaders gh
it "disconnects a peer whose user agent is 257 bytes long" $
withPeerTest net $ \t -> do
-- Bitcoin Core: MAX_SUBVERSION_LENGTH = 256
v <- remoteVersion net nodeNetwork 1
remoteSend t.remote (MVersion (withUserAgent 257 v))
expectFailure t $ \case
InvalidPayload MCVersion _ -> True
_ -> False
it "accepts a user agent of 256 bytes" $ withPeerTest net $ \t -> do
v <- remoteVersion net nodeNetwork 1
let v' = withUserAgent 256 v
remoteSend t.remote (MVersion v')
received t `shouldReturn` MVersion v'
it "disconnects when the remote closes the connection" $
withPeerTest net $ \t -> do
remoteClose t.remote
expectFailure t $ \case
ConnectionClosed -> True
_ -> False
it "disconnects when told to" $ withPeerTest net $ \t -> do
expectRunning t
killPeer t.peer
expectStopped t
eventually 5 "connection closed" (readTVarIO t.remote.closed)
describe "peer data requests" $ do
let serve :: PeerTest -> (Message -> [Message]) -> IO a -> IO a
serve t f =
withAsync (remote_loop t f) . const
remote_loop :: PeerTest -> (Message -> [Message]) -> IO ()
remote_loop t f =
remoteReceive t.remote >>= \case
Nothing -> return ()
Just m -> mapM_ (remoteSend t.remote) (f m) >> remote_loop t f
pong (MPing (Ping n)) = [MPong (Pong n)]
pong _ = []
honest = \case
MGetData (GetData ivs) ->
[MBlock b | iv <- ivs, b <- blocks, block_iv b == iv]
m -> pong m
it "downloads blocks" $ withPeerTest net $ \t -> serve t honest $ do
r <- getBlocks net 5 t.peer (map bhash (take 3 blocks))
r `shouldBe` Just (take 3 blocks)
it "ignores unrelated messages while downloading" $
withPeerTest net $ \t -> do
let noisy = \case
MGetData (GetData ivs) ->
concat
[ [MBlock b, MPing (Ping 5), MInv (Inv [block_iv b])]
| iv <- ivs,
b <- blocks,
block_iv b == iv
]
m -> pong m
serve t noisy $ do
r <- getBlocks net 5 t.peer (map bhash (take 3 blocks))
r `shouldBe` Just (take 3 blocks)
it "fails when a requested block is not found" $
withPeerTest net $ \t -> do
let f = \case
MGetData (GetData (iv : _)) -> [MNotFound (NotFound [iv])]
m -> pong m
serve t f $
getBlocks net 5 t.peer [bhash b0] `shouldReturn` Nothing
it "times out when the peer does not answer" $
withPeerTest net $ \t ->
getBlocks net 1 t.peer [bhash b0] `shouldReturn` Nothing
it "gives nothing and drops a peer whose block does not match its header" $
withPeerTest net $ \t -> do
let forged = Block {header = b0.header, txs = [coinbaseTx 99 7]}
f = \case
MGetData _ -> [MBlock forged]
m -> pong m
serve t f $ getBlocks net 2 t.peer [bhash b0] `shouldReturn` Nothing
expectFailure t $ \case
MutatedBlock h BadMerkleRoot -> h == bhash b0
_ -> False
it "returns a SegWit block that Bitcoin Core mined, with its witness" $
withPeerTestKnowing net [(parent302, 301)] $ \t -> do
let f = \case
MGetData _ -> [MBlock coreSegWitBlock]
m -> pong m
serve t f $
getBlocks net 5 t.peer [bhash coreSegWitBlock]
`shouldReturn` Just [coreSegWitBlock]
it "gives nothing and drops a peer that stripped the witness of a SegWit block" $
withPeerTestKnowing net [(parent302, 301)] $ \t -> do
let f = \case
MGetData _ -> [MBlock (stripped coreSegWitBlock)]
m -> pong m
serve t f $ getBlocks net 2 t.peer [bhash coreSegWitBlock] `shouldReturn` Nothing
expectFailure t $ \case
MutatedBlock _ BadWitnessNonceSize -> True
_ -> False
it "gives nothing and drops a peer that repeats the last transaction of a block" $
withPeerTestKnowing net [(parent302, 301)] $ \t -> do
-- CVE-2012-2459: the repeated transaction keeps the Merkle root.
let b = coreSegWitBlock {txs = coreSegWitBlock.txs <> drop 2 coreSegWitBlock.txs}
f = \case
MGetData _ -> [MBlock b]
m -> pong m
serve t f $ getBlocks net 2 t.peer [bhash coreSegWitBlock] `shouldReturn` Nothing
expectFailure t $ \case
MutatedBlock _ DuplicateTransactions -> True
_ -> False
it "does not check the witness of a block whose parent it does not know" $
withPeerTest net $ \t -> do
-- Bitcoin Core does not check it either: without the parent, the
-- height and so the witness rule are unknown.
let f = \case
MGetData _ -> [MBlock (stripped coreSegWitBlock)]
m -> pong m
serve t f $
getBlocks net 5 t.peer [bhash coreSegWitBlock]
`shouldReturn` Just [stripped coreSegWitBlock]
it "downloads transactions" $ withPeerTest net $ \t -> do
let txs = concatMap (.txs) blocks
tx_iv tx = InvVector InvWitnessTx (txHash tx).get
f = \case
MGetData (GetData ivs) ->
[MTx tx | iv <- ivs, tx <- txs, tx_iv tx == iv]
m -> pong m
serve t f $
getTxs net 5 t.peer (map txHash (take 2 txs))
`shouldReturn` Just (take 2 txs)
it "pings the peer" $ withPeerTest net $ \t -> do
serve t pong $ pingPeer 5 t.peer `shouldReturn` True
it "reports an unanswered ping" $ withPeerTest net $ \t ->
pingPeer 1 t.peer `shouldReturn` False