haskoin-store-1.9.0: test/Haskoin/Store/Fleet/PoolSpec.hs
module Haskoin.Store.Fleet.PoolSpec (spec) where
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Set (Set)
import Data.Set qualified as Set
import Haskoin.Store.Fleet.Pool
import Test.Hspec
import Test.Hspec.QuickCheck (modifyMaxSuccess)
import Test.QuickCheck
-- | An output: the transaction that made it, and which of its two outputs.
type Out = (Int, Int)
-- | A chain, newest block first, each block its coinbase and then its
-- other transactions; what every transaction ever made spends; the shared
-- pool; and the number of the next transaction.
data World = World
{ chain :: ![[Int]],
inputs :: !(Map Int [Out]),
pool :: !(Pool Int Out),
fresh :: !Int
}
deriving (Show)
-- | Something that happens, with a number to choose the details from.
data Op
= -- | a store offers a transaction, which may spend what is not there or
-- what the pool spends, naming what it replaces or not
Offer !Int
| -- | a store withdraws a transaction
Withdraw !Int
| -- | the tip moves to a new block, with some of the pool and perhaps a
-- transaction that the pool has not seen
Mine !Int
| -- | the tip moves back a block
Unwind
deriving (Show)
instance Arbitrary Op where
arbitrary =
frequency
[ (8, Offer <$> choose (0, 100000)),
(1, Withdraw <$> choose (0, 100000)),
(3, Mine <$> choose (0, 100000)),
(1, pure Unwind)
]
start :: World
start = World {chain = [[0]], inputs = Map.singleton 0 [], pool = empty, fresh = 1}
-- | The outputs of the chain that no transaction of the chain spends.
unspent :: World -> Set Out
unspent w = made `Set.difference` used
where
txs = concat w.chain
made = Set.fromList [(t, i) | t <- txs, i <- [0, 1]]
used = Set.fromList (concatMap (\t -> Map.findWithDefault [] t w.inputs) txs)
pick :: Int -> [a] -> [a]
pick _ [] = []
pick n xs = [xs !! (n `mod` length xs)]
step :: World -> Op -> World
step w = \case
Offer n ->
let t = w.fresh
chainOuts = Set.toList (unspent w)
poolOuts = [(p, i) | p <- Map.keys w.pool.spends, i <- [0, 1]]
-- now and then an output that never was, or one long spent
odd' = [(t + 7, 0), (0, 0)]
from = case n `mod` 10 of
0 -> odd'
k | k < 6 -> chainOuts
_ -> poolOuts <> chainOuts
spent = Set.toList (Set.fromList (pick n from <> pick (n `div` 7) (chainOuts <> poolOuts)))
rivals = [x | o <- spent, Just x <- [Map.lookup o w.pool.spender]]
replaced = if even (n `div` 3) then rivals else []
w' = w {fresh = t + 1, inputs = Map.insert t spent w.inputs}
in case offer fst (`Set.member` unspent w) t spent replaced w.pool of
Taken p -> w' {pool = p}
Refused _ -> w'
Withdraw n -> w {pool = withdraw (pick n (Map.keys w.pool.spends)) w.pool}
Mine n ->
let coinbase = w.fresh
free = unspent w
-- transactions of the pool whose parents are in the chain or picked
-- before them, as a block needs
ready picked t = all (\o -> Set.member o free || fst o `elem` picked) (Map.findWithDefault [] t w.pool.spends)
close picked =
case [t | t <- Map.keys w.pool.spends, t `notElem` picked, ready picked t] of
t : _ | length picked < n `mod` 5 -> close (picked <> [t])
_ -> picked
fromPool = close []
-- and one that the pool never saw, which may spend what it spends
taken = concatMap (\t -> Map.findWithDefault [] t w.pool.spends) fromPool
unseen = [(coinbase + 1, pick (n `div` 5) [o | o <- Set.toList free, o `notElem` taken]) | even n]
unseen' = [(t, os) | (t, os) <- unseen, not (null os)]
block = coinbase : fromPool <> map fst unseen'
ins = foldr (uncurry Map.insert) (Map.insert coinbase [] w.inputs) unseen'
change =
Change
{ returned = [],
gone = [],
confirmed = block,
spent = concatMap (\t -> Map.findWithDefault [] t ins) block
}
in w {chain = block : w.chain, inputs = ins, fresh = coinbase + 2, pool = advance fst change w.pool}
Unwind -> case w.chain of
(coinbase : txs) : rest@(_ : _) ->
let change =
Change
{ returned = [(t, Map.findWithDefault [] t w.inputs) | t <- txs],
gone = [coinbase],
confirmed = [],
spent = []
}
in w {chain = rest, pool = advance fst change w.pool}
_ -> w
-- | What is wrong with the pool of a world, if anything.
wrong :: World -> [String]
wrong w =
[ "transaction " <> show t <> " spends " <> show o <> ", which nothing made or something spent"
| (t, os) <- Map.toList w.pool.spends,
o <- os,
not (member (fst o) w.pool),
not (Set.member o free)
]
<> ["transaction " <> show t <> " is in a block and in the pool" | t <- concat w.chain, member t w.pool]
<> ["two transactions spend one output" | Map.size spenders /= length (concat (Map.elems w.pool.spends))]
<> ["the index of spenders is off" | spenders /= w.pool.spender]
<> ["the index of children is off" | kids /= w.pool.children]
where
free = unspent w
spenders = Map.fromList [(o, t) | (t, os) <- Map.toList w.pool.spends, o <- os]
kids = Map.fromListWith Set.union [(fst o, Set.singleton t) | (t, os) <- Map.toList w.pool.spends, o <- os]
spec :: Spec
spec = describe "shared mempool" $ do
let a = 10 :: Int
b = 11 :: Int
c = 12 :: Int
onChain o = fst o == (0 :: Int)
taken = \case
Taken p -> p
Refused why -> error (show why)
one = taken (offer fst onChain a [(0, 0)] [] (empty :: Pool Int Out))
two = taken (offer fst onChain b [(a, 0)] [] one)
it "keeps the first of two that spend the same output, unless it is replaced" $ do
offer fst onChain c [(0, 0)] [] two `shouldBe` Refused (Conflict [a])
-- replacing the first takes what spends it along
Map.keys (taken (offer fst onChain c [(0, 0)] [a] two)).spends `shouldBe` [c]
it "refuses what spends an output that is neither in the pool nor unspent at the tip" $ do
offer fst onChain c [(5, 0)] [] two `shouldBe` Refused (Orphan [(5, 0)])
offer fst onChain a [(0, 0)] [] two `shouldBe` Refused Known
it "lets a confirmed transaction go alone, and a conflict with all that spends it" $ do
let confirmedA = Change {returned = [], gone = [], confirmed = [a], spent = [(0, 0)]}
rival = Change {returned = [], gone = [], confirmed = [c], spent = [(0, 0)]}
Map.keys (advance fst confirmedA two).spends `shouldBe` [b]
advance fst rival two `shouldBe` empty
it "takes back what an abandoned block had confirmed, with what spends it" $ do
let forward = Change {returned = [], gone = [], confirmed = [a], spent = [(0, 0)]}
back = Change {returned = [(a, [(0, 0)])], gone = [], confirmed = [], spent = []}
undone = advance fst back (advance fst forward two)
undone `shouldBe` two
-- the transaction is the parent of the other undone
withdraw [a] undone `shouldBe` empty
modifyMaxSuccess (const 3000) $ do
it "stays valid on top of the tip, whatever is offered, mined and unwound" $
property $ \ops ->
let worlds = scanl step start ops
trouble = [(i, problems) | (i, w) <- zip [0 :: Int ..] worlds, let problems = wrong w, not (null problems)]
final = last worlds
in counterexample (show (take 1 trouble)) $
cover 20 (Map.size final.pool.spends >= 3) "three or more in the pool at the end" $
cover 20 (length final.chain >= 3) "three or more blocks at the end" (null trouble)