haskoin-node 2.4.0 → 2.5.0
raw patch · 27 files changed
+2045/−240 lines, 27 filesdep +streaming-commonsdep ~rocksdb-haskell-jpruppdep ~textPVP ok
version bump matches the API change (PVP)
Dependencies added: streaming-commons
Dependency ranges changed: rocksdb-haskell-jprupp, text
API changes (from Hackage documentation)
+ Haskoin.Node.Address: IPv4Group :: !Word8 -> !Word8 -> NetGroup
+ Haskoin.Node.Address: IPv6Group :: ![Word8] -> NetGroup
+ Haskoin.Node.Address: LocalGroup :: NetGroup
+ Haskoin.Node.Address: UnroutableGroup :: NetGroup
+ Haskoin.Node.Address: connectTimeout :: NominalDiffTime
+ Haskoin.Node.Address: data NetGroup
+ Haskoin.Node.Address: instance GHC.Classes.Eq Haskoin.Node.Address.NetGroup
+ Haskoin.Node.Address: instance GHC.Classes.Ord Haskoin.Node.Address.NetGroup
+ Haskoin.Node.Address: instance GHC.Internal.Show.Show Haskoin.Node.Address.NetGroup
+ Haskoin.Node.Address: netGroup :: SockAddr -> NetGroup
+ Haskoin.Node.Block: BadMerkleRoot :: BlockMutation
+ Haskoin.Node.Block: BadWitnessCommitment :: BlockMutation
+ Haskoin.Node.Block: BadWitnessNonceSize :: BlockMutation
+ Haskoin.Node.Block: DuplicateTransactions :: BlockMutation
+ Haskoin.Node.Block: SegWitActive :: WitnessCheck
+ Haskoin.Node.Block: SegWitInactive :: WitnessCheck
+ Haskoin.Node.Block: SixtyFourByteTransaction :: BlockMutation
+ Haskoin.Node.Block: UnexpectedWitness :: BlockMutation
+ Haskoin.Node.Block: WitnessUnknown :: WitnessCheck
+ Haskoin.Node.Block: blockMutation :: WitnessCheck -> Block -> Maybe BlockMutation
+ Haskoin.Node.Block: data BlockMutation
+ Haskoin.Node.Block: data WitnessCheck
+ Haskoin.Node.Block: instance GHC.Classes.Eq Haskoin.Node.Block.BlockMutation
+ Haskoin.Node.Block: instance GHC.Classes.Eq Haskoin.Node.Block.WitnessCheck
+ Haskoin.Node.Block: instance GHC.Internal.Show.Show Haskoin.Node.Block.BlockMutation
+ Haskoin.Node.Block: instance GHC.Internal.Show.Show Haskoin.Node.Block.WitnessCheck
+ Haskoin.Node.Block: segWitHeight :: Network -> Maybe BlockHeight
+ Haskoin.Node.Block: witnessCheck :: Network -> Maybe BlockNode -> WitnessCheck
+ Haskoin.Node.Chain.Sync: askHeaders :: Eq p => UTCTime -> p -> SyncState p -> SyncState p
+ Haskoin.Node.Chain.Sync: gotHeaders :: Eq p => p -> SyncState p -> SyncState p
+ Haskoin.Node.Chain.Sync: headersResponseTime :: NominalDiffTime
+ Haskoin.Node.Chain.Sync: headersSyncTimeoutBase :: NominalDiffTime
+ Haskoin.Node.Chain.Sync: headersSyncTimeoutPerHeader :: NominalDiffTime
+ Haskoin.Node.Chain.Sync: mayAsk :: Eq p => UTCTime -> p -> SyncState p -> Bool
+ Haskoin.Node.Chain.Sync: syncDeadline :: Network -> UTCTime -> BlockNode -> UTCTime
+ Haskoin.Node.Chain.Sync: syncTimedOut :: Eq p => UTCTime -> SyncState p -> Bool
+ Haskoin.Node.Peer: MutatedBlock :: !BlockHash -> !BlockMutation -> PeerException
+ Haskoin.Node.Peer: [lookupBlock] :: PeerConfig -> !BlockHash -> IO (Maybe BlockNode)
+ Haskoin.Node.PeerMgr: [lookupBlock] :: PeerMgrConfig -> !BlockHash -> IO (Maybe BlockNode)
+ Haskoin.Node.PeerMgr.Addresses: Seeded :: Source
+ Haskoin.Node.PeerMgr.Addresses: addressSource :: Ord a => a -> AddressBook a -> Maybe Source
+ Haskoin.Node.PeerMgr.Addresses: instance GHC.Classes.Ord Haskoin.Node.PeerMgr.Addresses.Source
+ Haskoin.Node.PeerMgr.Handshake: NotWitnessNode :: HandshakeError
- Haskoin.Node.Chain.Sync: startSync :: Eq p => UTCTime -> p -> SyncState p -> SyncState p
+ Haskoin.Node.Chain.Sync: startSync :: Eq p => UTCTime -> UTCTime -> p -> SyncState p -> SyncState p
- Haskoin.Node.Peer: PeerConfig :: !Publisher PeerEvent -> !Network -> !Text -> !WithConnection -> !Logger -> PeerConfig
+ Haskoin.Node.Peer: PeerConfig :: !Publisher PeerEvent -> !Network -> !Text -> !WithConnection -> !Logger -> !BlockHash -> IO (Maybe BlockNode) -> PeerConfig
- Haskoin.Node.PeerMgr: PeerMgrConfig :: !Int -> ![String] -> !Bool -> !NetworkAddress -> !Network -> !Publisher PeerEvent -> !NominalDiffTime -> !NominalDiffTime -> !SockAddr -> WithConnection -> !Logger -> PeerMgrConfig
+ Haskoin.Node.PeerMgr: PeerMgrConfig :: !Int -> ![String] -> !Bool -> !NetworkAddress -> !Network -> !Publisher PeerEvent -> !NominalDiffTime -> !NominalDiffTime -> !SockAddr -> WithConnection -> !Logger -> !BlockHash -> IO (Maybe BlockNode) -> PeerMgrConfig
- Haskoin.Node.PeerMgr.Addresses: takeAddress :: Ord a => UTCTime -> AddressBook a -> Maybe (a, AddressBook a)
+ Haskoin.Node.PeerMgr.Addresses: takeAddress :: Ord a => UTCTime -> Word64 -> (a -> Bool) -> AddressBook a -> Maybe (a, AddressBook a)
- Haskoin.Node.PeerMgr.Handshake: checkVersion :: [Word64] -> Version -> Either HandshakeError Version
+ Haskoin.Node.PeerMgr.Handshake: checkVersion :: Network -> [Word64] -> Version -> Either HandshakeError Version
Files
- CHANGELOG.md +117/−0
- README.md +112/−34
- haskoin-node.cabal +11/−3
- src/Haskoin/Node.hs +44/−18
- src/Haskoin/Node/Address.hs +112/−22
- src/Haskoin/Node/Block.hs +223/−0
- src/Haskoin/Node/Chain.hs +71/−12
- src/Haskoin/Node/Chain/Sync.hs +104/−11
- src/Haskoin/Node/Peer.hs +69/−11
- src/Haskoin/Node/Peer/Request.hs +6/−0
- src/Haskoin/Node/PeerMgr.hs +46/−15
- src/Haskoin/Node/PeerMgr/Addresses.hs +136/−25
- src/Haskoin/Node/PeerMgr/Handshake.hs +15/−8
- test/common/Haskoin/Node/Test.hs +61/−1
- test/regtest/Containerfile +9/−23
- test/regtest/Regtest/Podman.hs +2/−1
- test/regtest/install-nodes.sh +61/−0
- test/unit/Haskoin/Node/AddressSpec.hs +110/−1
- test/unit/Haskoin/Node/BlockSpec.hs +170/−0
- test/unit/Haskoin/Node/Chain/SyncSpec.hs +49/−2
- test/unit/Haskoin/Node/ChainSpec.hs +77/−1
- test/unit/Haskoin/Node/PeerMgr/AddressesSpec.hs +85/−15
- test/unit/Haskoin/Node/PeerMgr/HandshakeSpec.hs +12/−5
- test/unit/Haskoin/Node/PeerMgr/LifecycleSpec.hs +6/−1
- test/unit/Haskoin/Node/PeerMgrSpec.hs +5/−0
- test/unit/Haskoin/Node/PeerSpec.hs +206/−4
- test/unit/Haskoin/NodeSpec.hs +126/−27
CHANGELOG.md view
@@ -4,6 +4,123 @@ 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). +## [Unreleased]++## [2.5.0] - 2026-11-10++This release fixes the findings of a security review. Several fixes change+the API; each is marked "Breaking" below.++### Added++- `Haskoin.Node.Block`, re-exported by `Haskoin.Node`: `blockMutation` tells+ whether the transactions of a block are the ones its header commits to, as+ `IsBlockMutated` in Bitcoin Core 32.1 does. `witnessCheck` and+ `segWitHeight` choose the witness rule for a block from the height of its+ parent.+- `Haskoin.Node.Address.netGroup` and `NetGroup`: the network group of an+ address, as Bitcoin Core forms it, and `connectTimeout`.+- `Haskoin.Node.Chain.Sync`: `syncDeadline`, `syncTimedOut`, `askHeaders`,+ `gotHeaders`, `mayAsk`, `headersResponseTime`, `headersSyncTimeoutBase` and+ `headersSyncTimeoutPerHeader`, for the deadline of a turn and the requests+ for headers in flight.+- `Haskoin.Node.PeerMgr.Addresses`: the `Seeded` source and `addressSource`.++### Security++- Blocks are checked against their headers. A peer could answer a request for+ a block with the real header, and its proof of work, together with+ transactions of its choice, and `getBlocks`, `getData` and the `MBlock`+ events passed them on. Applications such as Haskoin Store would then record+ payments that never happened. Every block a peer sends now has to pass+ `blockMutation`: the transaction ids hash to the Merkle root, the tree does+ not repeat transactions (CVE-2012-2459), a block without a coinbase holds no+ 64-byte transaction, and, once SegWit is active at the block's height, the+ witness data matches the commitment in the coinbase. A peer that sends a+ block that fails is disconnected with `MutatedBlock`. The header is not+ marked invalid. When the node does not know the parent of a block, the+ witness data is not checked, as in Bitcoin Core.+- One peer could hold the header sync forever. It kept its turn as long as it+ sent full batches, even of headers the node already had, and no other peer+ was asked. Now a full batch that brings no new header ends the turn, a turn+ that runs past the deadline Bitcoin Core gives its initial headers sync (15+ minutes plus 1 millisecond per block expected since the best header) ends+ with the peer dropped when another peer can take over, and a peer that+ announces an unknown block is asked for headers at once, with at most one+ request in flight per peer, as in Bitcoin Core.+- Reading a message took time that grew with the square of its size when it+ arrived in small pieces. A peer could keep a core busy for seconds with one+ 4 MB message, and for about a minute with a 64 MB Bitcoin Cash block+ message. Reading is now linear.+- One IP address could take every outbound slot. Addresses were tried in the+ order of their port, any number of connections could go to one network, and+ a book full of gossip learned no new seed address. Learned addresses are now+ picked at random, at most one connection goes to each network group (a /16+ of IPv4, a /32 of IPv6) unless the address is configured, seed addresses+ have their own room, and a new address can replace one that failed.+- Messages from a peer that had not completed the handshake were published to+ the application, outside back-pressure. They are now dropped, except the+ `version` and `verack` messages, as Bitcoin Core does.+- Addresses returned by DNS seeds must be routable, like relayed addresses,+ and the ORCHID ranges `2001:10::/28` and `2001:20::/28` are no longer+ routable, as in Bitcoin Core.+- On networks with SegWit, a peer must offer `NODE_WITNESS`, as Bitcoin Core+ requires of its outbound peers. The node asks for blocks and transactions+ with their witness, which such a peer cannot serve.+- `withConnection` gives up on a connection that does not open within five+ seconds. A relayed address that dropped connection requests held a slot for+ about two minutes.++### Changed++- Allow `rocksdb-haskell-jprupp` 2.7, which adds iterators that stop at a+ bound. Nothing in this package changes with it: version 2.7 is a new+ major version only because the `DB` record gained a field, and this+ package gets its `DB` from `withDBCF`. It is built and tested with 2.7.1,+ and versions from 2.6.1 on are still allowed.+- Breaking: `PeerConfig` and `PeerMgrConfig` have a `lookupBlock` field, the+ function that finds a stored header. A peer uses it to tell whether SegWit+ applies to a block it receives. `withNode` fills it in; code that builds+ these records itself must add it. A function that always gives `Nothing`+ keeps the Merkle root check but skips the witness check.+- Breaking: `PeerException` has a `MutatedBlock` constructor, and+ `HandshakeError` a `NotWitnessNode` one.+- Breaking: `checkVersion` takes the network.+- Breaking: `takeAddress` takes a random number and a predicate that says+ which seed and gossiped addresses may be taken. Configured addresses come+ first. `Source` has a `Seeded` constructor, and the peer manager adds seed+ addresses with it.+- Breaking: `startSync` takes the deadline of the turn.+- On regtest, the DNS seed of haskoin-core is `localhost`, which is not+ routable, so discovery no longer finds a local node. Configure local peers+ in `peers`.+- When a batch of headers fails after some valid headers, the node keeps the+ valid ones, as before, and now publishes the `ChainBestBlock` event for the+ best block they set.+- Counting the gossiped addresses in the book no longer scans it for every+ new address.+- The library depends on streaming-commons directly, for the TCP connection+ that `withConnection` opens. It was already a dependency of conduit-extra.+- The documentation says that a subscriber that falls behind holds memory,+ that witness data from `getTxs` and `MTx` events is not authenticated, and+ that headers below the minimum chain work are stored during the initial+ sync.+- The README quick start compiles again: it needed `DisambiguateRecordFields`+ and the fields of `BlockNode`. The tutorial names the imports it adds.+- Tests: the Bitcoin Core blocks used as fixtures, the limits of inventory,+ locator and user agent sizes, back-pressure, the edges of the tip age and of+ the maximum life, reserved address ranges, and ties between forks of equal+ work are tested. Mock peers send their test messages once they are online,+ which ends a race that made some tests time out or hang.+- The CI workflow's shell steps moved into `scripts/check-local-deps.sh` and+ `scripts/install-system-libraries.sh`, and the regtest Containerfile's+ install step into `test/regtest/install-nodes.sh`, so each can be read and+ run on its own. The package description in `package.yaml` keeps its line+ breaks, so the generated cabal file has shorter lines.+- The instructions for contributors and for Claude ask for lines of code+ shorter than 120 columns and for scripts in their own files.+- The README shows a badge with the status of CI on `master`.+ ## [2.4.0] - 2026-11-09 ### Added
README.md view
@@ -1,5 +1,6 @@ # Haskoin Node +[](https://github.com/jprupp/haskoin-node/actions/workflows/ci.yml) [](https://hackage.haskell.org/package/haskoin-node) [](https://github.com/jprupp/haskoin-node/blob/master/LICENSE) @@ -62,8 +63,11 @@ publisher. The application subscribes and reacts; the node never needs to know who is listening. - **Peers are strangers.** Message sizes are checked before payloads are read,- anything invalid ends the connection, relayed addresses are filtered, and a- peer cannot push data faster than the node can handle it.+ a payload costs time linear in its size however it is split, anything+ invalid ends the connection, a block must hold the transactions its header+ commits to, relayed addresses are filtered and spread over network groups,+ and the node reads a peer only as fast as it handles its messages. Your+ subscribers are not throttled: one that falls behind holds memory. - **Prefer good peers, but keep moving.** Peers are ranked by measured latency so the fastest are used first, and every connection is eventually replaced so the node never depends on the same few peers forever.@@ -79,9 +83,12 @@ no `MonadIO` or `MonadUnliftIO` constraints. Types say exactly what runs, GHC has nothing to specialise, and long-running loops cannot leak through an unspecialised dictionary. From your own monad, call the node with `liftIO`.-- **Never stuck.** Every hint that a peer has blocks the node lacks makes it ask- for headers, and a quiet period makes it ask every peer, so a missed- announcement cannot leave the chain behind.+- **Keep moving.** Every hint that a peer has blocks the node lacks makes it+ ask that peer for headers, at once even while another peer syncs, and a+ quiet period makes it ask every peer, so a missed announcement cannot leave+ the chain behind. A syncing peer loses its turn when it sends nothing new,+ and when it runs past the deadline that Bitcoin Core gives its initial+ headers sync. ## Features @@ -89,11 +96,11 @@ | --------------- | ---------------------------------------------------------------------------------------------- | | Networks | Every network of haskoin-core: Bitcoin and Bitcoin Cash mainnet, testnets and regtest | | Peer discovery | DNS seeds and addresses relayed by peers, or a static list of peers |-| Peer management | Version handshake, ping-based latency, peers sorted by median ping, rotation after a maximum age, retry backoff and bans for bad addresses |-| Header sync | Headers downloaded from one peer at a time, connected to the chain with the most work, stored in RocksDB; new blocks followed whether peers announce them with headers or inventory, and peers polled after a quiet period |+| Peer management | Version handshake, ping-based latency, peers sorted by median ping, rotation after a maximum age, retry backoff and bans for bad addresses, random choice among learned addresses with one connection per network group |+| Header sync | Headers downloaded from one peer at a time, with a deadline per turn, connected to the chain with the most work, stored in RocksDB; new blocks followed whether peers announce them with headers or inventory, announcing peers asked at once, and peers polled after a quiet period | | Events | Best header changes, sync completion, peer connections and every message received |-| Data requests | Fetch blocks and transactions from a peer, with a timeout |-| Protection | Strict parsing, per-command size limits, back-pressure, bounded address pool, explicit handshake states |+| Data requests | Fetch blocks and transactions from a peer, with a timeout; every block checked against its header's Merkle root and witness commitment |+| Protection | Strict parsing, per-command size limits, linear-time reads, back-pressure, bounded address pool, explicit handshake states, connect timeout | ### What is not included @@ -162,11 +169,11 @@ ```yaml extra-deps:- - haskoin-node-2.4.0+ - haskoin-node-2.5.0 - haskoin-core-1.6.1 - secp256k1-haskell-2.0.1 - base16-2.0- - rocksdb-haskell-jprupp-2.6.1+ - rocksdb-haskell-jprupp-2.7.1 - rocksdb-query-0.6.1 - nqe-0.6.9 ```@@ -201,13 +208,14 @@ This program connects to Bitcoin, syncs headers, and prints what happens: ```haskell+{-# LANGUAGE DisambiguateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedRecordDot #-} import Control.Monad (forever) import Data.Default (def) import Database.RocksDB (Config (..), withDB)-import Haskoin (NetworkAddress (..), btc, sockToHostAddress)+import Haskoin (BlockNode (..), NetworkAddress (..), btc, sockToHostAddress) import Haskoin.Node import NQE (receive, withPublisher, withSubscription) import Network.Socket (SockAddr (..))@@ -301,7 +309,8 @@ ### 4. Put it together Use the configuration from the [quick start](#quick-start), and run the watcher-inside `withNode`:+inside `withNode`. Besides the imports of the quick start, the watcher needs+`Block (..)`, `Network` and `headerHash` from `Haskoin`, and `Inbox` from `NQE`: ```haskell withNode cfg $ \node -> do@@ -324,13 +333,17 @@ - The **peer manager** (`Haskoin.Node.PeerMgr`) finds peer addresses, connects to them, does the version handshake, pings them, and drops those that are slow,- silent, misbehaving or too old. An address whose connection failed is tried- again later each time, and one whose peer misbehaved waits a day.+ silent, misbehaving or too old. Configured peers are tried first; learned+ addresses are picked at random, at most one per network group. An address+ whose connection failed is tried again later each time, and one whose peer+ misbehaved waits a day. - The **chain** (`Haskoin.Node.Chain`) asks one peer at a time for headers, connects them to the headers it has, follows the chain with the most work, and stores the headers in RocksDB. It asks a peer for headers whenever the peer- announces blocks it does not know, with headers or with inventory, and asks- every peer when it has heard nothing for `timeout` seconds.+ announces blocks it does not know, with headers or with inventory, at once if+ another peer holds the turn, and asks every peer when it has heard nothing+ for `timeout` seconds. A turn ends when the peer has nothing more, sends a+ full batch with nothing new, or runs past its deadline. The rules for talking to peers are pure state machines, each in its own module: @@ -346,6 +359,10 @@ The processes store the state and carry out what a transition says. Time is an input to the transitions, so every timing rule is tested without waiting. +`Haskoin.Node.Block` is a pure check of another kind: whether the+transactions of a block are the ones its header commits to. Every block a peer+sends passes through it.+ Each peer connection is its own process (`Haskoin.Node.Peer`), run by a supervisor inside the peer manager. @@ -359,8 +376,15 @@ | `ChainEvent (ChainSynced node)` | Headers caught up with the network (once) | | `PeerEvent (PeerConnected peer)` | A peer finished the handshake | | `PeerEvent (PeerDisconnected peer)` | An online peer went away |-| `PeerEvent (PeerMessage peer msg)` | A peer sent a message: `MInv`, `MBlock`, `MTx` and so on |+| `PeerEvent (PeerMessage peer msg)` | An online peer sent a message: `MInv`, `MBlock`, `MTx` and so on | +Messages are published only from peers that are online, except `version` and+`verack`, which are the handshake itself. A block in an `MBlock` message has+passed `blockMutation`: its transactions hash to the Merkle root of its header,+and its witness data matches its coinbase when the node knows its parent.+Nothing else about it is checked, and a transaction in an `MTx` message is not+checked at all.+ "In sync" means the best header is less than two hours old, has at least the minimum chain work of the network, and no peer is left to sync from. @@ -368,8 +392,10 @@ A peer may have at most 16 messages published and not yet handled by the node. Past that, the node stops reading its socket, so a fast peer fills its own TCP-window instead of the node's memory. Your subscriber should keep reading events;-a subscription that falls far behind holds memory.+window instead of the node's memory. The node does not wait for your+subscriber, though: a subscription is an unbounded queue, and one that falls+behind holds every event it has not read. Keep reading events, and hand slow+work to another thread. ### Database layout @@ -404,6 +430,13 @@ everything, in order, before the timeout in seconds. Unrelated messages that the peer sends in between are ignored. `pingPeer` measures a round trip. +A block that `getBlocks` returns holds the transactions its header commits to.+A peer that sends another block under the requested header is disconnected,+and the request gives `Nothing` when it times out. The transactions are not+validated otherwise. A transaction from `getTxs` is matched by its id, which+does not cover its witness, so treat its witness data as unauthenticated until+you find the transaction in a block.+ `getPeers` leaves out peers that are being disconnected, even before their connection closes. @@ -445,11 +478,16 @@ A peer without a port uses the default port of the network. +Addresses from DNS seeds must be public, like relayed addresses. On regtest the+seed of haskoin-core is `localhost`, which is not, so list local peers in+`peers` instead of relying on discovery.+ ### Custom connections `connect` decides how to open a connection. `withConnection` opens a plain TCP-socket. Replace it to go through a proxy, or to plug in mock peers in tests, as-the test suite does.+socket, and gives up if it does not open within five seconds (`connectTimeout`,+as in Bitcoin Core). Replace it to go through a proxy, or to plug in mock peers+in tests, as the test suite does. ### Logging @@ -482,6 +520,7 @@ | -------------- | --------------------------------------------------------------------------- | | Node | `NodeConfig`, `NodeEvent`, `Node`, `withNode`, `withConnection` | | Peers | `Peer`, `PeerEvent`, `PeerException`, `sendMessage`, `getBlocks`, `getTxs`, `getData`, `pingPeer`, `killPeer`, `newOwner`, `reservePeer`, `releasePeer` |+| Blocks | `blockMutation`, `BlockMutation`, `witnessCheck`, `WitnessCheck`, `segWitHeight` | | Peer manager | `PeerMgr`, `OnlinePeer`, `PeerState`, `Stage`, `DisconnectReason`, `getPeers`, `getOnlinePeer`, `toSockAddr`, `isRoutable` | | Chain | `Chain`, `ChainEvent`, `chainGetBest`, `chainGetBlock`, `chainGetAncestor`, `chainGetParents`, `chainGetSplitBlock`, `chainBlockMain`, `chainIsSynced`, `chainRunHeaders` | | Logging | `Logger`, `LogLevel`, `withLogger`, `withSource`, `logDebug` ... `logError` |@@ -496,6 +535,7 @@ | `Haskoin.Node.Peer` | The process that talks to one peer, and data requests | | `Haskoin.Node.Peer.Request` | State machine for receiving requested blocks and transactions | | `Haskoin.Node.Peer.Lock` | State machine for reserving a peer |+| `Haskoin.Node.Block` | Whether a block holds the transactions its header commits to | | `Haskoin.Node.PeerMgr` | Finds, connects, ranks and drops peers | | `Haskoin.Node.PeerMgr.Lifecycle` | State machine for the life of a connection | | `Haskoin.Node.PeerMgr.Handshake` | State machine for the version handshake |@@ -503,27 +543,49 @@ | `Haskoin.Node.Chain` | Keeps the header chain in sync | | `Haskoin.Node.Chain.Sync` | State machine that decides which peer to ask for headers, and when the chain is synced | | `Haskoin.Node.Chain.Database` | Storage of headers in RocksDB |-| `Haskoin.Node.Address` | Parsing peer addresses and opening connections |+| `Haskoin.Node.Address` | Parsing peer addresses, network groups and opening connections | | `Haskoin.Node.Logger` | Logging | ## Security notes - Every message header is checked before its payload is read. The payload limit- is the `maxPayloadSize` of the command on the network: on Bitcoin Cash, 2 MiB- for everything but block messages.+ is the `maxPayloadSize` of the command on the network: 4,000,000 bytes on+ Bitcoin, and on Bitcoin Cash 2 MiB for everything but block messages.+- Reading a payload costs time and memory linear in its size, however small+ the pieces it arrives in. - List messages are refused as soon as their count is over the limit, such as- 2000 headers, 1000 addresses or 50000 inventory entries.+ 2000 headers, 1000 addresses, 50000 inventory entries or 101 locator hashes. - A peer that sends anything invalid is disconnected, with a `PeerException` saying why.+- A peer that sends a block whose transactions are not the ones its header+ commits to is disconnected (`MutatedBlock`). The check is Bitcoin Core's+ `IsBlockMutated`: the Merkle root, a tree that repeats transactions+ (CVE-2012-2459), and, once SegWit is active at the block's height, the+ witness commitment. The header itself is not marked invalid. When the node+ does not know the parent of a block, its witness data is not checked.+- Messages from a peer that has not completed the handshake are not acted on+ or published, except the handshake messages.+- On Bitcoin, a peer must offer witness data (`NODE_WITNESS`) as well as the+ full chain, as Bitcoin Core requires of its outbound peers. - A peer that sends a second version or verack, or does not complete the handshake within `timeout` seconds even while sending other messages, is disconnected. - A peer that sends invalid headers, or does not answer a request for headers in time, is never asked for headers again before it disconnects.-- Addresses relayed by peers are only used if they are public unicast- addresses, so a peer cannot make the node connect to services on its own host- or network. Static peers are not checked.-- At most 4096 relayed addresses are kept.+- A syncing peer whose full batch of headers brings nothing new loses its+ turn. One that is still syncing after 15 minutes plus 1 millisecond for each+ block expected since the best header is disconnected when another peer can+ take over, as Bitcoin Core does.+- Addresses relayed by peers and returned by DNS seeds are only used if they+ are public unicast addresses, so a peer or a resolver cannot make the node+ connect to services on its own host or network. Static peers are not checked.+- Learned addresses are picked at random, and at most one connection is open to+ each network group (a /16 of IPv4, a /32 of IPv6), so one operator cannot take+ every slot. Static peers are exempt.+- At most 4096 relayed addresses and 4096 seed addresses are kept, apart, so+ gossip cannot crowd out the seeds. When the room for one is full, a new+ address replaces one that has failed to connect.+- A connection that does not open within five seconds is given up. - An address whose connection fails waits twice as long after each failure before it is tried again, up to ten minutes. A relayed address whose peer broke the protocol is not tried again for a day. Static peers are never@@ -531,8 +593,13 @@ - Headers are not reported as synced until they have the minimum chain work of the network. Above that work, forks from far below the tip on a chain with less work are refused.-- The node trusts the chain with the most work. It does not validate blocks:- check the data you get from peers before relying on it.+- Below that work, during the initial sync, every valid header is stored. A+ peer can make the node store a fork of low difficulty, at the cost of its+ proof of work. Bitcoin Core first checks that a peer's headers reach the+ minimum work before it stores any; this node does not yet.+- The node trusts the chain with the most work. It checks that a block holds+ the transactions its header commits to, but does not validate the block:+ check the transactions you get from peers before relying on them. ## Development @@ -569,8 +636,9 @@ [Bitcoin Core](https://bitcoincore.org) and [Bitcoin Cash Node](https://bitcoincashnode.org) in regression test mode. The nodes run in a [Podman](https://podman.io) container built from- `test/regtest/Containerfile`, which installs the official release binaries on- Ubuntu after checking their SHA-256 hashes. The image is built the first time+ `test/regtest/Containerfile`, whose script `test/regtest/install-nodes.sh`+ installs the official release binaries on Ubuntu after checking their SHA-256+ hashes. The image is built the first time the suite runs. Nodes use random ports, so runs do not collide. The suite is skipped if Podman is missing or `HASKOIN_NODE_SKIP_REGTEST` is set. @@ -580,12 +648,22 @@ Set `HASKOIN_TEST_LOG` to see the node's logs while tests run. +CI builds and tests every change with the OS libraries, with `stack --nix`,+and against the regtest nodes. Its steps call the scripts in `scripts/`, which+you can also run locally: `scripts/check-local-deps.sh` fails if `stack.yaml`+points at a package outside the repository, which CI cannot build, and+`scripts/install-system-libraries.sh` installs the C libraries on Ubuntu.+ ### Contributing Pull requests are welcome. - Format the code with [Ormolu](https://github.com/tweag/ormolu). - Add tests for the behaviour you change.+- Keep lines of code, including scripts, workflows and the examples in the+ documentation, shorter than 120 columns.+- Put multi-line scripts in their own files rather than embedding them in a+ workflow or a Containerfile. - Describe the change under a new version in `CHANGELOG.md`. The file follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and versions follow [Semantic Versioning](https://semver.org/).
haskoin-node.cabal view
@@ -5,9 +5,12 @@ -- see: https://github.com/sol/hpack name: haskoin-node-version: 2.4.0+version: 2.5.0 synopsis: P2P library for Bitcoin and Bitcoin Cash-description: Connect to Bitcoin (BTC) and Bitcoin Cash (BCH) peers, keep them healthy, and sync block headers into a RocksDB database. Blocks and transactions can be downloaded from connected peers. See the README on GitHub at <https://github.com/jprupp/haskoin-node#readme>.+description: Connect to Bitcoin (BTC) and Bitcoin Cash (BCH) peers, keep them healthy, and+ sync block headers into a RocksDB database. Blocks and transactions can be+ downloaded from connected peers. See the README on GitHub at+ <https://github.com/jprupp/haskoin-node#readme>. category: Bitcoin, Finance, Network homepage: https://github.com/jprupp/haskoin-node#readme bug-reports: https://github.com/jprupp/haskoin-node/issues@@ -21,6 +24,7 @@ GHC == 9.12.4 extra-source-files: test/regtest/Containerfile+ test/regtest/install-nodes.sh extra-doc-files: README.md CHANGELOG.md@@ -33,6 +37,7 @@ exposed-modules: Haskoin.Node Haskoin.Node.Address+ Haskoin.Node.Block Haskoin.Node.Chain Haskoin.Node.Chain.Database Haskoin.Node.Chain.Sync@@ -67,9 +72,10 @@ , network >=3.1 && <3.3 , nqe >=0.6.9 && <0.7 , random >=1.2 && <1.4- , rocksdb-haskell-jprupp >=2.6.1 && <2.7+ , rocksdb-haskell-jprupp >=2.6.1 && <2.8 , rocksdb-query >=0.6.1 && <0.7 , stm ==2.5.*+ , streaming-commons ==0.2.* , string-conversions ==0.4.* , text >=2.0 && <2.2 , time >=1.12 && <1.15@@ -109,6 +115,7 @@ , process , random , rocksdb-haskell-jprupp+ , text , time , unliftio default-language: GHC2024@@ -118,6 +125,7 @@ main-is: Spec.hs other-modules: Haskoin.Node.AddressSpec+ Haskoin.Node.BlockSpec Haskoin.Node.Chain.SyncSpec Haskoin.Node.ChainSpec Haskoin.Node.Peer.LockSpec
src/Haskoin/Node.hs view
@@ -28,8 +28,11 @@ -- -- Everything that happens is published as a 'NodeEvent' to the 'Publisher' -- given in 'NodeConfig'. Subscribe to it to learn about new best blocks, peers--- connecting and disconnecting, and every message received from a peer, such as--- inventory announcements, blocks and transactions.+-- connecting and disconnecting, and every message received from an online+-- peer, such as inventory announcements, blocks and transactions. A+-- subscription is an unbounded queue: the node does not wait for a slow+-- subscriber, so a subscriber that falls behind holds every event it has not+-- read in memory. -- -- = Purpose --@@ -67,8 +70,11 @@ -- -- * __Trust nothing from the network.__ Peers are strangers. Every message is -- checked against size limits before it is read, invalid input disconnects--- the peer, and a peer cannot make the node allocate memory faster than it--- can work.+-- the peer, every block must hold the transactions its header commits to+-- ("Haskoin.Node.Block"), and a peer cannot make the node allocate memory+-- faster than it can work. The node's subscribers are another matter: the+-- node does not wait for them, so a subscriber must keep up with what it+-- asked for. -- -- = Example --@@ -117,6 +123,9 @@ -- * Peers module Haskoin.Node.Peer, + -- * Blocks+ module Haskoin.Node.Block,+ -- * Peer manager module Haskoin.Node.PeerMgr, @@ -133,6 +142,7 @@ import Database.RocksDB (ColumnFamily, DB) import Haskoin import Haskoin.Node.Address (withConnection)+import Haskoin.Node.Block import Haskoin.Node.Chain import Haskoin.Node.Logger import Haskoin.Node.Loop (loopForever)@@ -205,23 +215,33 @@ _ -> return () publish (ChainEvent event) output --- | Route events from peers to the peer manager and chain. A message from a--- peer is handled to completion before the peer may send another one past its--- small allowance of pending messages (see 'maxPendingMessages'), so that the--- node's queues cannot grow faster than it can work. Address, header and--- inventory messages are only taken from peers that are online: the handshake--- is complete and the peer is not being disconnected.--- Inventory with blocks goes to the chain, which asks for headers if it does--- not know them.+-- | Route events from peers to the peer manager and chain, and publish them+-- to the application.+--+-- A message from a peer is handled to completion before the peer may send+-- another one past its small allowance of pending messages (see+-- 'maxPendingMessages'), so that the node's own queues cannot grow faster+-- than it can work. Address, header and inventory messages are only taken+-- from peers that are online: the handshake is complete and the peer is not+-- being disconnected. Inventory with blocks goes to the chain, which asks for+-- headers if it does not know them.+--+-- Messages reach the application only from online peers, except @version@+-- and @verack@, which are the handshake itself. Bitcoin Core ignores+-- anything else a peer sends before the handshake is done, and so does the+-- node: a peer whose version may still be refused cannot feed the+-- application blocks and transactions. peerEvents :: Chain -> PeerMgr -> Inbox PeerEvent -> Publisher NodeEvent -> IO () peerEvents ch mgr input output = loopForever $ do event <- receive input case event of- PeerConnected p ->+ PeerConnected p -> do chainPeerConnected ch p- PeerDisconnected p ->+ forward event+ PeerDisconnected p -> do chainPeerDisconnected ch p+ forward event PeerMessage p m -> do case m of MVersion v ->@@ -241,14 +261,19 @@ bhs -> whenOnline p $ chainBlocksAnnounced ch p bhs >> chainSync ch _ -> return () ticklePeer mgr p+ if handshake m then forward event else whenOnline p (forward event) peerMessageDone p- publish (PeerEvent event) output where+ forward event = publish (PeerEvent event) output whenOnline p act = getOnlinePeer mgr p >>= \case Just o | o.online -> act _ -> return () isBlock iv = iv.invType `elem` [InvBlock, InvWitnessBlock]+ handshake = \case+ MVersion _ -> True+ MVerAck -> True+ _ -> False -- | Launch node process in the foreground. --@@ -263,7 +288,7 @@ withPublisher $ \chainPub -> withSubscription chainPub $ \chainSub -> withChain (chainConfig chainPub) $ \chain ->- withPeerMgr (peerMgrConfig peerPub) $ \peerMgr ->+ withPeerMgr (peerMgrConfig chain peerPub) $ \peerMgr -> withAsync (chainEvents peerMgr chainSub cfg.pub) $ \a1 -> withAsync (peerEvents chain peerMgr peerSub cfg.pub) $ \a2 -> do link a1@@ -279,7 +304,7 @@ timeout = cfg.timeout, logger = withSource "Chain" cfg.logger }- peerMgrConfig pub =+ peerMgrConfig chain pub = PeerMgrConfig { maxPeers = cfg.maxPeers, peers = cfg.peers,@@ -290,5 +315,6 @@ timeout = cfg.timeout, maxPeerLife = cfg.maxPeerLife, connect = cfg.connect,- logger = withSource "PeerMgr" cfg.logger+ logger = withSource "PeerMgr" cfg.logger,+ lookupBlock = chainGetBlock chain }
src/Haskoin/Node/Address.hs view
@@ -17,35 +17,44 @@ ( toHostService, toSockAddr, isRoutable,+ NetGroup (..),+ netGroup, withConnection,+ connectTimeout, ) where import Control.Applicative ((<|>)) import Control.Arrow (second) import Control.Exception (IOException, catch)-import Data.Bits ((.&.))-import Data.Conduit.Network- ( ClientSettings,- appSink,- appSource,- clientSettings,- runTCPClient,- )+import Data.Bits (complement, shiftR, (.&.), (.|.))+import Conduit (liftIO, yield)+import Control.Exception (finally)+import Control.Monad (unless)+import Data.ByteString (ByteString)+import Data.ByteString qualified as B+import Data.Conduit.Network (sinkSocket)+import Data.Streaming.Network (clientSettingsTCP, getReadBufferSize, getSocketFamilyTCP)+import Data.Time.Clock (NominalDiffTime) import Data.List (elemIndex) import Data.String.Conversions (cs)-import Data.Word (Word8)+import Data.Word (Word16, Word8) import Haskoin (Network (..)) import Haskoin.Node.Peer (Conduits (..), WithConnection) import Network.Socket ( AddrInfo (..),+ Family (AF_UNSPEC), NameInfoFlag (..), SockAddr (..),+ close, getAddrInfo, getNameInfo, hostAddress6ToTuple, hostAddressToTuple,+ tupleToHostAddress6, )+import Network.Socket.ByteString (recv)+import System.Timeout (timeout) import Text.Read (readMaybe) -- | Split a peer address into host and port. Accepts @host@, @host:port@,@@ -89,11 +98,14 @@ (host, srv) = second (<|> Just (show net.defaultPort)) (toHostService str) --- | Whether an address that a peer relayed is worth connecting to: a public--- unicast address with a port. Loopback, private, link-local, shared,--- documentation, multicast and reserved ranges are refused, so that a peer--- cannot make the node connect to services on its own host or network.--- Static peers in the configuration are not checked.+-- | Whether an address that a peer relayed, or that a DNS seed returned, is+-- worth connecting to: a public unicast address with a port. Loopback,+-- private, link-local, shared, documentation, multicast and reserved ranges+-- are refused, so that a peer or a resolver cannot make the node connect to+-- services on its own host or network. So are the ORCHID ranges+-- @2001:10::\/28@ and @2001:20::\/28@, which Bitcoin Core 32.1 refuses too+-- (@IsRFC4843@ and @IsRFC7343@). Static peers in the configuration are not+-- checked. isRoutable :: SockAddr -> Bool isRoutable (SockAddrInet port h) = port /= 0 && routable4 (hostAddressToTuple h)@@ -113,9 +125,65 @@ | a .&. 0xffc0 == 0xfec0 -> False -- site-local | a .&. 0xff00 == 0xff00 -> False -- multicast | a == 0x2001 && b == 0x0db8 -> False -- documentation+ | a == 0x2001 && b .&. 0xfff0 == 0x0010 -> False -- ORCHID+ | a == 0x2001 && b .&. 0xfff0 == 0x0020 -> False -- ORCHIDv2 | otherwise -> True isRoutable _ = False +-- | A group of addresses that one operator can easily hold many of, from the+-- same block of a network. The node opens at most one connection to each+-- group from the addresses it learned, so that one operator cannot take every+-- slot by gossiping many addresses of its own.+data NetGroup+ = -- | loopback addresses+ LocalGroup+ | -- | other addresses that are not routable+ UnroutableGroup+ | -- | an IPv4 address, or an IPv6 address that carries one, by its first+ -- two bytes (a /16)+ IPv4Group !Word8 !Word8+ | -- | another IPv6 address, by its first four bytes (a /32), or its first+ -- four and a half for Hurricane Electric (a /36)+ IPv6Group ![Word8]+ deriving stock (Eq, Ord, Show)++-- | The network group of an address, as @NetGroupManager::GetGroup@ in+-- Bitcoin Core 32.1 forms it without an AS map. The port does not count.+--+-- IPv4 addresses are grouped by /16. So are IPv6 addresses that carry an+-- IPv4 address, by that address: IPv4-mapped (@::ffff:0:0\/96@), NAT64+-- (@64:ff9b::\/96@), SIIT (@::ffff:0:0:0\/96@), 6to4 (@2002::\/16@) and+-- Teredo (@2001::\/32@, with the bits of the address inverted). Hurricane+-- Electric (@2001:470::\/32@) is grouped by /36, and the rest of IPv6 by+-- /32.+netGroup :: SockAddr -> NetGroup+netGroup = \case+ SockAddrInet _ h -> v4 (hostAddressToTuple h)+ SockAddrInet6 _ _ h _ -> v6 (hostAddress6ToTuple h)+ _ -> UnroutableGroup+ where+ v4 t@(a, b, _, _)+ | a == 127 || a == 0 = LocalGroup+ | not (routable4 t) = UnroutableGroup+ | otherwise = IPv4Group a b+ v6 t@(a, b, c, d, e, f, g, h)+ | t == (0, 0, 0, 0, 0, 0, 0, 1) = LocalGroup+ | (a, b, c, d, e, f) == (0, 0, 0, 0, 0, 0xffff) = v4 (word32 g h)+ | not (isRoutable (SockAddrInet6 1 0 (tupleToHostAddress6 t) 0)) = UnroutableGroup+ | (a, b, c, d, e, f) == (0x64, 0xff9b, 0, 0, 0, 0) = linked g h+ | (a, b, c, d, e, f) == (0, 0, 0, 0, 0xffff, 0) = linked g h+ | a == 0x2002 = linked b c+ | (a, b) == (0x2001, 0) = linked (complement g) (complement h)+ | (a, b) == (0x2001, 0x0470) = IPv6Group (bytes [a, b] <> [hi c .|. 0x0f])+ | otherwise = IPv6Group (bytes [a, b])+ linked g h = let (w, x, _, _) = word32 g h in IPv4Group w x+ word32 :: Word16 -> Word16 -> (Word8, Word8, Word8, Word8)+ word32 g h = (hi g, lo g, hi h, lo h)+ bytes = concatMap (\w -> [hi w, lo w])+ hi, lo :: Word16 -> Word8+ hi w = fromIntegral (w `shiftR` 8)+ lo w = fromIntegral (w .&. 0xff)+ -- | Whether an IPv4 address is public unicast. routable4 :: (Word8, Word8, Word8, Word8) -> Bool routable4 (a, b, c, _)@@ -134,24 +202,46 @@ | a >= 224 = False -- multicast and reserved | otherwise = True +-- | Longest wait for a TCP connection to open: @DEFAULT_CONNECT_TIMEOUT@ in+-- Bitcoin Core 32.1, 5 seconds.+connectTimeout :: NominalDiffTime+connectTimeout = 5+ -- | Open a TCP connection to a peer address and pass its conduits to a -- function. The connection is closed when the function returns. Calls 'error' -- if the address cannot be converted into a host and port.+--+-- Opening the connection fails with an 'IOException' after 'connectTimeout'.+-- Without the limit, an address that drops connection requests held a slot+-- of the peer manager until the operating system gave up, about two minutes+-- on Linux. The peer manager cannot stop a connection that is still opening,+-- because the peer process reads its mailbox only once the connection is+-- open. withConnection :: SockAddr -> WithConnection withConnection sa f = fromSockAddr sa >>= \case Nothing -> error $ "Invalid address " <> show sa- Just settings ->- runTCPClient settings $ \ad ->- f Conduits {inboundConduit = appSource ad, outboundConduit = appSink ad}+ Just (host, port) ->+ timeout micros (getSocketFamilyTCP host port AF_UNSPEC) >>= \case+ Nothing -> ioError (userError ("Connection to " <> show sa <> " timed out"))+ Just (s, _) ->+ f Conduits {inboundConduit = source s, outboundConduit = sinkSocket s}+ `finally` close s+ where+ micros = floor (connectTimeout * 1000000)+ -- Pieces of the size that the TCP client of streaming-commons reads.+ size = getReadBufferSize (clientSettingsTCP 0 "")+ source s = do+ bs <- liftIO (recv s size)+ unless (B.null bs) (yield bs >> source s) --- | Turn a socket address into client settings for a TCP connection, or--- 'Nothing' if it cannot be converted.-fromSockAddr :: SockAddr -> IO (Maybe ClientSettings)+-- | Turn a socket address into a numeric host and a port, or 'Nothing' if it+-- cannot be converted.+fromSockAddr :: SockAddr -> IO (Maybe (ByteString, Int)) fromSockAddr sa = resolve `catch` \(_ :: IOException) -> return Nothing where flags = [NI_NUMERICHOST, NI_NUMERICSERV] resolve = do- (host, port) <- (getNameInfo flags True True sa)- return $ clientSettings <$> (readMaybe =<< port) <*> (cs <$> host)+ (host, port) <- getNameInfo flags True True sa+ return $ (,) <$> (cs <$> host) <*> (readMaybe =<< port)
+ src/Haskoin/Node/Block.hs view
@@ -0,0 +1,223 @@+-- |+-- Module : Haskoin.Node.Block+-- Copyright : (c) Haskoin Developers+-- License : MIT+-- Maintainer : jprupp@protonmail.ch+-- Stability : experimental+-- Portability : POSIX+--+-- Whether the transactions of a block are the ones its header commits to.+--+-- A block on the wire is a header and a list of transactions. The proof of+-- work covers the header only. The header commits to the transactions+-- through its Merkle root, and since SegWit the coinbase commits to their+-- witness data. A peer can send a real header, with real proof of work,+-- together with transactions of its choice. Nothing else stops it.+--+-- 'blockMutation' is the check that catches this. It is a port of+-- @IsBlockMutated@ in Bitcoin Core 32.1 (@src/validation.cpp@), which Core+-- runs on every block a peer sends before it does anything else with it. A+-- block that fails it is called mutated.+--+-- = Why it works this way+--+-- * __The same check as Bitcoin Core.__ The rules, their order and the+-- reasons are those of @CheckMerkleRoot@, @IsBlockMutated@ and+-- @CheckWitnessMalleation@. A stricter check would refuse valid blocks+-- that some peer serves. A laxer one would let a peer change what a block+-- holds without changing its hash.+--+-- * __Not block validation.__ The check says nothing about whether the+-- transactions are valid, spend existing coins, or pay the right subsidy.+-- It only says that the block is the one the header names. A node that+-- needs more must validate blocks itself.+--+-- * __The header stays good.__ A mutated block does not make its header+-- invalid. The same header with the right transactions is still a valid+-- block (CVE-2012-2459). The node blames the peer that sent the mutated+-- block and leaves the header chain alone.+--+-- * __SegWit depends on the height.__ Core checks the witness commitment+-- only for a block at or above the SegWit activation height of the+-- network. Before it, miners already put commitments in coinbases without+-- the matching witness data, and those blocks are valid. So the witness+-- rule needs the height of the parent block, which 'witnessCheck' turns+-- into a 'WitnessCheck'. When the parent is not known, Core does not check+-- the witness either, and neither does this module.+module Haskoin.Node.Block+ ( BlockMutation (..),+ WitnessCheck (..),+ blockMutation,+ witnessCheck,+ segWitHeight,+ )+where++import Data.ByteString (ByteString)+import Data.ByteString qualified as B+import Data.Serialize (encode)+import Haskoin++-- | Why a block is not the one its header commits to. The names follow the+-- reasons that Bitcoin Core 32.1 gives.+data BlockMutation+ = -- | the transactions do not hash to the Merkle root of the header, or+ -- there are none (@bad-txnmrklroot@)+ BadMerkleRoot+ | -- | the Merkle tree hashes two identical nodes together, so the list of+ -- transactions repeats some to keep the root (@bad-txns-duplicate@,+ -- CVE-2012-2459)+ DuplicateTransactions+ | -- | the first transaction is not a coinbase and some transaction is 64+ -- bytes long without its witness, so it may be an inner node of the+ -- Merkle tree posing as a transaction+ SixtyFourByteTransaction+ | -- | the coinbase commits to witness data, but its witness is not a single+ -- item of 32 bytes (@bad-witness-nonce-size@)+ BadWitnessNonceSize+ | -- | the witness data does not hash to the commitment in the coinbase+ -- (@bad-witness-merkle-match@)+ BadWitnessCommitment+ | -- | a transaction carries witness data, but the block does not commit to+ -- any (@unexpected-witness@)+ UnexpectedWitness+ deriving stock (Eq, Show)++-- | Which witness rule applies to a block.+data WitnessCheck+ = -- | SegWit is active for the block. If the coinbase has a witness+ -- commitment, the witness data must match it. If not, no transaction may+ -- have witness data.+ SegWitActive+ | -- | SegWit is not active for the block, or the network has no SegWit. No+ -- transaction may have witness data.+ SegWitInactive+ | -- | The height of the block is not known, so its witness data is not+ -- checked. Its Merkle root still is.+ WitnessUnknown+ deriving stock (Eq, Show)++-- | Height of the first block whose witness data is checked, on networks+-- with SegWit. The values are @consensus.SegwitHeight@ in the chain+-- parameters of Bitcoin Core 32.1 (@src/kernel/chainparams.cpp@). Networks+-- without SegWit, and networks this module does not know, give 'Nothing'.+segWitHeight :: Network -> Maybe BlockHeight+segWitHeight net+ | not net.segWit = Nothing+ | otherwise = case net.name of+ "btc" -> Just 481824+ "btctest" -> Just 834624+ "btctest4" -> Just 1+ "btcreg" -> Just 0+ _ -> Nothing++-- | The witness rule for a block, given the header of its parent if the node+-- has it. This is @DeploymentActiveAfter(prev_block, DEPLOYMENT_SEGWIT)@ in+-- Bitcoin Core: SegWit is active when the height of the block, one more than+-- its parent's, is at least 'segWitHeight'.+witnessCheck :: Network -> Maybe BlockNode -> WitnessCheck+witnessCheck net parent+ | not net.segWit = SegWitInactive+ | otherwise = case (parent, segWitHeight net) of+ (Just p, Just h)+ | p.height + 1 >= h -> SegWitActive+ | otherwise -> SegWitInactive+ _ -> WitnessUnknown++-- | Why a block is not the one its header commits to, or 'Nothing' if it is.+--+-- The checks come in the order of @IsBlockMutated@ in Bitcoin Core 32.1:+--+-- 1. The transaction ids hash to the Merkle root of the header+-- ('BadMerkleRoot'), and no level of the tree hashes two identical nodes+-- together ('DuplicateTransactions').+--+-- 2. If the first transaction is not a coinbase, no transaction is 64 bytes+-- long without its witness ('SixtyFourByteTransaction'). Such a block is+-- invalid anyway, and the witness is not looked at.+--+-- 3. The witness rule that 'WitnessCheck' names.+--+-- A block without transactions has no Merkle root and fails the first+-- check. Bitcoin Core refuses it as @bad-blk-length@.+blockMutation :: WitnessCheck -> Block -> Maybe BlockMutation+blockMutation rule b = case b.txs of+ [] -> Just BadMerkleRoot+ first : rest+ | root /= Just b.header.merkle -> Just BadMerkleRoot+ | mutated -> Just DuplicateTransactions+ | not (isCoinbase first) ->+ if any ((== 64) . strippedSize) b.txs+ then Just SixtyFourByteTransaction+ else Nothing+ | otherwise -> witnessMutation rule first rest+ where+ txids = map txHash b.txs+ -- haskoin-core gives the root of a tree that is not mutated, and refuses+ -- to build a mutated one. Only then is the root built a second time, to+ -- tell a wrong root from a mutated tree, in Bitcoin Core's order.+ (root, mutated) = case buildMerkleRoot txids of+ Right r -> (Just r, False)+ Left _ -> (merkleRoot txids, True)++-- | The witness part of the check, @CheckWitnessMalleation@ in Bitcoin Core,+-- given the coinbase and the other transactions of the block.+witnessMutation :: WitnessCheck -> Tx -> [Tx] -> Maybe BlockMutation+witnessMutation rule coinbase rest = case rule of+ WitnessUnknown -> Nothing+ SegWitInactive -> noWitness+ SegWitActive -> case witnessCommitment coinbase of+ Nothing -> noWitness+ Just commitment -> case coinbaseStack of+ [reserved]+ | B.length reserved == 32 ->+ if committed reserved == Just commitment+ then Nothing+ else Just BadWitnessCommitment+ _ -> Just BadWitnessNonceSize+ where+ noWitness+ | any hasWitness (coinbase : rest) = Just UnexpectedWitness+ | otherwise = Nothing+ -- The witness stack of the only input of the coinbase.+ coinbaseStack = case coinbase.witness of+ stack : _ -> stack+ [] -> []+ -- The witness id of the coinbase counts as zero.+ zero = "0000000000000000000000000000000000000000000000000000000000000000"+ witnessRoot = merkleRoot (zero : map txWitnessHash rest)+ committed reserved = do+ root <- witnessRoot+ return (encode (doubleSHA256 (encode root <> reserved)))++-- | The 32 bytes of the last witness commitment among the outputs of a+-- coinbase, as @GetWitnessCommitmentIndex@ in Bitcoin Core finds it: an+-- output script of at least 38 bytes that starts with @OP_RETURN@, a push of+-- 36 bytes, and the tag @aa21a9ed@.+witnessCommitment :: Tx -> Maybe ByteString+witnessCommitment tx =+ case filter isCommitment (map (.script) tx.outputs) of+ [] -> Nothing+ scripts -> Just (B.take 32 (B.drop 6 (last scripts)))+ where+ isCommitment s =+ B.length s >= 38 && B.take 6 s == B.pack [0x6a, 0x24, 0xaa, 0x21, 0xa9, 0xed]++-- | The root of the Merkle tree over the given leaves, whether or not the+-- tree is mutated, as @ComputeMerkleRoot@ in Bitcoin Core computes it.+-- 'Nothing' for no leaves.+merkleRoot :: [TxHash] -> Maybe Hash256+merkleRoot leaves = calcHash (calcTreeHeight (length leaves)) 0 leaves++-- | Whether a transaction is a coinbase, as @IsCoinBase@ in Bitcoin Core+-- tells: it has one input, and that input spends the null outpoint.+isCoinbase :: Tx -> Bool+isCoinbase tx = case tx.inputs of+ [i] -> i.outpoint == OutPoint {hash = zeroTx, index = 0xffffffff}+ _ -> False+ where+ zeroTx = "0000000000000000000000000000000000000000000000000000000000000000"++-- | Size of a transaction serialized without its witness.+strippedSize :: Tx -> Int+strippedSize tx = B.length (encode tx {witness = []})
src/Haskoin/Node/Chain.hs view
@@ -30,18 +30,37 @@ -- to show a better chain. "Haskoin.Node.Chain.Sync" decides whose turn it -- is. --+-- * __A turn cannot be held forever.__ A turn ends when the peer sends a+-- short batch, or a full batch that brings no header the node did not+-- have, as a peer that replays known headers does. It also ends, with the+-- peer dropped, when it passes the deadline that Bitcoin Core gives its+-- initial headers sync and another peer could take over (see+-- 'syncDeadline'). Without these, one peer that kept sending full batches+-- held the turn until it disconnected, and no other peer was asked.+-- -- * __Every hint of unknown blocks is followed.__ A peer announces a new block -- with a headers message only while it believes the node has the parent. -- When many blocks are found at once, or the node missed an announcement, -- the peer sends an inventory message instead, or headers that do not--- connect. Either way the node asks that peer for headers. Ignoring these+-- connect. Either way the node asks that peer for headers: at once if+-- another peer holds the turn, as Bitcoin Core does, with at most one+-- request in flight per peer, and again when its turn comes. Ignoring these -- left the chain stuck at an old height until the peer reconnected. -- -- * __Quiet means poll.__ If no headers are requested or received for -- 'timeout' seconds, every connected peer is asked for headers again. This--- costs one small request per peer per quiet period, and puts a bound on how--- long any unforeseen stall can last.+-- costs one small request per peer per quiet period, and ends a stall in+-- which nothing happens at all. A stall in which a peer keeps sending+-- headers is not quiet; the rules above end that one. --+-- * __Headers are kept before the chain has the minimum work.__ Bitcoin Core+-- first checks that a peer's headers reach the network's minimum chain+-- work and only then stores them (its @HeadersSyncState@). This node+-- stores every valid header at once. During the initial sync, a peer can+-- therefore make the node store a fork of low difficulty, at the cost of+-- its proof of work. The deadline above bounds how long one turn can do+-- this, but not the disk used over many turns.+-- -- * __Most work wins.__ The best chain is the one with the most accumulated -- proof of work, not the longest or the first seen, as in every Bitcoin -- implementation.@@ -82,7 +101,8 @@ import Control.Monad (filterM, unless, void, when) import Data.Function (on) import Data.Functor ((<&>))-import Data.Maybe (isNothing)+import Data.List.NonEmpty qualified as NE+import Data.Maybe (isJust, isNothing) import Data.String.Conversions (cs) import Data.Text (Text) import Data.Time.Clock@@ -257,9 +277,10 @@ processHeaders :: Chain -> Peer -> [BlockHeader] -> IO () processHeaders ch p [] = do logDebug ch.config.logger ("No headers from peer " <> p.label)+ update ch (gotHeaders p) syncing <- isSyncing ch p when syncing $ finish ch p-processHeaders ch p hs@(h : _) = do+processHeaders ch p hs@(h : rest) = do logDebug ch.config.logger ("Processing " <> cs (show (length hs)) <> " headers from peer " <> p.label)@@ -270,13 +291,21 @@ ("Headers from peer " <> p.label <> " do not connect") update ch (requestSync p) syncNext ch- Just _ ->+ askAnnouncer ch p+ Just _ -> do+ update ch (gotHeaders p)+ -- Stored headers form a tree from genesis, so the batch brings nothing+ -- new exactly when its last header is stored already.+ known <- isJust <$> chainGetBlock ch (headerHash (NE.last (h NE.:| rest))) importHeaders ch hs >>= \case Left e -> misbehaving ch p ("could not connect headers: " <> cs e) Right lst -> do syncing <- isSyncing ch p let full = length hs >= fromIntegral maxHeadersSize if+ | full && syncing && known -> do+ logDebug ch.config.logger ("No new headers from peer " <> p.label)+ finish ch p | full && syncing -> requestHeaders ch p (Just lst) | full -> update ch (requestSync p) >> syncNext ch | syncing -> finish ch p@@ -298,8 +327,24 @@ ) update ch (requestSync p) syncNext ch+ askAnnouncer ch p --- | Disconnect the syncing peer if it has not answered in time. If no peer is+-- | Ask a peer for headers at once, because it told the node about blocks+-- the node does not have, unless it is the syncing peer, whose request is+-- outstanding, or a request to it is in flight already (see 'mayAsk'). The+-- peer stays queued for its turn as well.+askAnnouncer :: Chain -> Peer -> IO ()+askAnnouncer ch p = do+ now <- getCurrentTime+ s <- (.sync) <$> readTVarIO ch.state+ let syncing = fmap fst (syncingPeer s) == Just p+ when (not syncing && mayAsk now p s) $ do+ best <- withDB ch getBestBlockHeader+ logDebug ch.config.logger ("Asking announcing peer for headers: " <> p.label)+ sendGetHeaders ch now p best++-- | Disconnect the syncing peer if it has not answered in time, or if its+-- turn is past its deadline and another peer can take over. If no peer is -- syncing, start with a queued one, or queue every peer if nothing has -- happened for a while. checkSync :: Chain -> IO ()@@ -310,6 +355,7 @@ case syncingPeer s of Just (p, t) | quiet t -> misbehaving ch p "timed out"+ | syncTimedOut now s -> misbehaving ch p "headers sync took too long" | otherwise -> return () Nothing | not (null (queuedPeers s)) -> syncNext ch@@ -333,7 +379,8 @@ True -> do logDebug ch.config.logger ("Syncing against peer " <> p.label) now <- getCurrentTime- update ch (startSync now p)+ best <- withDB ch getBestBlockHeader+ update ch (startSync now (syncDeadline ch.config.net now best) p) requestHeaders ch p Nothing False -> do logDebug ch.config.logger ("Could not lock peer: " <> p.label)@@ -346,6 +393,13 @@ now <- getCurrentTime update ch (refreshSync now p) bn <- maybe (withDB ch getBestBlockHeader) return after+ sendGetHeaders ch now p bn++-- | Send a peer a request for the headers after the given block, and record+-- it as in flight.+sendGetHeaders :: Chain -> UTCTime -> Peer -> BlockNode -> IO ()+sendGetHeaders ch now p bn = do+ update ch (askHeaders now p) loc <- withDB ch (blockLocator bn) logDebug ch.config.logger@@ -400,22 +454,27 @@ -- | Validate and store a run of headers, and publish the best block if it -- changed. Returns the node of the last header.+--+-- The best block is compared even when a header fails. haskoin-core's+-- 'connectBlocks' keeps the headers before the one that failed, and they may+-- have moved the best block. importHeaders :: Chain -> [BlockHeader] -> IO (Either String BlockNode) importHeaders ch hs = do now <- getCurrentTime let timestamp = floor (utcTimeToPOSIXSeconds now) before <- withDB ch getBestBlockHeader- withDB ch (connectBlocks ch.config.net timestamp hs) >>= \case+ result <- withDB ch (connectBlocks ch.config.net timestamp hs)+ after <- withDB ch getBestBlockHeader+ when (before.header /= after.header) $+ chainEvent ch (ChainBestBlock after)+ case result of Left e -> do logError ch.config.logger ("connectBlocks: " <> cs e) return (Left e) Right [] -> return (Left "no headers") Right (lst : _) -> do update ch (touchSync now)- after <- withDB ch getBestBlockHeader- when (before.header /= after.header) $- chainEvent ch (ChainBestBlock after) return (Right lst) -- | Publish a 'ChainSynced' event the first time the chain is synced (see
src/Haskoin/Node/Chain/Sync.hs view
@@ -32,6 +32,26 @@ -- out is marked 'Dropped' until its disconnection arrives, so nothing can -- queue it in between. --+-- * __A turn has a deadline.__ A peer that keeps sending full batches never+-- finishes its turn, and while it holds the turn nobody else is asked. So+-- each turn gets the deadline that Bitcoin Core gives its initial headers+-- sync, 15 minutes plus 1 millisecond for each header expected from the age+-- of the best block ('syncDeadline'). When it passes and another peer could+-- take over, the turn ends with the peer dropped ('syncTimedOut'). Core+-- applies the deadline only while the best block is more than a day old.+-- Here it applies to every turn, because the chain only counts as synced+-- once no peer holds a turn, so a peer that held one forever would keep it+-- from ever being synced. An honest turn after the initial sync takes+-- seconds.+--+-- * __Announcements get an answer at once.__ A peer that announces an+-- unknown block while another peer holds the turn is asked for headers+-- directly, as Bitcoin Core does, so that a slow or hostile syncing peer+-- cannot keep the node from hearing about new blocks. At most one such+-- request is in flight per peer ('mayAsk'): a request counts as answered+-- when the peer sends headers that connect, or none, and expires after+-- 'headersResponseTime'.+-- -- * __Pure and polymorphic.__ The rules are plain functions on a value, over -- any peer type with equality, so they can be tested exhaustively with -- integers instead of network connections. Transitions that do not apply to@@ -45,11 +65,19 @@ peerDisconnected, requestSync, startSync,+ syncDeadline,+ syncTimedOut, refreshSync, finishSync, dropPeer, pollPeers, touchSync,+ askHeaders,+ gotHeaders,+ mayAsk,+ headersResponseTime,+ headersSyncTimeoutBase,+ headersSyncTimeoutPerHeader, peerStatus, syncingPeer, queuedPeers,@@ -61,7 +89,7 @@ where import Data.Maybe (isNothing, listToMaybe)-import Data.Time.Clock (NominalDiffTime, UTCTime, diffUTCTime)+import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, diffUTCTime) import Data.Time.Clock.POSIX (posixSecondsToUTCTime) import Haskoin (BlockHeader (..), BlockNode (..), Network (..)) @@ -80,16 +108,21 @@ deriving stock (Eq, Show) -- | Connected peers in queue order, with the last time that headers were--- requested or received.+-- requested or received, the deadline of the current turn, and the requests+-- for headers in flight. data SyncState p = SyncState { peers :: ![(p, PeerStatus)],- activity :: !UTCTime+ activity :: !UTCTime,+ -- | when the turn of the syncing peer must end; set by 'startSync'+ deadline :: !(Maybe UTCTime),+ -- | peers asked for headers that have not answered, with the time asked+ asked :: ![(p, UTCTime)] } deriving stock (Eq, Show) -- | No peers, with the given time as the last activity. emptySync :: UTCTime -> SyncState p-emptySync = SyncState []+emptySync t = SyncState {peers = [], activity = t, deadline = Nothing, asked = []} -- | A peer completed the handshake. It is queued to be asked for headers. peerConnected :: (Eq p) => p -> SyncState p -> SyncState p@@ -99,7 +132,8 @@ -- | A peer disconnected. peerDisconnected :: (Eq p) => p -> SyncState p -> SyncState p-peerDisconnected p s = s {peers = filter ((/= p) . fst) s.peers}+peerDisconnected p s =+ s {peers = filter ((/= p) . fst) s.peers, asked = filter ((/= p) . fst) s.asked} -- | The peer told the node about blocks that it does not have. requestSync :: (Eq p) => p -> SyncState p -> SyncState p@@ -109,14 +143,49 @@ Just (Syncing t _) -> setStatus p (Syncing t True) s _ -> s --- | Start asking a queued peer for headers, unless another peer is being--- asked already.-startSync :: (Eq p) => UTCTime -> p -> SyncState p -> SyncState p-startSync now p s =+-- | Start asking a queued peer for headers at the given time, unless another+-- peer is being asked already. The second time is the deadline of the turn+-- (see 'syncDeadline').+startSync :: (Eq p) => UTCTime -> UTCTime -> p -> SyncState p -> SyncState p+startSync now end p s = case (syncingPeer s, peerStatus p s) of- (Nothing, Just Queued) -> setStatus p (Syncing now False) s {activity = now}+ (Nothing, Just Queued) ->+ setStatus p (Syncing now False) s {activity = now, deadline = Just end} _ -> s +-- | Base of the deadline of a turn: @HEADERS_DOWNLOAD_TIMEOUT_BASE@ in+-- Bitcoin Core 32.1, 15 minutes.+headersSyncTimeoutBase :: NominalDiffTime+headersSyncTimeoutBase = 15 * 60++-- | Time added to the deadline of a turn for each header expected:+-- @HEADERS_DOWNLOAD_TIMEOUT_PER_HEADER@ in Bitcoin Core 32.1, 1 millisecond.+headersSyncTimeoutPerHeader :: NominalDiffTime+headersSyncTimeoutPerHeader = 0.001++-- | Deadline of a turn that starts at the given time with the given best+-- block, as Bitcoin Core sets it for its initial headers sync:+-- 'headersSyncTimeoutBase', plus 'headersSyncTimeoutPerHeader' for each+-- block that should have been found since the best block, one every+-- 'targetSpacing' of the network.+syncDeadline :: Network -> UTCTime -> BlockNode -> UTCTime+syncDeadline net now best = timeout `addUTCTime` now+ where+ tipTime = posixSecondsToUTCTime (fromIntegral best.header.timestamp)+ age = max 0 (floor (now `diffUTCTime` tipTime)) :: Integer+ expected = age `div` max 1 (fromIntegral net.targetSpacing)+ timeout = headersSyncTimeoutBase + fromIntegral expected * headersSyncTimeoutPerHeader++-- | Whether the turn of the syncing peer has passed its deadline while+-- another connected peer could take over. Bitcoin Core only drops its+-- initial sync peer when it has another one to use.+syncTimedOut :: (Eq p) => UTCTime -> SyncState p -> Bool+syncTimedOut now s =+ case (syncingPeer s, s.deadline) of+ (Just (p, _), Just end) ->+ now > end && any (/= p) (connectedPeers s)+ _ -> False+ -- | Another request was sent to the peer being synced against. refreshSync :: (Eq p) => UTCTime -> p -> SyncState p -> SyncState p refreshSync now p s =@@ -142,7 +211,7 @@ -- | Queue every idle peer, and record the time as activity so that polling -- happens at most once per quiet period. pollPeers :: UTCTime -> SyncState p -> SyncState p-pollPeers now s = SyncState {peers = map poll s.peers, activity = now}+pollPeers now s = s {peers = map poll s.peers, activity = now} where poll (p, Idle) = (p, Queued) poll x = x@@ -150,6 +219,30 @@ -- | Headers were received at the given time. touchSync :: UTCTime -> SyncState p -> SyncState p touchSync now s = s {activity = now}++-- | Longest that a request for headers counts as in flight:+-- @HEADERS_RESPONSE_TIME@ in Bitcoin Core 32.1, 2 minutes.+headersResponseTime :: NominalDiffTime+headersResponseTime = 120++-- | A request for headers was sent to the peer at the given time.+askHeaders :: (Eq p) => UTCTime -> p -> SyncState p -> SyncState p+askHeaders now p s = s {asked = (p, now) : filter ((/= p) . fst) s.asked}++-- | The peer sent headers that answer a request: headers that connect to+-- the chain, or none. Headers that do not connect cannot be an answer, as+-- Bitcoin Core reasons, so they leave the request in flight.+gotHeaders :: (Eq p) => p -> SyncState p -> SyncState p+gotHeaders p s = s {asked = filter ((/= p) . fst) s.asked}++-- | Whether the peer may be asked for headers at the given time: no request+-- to it is in flight, or the last one is older than 'headersResponseTime'.+-- This is the rule of @MaybeSendGetHeaders@ in Bitcoin Core.+mayAsk :: (Eq p) => UTCTime -> p -> SyncState p -> Bool+mayAsk now p s =+ case lookup p s.asked of+ Nothing -> True+ Just t -> now `diffUTCTime` t > headersResponseTime -- | Status of a peer, or 'Nothing' if it is not connected. peerStatus :: (Eq p) => p -> SyncState p -> Maybe PeerStatus
src/Haskoin/Node/Peer.hs view
@@ -24,10 +24,23 @@ -- the payload read, so a peer cannot make the node buffer a huge message it -- would refuse anyway. --+-- * A payload is read in time and memory linear in its size, however small+-- the pieces it arrives in. The pieces are collected and joined once. An+-- earlier version appended each piece to the bytes read so far, so a peer+-- that sent a large message in small TCP segments made the node copy it+-- over and over, with a cost that grew with the square of the size.+-- -- * Anything that does not parse closes the connection with a -- 'PeerException' saying why. There is no attempt to recover, because a -- peer that sends garbage once is not worth the risk. --+-- * A block whose transactions are not the ones its header commits to closes+-- the connection too ('MutatedBlock'). The header carries the proof of+-- work, but anyone can attach other transactions to it. The check is+-- "Haskoin.Node.Block", the same as Bitcoin Core runs on every block it+-- receives. It is done here, once, so that 'getBlocks', 'getData' and the+-- 'PeerMessage' events all see only blocks that passed it.+-- -- * Reading pauses when the node falls behind ('maxPendingMessages'), so the -- speed of the node, not the speed of the peer, sets the pace. module Haskoin.Node.Peer@@ -75,6 +88,7 @@ import Data.Text (Text) import Data.Word (Word32) import Haskoin+import Haskoin.Node.Block import Haskoin.Node.Logger import Haskoin.Node.Loop (loopForever) import Haskoin.Node.Peer.Lock@@ -109,7 +123,14 @@ -- | how to open the connection to the peer connect :: !WithConnection, -- | log messages here- logger :: !Logger+ logger :: !Logger,+ -- | look up a block header that the node has stored, or give 'Nothing'.+ -- A block from the peer is checked against its parent's height, to know+ -- whether its witness data must match the commitment in its coinbase+ -- (see 'witnessCheck'). "Haskoin.Node" passes 'Haskoin.Node.chainGetBlock'.+ -- A function that always gives 'Nothing' still checks the Merkle root of+ -- every block, but not its witness data.+ lookupBlock :: !(BlockHash -> IO (Maybe BlockNode)) } -- | Events published about peers.@@ -118,7 +139,11 @@ PeerConnected !Peer | -- | the peer disconnected PeerDisconnected !Peer- | -- | the peer sent a message+ | -- | the peer sent a message. A block in an 'MBlock' message has passed+ -- 'blockMutation': its transactions hash to the Merkle root of its+ -- header, and its witness data matches its coinbase when the node knows+ -- the parent of the block. Nothing else about it is checked. A+ -- transaction in an 'MTx' message is not checked at all. PeerMessage !Peer !Message deriving (Eq) @@ -138,6 +163,9 @@ MessageTooLarge !MessageCommand !Word32 | -- | the payload could not be parsed; holds the reason InvalidPayload !MessageCommand !String+ | -- | a block whose transactions are not the ones its header commits to;+ -- holds the hash of the block and the reason (see 'blockMutation')+ MutatedBlock !BlockHash !BlockMutation deriving stock (Show) instance Exception PeerException@@ -263,29 +291,46 @@ -- The counts of list messages are not checked here. 'getMessage' fails as -- soon as it reads a count above the limit of the message. The limits are -- 2000 headers and 1000 addresses, for example.+--+-- The bytes of a message are collected as the pieces that arrive, and joined+-- once, header and payload together. The cost of reading is linear in the+-- size of the message, whatever the size of the pieces.+--+-- A block must pass 'blockMutation' before it is passed on. Its parent is+-- looked up with 'lookupBlock' to choose the witness rule. inPeerConduit :: PeerConfig -> ConduitT ByteString Message IO ()-inPeerConduit PeerConfig {label, net, logger} = loopForever $ do+inPeerConduit PeerConfig {label, net, logger, lookupBlock} = loopForever $ do debug (label <> " awaiting message...")- x <- takeExactly 24+ hdr <- takePieces 24+ let x = B.concat hdr MessageHeader mgc cmd len _ <- case decode x of Left _ -> bad InvalidMessageHeader Right h -> return h when (mgc /= net.magic) $ bad (WrongNetwork mgc) when (len > maxPayloadSize net cmd) $ bad (MessageTooLarge cmd len) debug (label <> " sent cmd " <> cs (show cmd))- y <- takeExactly (fromIntegral len)- case runGet (getMessage net) $ x `B.append` y of+ payload <- takePieces (fromIntegral len)+ case runGet (getMessage net) (B.concat (x : payload)) of Left e -> bad (InvalidPayload cmd (firstLine e)) Right m -> do debug (label <> " sent payload for cmd " <> cs (show cmd))+ checkBlock m yield m where debug = liftIO . logDebug logger- takeExactly n = do- bs <- takeCE n .| foldC- when (B.length bs /= n) $ bad ConnectionClosed- return bs+ -- Exactly n bytes, as the pieces they arrived in. Joining them is left+ -- to the caller, so that it happens once.+ takePieces n = do+ pieces <- takeCE n .| sinkList+ when (sum (map B.length pieces) /= n) $ bad ConnectionClosed+ return pieces+ checkBlock = \case+ MBlock b -> do+ parent <- liftIO (lookupBlock b.header.prev)+ forM_ (blockMutation (witnessCheck net parent) b) $+ bad . MutatedBlock (headerHash b.header)+ _ -> return () bad :: PeerException -> ConduitT i o IO a bad e = liftIO $ do logError logger (label <> ": " <> cs (show e))@@ -356,6 +401,13 @@ -- -- The 'Int' argument is the timeout in seconds. On networks with SegWit, -- blocks are requested with their witness data.+--+-- Every block returned has passed 'blockMutation': its transactions hash to+-- the Merkle root of its header, and, when the node knows its parent, its+-- witness data matches the commitment in its coinbase. A peer that sends a+-- block that fails is disconnected, and the request then gives 'Nothing'+-- when it times out. The blocks are not validated otherwise: their+-- transactions may still be invalid. getBlocks :: Network -> Int ->@@ -377,6 +429,11 @@ -- -- The 'Int' argument is the timeout in seconds. Peers normally only serve -- transactions that are in their mempool.+--+-- A transaction is matched by its id, which does not cover its witness. The+-- peer can return the right transaction with any witness data, and nothing+-- here can tell. Treat the witness of a transaction from 'getTxs', or from an+-- 'MTx' event, as unauthenticated until the transaction is found in a block. getTxs :: Network -> Int ->@@ -401,7 +458,8 @@ -- items means that the peer will not send the rest. Unrelated messages that -- arrive meanwhile are ignored. Transactions are returned as 'Left' and blocks -- as 'Right', in the order they were requested. "Haskoin.Node.Peer.Request"--- has the rules.+-- has the rules. Blocks are checked as 'getBlocks' says, and transactions as+-- 'getTxs' says. getData :: Int -> Peer -> GetData -> IO (Maybe [Either Tx Block]) getData seconds p gd@(GetData ivs) =
src/Haskoin/Node/Peer/Request.hs view
@@ -30,6 +30,12 @@ -- -- Keeping these rules in a pure function means each one can be tested on its -- own, without a connection.+--+-- A block is matched to a request by the hash of its header only. That is+-- enough because the peer process checks every block before passing it on,+-- with 'Haskoin.Node.Block.blockMutation': a block that arrives here holds+-- the transactions its header commits to. A transaction is matched by its+-- id, which does not cover its witness. module Haskoin.Node.Peer.Request ( Request, RequestStep (..),
src/Haskoin/Node/PeerMgr.hs view
@@ -63,8 +63,16 @@ -- -- * __Relayed addresses are untrusted.__ Peers gossip addresses, and a -- malicious peer could gossip private or loopback addresses to make the node--- probe its own network. Only public addresses are accepted, and only a--- bounded number are kept, so gossip cannot exhaust memory.+-- probe its own network. Only public addresses are accepted, from gossip and+-- from DNS seeds alike, and only a bounded number are kept, so gossip cannot+-- exhaust memory.+--+-- * __One connection per network group.__ Addresses that the node learned are+-- picked at random, and at most one connection is open to each network group+-- (a /16 of IPv4, a /32 of IPv6; see 'Haskoin.Node.Address.netGroup'), as in+-- Bitcoin Core. One operator with one block of addresses can then hold one+-- slot, not all of them. Configured peers are exempt: the operator chose+-- them. module Haskoin.Node.PeerMgr ( PeerMgrConfig (..), PeerEvent (..),@@ -107,7 +115,7 @@ import Data.Word (Word32, Word64) import GHC.Records (HasField (..)) import Haskoin-import Haskoin.Node.Address (isRoutable, toHostService, toSockAddr)+import Haskoin.Node.Address (NetGroup, isRoutable, netGroup, toHostService, toSockAddr) import Haskoin.Node.Logger import Haskoin.Node.Loop (loopForever) import Haskoin.Node.Peer@@ -143,7 +151,10 @@ -- | how to open a connection to a peer address connect :: !(SockAddr -> WithConnection), -- | log messages here- logger :: !Logger+ logger :: !Logger,+ -- | look up a stored block header; given to every peer as the+ -- @lookupBlock@ of its t'PeerConfig', to check the blocks it sends+ lookupBlock :: !(BlockHash -> IO (Maybe BlockNode)) } -- | Handle to a running peer manager. Obtain one with 'withPeerMgr', or from@@ -298,7 +309,7 @@ PeerVersion p v -> do logDebug mgr.config.logger ("Received version from peer " <> p.label) nonces <- map (.nonce) <$> readTVarIO mgr.peers- peerInput mgr p $ either Refused (GotHandshake . GotVersion) (checkVersion nonces v)+ peerInput mgr p $ either Refused (GotHandshake . GotVersion) (checkVersion mgr.config.net nonces v) PeerVerAck p -> do logDebug mgr.config.logger ("Received version ack from peer " <> p.label) peerInput mgr p (GotHandshake GotVerAck)@@ -437,37 +448,56 @@ -- | Next address to connect to. The configured peers and, if discovery is on, -- the seeds are loaded when there is nothing to try and they were not loaded -- recently.+--+-- Configured peers come first. Otherwise an address is picked at random among+-- those in a network group that the node is not connected to yet, as Bitcoin+-- Core requires of its automatic outbound connections (see 'netGroup'). getNewPeer :: PeerMgr -> IO (Maybe SockAddr) getNewPeer mgr = do now <- getCurrentTime reload <- needsReload now <$> readTVarIO mgr.addresses when reload $ loadPeers mgr now- atomically . stateTVar mgr.addresses $ \book ->- case takeAddress now book of- Just (sa, book') -> (Just sa, book')- Nothing -> (Nothing, book)+ r <- randomIO+ atomically $ do+ used <- usedGroups <$> readTVar mgr.peers+ stateTVar mgr.addresses $ \book ->+ case takeAddress now r ((`notElem` used) . netGroup) book of+ Just (sa, book') -> (Just sa, book')+ Nothing -> (Nothing, book) +-- | Network groups of the open connections.+usedGroups :: [OnlinePeer] -> [NetGroup]+usedGroups = map (netGroup . (.address))+ -- | Resolve the configured peers and, if discovery is on, the seeds of the--- network, and add them to the address book.+-- network, and add them to the address book. Seed results must be routable,+-- like relayed addresses: whoever answers the DNS query could otherwise point+-- the node at its own network. Bitcoin Core's address manager refuses them+-- too. On regtest the seed is @localhost@, so local peers there have to be+-- configured. loadPeers :: PeerMgr -> UTCTime -> IO () loadPeers mgr now = do let net = mgr.config.net resolve = fmap concat . mapM (toSockAddr net) configured <- resolve mgr.config.peers- seeds <- if mgr.config.discover then resolve net.seeds else return []+ seeds <- if mgr.config.discover then filter isRoutable <$> resolve net.seeds else return [] atomically . modifyTVar' mgr.addresses $ \book -> markLoaded now $- foldr (addAddress Discovered) (foldr (addAddress Configured) book configured) seeds+ foldr (addAddress Seeded) (foldr (addAddress Configured) book configured) seeds -- | Start a peer process for an address taken from the address book and send--- it our version, unless there are no free slots or the address is connected--- already, in which case the address is given back.+-- it our version, unless there are no free slots, the address is connected+-- already, or it is a seed or gossiped address in a network group that the+-- node is connected to already. Then the address is given back. connectPeer :: PeerMgr -> SockAddr -> IO () connectPeer mgr sa = do ops <- readTVarIO mgr.peers+ source <- addressSource sa <$> readTVarIO mgr.addresses if | length ops >= mgr.config.maxPeers -> giveBack "no free slots" | any ((== sa) . (.address)) ops -> giveBack "already connected"+ | source /= Just Configured && netGroup sa `elem` usedGroups ops ->+ giveBack "network group in use" | otherwise -> do logInfo mgr.config.logger ("Connecting to peer " <> cs (show sa)) nonce <- randomIO@@ -484,7 +514,8 @@ net = net, label = cs (show sa), connect = mgr.config.connect sa,- logger = withSource "Peer" mgr.config.logger+ logger = withSource "Peer" mgr.config.logger,+ lookupBlock = mgr.config.lookupBlock } (inbox, mailbox) <- newMailbox busy <- newTVarIO Free
src/Haskoin/Node/PeerMgr/Addresses.hs view
@@ -24,10 +24,28 @@ -- * __Configured peers are trusted more.__ Addresses from the configuration -- are what the operator asked for, so they are never banned and never -- forgotten: they back off at most 'maxBackoff', even after misbehaving.--- Discovered addresses, from DNS seeds and gossip, are banned for--- misbehaving, and at most 'maxDiscovered' of them are kept, so gossip--- cannot exhaust memory.+-- They are also tried first. Addresses from DNS seeds and gossip are+-- banned for misbehaving, and at most 'maxDiscovered' of each are kept, so+-- gossip cannot exhaust memory. --+-- * __Seeds and gossip are kept apart.__ Addresses from DNS seeds count+-- against their own limit. A peer that gossips thousands of addresses can+-- fill the room for gossip, but not crowd out the seeds. Before, both+-- shared one limit, and once gossip had filled it, no new seed address was+-- learned.+--+-- * __A full book still learns.__ When the limit of a source is reached, a+-- new address takes the place of one from the same source that has failed+-- to connect, if there is one. Addresses that work, or have not been tried,+-- stay.+--+-- * __Random choice.__ 'takeAddress' picks among the addresses that can be+-- tried at random, as Bitcoin Core's address manager does. It used to take+-- the first one in the order of the map, which for socket addresses is the+-- order of the port, so an attacker that gossiped addresses with low ports+-- was always tried first. The randomness is an input, so that the book+-- stays pure.+-- -- * __Pure, with time as an input.__ Every transition takes the current time, -- so the timing rules can be tested without waiting. "Haskoin.Node.PeerMgr" -- keeps the book in a 'Control.Concurrent.STM.TVar'.@@ -43,6 +61,7 @@ markConnected, finishAddress, addressState,+ addressSource, needsReload, markLoaded, knownAddresses,@@ -54,19 +73,23 @@ ) where -import Data.List (find) import Data.Map.Strict (Map) import Data.Map.Strict qualified as Map import Data.Maybe (isNothing)+import Data.Set (Set)+import Data.Set qualified as Set import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, diffUTCTime)+import Data.Word (Word64) -- | Where an address came from. data Source = -- | the static peers of the configuration Configured- | -- | DNS seeds, or addresses relayed by peers+ | -- | the DNS seeds of the network+ Seeded+ | -- | relayed by peers Discovered- deriving stock (Eq, Show)+ deriving stock (Eq, Ord, Show) -- | Whether an address can be tried. data AddressState@@ -98,11 +121,23 @@ } deriving stock (Eq, Show) --- | Known addresses, and when the configured peers and seeds were last--- loaded.+-- | Known addresses, how many of them came from seeds and from gossip, the+-- ones that may make room for a new address, and when the configured peers+-- and seeds were last loaded.+--+-- The counts and the index are kept in step with the entries by 'putEntry',+-- through which every change goes, so that learning an address never scans+-- the book. data AddressBook a = AddressBook { entries :: !(Map a Entry),- loaded :: !(Maybe UTCTime)+ loaded :: !(Maybe UTCTime),+ -- | how many entries are 'Seeded'+ seeded :: !Int,+ -- | how many entries are 'Discovered'+ discovered :: !Int,+ -- | seed and gossiped addresses that failed and are not in use, by+ -- source, with their failures in a row (see 'replaceable')+ failing :: !(Map Source (Set (Int, a))) } deriving stock (Eq, Show) @@ -122,37 +157,64 @@ reloadInterval :: NominalDiffTime reloadInterval = 300 --- | Most discovered addresses kept.+-- | Most addresses kept from each of the DNS seeds and gossip. maxDiscovered :: Int maxDiscovered = 4096 -- | No addresses, never loaded. emptyBook :: AddressBook a-emptyBook = AddressBook {entries = Map.empty, loaded = Nothing}+emptyBook =+ AddressBook+ { entries = Map.empty,+ loaded = Nothing,+ seeded = 0,+ discovered = 0,+ failing = Map.empty+ } -- | Learn an address. A known address keeps its state; one from the--- configuration is marked 'Configured'. A discovered address is dropped if--- 'maxDiscovered' are known already.+-- configuration is marked 'Configured'. When 'maxDiscovered' addresses from+-- the same source are known already, a new seed or gossiped address takes+-- the place of one from that source that has failed to connect and is not in+-- use, the one with the most failures in a row. If there is none, the new+-- address is dropped. addAddress :: (Ord a) => Source -> a -> AddressBook a -> AddressBook a addAddress src a book = case Map.lookup a book.entries of Just e- | src == Configured -> put e {source = Configured}+ | src == Configured -> setEntry a e {source = Configured} book | otherwise -> book Nothing- | src == Discovered && discovered >= maxDiscovered -> book- | otherwise -> put Entry {source = src, state = Ready, failures = 0}+ | src == Configured -> fresh book+ | sourceCount src book < maxDiscovered -> fresh book+ | otherwise -> case evictable of+ Just old -> fresh (forget old book)+ Nothing -> book where- put e = book {entries = Map.insert a e book.entries}- discovered = Map.size (Map.filter ((== Discovered) . (.source)) book.entries)+ fresh = setEntry a Entry {source = src, state = Ready, failures = 0}+ -- the one with the most failures, and the greatest address among those+ evictable = snd <$> (Set.lookupMax =<< Map.lookup src book.failing) -- | Take an address to connect to: a 'Ready' one, or a 'Waiting' one whose -- time has come. It becomes 'InUse'.-takeAddress :: (Ord a) => UTCTime -> AddressBook a -> Maybe (a, AddressBook a)-takeAddress now book = do- (a, e) <- find (ready . snd) (Map.toList book.entries)- return (a, setEntry a e {state = InUse} book)+--+-- Configured addresses come first, in order. Otherwise the address is picked+-- at random, using the given number, among the seed and gossiped addresses+-- that the predicate allows. "Haskoin.Node.PeerMgr" uses the predicate to+-- skip addresses in a network group that it is connected to already.+takeAddress ::+ (Ord a) => UTCTime -> Word64 -> (a -> Bool) -> AddressBook a -> Maybe (a, AddressBook a)+takeAddress now r allowed book =+ case filter ((== Configured) . (.source) . snd) candidates of+ (a, e) : _ -> Just (take_ a e)+ [] -> case filter (allowed . fst) candidates of+ [] -> Nothing+ others ->+ let (a, e) = others !! fromIntegral (r `mod` fromIntegral (length others))+ in Just (take_ a e) where+ candidates = filter (ready . snd) (Map.toList book.entries)+ take_ a e = (a, setEntry a e {state = InUse} book) ready e = case e.state of Ready -> True InUse -> False@@ -186,11 +248,15 @@ addressState :: (Ord a) => a -> AddressBook a -> Maybe AddressState addressState a book = (.state) <$> Map.lookup a book.entries +-- | Where an address came from, or 'Nothing' if it is not known.+addressSource :: (Ord a) => a -> AddressBook a -> Maybe Source+addressSource a book = (.source) <$> Map.lookup a book.entries+ -- | Whether to load the configured peers and seeds: when no address can be -- tried now, and they were never loaded or not loaded for 'reloadInterval'. needsReload :: (Ord a) => UTCTime -> AddressBook a -> Bool needsReload now book =- isNothing (takeAddress now book)+ isNothing (takeAddress now 0 (const True) book) && maybe True (\t -> now `diffUTCTime` t >= reloadInterval) book.loaded -- | The configured peers and seeds were loaded at the given time.@@ -202,7 +268,52 @@ knownAddresses book = Map.keys book.entries setEntry :: (Ord a) => a -> Entry -> AddressBook a -> AddressBook a-setEntry a e book = book {entries = Map.insert a e book.entries}+setEntry a e = putEntry a (Just e) +-- | Remove an address.+forget :: (Ord a) => a -> AddressBook a -> AddressBook a+forget a = putEntry a Nothing+ adjust :: (Ord a) => (Entry -> Entry) -> a -> AddressBook a -> AddressBook a-adjust f a book = book {entries = Map.adjust f a book.entries}+adjust f a book = case Map.lookup a book.entries of+ Nothing -> book+ Just e -> putEntry a (Just (f e)) book++-- | Whether an entry may make room for a new address from its source: it is+-- a seed or gossiped address that failed and is not in use.+replaceable :: Entry -> Bool+replaceable e = e.source /= Configured && e.failures > 0 && e.state /= InUse++-- | Set the entry of an address, or remove it with 'Nothing'. The counts of+-- seed and gossiped addresses and the index of 'replaceable' ones follow.+putEntry :: (Ord a) => a -> Maybe Entry -> AddressBook a -> AddressBook a+putEntry a new book =+ maybe id include new . maybe id exclude old $+ book {entries = Map.alter (const new) a book.entries}+ where+ old = Map.lookup a book.entries+ exclude e b =+ count e.source (-1) $+ if replaceable e+ then b {failing = Map.adjust (Set.delete (e.failures, a)) e.source b.failing}+ else b+ include e b =+ count e.source 1 $+ if replaceable e+ then b {failing = Map.insertWith Set.union e.source (Set.singleton (e.failures, a)) b.failing}+ else b++-- | Change the count of addresses from a source. Configured addresses are+-- not counted.+count :: Source -> Int -> AddressBook a -> AddressBook a+count src n book = case src of+ Configured -> book+ Seeded -> book {seeded = book.seeded + n}+ Discovered -> book {discovered = book.discovered + n}++-- | How many addresses from a source are known.+sourceCount :: Source -> AddressBook a -> Int+sourceCount src book = case src of+ Configured -> 0+ Seeded -> book.seeded+ Discovered -> book.discovered
src/Haskoin/Node/PeerMgr/Handshake.hs view
@@ -43,7 +43,7 @@ import Data.Bits ((.&.)) import Data.Word (Word64)-import Haskoin (Version (..), nodeNetwork)+import Haskoin (Network (..), Version (..), nodeNetwork, nodeWitness) -- | How far the handshake with a peer has progressed. data Handshake@@ -81,6 +81,8 @@ DuplicateVerAck | -- | the peer does not serve the full block chain NotFullNode+ | -- | the peer does not serve witness data, on a network with SegWit+ NotWitnessNode | -- | the version carries one of our own nonces: we connected to ourselves ConnectedToSelf deriving stock (Eq, Show)@@ -128,15 +130,20 @@ Established _ -> True _ -> False --- | Check the version of a peer before accepting it. Takes the nonces of the--- version messages that we sent to all our peers.+-- | Check the version of a peer before accepting it. Takes the network and+-- the nonces of the version messages that we sent to all our peers. -- -- A peer must serve the full chain ('nodeNetwork'), because the node syncs--- headers and applications download blocks from it. A version carrying one of--- our nonces means the node connected to itself, which happens when it learns--- its own address from gossip.-checkVersion :: [Word64] -> Version -> Either HandshakeError Version-checkVersion nonces v+-- headers and applications download blocks from it. On a network with SegWit+-- it must also serve witness data ('nodeWitness'), because+-- 'Haskoin.Node.Peer.getBlocks' and 'Haskoin.Node.Peer.getTxs' ask for+-- blocks and transactions with their witness. Bitcoin Core+-- 32.1 requires both of its outbound peers (@GetDesirableServiceFlags@). A+-- version carrying one of our nonces means the node connected to itself,+-- which happens when it learns its own address from gossip.+checkVersion :: Network -> [Word64] -> Version -> Either HandshakeError Version+checkVersion net nonces v | v.services .&. nodeNetwork == 0 = Left NotFullNode+ | net.segWit && v.services .&. nodeWitness == 0 = Left NotWitnessNode | v.nonce `elem` nonces = Left ConnectedToSelf | otherwise = Right v
test/common/Haskoin/Node/Test.hs view
@@ -17,6 +17,10 @@ headersMessage, nowTimestamp, + -- * Blocks from Bitcoin Core+ corePreSegWitBlock,+ coreSegWitBlock,+ -- * Database withTestDB, withDBAt,@@ -30,6 +34,7 @@ remoteReceiveMatch, remoteClose, remoteVersion,+ fullNodeServices, respondHonestly, honestRemote, encodeMessage,@@ -48,12 +53,14 @@ import Conduit (awaitForever, yield) import Control.Monad (forM_, unless)+import Data.Bits ((.|.)) 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.Text (Text) import Data.Time.Clock (NominalDiffTime) import Data.Time.Clock.POSIX (getPOSIXTime) import Data.Word (Word32, Word64)@@ -181,6 +188,52 @@ headersMessage :: [BlockHeader] -> Message headersMessage hs = MHeaders (Headers [(h, VarInt 0) | h <- hs]) +-- | Block 1 of a regtest chain that Bitcoin Core 32.1 mined with+-- @-testactivationheight=segwit\@200@, so before SegWit was active. Its+-- hash is @55e949fa...bd71d75@. Core put a witness commitment in the+-- coinbase but no witness in it, as its miner does before activation, and+-- Core accepts the block. It has one transaction.+corePreSegWitBlock :: Block+corePreSegWitBlock =+ coreBlock+ "0000002006226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f010cb977ece0103280894534\+ \38b7a55c1a016c9fa40c5205571956069623333bd8faf16affff7f200100000001020000000100000000000000000000\+ \00000000000000000000000000000000000000000000ffffffff025100feffffff0200f2052a010000001976a914636d\+ \4698f1523dc6688ccccd04850ade37a6c94988ac0000000000000000266a24aa21a9ede2f61c3f71d1defd3fa999dfa3\+ \6953755c690689799962b48bebd836974e8cf900000000"++-- | Block 302 of the same chain, after SegWit activated. Its hash is+-- @30f22128...3a62efb@ and its parent is block 301, @7bd5bfeb...b63e52b9@.+-- It has three transactions: the coinbase, whose witness is the 32-byte+-- reserved value; a P2WPKH spend, txid @9d304fed...6401d23d@ and wtxid+-- @a6b593b3...256a158e@; and a legacy spend without witness. Core accepted+-- it, and gave @bad-witness-merkle-match@ for a block like it whose spend had+-- one byte of its witness changed.+coreSegWitBlock :: Block+coreSegWitBlock =+ coreBlock+ "00000030b9523eb63f97c829f511bb532cf784c8827dc52b299f2fd994316deaebbfd57b9fa7b223e45c1d3509c35803\+ \274fc47a62cdbdd25f8b081e80d3c026577dfe7f0cfbf16affff7f200000000003020000000001010000000000000000\+ \000000000000000000000000000000000000000000000000ffffffff03022e01feffffff02ea0b834a000000001976a9\+ \145b0082ee7098f21fb42b61bf40ba80ed4714108288ac0000000000000000266a24aa21a9ed3689d337d76ca8ae1070\+ \b00bcfde4a16d82ae87ea9307d8705ef1b308792ef750120000000000000000000000000000000000000000000000000\+ \00000000000000002d0100000200000000010156ed4c57e51a1912b3d4a0c92dec5aa90e5db79f60ee3294490803bea1\+ \32ef1c0100000000fdffffff01d29f6659000000001976a9142d26b779a8053861ca5d01bf5d3880292280290288ac02\+ \47304402206b38df4637ea3c6474da4d1310260d3cd9738e0bd496ed33841c02973d65d56f0220465694613885394876\+ \ff1722e5675fd1e6dec5621b285fca4212c3e4a3135ade0121039e06e681933cef4160b02d907c68e3e54144b9ae0bdf\+ \d2c696d0d51132c6058c0000000002000000014e4d8f4536ded4cdfb0e1769f70e60904ee6fb6babca4cdf0dbf522030\+ \3c8985000000006a473044022013d2f4e1bd3a3f304d9028a4d1e3b4157989ac0481934c6250d8a29595b873d302201e\+ \df49731a27af137e0e0b6aee291d940db33033bbcebec78311c67650194472012102034d48cf5a3fcbd59d813ab02bec\+ \67ca2b351830ce6931a76c2ffe2529433d87fdffffff02f67e8b3b000000001976a914ca4b520b0a1c5d5595e337cd1f\+ \945bc9e2860b8c88ac40717759000000001976a9142b83d6a87a77b0f96f812d4df53830a64f6a909e88ac2d010000"++-- | A block from its serialization in hex, as @getblock@ prints it.+coreBlock :: Text -> Block+coreBlock hex =+ case decode =<< maybe (Left "bad hex") Right (decodeHex hex) of+ Left e -> error ("coreBlock: " <> e)+ Right b -> b+ -- | Temporary RocksDB database. withTestDB :: (DB -> IO a) -> IO a withTestDB f =@@ -283,6 +336,13 @@ zeroAddress :: Word64 -> NetworkAddress zeroAddress srv = NetworkAddress srv (sockToHostAddress (SockAddrInet 0 0)) +-- | Services that a full node of the network offers, as Bitcoin Core does:+-- the full chain, and witness data on a network with SegWit.+fullNodeServices :: Network -> Word64+fullNodeServices net+ | net.segWit = nodeNetwork .|. nodeWitness+ | otherwise = nodeNetwork+ -- | Version message for a remote peer. remoteVersion :: Network -> Word64 -> Word64 -> IO Version remoteVersion net services nonce = do@@ -320,7 +380,7 @@ -- | 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+ v <- remoteVersion r.net (fullNodeServices r.net) =<< randomIO remoteSend r (MVersion v) let go = remoteReceive r >>= \case
test/regtest/Containerfile view
@@ -1,7 +1,7 @@ # Regtest image with official Bitcoin Core and Bitcoin Cash Node binaries. # # Build with:-# podman build -t haskoin-node-regtest regtest+# podman build -t haskoin-node-regtest test/regtest # # Hashes come from the official SHA256SUMS files published by each project: # https://bitcoincore.org/bin/bitcoin-core-32.1/SHA256SUMS@@ -16,31 +16,17 @@ ARG BCHN_SHA256_amd64=23bbe9ff749136a9b832a2a59023ce76a5391322c1ea2f4f24b5b91f035081bc ARG BCHN_SHA256_arm64=27e177e5a07dc78c2c0d18798ea2e3925111955509ecdc476e828e7ce8f46383 +# One RUN step, so that the image keeps no package lists. RUN apt-get update \- && apt-get install -y --no-install-recommends ca-certificates curl libatomic1 \+ && apt-get install -y --no-install-recommends \+ ca-certificates \+ curl \+ libatomic1 \ && rm -rf /var/lib/apt/lists/* -RUN set -eu; \- arch="${TARGETARCH:-$(dpkg --print-architecture)}"; \- case "$arch" in \- amd64) triple=x86_64-linux-gnu; core_sha="$CORE_SHA256_amd64"; bchn_sha="$BCHN_SHA256_amd64" ;; \- arm64) triple=aarch64-linux-gnu; core_sha="$CORE_SHA256_arm64"; bchn_sha="$BCHN_SHA256_arm64" ;; \- *) echo "unsupported architecture: $arch" >&2; exit 1 ;; \- esac; \- cd /tmp; \- core="bitcoin-${CORE_VERSION}-${triple}.tar.gz"; \- curl -fsSLO "https://bitcoincore.org/bin/bitcoin-core-${CORE_VERSION}/${core}"; \- echo "${core_sha} ${core}" | sha256sum -c -; \- mkdir -p /opt/bitcoin-core; \- tar -xzf "$core" -C /opt/bitcoin-core --strip-components=1; \- bchn="bitcoin-cash-node-${BCHN_VERSION}-${triple}.tar.gz"; \- curl -fsSLO "https://github.com/bitcoin-cash-node/bitcoin-cash-node/releases/download/v${BCHN_VERSION}/${bchn}"; \- echo "${bchn_sha} ${bchn}" | sha256sum -c -; \- mkdir -p /opt/bchn; \- tar -xzf "$bchn" -C /opt/bchn --strip-components=1; \- rm -f /tmp/*.tar.gz; \- /opt/bitcoin-core/bin/bitcoind -version | head -1; \- /opt/bchn/bin/bitcoind -version | head -1+# The script reads the versions and hashes above from the build arguments.+COPY install-nodes.sh /usr/local/bin/install-nodes.sh+RUN /usr/local/bin/install-nodes.sh # Peer-to-peer ports for up to three nodes per container. EXPOSE 18444 18445 18446
test/regtest/Regtest/Podman.hs view
@@ -63,7 +63,8 @@ image :: String image = "localhost/haskoin-node-regtest:latest" --- | Directory with the Containerfile, relative to the package root.+-- | Build context of the image: the directory with the Containerfile and the+-- @install-nodes.sh@ script it runs, relative to the package root. imageDir :: FilePath imageDir = "test/regtest"
+ test/regtest/install-nodes.sh view
@@ -0,0 +1,61 @@+#!/bin/sh+# Install the official Bitcoin Core and Bitcoin Cash Node release binaries+# into /opt/bitcoin-core and /opt/bchn, after checking their SHA-256 hashes.+#+# The Containerfile runs this while building the regtest image. It reads the+# versions and hashes from the build arguments declared there:+# CORE_VERSION, BCHN_VERSION, and CORE_SHA256_<arch> and BCHN_SHA256_<arch>+# for each supported architecture.+set -eu++# Podman sets TARGETARCH; fall back to the architecture of the build host.+arch="${TARGETARCH:-$(dpkg --print-architecture)}"++case "$arch" in+ amd64)+ triple=x86_64-linux-gnu+ core_sha="$CORE_SHA256_amd64"+ bchn_sha="$BCHN_SHA256_amd64"+ ;;+ arm64)+ triple=aarch64-linux-gnu+ core_sha="$CORE_SHA256_arm64"+ bchn_sha="$BCHN_SHA256_arm64"+ ;;+ *)+ echo "unsupported architecture: $arch" >&2+ exit 1+ ;;+esac++# Download a release tarball, check its hash and unpack it into a directory.+install_release() {+ url="$1"+ sha="$2"+ dest="$3"+ file="${url##*/}"+ curl -fsSLO "$url"+ echo "${sha} ${file}" | sha256sum -c -+ mkdir -p "$dest"+ tar -xzf "$file" -C "$dest" --strip-components=1+ rm -f "$file"+}++cd /tmp++core_base="https://bitcoincore.org/bin/bitcoin-core-${CORE_VERSION}"+install_release \+ "${core_base}/bitcoin-${CORE_VERSION}-${triple}.tar.gz" \+ "$core_sha" \+ /opt/bitcoin-core++bchn_base="https://github.com/bitcoin-cash-node/bitcoin-cash-node"+bchn_base="${bchn_base}/releases/download/v${BCHN_VERSION}"+install_release \+ "${bchn_base}/bitcoin-cash-node-${BCHN_VERSION}-${triple}.tar.gz" \+ "$bchn_sha" \+ /opt/bchn++# Show the installed versions in the build log.+/opt/bitcoin-core/bin/bitcoind -version | head -1+/opt/bchn/bin/bitcoind -version | head -1
test/unit/Haskoin/Node/AddressSpec.hs view
@@ -1,14 +1,123 @@ module Haskoin.Node.AddressSpec (spec) where +import Control.Exception (IOException, bracket, try) import Control.Monad (forM_)+import Data.Time.Clock (diffUTCTime, getCurrentTime)+import Data.Word (Word16, Word8) import Haskoin (Network (..), btcRegTest) import Haskoin.Node (toHostService, toSockAddr)-import Network.Socket (SockAddr (..))+import Haskoin.Node.Address (NetGroup (..), isRoutable, netGroup, withConnection)+import Network.Socket+ ( Family (AF_INET),+ SockAddr (..),+ SocketType (Stream),+ bind,+ close,+ connect,+ defaultProtocol,+ listen,+ socket,+ socketPort,+ tupleToHostAddress,+ tupleToHostAddress6,+ ) import Test.Hspec import Test.Hspec.QuickCheck (prop) +v4 :: (Word8, Word8, Word8, Word8) -> SockAddr+v4 = SockAddrInet 8333 . tupleToHostAddress++v6 :: (Word16, Word16, Word16, Word16, Word16, Word16, Word16, Word16) -> SockAddr+v6 t = SockAddrInet6 8333 0 (tupleToHostAddress6 t) 0+ spec :: Spec spec = do+ describe "routable addresses" $ do+ it "refuses the reserved IPv4 ranges" $+ forM_+ [ (198, 18, 0, 1), -- benchmarking, 198.18.0.0/15+ (198, 19, 255, 1),+ (192, 0, 2, 1), -- documentation+ (198, 51, 100, 1),+ (203, 0, 113, 1),+ (0, 1, 2, 3), -- this network+ (192, 0, 0, 1) -- protocol assignments+ ]+ $ \a -> isRoutable (v4 a) `shouldBe` False++ it "accepts the addresses just outside the benchmarking range" $ do+ isRoutable (v4 (198, 17, 255, 1)) `shouldBe` True+ isRoutable (v4 (198, 20, 0, 1)) `shouldBe` True++ it "refuses the reserved IPv6 ranges" $+ forM_+ [ (0xff02, 0, 0, 0, 0, 0, 0, 1), -- multicast+ (0xfec0, 0, 0, 0, 0, 0, 0, 1), -- site-local+ (0x2001, 0x0db8, 0, 0, 0, 0, 0, 1) -- documentation+ ]+ $ \a -> isRoutable (v6 a) `shouldBe` False++ it "refuses the ORCHID ranges, as Bitcoin Core does" $+ -- RFC 4843: 2001:10::/28; RFC 7343: 2001:20::/28+ forM_ [0x0010, 0x001f, 0x0020, 0x002f] $ \b ->+ isRoutable (v6 (0x2001, b, 0, 0, 0, 0, 0, 1)) `shouldBe` False++ it "accepts the addresses just outside the ORCHID ranges" $+ forM_ [0x000f, 0x0030] $ \b ->+ isRoutable (v6 (0x2001, b, 0, 0, 0, 0, 0, 1)) `shouldBe` True++ describe "connections" $ do+ it "gives up opening a connection that gets no answer after five seconds" $ do+ -- Bitcoin Core: DEFAULT_CONNECT_TIMEOUT = 5000 ms. A listening socket+ -- whose queue of one connection is full drops new requests on Linux,+ -- so opening a connection to it hangs.+ let loopback = tupleToHostAddress (127, 0, 0, 1)+ listener = do+ s <- socket AF_INET Stream defaultProtocol+ bind s (SockAddrInet 0 loopback)+ listen s 0+ return s+ bracket listener close $ \srv -> do+ port <- socketPort srv+ let sa = SockAddrInet port loopback+ bracket (socket AF_INET Stream defaultProtocol) close $ \filler -> do+ connect filler sa+ start <- getCurrentTime+ r <- try (withConnection sa (\_ -> return ()))+ end <- getCurrentTime+ either (\(_ :: IOException) -> True) (const False) r `shouldBe` True+ end `diffUTCTime` start `shouldSatisfy` (\d -> d >= 4.5 && d < 8)++ describe "network groups" $ do+ -- 45.1.2.3 is 0x2d010203.+ it "groups IPv4 addresses by their first two bytes, whatever the port" $ do+ netGroup (SockAddrInet 1000 (tupleToHostAddress (45, 1, 2, 3))) `shouldBe` IPv4Group 45 1+ netGroup (v4 (45, 1, 200, 9)) `shouldBe` IPv4Group 45 1+ netGroup (v4 (45, 2, 0, 1)) `shouldBe` IPv4Group 45 2++ it "groups IPv6 addresses that carry an IPv4 address by that address" $ do+ netGroup (v6 (0, 0, 0, 0, 0, 0xffff, 0x2d01, 0x0203)) `shouldBe` IPv4Group 45 1+ netGroup (v6 (0x0064, 0xff9b, 0, 0, 0, 0, 0x2d01, 0x0203)) `shouldBe` IPv4Group 45 1+ netGroup (v6 (0x2002, 0x2d01, 0x0203, 0, 0, 0, 0, 1)) `shouldBe` IPv4Group 45 1+ -- Teredo inverts the bits: 0xffffffff - 0x2d010203 = 0xd2fefdfc+ netGroup (v6 (0x2001, 0, 0, 0, 0, 0, 0xd2fe, 0xfdfc)) `shouldBe` IPv4Group 45 1++ it "groups other IPv6 addresses by their first four bytes" $ do+ netGroup (v6 (0x2a01, 0x04f8, 0x0001, 0, 0, 0, 0, 1))+ `shouldBe` IPv6Group [0x2a, 0x01, 0x04, 0xf8]+ netGroup (v6 (0x2a01, 0x04f8, 0xffff, 0, 0, 0, 0, 1))+ `shouldBe` IPv6Group [0x2a, 0x01, 0x04, 0xf8]++ it "groups Hurricane Electric addresses by 36 bits" $+ -- the fifth byte 0x12 with its low four bits set: 0x1f+ netGroup (v6 (0x2001, 0x0470, 0x1234, 0, 0, 0, 0, 1))+ `shouldBe` IPv6Group [0x20, 0x01, 0x04, 0x70, 0x1f]++ it "puts loopback and unroutable addresses in groups of their own" $ do+ netGroup (v4 (127, 0, 0, 1)) `shouldBe` LocalGroup+ netGroup (v6 (0, 0, 0, 0, 0, 0, 0, 1)) `shouldBe` LocalGroup+ netGroup (v4 (10, 0, 0, 1)) `shouldBe` UnroutableGroup+ let net = btcRegTest describe "addresses" $ do it "parses host and service"
+ test/unit/Haskoin/Node/BlockSpec.hs view
@@ -0,0 +1,170 @@+-- | Whether a block holds the transactions its header commits to, against+-- blocks mined by Bitcoin Core 32.1 and copies of them changed the ways a+-- peer could change them.+module Haskoin.Node.BlockSpec (spec) where++import Data.ByteString qualified as B+import Data.Serialize (encode)+import Haskoin+import Haskoin.Node+import Haskoin.Node.Test (corePreSegWitBlock, coreSegWitBlock)+import Test.Hspec++-- | The same block with the witness of every transaction removed.+stripWitness :: Block -> Block+stripWitness b = b {txs = [tx {witness = []} | tx <- b.txs]}++-- | The same block with the last byte of the last witness item of its+-- second transaction changed. Only the witness changes, so the txid and the+-- Merkle root stay the same.+alterWitness :: Block -> Block+alterWitness b = case b.txs of+ cb : tx : rest -> b {txs = cb : tx {witness = map (map flipLast) tx.witness} : rest}+ _ -> b+ where+ flipLast item+ | B.null item = item+ | otherwise = B.init item <> B.singleton (B.last item + 1)++-- | A node at the given height, standing for the parent of a block.+parentAt :: Network -> BlockHeight -> BlockNode+parentAt net h =+ BlockNode+ { header = net.genesisHeader,+ height = h,+ work = 0,+ skip = headerHash net.genesisHeader+ }++-- | A transaction that is not a coinbase, with an output script of the+-- given length. Without witness it is 60 bytes plus the script: 4 version+-- + 1 input count + 41 input + 1 output count + 9 output value and script+-- length + 4 lock time = 60.+nonCoinbase :: Int -> Tx+nonCoinbase n =+ Tx+ { version = 1,+ inputs =+ [ TxIn+ { outpoint = OutPoint {hash = spent, index = 0},+ script = B.empty,+ sequence = 0+ }+ ],+ outputs = [TxOut {value = 1, script = B.replicate n 0x51}],+ witness = [],+ locktime = 0+ }+ where+ spent = "1111111111111111111111111111111111111111111111111111111111111111"++-- | A block with the given transactions under a header that commits to them.+blockOf :: [Tx] -> Block+blockOf txs =+ Block+ { header =+ btcRegTest.genesisHeader+ { merkle = either error id (buildMerkleRoot (map txHash txs))+ },+ txs = txs+ }++spec :: Spec+spec = do+ describe "Bitcoin Core blocks used as fixtures" $ do+ it "are the blocks that Bitcoin Core printed" $ do+ headerHash corePreSegWitBlock.header+ `shouldBe` "55e949faadd10136287852156f9078c4ba8cadd1b602697ff4202b395bd71d75"+ headerHash coreSegWitBlock.header+ `shouldBe` "30f221284bb2468933c8a37992771939bf4257cebfb4191f7c0fab5703a62efb"+ map txHash coreSegWitBlock.txs+ `shouldBe` [ "7581bf22701c376882dac3d3d70a86e9341a35eb32bcd797f47619907f54a81e",+ "9d304fed9ba3333c3cf6c23739376e5b740b3c181ebd08578dab63dc6401d23d",+ "8ba9b11d4a6a205d5dd4625c71f2fb7292c08926a87c5f622bf38c09980f49c4"+ ]+ map txWitnessHash coreSegWitBlock.txs+ `shouldBe` [ "67188a253a1f92310c43db8ebc98cb5b3b6cb26668710402f2362e29193b2758",+ "a6b593b3a88dba0efcfe44d125eaf1c8413be672519300d001458197256a158e",+ "8ba9b11d4a6a205d5dd4625c71f2fb7292c08926a87c5f622bf38c09980f49c4"+ ]++ describe "blockMutation" $ do+ it "accepts a SegWit block that Bitcoin Core mined" $+ blockMutation SegWitActive coreSegWitBlock `shouldBe` Nothing++ it "accepts a block mined before SegWit with a commitment but no coinbase witness" $+ blockMutation SegWitInactive corePreSegWitBlock `shouldBe` Nothing++ it "would refuse that block if SegWit applied to it, so the height must be known" $+ -- Core's miner adds the commitment before activation, but not the+ -- 32-byte value in the coinbase witness that goes with it.+ blockMutation SegWitActive corePreSegWitBlock `shouldBe` Just BadWitnessNonceSize++ it "refuses a block whose transactions do not hash to the Merkle root" $ do+ -- Bitcoin Core: bad-txnmrklroot+ let b = coreSegWitBlock {txs = take 2 coreSegWitBlock.txs}+ blockMutation SegWitActive b `shouldBe` Just BadMerkleRoot++ it "refuses a block that repeats its last transaction to keep the Merkle root" $ do+ -- CVE-2012-2459. Bitcoin Core: bad-txns-duplicate. Three transactions+ -- and the same three with the last one repeated give the same root,+ -- because an odd level pairs its last node with itself.+ let b = coreSegWitBlock {txs = coreSegWitBlock.txs <> drop 2 coreSegWitBlock.txs}+ length b.txs `shouldBe` 4+ blockMutation SegWitActive b `shouldBe` Just DuplicateTransactions++ it "refuses a block without transactions" $ do+ let b = coreSegWitBlock {txs = []}+ blockMutation SegWitActive b `shouldBe` Just BadMerkleRoot++ it "refuses a SegWit block whose witness data was stripped" $+ -- The coinbase loses its 32-byte reserved value first.+ blockMutation SegWitActive (stripWitness coreSegWitBlock)+ `shouldBe` Just BadWitnessNonceSize++ it "refuses a SegWit block whose witness data was changed" $+ -- Bitcoin Core gave bad-witness-merkle-match for such a block.+ blockMutation SegWitActive (alterWitness coreSegWitBlock)+ `shouldBe` Just BadWitnessCommitment++ it "refuses witness data in a block for which SegWit is not active" $+ -- Bitcoin Core: unexpected-witness+ blockMutation SegWitInactive coreSegWitBlock `shouldBe` Just UnexpectedWitness++ it "does not check the witness data of a block whose height is unknown" $+ blockMutation WitnessUnknown (alterWitness coreSegWitBlock) `shouldBe` Nothing++ it "still checks the Merkle root of a block whose height is unknown" $ do+ let b = coreSegWitBlock {txs = take 2 coreSegWitBlock.txs}+ blockMutation WitnessUnknown b `shouldBe` Just BadMerkleRoot++ it "refuses a block without a coinbase that holds a 64-byte transaction" $ do+ -- 60 + 4 script bytes = 64+ B.length (encode (nonCoinbase 4)) `shouldBe` 64+ blockMutation SegWitActive (blockOf [nonCoinbase 4])+ `shouldBe` Just SixtyFourByteTransaction++ it "leaves other blocks without a coinbase to block validation" $ do+ -- 60 + 5 script bytes = 65+ B.length (encode (nonCoinbase 5)) `shouldBe` 65+ blockMutation SegWitActive (blockOf [nonCoinbase 5]) `shouldBe` Nothing++ describe "witnessCheck" $ do+ it "applies the witness rule on Bitcoin from block 481824" $ do+ witnessCheck btc (Just (parentAt btc 481822)) `shouldBe` SegWitInactive+ witnessCheck btc (Just (parentAt btc 481823)) `shouldBe` SegWitActive++ it "applies the witness rule on testnet3 from block 834624" $ do+ witnessCheck btcTest (Just (parentAt btcTest 834622)) `shouldBe` SegWitInactive+ witnessCheck btcTest (Just (parentAt btcTest 834623)) `shouldBe` SegWitActive++ it "applies the witness rule on testnet4 and regtest from the first block" $ do+ witnessCheck btcTest4 (Just (parentAt btcTest4 0)) `shouldBe` SegWitActive+ witnessCheck btcRegTest (Just (parentAt btcRegTest 0)) `shouldBe` SegWitActive++ it "leaves the witness unchecked when the parent is unknown" $+ witnessCheck btc Nothing `shouldBe` WitnessUnknown++ it "allows no witness data on Bitcoin Cash, whatever the height" $ do+ witnessCheck bch Nothing `shouldBe` SegWitInactive+ witnessCheck bch (Just (parentAt bch 800000)) `shouldBe` SegWitInactive
test/unit/Haskoin/Node/Chain/SyncSpec.hs view
@@ -27,6 +27,8 @@ | Finish Int | Drop Int | Poll+ | Ask Int+ | Answer Int deriving stock (Show) instance Arbitrary Op where@@ -39,7 +41,9 @@ Refresh <$> peer, Finish <$> peer, Drop <$> peer,- pure Poll+ pure Poll,+ Ask <$> peer,+ Answer <$> peer ] where peer = chooseInt (1, 4)@@ -49,11 +53,13 @@ Connect p -> peerConnected p s Disconnect p -> peerDisconnected p s Request p -> requestSync p s- Start p -> startSync (at 1) p s+ Start p -> startSync (at 1) (at 1000) p s Refresh p -> refreshSync (at 2) p s Finish p -> finishSync p s Drop p -> dropPeer p s Poll -> pollPeers (at 3) s+ Ask p -> askHeaders (at 4) p s+ Answer p -> gotHeaders p s states :: [Op] -> [SyncState Int] states = scanl apply (emptySync t0)@@ -131,6 +137,47 @@ it "is not synced while a peer is queued or syncing" $ do isSynced net now recent (run [Connect 1]) `shouldBe` False isSynced net now recent (run [Connect 1, Start 1]) `shouldBe` False+ it "is synced with a tip exactly two hours old, and not one second older" $ do+ -- two hours = 7200 seconds, the most a block may be ahead of the clock+ let nowSecs = floor (utcTimeToPOSIXSeconds now)+ isSynced net now (tipAt (nowSecs - 7200) 1) idle `shouldBe` True+ isSynced net now (tipAt (nowSecs - 7201) 1) idle `shouldBe` False++ describe "deadline of a turn" $ do+ let net = btcRegTest+ bestAt t =+ BlockNode+ { header = net.genesisHeader {timestamp = t},+ height = 1,+ work = 1,+ skip = headerHash net.genesisHeader+ }+ tipSecs = floor (utcTimeToPOSIXSeconds t0)+ it "is 15 minutes plus 1 ms for each block expected since the best block" $+ -- 6,000,000 s / 600 s per block = 10,000 blocks x 1 ms = 10 s,+ -- plus 900 s = 910 s+ syncDeadline net (at 6000000) (bestAt tipSecs) `shouldBe` at (6000000 + 910)+ it "is 15 minutes when the best block is not older than now" $+ syncDeadline net t0 (bestAt (tipSecs + 60)) `shouldBe` at 900+ it "has passed only after the deadline" $ do+ let s = run [Connect 1, Connect 2, Start 1]+ syncTimedOut (at 1000) s `shouldBe` False+ syncTimedOut (at 1001) s `shouldBe` True+ it "does not end the turn when no other peer could take over" $ do+ syncTimedOut (at 1001) (run [Connect 1, Start 1]) `shouldBe` False+ syncTimedOut (at 1001) (run [Connect 1, Connect 2, Start 1, Drop 2]) `shouldBe` False++ describe "requests in flight" $ do+ it "does not ask a peer again within two minutes of asking it" $ do+ let s = askHeaders (at 0) 1 (run [Connect 1])+ mayAsk (at 120) 1 s `shouldBe` False+ mayAsk (at 121) 1 s `shouldBe` True+ it "asks a peer again once it answered" $+ mayAsk (at 1) 1 (gotHeaders 1 (askHeaders (at 0) 1 (run [Connect 1]))) `shouldBe` True+ it "keeps track of each peer separately" $+ mayAsk (at 1) 2 (askHeaders (at 0) 1 (run [Connect 1, Connect 2])) `shouldBe` True+ it "forgets the requests of a peer that disconnects" $+ mayAsk (at 1) 1 (run [Connect 1, Ask 1, Disconnect 1, Connect 1]) `shouldBe` True prop "never syncs against more than one peer" $ \ops -> all ((<= 1) . length . filter isSyncingStatus . statuses) (states ops)
test/unit/Haskoin/Node/ChainSpec.hs view
@@ -138,8 +138,25 @@ expectSendHeaders fp return fp +-- | The header with the first nonce for which its hash is above its target.+-- Only its proof of work is wrong: its bits, time and parent are those of a+-- valid header. invalidPow :: BlockHeader -> BlockHeader-invalidPow h = h {bits = 0x1d00ffff}+invalidPow h =+ case [h' | n <- [0 ..], let h' = withNonce n, blockPOW (headerHash h') > target] of+ h' : _ -> h'+ [] -> h+ where+ (target, _) = decodeCompact h.bits+ withNonce n =+ BlockHeader+ { version = h.version,+ prev = h.prev,+ merkle = h.merkle,+ timestamp = h.timestamp,+ bits = h.bits,+ nonce = n+ } spec :: Spec spec = forM_ [btcRegTest, bchRegTest] $ \net ->@@ -256,6 +273,32 @@ g <- expectGetHeaders b take 1 g.locator `shouldBe` [hashAt 2000] + it "asks a peer that announces an unknown block while another peer syncs" $+ slow $ \ct -> do+ a <- connectPeer ct "a"+ b <- connectPeer ct "b"+ -- a holds the turn and never answers+ _ <- expectGetHeaders a+ chainBlocksAnnounced ct.chain b.peer [hashAt 100]+ g <- expectGetHeaders b+ take 1 g.locator `shouldBe` [headerHash net.genesisHeader]++ it "asks an announcing peer only once until it answers" $+ slow $ \ct -> do+ a <- connectPeer ct "a"+ b <- connectPeer ct "b"+ _ <- expectGetHeaders a+ chainBlocksAnnounced ct.chain b.peer [hashAt 100]+ _ <- expectGetHeaders b+ chainBlocksAnnounced ct.chain b.peer [hashAt 101]+ sendHeaders ct b (take 5 (drop 49 main))+ expectNoRequest 1 b+ -- headers that connect answer the request+ sendHeaders ct b (take 5 main)+ _ <- waitHeight ct 5+ chainBlocksAnnounced ct.chain b.peer [hashAt 102]+ void $ expectGetHeaders b+ it "keeps syncing peer when another peer announces headers" $ slow $ \ct -> do a <- connectPeer ct "a"@@ -279,6 +322,15 @@ describe "reorganizations" $ do let fork n = mkHeaders net 1 (main !! 14) (t0 + 100) n+ it "keeps the first chain it saw when a fork has the same work" $ slow $ \ct -> do+ a <- syncPeer ct "a" short+ -- five headers on block 15 reach height 20, with the same work+ sendHeaders ct a (fork 5)+ let tip = headerHash (last (fork 5))+ eventually 10 "fork stored" $+ (/= Nothing) <$> chainGetBlock ct.chain tip+ bestHash ct `shouldReturn` hashAt 20+ it "switches to a fork with more work" $ slow $ \ct -> do a <- syncPeer ct "a" short sendHeaders ct a (fork 10)@@ -306,6 +358,30 @@ _ <- expectGetHeaders a sendHeaders ct a [invalidPow (main !! 0)] expectKilled a++ it "publishes the best block when a batch fails after valid headers" $+ slow $ \ct -> do+ a <- connectPeer ct "a"+ _ <- expectGetHeaders a+ sendHeaders ct a (take 5 main <> [invalidPow (main !! 5)])+ expectKilled a+ bn <- receiveWithin 10 "best block at height 5" ct.events $ \case+ ChainBestBlock bn | bn.height == 5 -> Just bn+ _ -> Nothing+ nodeHash bn `shouldBe` hashAt 5++ it "ends the turn of a peer whose full batch brings no new header" $+ slow $ \ct -> do+ a <- connectPeer ct "a"+ b <- connectPeer ct "b"+ _ <- answer ct a main+ _ <- waitHeight ct 2000+ -- asked for the headers after 2000, a sends the first 2000 again+ _ <- expectGetHeaders a+ sendHeaders ct a (take 2000 main)+ expectSendHeaders a+ g <- expectGetHeaders b+ take 1 g.locator `shouldBe` [hashAt 2000] it "disconnects a peer that sends headers that do not form a chain" $ slow $ \ct -> do
test/unit/Haskoin/Node/PeerMgr/AddressesSpec.hs view
@@ -1,9 +1,12 @@ module Haskoin.Node.PeerMgr.AddressesSpec (spec) where -import Data.Maybe (isJust, isNothing)+import Data.List (nub, sort)+import Data.Maybe (isJust, isNothing, mapMaybe) import Data.Time.Calendar (fromGregorian) import Data.Time.Clock (NominalDiffTime, UTCTime (..), addUTCTime)+import Data.Word (Word64) import Haskoin.Node.PeerMgr.Addresses+import Network.Socket (SockAddr (..), tupleToHostAddress) import Test.Hspec import Test.Hspec.QuickCheck (prop) import Test.QuickCheck@@ -17,18 +20,28 @@ book :: [Int] -> AddressBook Int book = foldl' (flip (addAddress Discovered)) emptyBook +-- | Take an address, with any address allowed.+takeAny :: (Ord a) => UTCTime -> AddressBook a -> Maybe (a, AddressBook a)+takeAny now = takeAddress now 0 (const True)+ -- | Take an address that must be available. taken :: UTCTime -> AddressBook Int -> (Int, AddressBook Int)-taken now b = case takeAddress now b of+taken now b = case takeAny now b of Just x -> x Nothing -> error "no address to take" --- | Take an address, end its connection, and return the new book.+-- | Take a given address, end its connection, and return the new book. Only+-- that address may be taken. cycleWith :: Outcome -> Int -> AddressBook Int -> AddressBook Int-cycleWith outcome a b = case takeAddress t0 b of+cycleWith outcome a b = case takeAddress t0 0 (== a) b of Just (a', b') | a == a' -> finishAddress t0 outcome a b' _ -> error "address not taken" +-- | Every address that some random number makes 'takeAddress' pick, sorted.+picks :: (Ord a) => (a -> Bool) -> AddressBook a -> [a]+picks allowed b =+ sort (nub (mapMaybe (fmap fst . (\r -> takeAddress t0 r allowed b)) [0 .. 99]))+ -- | When an address can be tried again, if it is waiting. retryAt :: Int -> AddressBook Int -> Maybe UTCTime retryAt a b = case addressState a b of@@ -38,7 +51,7 @@ -- | Transitions applied to a few addresses at increasing times. data Op = Add Source Int- | Take NominalDiffTime+ | Take NominalDiffTime Word64 | Give Int | Connected Int | Finish NominalDiffTime Outcome Int@@ -47,8 +60,8 @@ instance Arbitrary Op where arbitrary = oneof- [ Add <$> elements [Configured, Discovered] <*> addr,- Take <$> time,+ [ Add <$> elements [Configured, Seeded, Discovered] <*> addr,+ Take <$> time <*> arbitrary, Give <$> addr, Connected <$> addr, Finish <$> time <*> elements [Disconnected, Failed, Misbehaved] <*> addr@@ -60,7 +73,7 @@ apply :: AddressBook Int -> Op -> AddressBook Int apply b = \case Add src a -> addAddress src a b- Take d -> maybe b snd (takeAddress (later d) b)+ Take d r -> maybe b snd (takeAddress (later d) r (const True) b) Give a -> returnAddress a b Connected a -> markConnected a b Finish d o a -> finishAddress (later d) o a b@@ -71,7 +84,7 @@ let (a, b) = taken t0 (book [1]) a `shouldBe` 1 addressState 1 b `shouldBe` Just InUse- takeAddress t0 b `shouldSatisfy` isNothing+ takeAny t0 b `shouldSatisfy` isNothing it "takes back an address that was not used" $ do let (_, b) = taken t0 (book [1])@@ -80,8 +93,8 @@ it "waits briefly later a normal disconnection" $ do let b = cycleWith Disconnected 1 (book [1]) retryAt 1 b `shouldBe` Just (later retryDelay)- takeAddress t0 b `shouldSatisfy` isNothing- takeAddress (later retryDelay) b `shouldSatisfy` isJust+ takeAny t0 b `shouldSatisfy` isNothing+ takeAny (later retryDelay) b `shouldSatisfy` isJust it "doubles the wait later each failure, up to a limit" $ do let waits = map (retryAt 1) (drop 1 (iterate (cycleFailed 1) (book [1])))@@ -105,9 +118,48 @@ it "keeps a bounded number of discovered addresses" $ do let b = book [1 .. maxDiscovered + 10] b' = addAddress Configured (maxDiscovered + 20) b- length (knownAddresses b) `shouldBe` maxDiscovered- length (knownAddresses b') `shouldBe` maxDiscovered + 1+ length (knownAddresses b) `shouldBe` 4096+ length (knownAddresses b') `shouldBe` 4096 + 1 + it "keeps a bounded number of seed addresses" $ do+ let b = foldl' (flip (addAddress Seeded)) emptyBook [1 .. 4106 :: Int]+ length (knownAddresses b) `shouldBe` 4096++ it "learns a new seed address when gossip has filled the book" $ do+ let b = addAddress Seeded 99999 (book [1 .. 4096])+ addressState 99999 b `shouldBe` Just Ready++ it "replaces a gossiped address that failed when the book is full" $ do+ let b = addAddress Discovered 5000 (cycleWith Failed 7 (book [1 .. 4096]))+ addressState 5000 b `shouldBe` Just Ready+ addressState 7 b `shouldBe` Nothing+ length (knownAddresses b) `shouldBe` 4096++ it "keeps gossiped addresses that have not failed when the book is full" $ do+ let b = addAddress Discovered 5000 (book [1 .. 4096])+ addressState 5000 b `shouldBe` Nothing+ length (knownAddresses b) `shouldBe` 4096++ describe "choice" $ do+ it "takes a configured address before any other" $ do+ let b = addAddress Configured 9 (book [1 .. 5])+ fmap fst (takeAddress t0 3 (const True) b) `shouldBe` Just 9++ it "takes only addresses that the predicate allows" $+ picks even (book [1 .. 10]) `shouldBe` [2, 4, 6, 8, 10]++ it "takes nothing when the predicate allows no address" $+ takeAddress t0 0 (const False) (book [1 .. 10]) `shouldSatisfy` isNothing++ it "can take any address, whatever its port" $ do+ -- 20 addresses on port 8333 and 8 on one host with lower ports. Taking+ -- them in the order of the map would always start with the low ports.+ let v4 a p = SockAddrInet p (tupleToHostAddress a)+ honest = [v4 (8, 8, 8, fromIntegral i) 8333 | i <- [1 .. 20 :: Int]]+ attacker = [v4 (45, 1, 2, 3) p | p <- [1000 .. 1007]]+ b = foldl' (flip (addAddress Discovered)) emptyBook (honest <> attacker)+ picks (const True) b `shouldBe` sort (honest <> attacker)+ describe "loading" $ do it "loads when the book is empty" $ needsReload t0 (emptyBook :: AddressBook Int) `shouldBe` True@@ -124,7 +176,7 @@ prop "only hands out addresses that can be tried" $ \(ops :: [Op]) (d :: Int) -> let b = foldl' apply emptyBook ops now = later (fromIntegral (abs d))- in case takeAddress now b of+ in case takeAny now b of Nothing -> property True Just (a, b') -> let ok = case addressState a b of@@ -133,7 +185,25 @@ _ -> False in ok .&&. addressState a b' === Just InUse - prop "never forgets an address" $ \(ops :: [Op]) ->+ -- Four addresses never fill the book, so none has to make room.+ prop "never forgets an address while the book has room" $ \(ops :: [Op]) -> let b = foldl' apply emptyBook ops added = [a | Add _ a <- ops] in all (\a -> isJust (addressState a b)) added++ prop "never keeps more than 4096 gossiped or 4096 seed addresses" $+ forAll (listOf manyAddresses) $ \ops ->+ let b = foldl' apply (book [1 .. 4096]) ops+ from src = [a | a <- knownAddresses b, addressSource a b == Just src]+ in length (from Discovered) <= 4096 .&&. length (from Seeded) <= 4096+ where+ -- Operations on 6000 addresses, on top of a book that is full already.+ manyAddresses =+ oneof+ [ Add <$> elements [Seeded, Discovered] <*> chooseInt (1, 6000),+ Take <$> (fromIntegral <$> chooseInt (0, 100000)) <*> arbitrary,+ Finish+ <$> (fromIntegral <$> chooseInt (0, 100000))+ <*> elements [Disconnected, Failed, Misbehaved]+ <*> chooseInt (1, 6000)+ ]
test/unit/Haskoin/Node/PeerMgr/HandshakeSpec.hs view
@@ -10,9 +10,11 @@ import Test.Hspec.QuickCheck (prop) import Test.QuickCheck --- | Version of a full node with nonce 7.+-- | Version of a full node with nonce 7. It offers the full chain and+-- witness data: NODE_NETWORK (1) + NODE_WITNESS (8) = 9, as Bitcoin Core+-- does. version :: Version-version = versionWith nodeNetwork+version = versionWith 9 versionWith :: Word64 -> Version versionWith services =@@ -86,10 +88,15 @@ describe "version checks" $ do it "accepts a full node" $- checkVersion [1, 2] version `shouldBe` Right version+ checkVersion btcRegTest [1, 2] version `shouldBe` Right version it "rejects a peer that does not serve blocks" $- checkVersion [] (versionWith 0) `shouldBe` Left NotFullNode+ checkVersion btcRegTest [] (versionWith 0) `shouldBe` Left NotFullNode+ it "rejects a Bitcoin peer that serves blocks without witness data" $+ -- NODE_NETWORK (1) without NODE_WITNESS (8)+ checkVersion btcRegTest [] (versionWith 1) `shouldBe` Left NotWitnessNode+ it "accepts a Bitcoin Cash peer that serves blocks, which have no witness" $+ checkVersion bchRegTest [] (versionWith 1) `shouldBe` Right (versionWith 1) it "rejects a connection to ourselves" $- checkVersion [7] version `shouldBe` Left ConnectedToSelf+ checkVersion btcRegTest [7] version `shouldBe` Left ConnectedToSelf it "uses our protocol version" $ version.version `shouldBe` fromIntegral myVersion
test/unit/Haskoin/Node/PeerMgr/LifecycleSpec.hs view
@@ -49,7 +49,7 @@ <$> oneof [ Heard <$> time, GotHandshake <$> elements [GotVersion version, GotVerAck],- Refused <$> elements [NotFullNode, ConnectedToSelf],+ Refused <$> elements [NotFullNode, NotWitnessNode, ConnectedToSelf], GotPong <$> time <*> nonce, Tick <$> time <*> nonce ]@@ -125,6 +125,11 @@ it "rotates a peer connected for too long" $ drop 2 (snd (run (online <> [Heard (at 100), Tick (at 101) 1]))) `shouldBe` [Disconnect TooOld]++ it "keeps a peer connected for exactly its maximum life" $+ -- 100 seconds is the maximum life here; the peer is pinged instead+ drop 2 (snd (run (online <> [Heard (at 100), Tick (at 100) 1])))+ `shouldBe` [SendPing 1] it "rotates before checking anything else" $ snd (run [Tick (at 101) 1]) `shouldBe` [Disconnect TooOld]
test/unit/Haskoin/Node/PeerMgrSpec.hs view
@@ -171,6 +171,11 @@ Just (MVersion _) <- remoteReceive r remoteSend r MVerAck Just MVerAck <- remoteReceive r+ -- The chain asks for headers once the peer is announced, so+ -- closing after that request cannot beat the announcement.+ remoteReceiveMatch 10 "header request" r $ \case+ MGetHeaders _ -> Just ()+ _ -> Nothing return () -- close the connection | otherwise = honestRemote blocks r withScriptedNode net id script $ \t -> do
test/unit/Haskoin/Node/PeerSpec.hs view
@@ -2,9 +2,11 @@ module Haskoin.Node.PeerSpec (spec) where import Control.Concurrent.Async (ExceptionInLinkedThread (..))-import Control.Monad (forM_)+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@@ -17,6 +19,7 @@ import Test.QuickCheck (Positive (..), ioProperty) import UnliftIO import UnliftIO.Concurrent (threadDelay)+import System.Mem (getAllocationCounter) data PeerTest = PeerTest { peer :: !Peer,@@ -25,12 +28,28 @@ thread :: !(Async ()) } --- | Run a peer process connected to a remote that the test drives.+-- | 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 f = do+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@@ -43,7 +62,8 @@ net = net, label = "remote", connect = memoryConnection net script,- logger = logger+ logger = logger,+ lookupBlock = lookup_block } p = wrapPeer cfg busy pending mailbox withAsync (peer cfg p inbox) $ \thread -> do@@ -85,6 +105,41 @@ 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 = []@@ -95,6 +150,21 @@ 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@@ -105,6 +175,9 @@ 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@@ -136,6 +209,29 @@ 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)))@@ -168,6 +264,59 @@ 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@@ -227,6 +376,59 @@ 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
test/unit/Haskoin/NodeSpec.hs view
@@ -27,7 +27,9 @@ import Haskoin import Haskoin.Node import NQE-import Network.Socket (SockAddr (..), tupleToHostAddress, tupleToHostAddress6)+import Data.Word (Word32, Word8)+import Haskoin.Node.Test (eventually)+import Network.Socket (PortNumber, SockAddr (..), tupleToHostAddress, tupleToHostAddress6) import System.Random (randomIO) import Test.Hspec import UnliftIO@@ -39,7 +41,9 @@ nodeEvents :: Inbox NodeEvent, testDB :: R.DB, -- | how many getheaders messages the mock peer has received- getHeadersCount :: TVar Int+ getHeadersCount :: TVar Int,+ -- | every address the node tried to connect to, newest first+ attempts :: TVar [SockAddr] } -- | What the mock peer sends back for each message: a message to serialize@@ -51,7 +55,11 @@ { react :: React, -- | runs on the database before the node starts setup :: R.DB -> IO (),- timeout :: NominalDiffTime+ timeout :: NominalDiffTime,+ -- | connect to seeds and relayed addresses+ discover :: Bool,+ -- | the static peers of the configuration+ staticPeers :: [String] } defOpts :: [Block] -> TestOpts@@ -59,7 +67,10 @@ TestOpts { react = map Right . mockPeerReact blocks, setup = \_ -> return (),- timeout = 120+ timeout = 120,+ discover = False,+ -- IPv4, so that the mock peer is reachable without IPv6+ staticPeers = ["127.0.0.1:17486"] } -- | Connect to a mock peer that reacts to messages like this.@@ -155,8 +166,11 @@ -- while every other message is limited to 2 MiB (2097152 bytes). let script = B.replicate 3000000 0x6a tx = Tx 1 [TxIn nullOutPoint B.empty maxBound] [TxOut 0 script] [] 0+ -- a header that commits to the transaction, or the node would+ -- refuse the block as not the one its header names+ root = either error id (buildMerkleRoot [txHash tx]) big = case allBlocks of- firstBlock : _ -> Block firstBlock.header [tx]+ firstBlock : _ -> Block firstBlock.header {merkle = root} [tx] [] -> error "no test blocks" withTestNodeBlocks [big] net "big-block" $ \TestNode {..} -> do p <- waitForPeer nodeEvents@@ -174,7 +188,7 @@ describe "untrusted peers" $ do it "imports a full batch of 2000 headers" $ withTestNodeOpts- (defOpts allBlocks) {react = reactAfterVerAck [Right (manyHeaders net 2000)]}+ (defOpts allBlocks) {react = reactWhenOnline [Right (manyHeaders net 2000)]} net "full-batch" $ \TestNode {..} -> do@@ -185,7 +199,7 @@ bn.height `shouldBe` 2000 it "disconnects a peer that sends more than 2000 headers" $ withTestNodeOpts- (defOpts allBlocks) {react = reactAfterVerAck [Right (manyHeaders net 2001)]}+ (defOpts allBlocks) {react = reactWhenOnline [Right (manyHeaders net 2001)]} net "too-many-headers" $ \TestNode {..} -> do@@ -196,7 +210,7 @@ let na = NetworkAddress 0 (sockToHostAddress (SockAddrInet 8333 0x08080808)) addrs = MAddr (Addr (replicate 1001 (0, na))) withTestNodeOpts- (defOpts allBlocks) {react = reactAfterVerAck [Right addrs]}+ (defOpts allBlocks) {react = reactWhenOnline [Right addrs]} net "too-many-addrs" $ \TestNode {..} -> do@@ -205,7 +219,7 @@ it "disconnects a peer that sends the wrong network magic" $ do let bad = runPut (putMessage btc (MPing (Ping 1))) withTestNodeOpts- (defOpts allBlocks) {react = reactAfterVerAck [Left bad]}+ (defOpts allBlocks) {react = reactWhenOnline [Left bad]} net "wrong-magic" $ \TestNode {..} -> do@@ -219,7 +233,7 @@ runPut . put $ MessageHeader net.magic MCTx 2097153 (checkSum32 B.empty) withTestNodeOpts- (defOpts allBlocks) {react = reactAfterVerAck [Left oversized]}+ (defOpts allBlocks) {react = reactWhenOnline [Left oversized]} net "oversized-tx" $ \TestNode {..} -> do@@ -227,7 +241,7 @@ waitForDisconnect nodeEvents p it "disconnects a peer that sends a second verack" $ withTestNodeOpts- (defOpts allBlocks) {react = reactAfterVerAck [Right MVerAck]}+ (defOpts allBlocks) {react = reactWhenOnline [Right MVerAck]} net "duplicate-verack" $ \TestNode {..} -> do@@ -261,6 +275,68 @@ $ \TestNode {..} -> do p <- waitForPeer nodeEvents waitForDisconnect nodeEvents p+ it "publishes no message from a peer that has not completed the handshake" $ do+ -- The mock peer sends its version, then a message instead of a verack.+ let react = \case+ MVersion _ -> [Right (MOther "zz" "early")]+ _ -> []+ withTestNodeOpts (defOpts allBlocks) {react = react} net "early-message" $+ \TestNode {..} -> do+ -- the version is the handshake itself, and is published+ receiveMatch nodeEvents $ \case+ PeerEvent (PeerMessage _ (MVersion _)) -> Just ()+ _ -> Nothing+ early <- timeout (2 * 1000 * 1000) . receiveMatch nodeEvents $ \case+ PeerEvent (PeerMessage _ (MOther "zz" _)) -> Just ()+ _ -> Nothing+ early `shouldBe` Nothing+ it "publishes the messages of a peer that is online" $ do+ let react = reactWhenOnline [Right (MOther "zz" "late")]+ withTestNodeOpts (defOpts allBlocks) {react = react} net "late-message" $+ \TestNode {..} -> do+ late <- timeout (10 * 1000 * 1000) . receiveMatch nodeEvents $ \case+ PeerEvent (PeerMessage _ (MOther "zz" payload)) -> Just payload+ _ -> Nothing+ late `shouldBe` Just "late"+ it "does not connect to a private address that an online peer relays" $ do+ let private = v4At (10, 1, 2, 3) 8333+ public = v4At (8, 8, 4, 4) 8333+ relay = MAddr (Addr [relayed private, relayed public])+ withTestNodeOpts+ (defOpts allBlocks) {react = reactWhenOnline [Right relay], discover = True}+ net+ "relay-private"+ $ \TestNode {..} -> do+ -- the public address shows that the relayed addresses were learned+ eventually 30 "public address tried" $ elem public <$> readTVarIO attempts+ -- the node tries a new address every 0.1 to 5 seconds+ threadDelay (6 * 1000 * 1000)+ readTVarIO attempts >>= (`shouldNotContain` [private])+ it "connects to at most one relayed address of each network group" $ do+ let sameGroup = [v4At (45, 1, 2, 3) p | p <- [1000 .. 1015]]+ others = [v4At (46, 1, 0, 1) 8333, v4At (47, 2, 0, 1) 8333]+ relay = MAddr (Addr (map relayed (sameGroup <> others)))+ withTestNodeOpts+ (defOpts allBlocks) {react = reactWhenOnline [Right relay], discover = True}+ net+ "relay-same-group"+ $ \TestNode {..} -> do+ eventually 30 "addresses of the other groups tried" $+ (\tried -> all (`elem` tried) others) <$> readTVarIO attempts+ threadDelay (6 * 1000 * 1000)+ tried <- readTVarIO attempts+ length (filter (`elem` sameGroup) tried) `shouldBe` 1+ it "does not connect to a DNS seed result that is not routable" $+ -- The seed of regtest is localhost, which resolves to loopback+ -- addresses. With no static peers, nothing else could be tried.+ withTestNodeOpts+ (defOpts allBlocks) {discover = True, staticPeers = []}+ net+ "seed-loopback"+ $ \TestNode {..} -> do+ -- the node tries a new address every 0.1 to 5 seconds+ threadDelay (6 * 1000 * 1000)+ readTVarIO attempts `shouldReturn` [] it "does not connect to private addresses that peers relay" $ do let v4 a b c d p = SockAddrInet p (tupleToHostAddress (a, b, c, d)) v6 t p = SockAddrInet6 p 0 (tupleToHostAddress6 t) 0@@ -362,6 +438,14 @@ forM_ (zip ps hs) $ \(p, h) -> headerHash p.header `shouldBe` h +-- | An IPv4 socket address.+v4At :: (Word8, Word8, Word8, Word8) -> PortNumber -> SockAddr+v4At a p = SockAddrInet p (tupleToHostAddress a)++-- | An address as a peer relays it in an addr message.+relayed :: SockAddr -> (Word32, NetworkAddress)+relayed sa = (0, NetworkAddress nodeNetwork (sockToHostAddress sa))+ -- | Wait for the node to disconnect from this peer, failing after a while. waitForDisconnect :: Inbox NodeEvent -> Peer -> IO () waitForDisconnect inbox p =@@ -374,11 +458,16 @@ PeerEvent (PeerDisconnected p') | p == p' -> Just () _ -> Nothing --- | Mock peer that answers the handshake and pings, and sends these right--- after its version ack.-reactAfterVerAck :: [Either ByteString Message] -> React-reactAfterVerAck extra = \case- MVersion _ -> Right MVerAck : extra+-- | Mock peer that answers the handshake and pings, and sends these each+-- time the node asks it for headers. The chain asks only once the peer is+-- announced as connected, so the messages cannot overtake the announcement.+-- Sent right after the verack, they raced with it: a peer that misbehaved at+-- once could die before the node handled its verack, and was then never+-- announced.+reactWhenOnline :: [Either ByteString Message] -> React+reactWhenOnline extra = \case+ MVersion _ -> [Right MVerAck]+ MGetHeaders _ -> extra MPing (Ping n) -> [Right (MPong (Pong n))] _ -> [] @@ -402,11 +491,17 @@ 0 genesis {version = 4, prev = headerHash genesis, timestamp = now} +-- | Wait for a peer to be announced, failing after a while. waitForPeer :: Inbox NodeEvent -> IO Peer waitForPeer inbox =- receiveMatch inbox $ \case- PeerEvent (PeerConnected p) -> Just p- _ -> Nothing+ timeout (30 * 1000 * 1000) connected >>= \case+ Just p -> return p+ Nothing -> expectationFailure "no peer connected" >> error "unreachable"+ where+ connected =+ receiveMatch inbox $ \case+ PeerEvent (PeerConnected p) -> Just p+ _ -> Nothing withLifted :: ((a -> IO b) -> IO b) -> (a -> IO b) -> IO b withLifted w m = withRunInIO $ \io -> w (io . m)@@ -428,36 +523,40 @@ withSubscription pub $ \sub -> withLifted (R.withDBCF w cfg cols) $ \db -> do count <- newTVarIO 0+ tried <- newTVarIO [] opts.setup db- withNode (nodeConfig count pub db) $ \(Node mgr ch) ->+ withNode (nodeConfig count tried pub db) $ \(Node mgr ch) -> f TestNode { testMgr = mgr, testChain = ch, nodeEvents = sub, testDB = db,- getHeadersCount = count+ getHeadersCount = count,+ attempts = tried } where- nodeConfig count pub db =+ nodeConfig count tried pub db = NodeConfig { maxPeers = 20, db = db, cf = listToMaybe (R.columnFamilies db),- -- IPv4, so that the mock peer is reachable without IPv6- peers = ["127.0.0.1:17486"],- discover = False,+ peers = opts.staticPeers,+ discover = opts.discover, address = NetworkAddress 0 anywhere, net = net, pub = pub, timeout = opts.timeout, maxPeerLife = 48 * 3600,- connect =+ connect = \sa conn -> do+ atomically $ modifyTVar' tried (sa :) dummyPeerConnect opts.react count net- (NetworkAddress nodeNetwork anywhere),+ (NetworkAddress nodeNetwork anywhere)+ sa+ conn, logger = Logger {level = LevelError, source = "", push = \_ -> return ()} } anywhere = sockToHostAddress (SockAddrInet 0 0)