haskoin-node 0.11.3 → 0.12.0
raw patch · 8 files changed
+250/−96 lines, 8 filesdep +base64
Dependencies added: base64
Files
- CHANGELOG.md +4/−0
- haskoin-node.cabal +5/−3
- src/Haskoin/Node.hs +13/−7
- src/Haskoin/Node/Chain.hs +10/−3
- src/Haskoin/Node/Common.hs +19/−6
- src/Haskoin/Node/Manager.hs +4/−2
- src/Haskoin/Node/Peer.hs +12/−13
- test/Haskoin/NodeSpec.hs +183/−62
CHANGELOG.md view
@@ -4,6 +4,10 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## 0.12.0+### Changed+- Add a test suite that simulates network instead of connecting to real one.+ ## 0.11.3 ### Changed - Revert including multiline decoding error text in logs.
haskoin-node.cabal view
@@ -4,10 +4,10 @@ -- -- see: https://github.com/sol/hpack ----- hash: 68c2f796296143516ef7f719674817c9233b3dec269de968a1ed75bc09da710d+-- hash: 5146a554596617bed514f4367a6d203381cfbe8e7848f1bd4835d92fa9727df9 name: haskoin-node-version: 0.11.3+version: 0.12.0 synopsis: Haskoin Node P2P library for Bitcoin and Bitcoin Cash description: Bitcoin and Bitcoin Cash peer-to-peer protocol library featuring headers-first synchronisation. category: Bitcoin, Finance, Network@@ -29,16 +29,17 @@ library exposed-modules: Haskoin.Node+ other-modules: Haskoin.Node.Chain Haskoin.Node.Common Haskoin.Node.Manager Haskoin.Node.Peer- other-modules: Paths_haskoin_node hs-source-dirs: src build-depends: base >=4.9 && <5+ , base64 , bytestring , cereal , conduit@@ -74,6 +75,7 @@ build-depends: HUnit , base >=4.9 && <5+ , base64 , bytestring , cereal , conduit
src/Haskoin/Node.hs view
@@ -21,6 +21,8 @@ , Peer , Chain , PeerManager+ , WithConnection+ , Conduits(..) , ChainMessage(..) , PeerManagerMessage(..) , PeerMessage(..)@@ -47,7 +49,9 @@ , chainGetSplitBlock , chainBlockMain , chainIsSynced+ , withConnection , myVersion+ , buildVersion ) where import Control.Monad.Logger (MonadLoggerIO)@@ -55,13 +59,14 @@ Message (..)) import Haskoin.Node.Chain (chain) import Haskoin.Node.Common (Chain, ChainConfig (..), ChainEvent (..),- ChainMessage (..), Host, HostPort,- NodeConfig (..), NodeEvent (..),- OnlinePeer (..), Peer, PeerEvent (..),- PeerException (..), PeerManager,- PeerManagerConfig (..),+ ChainMessage (..), Conduits (..), Host,+ HostPort, NodeConfig (..),+ NodeEvent (..), OnlinePeer (..), Peer,+ PeerEvent (..), PeerException (..),+ PeerManager, PeerManagerConfig (..), PeerManagerMessage (..),- PeerMessage (..), Port, chainBlockMain,+ PeerMessage (..), Port, WithConnection,+ buildVersion, chainBlockMain, chainGetAncestor, chainGetBest, chainGetBlock, chainGetParents, chainGetSplitBlock, chainIsSynced,@@ -69,7 +74,7 @@ managerGetPeers, managerPeerText, myVersion, peerGetBlocks, peerGetPublisher, peerGetTxs,- sendMessage)+ sendMessage, withConnection) import Haskoin.Node.Manager (peerManager) import NQE (Inbox, inboxToMailbox, newInbox, sendSTM) import UnliftIO (MonadUnliftIO, link, withAsync)@@ -108,6 +113,7 @@ , peerManagerEvents = mgr_events , peerManagerTimeout = nodeConfTimeout cfg , peerManagerTooOld = nodeConfPeerOld cfg+ , peerManagerConnect = nodeConfConnect cfg } withAsync (peerManager mgr_config mgr_inbox) $ \mgr_async -> do link mgr_async
src/Haskoin/Node/Chain.hs view
@@ -142,7 +142,8 @@ getSyncingPeer >>= \case Nothing -> do nextPeer >>= \case- Nothing -> return ()+ Nothing ->+ $(logDebugS) "Chain" "No more peers to sync headers against" Just p -> syncPeer p Just _ -> return () @@ -153,19 +154,25 @@ syncPeer :: MonadChain m => Peer -> m () syncPeer p = do+ mgr <- asks (chainConfManager . myReader) bb <- chainSyncingPeer >>= \case- Just ChainSync {chainSyncPeer = p', chainHighest = Just g}- | p == p' -> return g+ Just ChainSync {chainSyncPeer = p2, chainHighest = Just g}+ | p == p2 -> return g _ -> getBestBlockHeader now <- round <$> liftIO getPOSIXTime gh <- syncHeaders now bb p+ p' <- managerPeerText p mgr+ $(logDebugS) "Chain" $ "Syncing headers against peer: " <> p' MGetHeaders gh `sendMessage` p chainMessage :: MonadChain m => ChainMessage -> m () chainMessage (ChainGetBest reply) = getBestBlockHeader >>= atomically . reply chainMessage (ChainHeaders p hs) = processHeaders p hs chainMessage (ChainPeerConnected p _) = do+ mgr <- asks (chainConfManager . myReader)+ p' <- managerPeerText p mgr+ $(logDebugS) "Chain" $ "New peer connected: " <> p' addPeer p syncNewPeer chainMessage (ChainPeerDisconnected p _) = do
src/Haskoin/Node/Common.hs view
@@ -15,16 +15,19 @@ -} module Haskoin.Node.Common where +import Conduit (ConduitT) import Control.Monad (join) import Control.Monad.Trans.Maybe (MaybeT (..), runMaybeT)-import Data.Conduit.Network (AppData, clientSettings,- runGeneralTCPClient)+import Data.ByteString (ByteString)+import Data.Conduit.Network (appSink, appSource, clientSettings,+ runTCPClient) import Data.Function (on) import Data.List (union) import Data.Maybe (fromMaybe, isJust) import Data.String.Conversions (cs) import Data.Text (Text) import Data.Time.Clock (NominalDiffTime, UTCTime)+import Data.Void (Void) import Data.Word (Word32, Word64) import Database.RocksDB (DB) import Haskoin (Block (..), BlockHash,@@ -106,6 +109,14 @@ -- | Mailbox for peer manager process. type PeerManager = Mailbox PeerManagerMessage +data Conduits =+ Conduits+ { inboundConduit :: ConduitT () ByteString IO ()+ , outboundConduit :: ConduitT ByteString Void IO ()+ }++type WithConnection = SockAddr -> (Conduits -> IO ()) -> IO ()+ -- | General node configuration. data NodeConfig = NodeConfig { nodeConfMaxPeers :: !Int@@ -126,6 +137,7 @@ -- ^ timeout in seconds , nodeConfPeerOld :: !Int -- ^ peer disconnect after seconds+ , nodeConfConnect :: !WithConnection } -- | Peer manager configuration.@@ -147,6 +159,7 @@ -- ^ timeout in seconds , peerManagerTooOld :: !Int -- ^ disconnect peers after connected for so long+ , peerManagerConnect :: !WithConnection } -- | Messages that can be sent to the peer manager.@@ -232,6 +245,7 @@ -- ^ network constants , peerConfAddress :: !SockAddr -- ^ peer address+ , peerConfConnect :: !WithConnection } -- | Reasons why a peer may stop working.@@ -532,14 +546,13 @@ chainHeaders p hs ch = ChainHeaders p hs `send` ch -- | Connect to a socket via TCP.-withConnection ::- MonadUnliftIO m => SockAddr -> (AppData -> m a) -> m a+withConnection :: WithConnection withConnection na f = fromSockAddr na >>= \case Nothing -> throwIO PeerAddressInvalid- Just (host, port) ->+ Just (host, port) -> do let cset = clientSettings port (cs host)- in runGeneralTCPClient cset f+ runTCPClient cset $ \ad -> f (Conduits (appSource ad) (appSink ad)) -- | Calculate the median value from a list. The list must not be empty. median :: Fractional a => [a] -> Maybe a
src/Haskoin/Node/Manager.hs view
@@ -354,6 +354,7 @@ } <- asks myConfig mgr <- asks myMailbox sup <- asks mySupervisor+ conn <- asks (peerManagerConnect . myConfig) nonce <- liftIO randomIO bb <- getBestBlock now <- round <$> liftIO getPOSIXTime@@ -365,6 +366,7 @@ { peerConfListen = pub , peerConfNetwork = net , peerConfAddress = sa+ , peerConfConnect = conn } a <- withRunInIO $ \io -> sup `addChild` io (launch mgr pc inbox p) MVersion ver `sendMessage` p@@ -403,9 +405,9 @@ where go = forever $ do- threadDelay =<<- liftIO (randomRIO (2 * 1000 * 1000, 10 * 1000 * 1000)) PeerManagerConnect `send` mgr+ threadDelay =<<+ liftIO (randomRIO (100 * 1000, 10 * 500 * 1000)) -- | Add a peer. newPeer :: (MonadIO m, MonadManager m) => SockAddr -> m ()
src/Haskoin/Node/Peer.hs view
@@ -20,25 +20,24 @@ ) where import Conduit (ConduitT, awaitForever, foldC, mapM_C,- runConduit, takeCE, yield, (.|))+ runConduit, takeCE, transPipe, yield,+ (.|)) import Control.Monad (forever, when) import Control.Monad.Logger (MonadLoggerIO, logErrorS)-import Control.Monad.Reader (runReaderT) import Data.ByteString (ByteString) import qualified Data.ByteString as B-import Data.Conduit.Network (appSink, appSource) import Data.Serialize (decode, runGet, runPut) import Data.String.Conversions (cs) import Haskoin (Message, MessageHeader (..), Network, getMessage, putMessage)-import Haskoin.Node.Common (PeerConfig (..), PeerException (..),- PeerMessage (..), peerString,- withConnection)+import Haskoin.Node.Common (Conduits (..), PeerConfig (..),+ PeerException (..), PeerMessage (..),+ peerString) import Network.Socket (SockAddr) import NQE (Inbox, PublisherMessage (..), receive, send)-import UnliftIO (MonadUnliftIO, atomically, link,- throwIO, withAsync)+import UnliftIO (MonadUnliftIO, atomically, liftIO,+ link, throwIO, withAsync, withRunInIO) -- | Run peer process in current thread. peer ::@@ -46,15 +45,15 @@ => PeerConfig -> Inbox PeerMessage -> m ()-peer pc inbox = withConnection a $ \ad -> runReaderT (peer_session ad) pc+peer pc inbox = withRunInIO $ \r -> connect a (r . peer_session) where+ connect = peerConfConnect pc a = peerConfAddress pc- go = forever $ do- receive inbox >>= dispatchMessage pc+ go = forever $ receive inbox >>= dispatchMessage pc net = peerConfNetwork pc peer_session ad =- let ins = appSource ad- ons = appSink ad+ let ins = transPipe liftIO (inboundConduit ad)+ ons = transPipe liftIO (outboundConduit ad) src = runConduit $ ins .| inPeerConduit net a .| mapM_C send_msg snk = outPeerConduit net .| ons in withAsync src $ \as -> do
test/Haskoin/NodeSpec.hs view
@@ -3,33 +3,57 @@ {-# LANGUAGE MultiParamTypeClasses #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RecordWildCards #-}+{-# LANGUAGE TemplateHaskell #-} module Haskoin.NodeSpec ( spec ) where -import Control.Monad (forM_, replicateM, replicateM_)-import Control.Monad.Logger (runNoLoggingT)-import Control.Monad.Trans (lift)-import Data.Maybe (isJust)-import qualified Database.RocksDB as R-import Haskoin (Block (..), BlockHeader (..),- BlockNode (..), Network,- NetworkAddress (..), Version (..),- btcTest, buildMerkleRoot,- getGenesisHeader, headerHash,- sockToHostAddress, txHash)-import Haskoin.Node (Chain, ChainEvent (..), NodeConfig (..),- NodeEvent (..), OnlinePeer (..), Peer,- PeerEvent (..), PeerManager,- chainGetAncestor, chainGetBest,- chainGetParents, managerGetPeer,- peerGetBlocks, peerGetTxs, withNode)-import Network.Socket (SockAddr (..))-import NQE (Inbox, newInbox, receiveMatch, sendSTM)-import Test.Hspec (Spec, describe, it, shouldBe,- shouldReturn, shouldSatisfy)-import UnliftIO (MonadIO, MonadUnliftIO, throwString,- withSystemTempDirectory)+import Conduit (awaitForever, concatMapC, foldC, mapC,+ mapMC, runConduit, takeCE, yield,+ (.|))+import Control.Monad (forM_, forever, replicateM,+ replicateM_)+import Control.Monad.Logger (logDebug, runNoLoggingT)+import Control.Monad.Trans (lift)+import Data.ByteString (ByteString)+import qualified Data.ByteString as B+import Data.ByteString.Base64 (decodeBase64Lenient)+import Data.Either (fromRight)+import Data.Maybe (isJust, listToMaybe, mapMaybe)+import Data.Serialize (decode, get, runGet, runPut)+import Data.String.Conversions (cs)+import Data.Time.Clock.POSIX (getCurrentTime, getPOSIXTime)+import qualified Database.RocksDB as R+import Haskoin (Block (..), BlockHash (..),+ BlockHeader (..), BlockNode (..),+ GetData (..), GetHeaders (..),+ Headers (..), InvType (..),+ InvVector (..), Message (..),+ MessageHeader (..), Network (..),+ NetworkAddress (..), Ping (..),+ Pong (..), VarInt (..), Version (..),+ bchRegTest, btcTest, buildMerkleRoot,+ getGenesisHeader, getMessage,+ headerHash, nodeNetwork, putMessage,+ sockToHostAddress, txHash)+import Haskoin.Node (Chain, ChainEvent (..), Conduits (..),+ NodeConfig (..), NodeEvent (..),+ OnlinePeer (..), Peer, PeerEvent (..),+ PeerManager, WithConnection,+ buildVersion, chainGetAncestor,+ chainGetBest, chainGetParents,+ managerGetPeer, peerGetBlocks,+ peerGetTxs, withConnection, withNode)+import Network.Socket (SockAddr (..))+import NQE (Inbox, Mailbox, inboxToMailbox,+ newInbox, receive, receiveMatch, send,+ sendSTM)+import System.Random (randomIO, randomRIO)+import Test.Hspec (Spec, describe, it, shouldBe,+ shouldReturn, shouldSatisfy)+import UnliftIO (MonadIO, MonadUnliftIO, liftIO, link,+ throwString, withAsync,+ withSystemTempDirectory) data TestNode = TestNode { testMgr :: PeerManager@@ -37,9 +61,59 @@ , nodeEvents :: Inbox NodeEvent } +dummyPeerConnect :: Network -> NetworkAddress -> WithConnection+dummyPeerConnect net ad sa f = do+ r <- newInbox+ s <- newInbox+ let s' = inboxToMailbox s+ withAsync (go r s') $ \_ -> do+ let o = awaitForever (`send` r)+ i = forever (receive s >>= yield)+ f (Conduits i o) :: IO ()+ where+ go :: Inbox ByteString -> Mailbox ByteString -> IO ()+ go r s = do+ nonce <- randomIO+ now <- round <$> liftIO getPOSIXTime+ let rmt = NetworkAddress 0 (sockToHostAddress sa)+ ver = buildVersion net nonce 0 ad rmt now+ runPut (putMessage net (MVersion ver)) `send` s+ runConduit $+ forever (receive r >>= yield) .| inc .| concatMapC mockPeerReact .|+ outc .|+ awaitForever (`send` s)+ outc = mapMC $ \msg -> return $ runPut (putMessage net msg)+ inc =+ forever $ do+ x <- takeCE 24 .| foldC+ case decode x of+ Left _ -> error "Dummy peer not decode message header"+ Right (MessageHeader _ _ len _) -> do+ y <- takeCE (fromIntegral len) .| foldC+ case runGet (getMessage net) $ x `B.append` y of+ Left e ->+ error $+ "Dummy peer could not decode payload: " <> show e+ Right msg -> yield msg++mockPeerReact :: Message -> [Message]+mockPeerReact (MPing (Ping n)) = [MPong (Pong n)]+mockPeerReact (MVersion _) = [MVerAck]+mockPeerReact (MGetHeaders (GetHeaders _ _hs _)) = [MHeaders (Headers hs')]+ where+ f b = (blockHeader b, VarInt (fromIntegral (length (blockTxns b))))+ hs' = map f allBlocks+mockPeerReact (MGetData (GetData ivs)) = mapMaybe f ivs+ where+ f (InvVector InvBlock h) = MBlock <$> listToMaybe (filter (l h) allBlocks)+ f _ = Nothing+ l h b = headerHash (blockHeader b) == BlockHash h+mockPeerReact _ = []++ spec :: Spec spec = do- let net = btcTest+ let net = bchRegTest describe "peer manager on test network" $ do it "connects to a peer" $ withTestNode net "connect-one-peer" $ \TestNode {..} -> do@@ -50,11 +124,11 @@ it "downloads some blocks" $ withTestNode net "get-blocks" $ \TestNode {..} -> do let h1 =- "000000000babf10e26f6cba54d9c282983f1d1ce7061f7e875b58f8ca47db932"+ "3094ed3592a06f3d8e099eed2d9c1192329944f5df4a48acb29e08f12cfbb660" h2 =- "00000000851f278a8b2c466717184aae859af5b83c6f850666afbc349cf61577"+ "0c89955fc5c9f98ecc71954f167b938138c90c6a094c4737f2e901669d26763f" p <- waitForPeer nodeEvents- pbs <- peerGetBlocks net 30 p [h1, h2]+ pbs <- peerGetBlocks net 10 p [h1, h2] pbs `shouldSatisfy` isJust let Just [b1, b2] = pbs headerHash (blockHeader b1) `shouldBe` h1@@ -64,53 +138,39 @@ buildMerkleRoot (map txHash (blockTxns b)) testMerkle b1 testMerkle b2- it "connects to two peers" $- withTestNode net "connect-peers" $ \TestNode {..} ->- replicateM_ 2 (waitForPeer nodeEvents) `shouldReturn` ()- it "attempts to get inexistent things" $- withTestNode net "download-fail" $ \TestNode {..} -> do- let hs =- [ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"- , "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"- ]- p <- waitForPeer nodeEvents- peerGetTxs net 30 p hs `shouldReturn` Nothing describe "chain on test network" $ do it "syncs some headers" $ withTestNode net "connect-sync" $ \TestNode {..} -> do- let h =- "000000009ec921df4bb16aedd11567e27ede3c0b63835b257475d64a059f102b"- hs =- [ headerHash (getGenesisHeader net)- , "0000000005bdbddb59a3cd33b69db94fa67669c41d9d32751512b5d7b68c71cf"- , "00000000185b36fa6e406626a722793bea80531515e0b2a99ff05b73738901f1"- , "000000001ab69b12b73ccdf46c9fbb4489e144b54f1565e42e481c8405077bdd"- ]- bns <-- replicateM 4 . receiveMatch nodeEvents $ \case- ChainEvent (ChainBestBlock bn) -> Just bn+ let bh =+ "3bfa0c6da615fc45aa44ddea6854ac19d16f3ca167e0e21ac2cc262a49c9b002"+ ah =+ "7dc835a78a55fa76f9184dc4f6663a73e418c7afec789c5ae25e432fd7fc8467"+ bn <-+ receiveMatch nodeEvents $ \case+ ChainEvent (ChainBestBlock bn)+ | nodeHeight bn > 0 -> Just bn _ -> Nothing bb <- chainGetBest testChain- nodeHeight bb `shouldSatisfy` (>= 6000)+ nodeHeight bb `shouldSatisfy` (== 15) an <- maybe (throwString "No ancestor found") return =<<- chainGetAncestor 2357 (last bns) testChain- map (headerHash . nodeHeader) bns `shouldBe` hs- headerHash (nodeHeader an) `shouldBe` h+ chainGetAncestor 10 bn testChain+ headerHash (nodeHeader bn) `shouldBe` bh+ headerHash (nodeHeader an) `shouldBe` ah it "downloads some block parents" $ withTestNode net "parents" $ \TestNode {..} -> do let hs =- [ "00000000c74a24e1b1f2c04923c514ed88fc785cf68f52ed0ccffd3c6fe3fbd9"- , "000000007e5c5f40e495186ac4122f2e4ee25788cc36984a5760c55ecb376cb1"- , "00000000a6299059b2bff3479bc569019792e75f3c0f39b10a0bc85eac1b1615"+ [ "52e886df7b166d961ac2d3d2d561d806325d51a609dc0a5d9d5fcb65d47906d7"+ , "2537a081b9e2b24d217fac2886f387758cb3aa4e4956b3be7ed229bafbb71b0f"+ , "7c72f306215a296f9714320a497b1f2cb5f9b99f162d7e04333c243fac9a54d8" ] [_, bn] <- replicateM 2 $ receiveMatch nodeEvents $ \case ChainEvent (ChainBestBlock bn) -> Just bn _ -> Nothing- nodeHeight bn `shouldBe` 2000- ps <- chainGetParents 1997 bn testChain+ nodeHeight bn `shouldBe` 15+ ps <- chainGetParents 12 bn testChain length ps `shouldBe` 3 forM_ (zip ps hs) $ \(p, h) -> headerHash (nodeHeader p) `shouldBe` h@@ -121,7 +181,6 @@ PeerEvent (PeerConnected p _) -> Just p _ -> Nothing - withTestNode :: MonadUnliftIO m => Network@@ -140,18 +199,80 @@ , R.errorIfExists = True , R.compression = R.SnappyCompression }- let cfg =+ let ad = NetworkAddress nodeNetwork (sockToHostAddress (SockAddrInet 0 0))+ cfg = NodeConfig { nodeConfMaxPeers = 20 , nodeConfDB = db- , nodeConfPeers = []- , nodeConfDiscover = True+ , nodeConfPeers = [("127.0.0.1", 17486)]+ , nodeConfDiscover = False , nodeConfNetAddr = NetworkAddress 0 (sockToHostAddress (SockAddrInet 0 0)) , nodeConfNet = net , nodeConfEvents = (`sendSTM` node_inbox) , nodeConfTimeout = 120 , nodeConfPeerOld = 48 * 3600+ , nodeConfConnect = dummyPeerConnect net ad } withNode cfg $ \(mgr, ch) -> lift $ f TestNode {testMgr = mgr, testChain = ch, nodeEvents = node_inbox}++allBlocks :: [Block]+allBlocks =+ fromRight (error "Could not decode blocks") $+ runGet f (decodeBase64Lenient allBlocksBase64)+ where+ f = mapM (const get) [1..15]++allBlocksBase64 :: ByteString+allBlocksBase64 =+ "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\+ \wT6Wn6LtY47B39Th44JKsZ8BGJCvc6wAAAAA"